auth-proxy/src/Program.cs

190 lines
6.4 KiB
C#

using System.Globalization;
using System.Net;
using System.Security.Cryptography;
using Microsoft.Data.Sqlite;
using SQLitePCL;
namespace AuthProxy;
public static class Program {
private const string COOKIE_NAME = "__Secure-Token";
private static readonly string s_loginHtmlCache = File.ReadAllText(Path.Combine(
AppDomain.CurrentDomain.BaseDirectory, "login.html"));
private static readonly string? s_domain = Environment
.GetEnvironmentVariable("AUTH_DOMAIN");
private static readonly string s_db = Environment
.GetEnvironmentVariable("AUTH_DB") ?? "credentials.db";
private static readonly long s_lifetime = long.Parse(Environment
.GetEnvironmentVariable("AUTH_LIFETIME") ?? "7",
CultureInfo.InvariantCulture) * 60 * 60 * 24;
private static readonly string? s_password = Environment
.GetEnvironmentVariable("AUTH_PASSWORD");
private static readonly int s_port = int.Parse(Environment
.GetEnvironmentVariable("AUTH_PORT") ?? "5000",
CultureInfo.InvariantCulture);
private static string ConnectionString { get => $"Data Source={s_db}"; }
public static void Main(string[] args) {
var app = Initialize(args);
app.UseSession();
app.MapGet("/auth/check", async (context) => {
var token = context.Request.Cookies[COOKIE_NAME];
if (!TokenIsValid(token)) {
context.Response.ContentType = "text/plain";
context.Response.StatusCode = (int)HttpStatusCode.Unauthorized;
await context.Response.WriteAsync("unauthorized");
return;
}
context.Response.ContentType = "text/plain";
context.Response.StatusCode = (int)HttpStatusCode.OK;
await context.Response.WriteAsync("authorized");
});
app.MapGet("/auth/login", async (context) => {
context.Response.ContentType = "text/html";
context.Response.StatusCode = (int)HttpStatusCode.OK;
await context.Response.WriteAsync(s_loginHtmlCache);
});
app.MapGet("/auth/logout", (context) => {
if (context.Request.Cookies[COOKIE_NAME] == null)
return Task.CompletedTask;
var cookieOpts = new CookieOptions {
Path = "/",
Secure = true,
HttpOnly = true,
SameSite = SameSiteMode.None,
MaxAge = TimeSpan.FromSeconds(s_lifetime),
};
if (!string.IsNullOrEmpty(s_domain)) {
cookieOpts.Domain = s_domain;
}
context.Response.Cookies.Append(COOKIE_NAME, string.Empty, cookieOpts);
context.Response.Redirect("/");
return Task.CompletedTask;
});
app.MapPost("/auth/password", async (context) => {
if (context.Request.Form.TryGetValue("password", out var reqPassword)
&& !string.IsNullOrEmpty(s_password)
&& string.Equals(reqPassword.FirstOrDefault(), s_password, StringComparison.Ordinal)) {
var cookieOpts = new CookieOptions {
Path = "/",
Secure = true,
HttpOnly = true,
SameSite = SameSiteMode.None,
MaxAge = TimeSpan.FromSeconds(s_lifetime),
};
if (!string.IsNullOrEmpty(s_domain)) {
cookieOpts.Domain = s_domain;
}
using var connection = new SqliteConnection(ConnectionString);
await connection.OpenAsync();
context.Response.Cookies.Append(
COOKIE_NAME,
GenerateToken(connection),
cookieOpts);
if (!context.Request.Form.TryGetValue("target", out var target)
|| string.IsNullOrEmpty(target.FirstOrDefault())) {
target = [];
}
context.Response.Redirect(target.FirstOrDefault() ?? "/");
} else {
context.Response.StatusCode = 401;
await context.Response.WriteAsJsonAsync(new { error = "bad password" });
}
});
app.Run($"http://0.0.0.0:{s_port}");
}
private static string GenerateToken(SqliteConnection connection) {
var rng = RandomNumberGenerator.Create();
var bytes = new byte[32];
rng.GetBytes(bytes);
var token = Convert.ToBase64String(bytes);
using var cmd = new SqliteCommand(@"insert into tokens (token) values (@Token)",
connection);
var param = cmd.CreateParameter();
param.ParameterName = "@Token";
param.Value = token;
cmd.Parameters.Add(param);
cmd.ExecuteNonQuery();
return token;
}
private static bool TokenIsValid(string? token) {
if (string.IsNullOrWhiteSpace(token)) return false;
using var connection = new SqliteConnection(ConnectionString);
connection.Open();
using var cmd = new SqliteCommand(
@"select 1 from tokens where
token=@Token
and julianday(created_date, @Timeout) > julianday(CURRENT_TIMESTAMP)",
connection);
var tokenParam = cmd.CreateParameter();
tokenParam.ParameterName = "@Token";
tokenParam.Value = token;
var timeoutParam = cmd.CreateParameter();
timeoutParam.ParameterName = "@Timeout";
timeoutParam.Value = $"+{s_lifetime} seconds";
cmd.Parameters.Add(tokenParam);
cmd.Parameters.Add(timeoutParam);
return cmd.ExecuteScalar() != null;
}
private static void RemoveExpiredTokens() {
using var connection = new SqliteConnection(ConnectionString);
connection.Open();
using var cmd = new SqliteCommand(
@"delete from tokens where
julianday(created_date, @Timeout) < julianday(CURRENT_TIMESTAMP)",
connection);
var timeoutParam = cmd.CreateParameter();
timeoutParam.ParameterName = "@Timeout";
timeoutParam.Value = $"+{s_lifetime} seconds";
cmd.Parameters.Add(timeoutParam);
cmd.ExecuteNonQuery();
}
private static WebApplication Initialize(string[] args) {
Batteries.Init();
using var connection = new SqliteConnection(ConnectionString);
connection.Open();
using (var cmd = new SqliteCommand(
@"select 1 from sqlite_master
where type='table' and name='tokens'",
connection)) {
var exists = cmd.ExecuteScalar();
if (exists == null) {
var schemaPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "schema.sql");
if (!File.Exists(schemaPath)) {
throw new FileNotFoundException(schemaPath);
}
var schema = File.ReadAllText(schemaPath);
using var schemaCmd = new SqliteCommand(schema, connection);
schemaCmd.ExecuteNonQuery();
}
}
RemoveExpiredTokens();
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDistributedMemoryCache();
builder.Services.AddSession();
return builder.Build();
}
}