Web panel (like txAdmin) on the game port: login/roles/users, players, kick/ban/op, bans, live console, world reset/backups/restore, settings editor, save ZIP upload/download, start/stop/restart; in-process restart loop; tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-27 18:18:58 +02:00
co-authored by Claude Opus 5.5
parent b949418476
commit f8aad82d18
15 changed files with 2522 additions and 50 deletions
+670
View File
@@ -0,0 +1,670 @@
using System.Collections.Concurrent;
using System.Net;
using System.Net.Sockets;
using System.Reflection;
using System.Security.Cryptography;
using System.Text.Json;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.Extensions.Logging;
namespace MSCO.Server;
/// <summary>
/// Web-Panel (wie txAdmin): Anmelden, Spielerliste, Kick/Ban/Admin, Banns, Live-Konsole mit Eingabe, Chat,
/// Welt zurücksetzen, Backups, Benutzerverwaltung.
///
/// Port: Standard ist derselbe Port wie der Gameserver - der Server erkennt an den ersten Bytes, ob ein Browser
/// (HTTP) oder das Spiel verbindet, und reicht Browser-Verbindungen an das Panel weiter (das intern nur auf
/// 127.0.0.1 lauscht). Mit PanelPort in server.json läuft es stattdessen auf einem eigenen Port.
///
/// Hinter einem Reverse-Proxy: PanelTrustProxy = true übernimmt X-Forwarded-For/-Proto/-Prefix,
/// PanelBasePath (z.B. "/panel") für den Betrieb unter einem Unterpfad.
/// </summary>
public sealed class AdminPanel
{
const string CookieName = "msco_panel";
const string CsrfHeader = "X-MSCO-Panel";
static readonly TimeSpan SessionLifetime = TimeSpan.FromHours(12);
static readonly TimeSpan ActionTimeout = TimeSpan.FromSeconds(8);
const int MaxLoginFailures = 8;
static readonly TimeSpan LoginWindow = TimeSpan.FromMinutes(5);
sealed class Session
{
public string User;
public DateTime Expires;
}
GameServer server; // nach einem Neustart aus dem Panel ein neuer (Attach)
ServerConfig config;
readonly PanelUsers users;
readonly ConcurrentDictionary<string, Session> sessions = new();
readonly ConcurrentDictionary<string, List<DateTime>> failures = new();
// Weitergereichte Verbindungen (gleicher Port): lokaler Port der Weiterleitung -> echte Adresse des Browsers
readonly ConcurrentDictionary<int, IPAddress> relayed = new();
WebApplication app;
/// <summary>Einmal-Code für das erste Konto (null, sobald es einen Benutzer gibt).</summary>
public string SetupCode { get; private set; }
/// <summary>Interner Port, wenn das Panel den Spiel-Port mitbenutzt.</summary>
public int InternalPort { get; private set; }
public bool SharesGamePort { get; private set; }
AdminPanel(GameServer server, ServerConfig config, string dataDir)
{
this.server = server;
this.config = config;
users = new PanelUsers(Path.Combine(dataDir, "panel_users.json"));
}
// ---------------------------------------------------------------- Start
public static AdminPanel Start(GameServer server, ServerConfig config, string dataDir)
{
var panel = new AdminPanel(server, config, dataDir);
try
{
panel.StartWeb();
}
catch (Exception e)
{
GameServer.Log("Web panel could not start: " + e.Message);
return null;
}
return panel;
}
void StartWeb()
{
SharesGamePort = config.PanelPort <= 0 || config.PanelPort == config.Port;
IPAddress bind;
int port;
if (SharesGamePort)
{
bind = IPAddress.Loopback;
port = InternalPort = FreeLoopbackPort();
}
else
{
bind = ParseBind(config.PanelBindAddress);
port = config.PanelPort;
}
var builder = WebApplication.CreateSlimBuilder(new WebApplicationOptions { ContentRootPath = AppContext.BaseDirectory });
builder.Logging.ClearProviders();
builder.WebHost.ConfigureKestrel(k =>
{
k.Listen(bind, port);
k.Limits.MaxRequestBodySize = 64 * 1024;
k.AddServerHeader = false;
});
app = builder.Build();
// Weitergereichte Verbindung: echte Browser-Adresse wiederherstellen (sonst wäre jeder 127.0.0.1)
app.Use(async (ctx, next) =>
{
var remote = ctx.Connection.RemoteIpAddress;
if (remote != null && IPAddress.IsLoopback(remote) && relayed.TryGetValue(ctx.Connection.RemotePort, out var real))
ctx.Connection.RemoteIpAddress = real;
await next();
});
if (config.PanelTrustProxy)
{
var forwarded = new ForwardedHeadersOptions
{
ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto | ForwardedHeaders.XForwardedHost
};
forwarded.KnownIPNetworks.Clear(); // jedem Proxy vertrauen (PanelTrustProxy ist eine bewusste Entscheidung)
forwarded.KnownProxies.Clear();
app.UseForwardedHeaders(forwarded);
// Proxy unter Unterpfad (z.B. Traefik/nginx mit X-Forwarded-Prefix)
app.Use(async (ctx, next) =>
{
string prefix = ctx.Request.Headers["X-Forwarded-Prefix"];
if (!string.IsNullOrEmpty(prefix) && prefix.StartsWith('/')) ctx.Request.PathBase = prefix.TrimEnd('/');
await next();
});
}
string basePath = (config.PanelBasePath ?? "").Trim().TrimEnd('/');
if (basePath.Length > 0)
{
if (!basePath.StartsWith('/')) basePath = "/" + basePath;
app.UsePathBase(basePath);
}
// Sicherheits-Header
app.Use(async (ctx, next) =>
{
var h = ctx.Response.Headers;
h["X-Content-Type-Options"] = "nosniff";
h["X-Frame-Options"] = "DENY";
h["Referrer-Policy"] = "no-referrer";
h["Content-Security-Policy"] = "default-src 'self'; img-src 'self' data: https://avatars.steamstatic.com; style-src 'self'; script-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'";
if (ctx.Request.Path.StartsWithSegments("/api")) h["Cache-Control"] = "no-store";
await next();
});
MapFiles();
MapApi();
app.StartAsync().GetAwaiter().GetResult();
if (users.Empty) CreateSetupCode();
if (SharesGamePort)
GameServer.Log($"Web panel: http://<server>:{config.Port}/{(basePath.Length > 0 ? basePath.TrimStart('/') + "/" : "")} (same port as the game)");
else
GameServer.Log($"Web panel: http://{(bind.Equals(IPAddress.Any) ? "<server>" : bind.ToString())}:{port}{basePath}/");
}
/// <summary>Neustart im selben Programm: das Panel (und die Anmeldungen) bleiben, der Server ist neu.</summary>
public void Attach(GameServer newServer, ServerConfig newConfig)
{
server = newServer;
config = newConfig;
}
public void Stop()
{
try
{
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(2));
app?.StopAsync(timeout.Token).GetAwaiter().GetResult();
}
catch { }
}
void CreateSetupCode()
{
const string alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
var code = new char[10];
for (int i = 0; i < code.Length; i++) code[i] = alphabet[RandomNumberGenerator.GetInt32(alphabet.Length)];
SetupCode = new string(code, 0, 5) + "-" + new string(code, 5, 5);
GameServer.Log("============================================================");
GameServer.Log(" WEB PANEL SETUP - open the panel in your browser and enter");
GameServer.Log(" this code to create the owner account: " + SetupCode);
GameServer.Log("============================================================");
}
/// <summary>Konsolenbefehl "panel": Adresse und (falls noch nötig) den Einrichtungs-Code zeigen.</summary>
public void PrintInfo()
{
GameServer.Log(SharesGamePort ? $"Web panel on the game port {config.Port}" : $"Web panel on port {config.PanelPort}");
if (SetupCode != null) GameServer.Log("Setup code for the first account: " + SetupCode);
else GameServer.Log($"{users.All().Count} panel user(s): " + string.Join(", ", users.All().Select(u => $"{u.Name} ({u.Role})")));
}
static int FreeLoopbackPort()
{
var probe = new TcpListener(IPAddress.Loopback, 0);
probe.Start();
int port = ((IPEndPoint)probe.LocalEndpoint).Port;
probe.Stop();
return port;
}
static IPAddress ParseBind(string address)
{
if (string.IsNullOrWhiteSpace(address) || address == "*" || address == "0.0.0.0") return IPAddress.Any;
if (address == "localhost") return IPAddress.Loopback;
return IPAddress.TryParse(address, out var ip) ? ip : IPAddress.Any;
}
// ---------------------------------------------------------------- Gleicher Port: HTTP erkennen und weiterreichen
/// <summary>Beginnen die Bytes wie eine HTTP-Anfrage? (Spiel-Pakete beginnen mit einer kleinen Längenangabe.)</summary>
public static bool LooksLikeHttp(byte[] b, int n)
{
if (n < 4) return false;
for (int i = 0; i < 3; i++)
if (b[i] < 'A' || b[i] > 'Z') return false;
return (b[3] >= 'A' && b[3] <= 'Z') || b[3] == ' '; // "GET ", "PUT ", POST, HEAD, DELE, OPTI, PATC ...
}
/// <summary>Browser-Verbindung vom Spiel-Port an das intern laufende Panel weiterreichen (in beide Richtungen).</summary>
public async Task RelayAsync(TcpClient browser)
{
var real = (browser.Client.RemoteEndPoint as IPEndPoint)?.Address;
using var panel = new TcpClient { NoDelay = true };
int localPort = 0;
try
{
await panel.ConnectAsync(IPAddress.Loopback, InternalPort);
localPort = ((IPEndPoint)panel.Client.LocalEndPoint).Port;
if (real != null) relayed[localPort] = real.IsIPv4MappedToIPv6 ? real.MapToIPv4() : real;
using var a = browser.GetStream();
using var b = panel.GetStream();
var up = a.CopyToAsync(b);
var down = b.CopyToAsync(a);
await Task.WhenAny(up, down);
}
catch { }
finally
{
if (localPort != 0) relayed.TryRemove(localPort, out _);
try { browser.Close(); } catch { }
}
}
// ---------------------------------------------------------------- Dateien (Oberfläche)
void MapFiles()
{
byte[] html = Resource("panel.index.html"), js = Resource("panel.app.js"), css = Resource("panel.app.css"), logo = Resource("panel.logo.png");
app.MapGet("/", (HttpContext ctx) =>
{
// /panel ohne Schrägstrich -> /panel/ (sonst zeigen die relativen Adressen der Seite ins Leere)
if (ctx.Request.PathBase.HasValue && !ctx.Request.Path.HasValue)
return Results.Redirect(ctx.Request.PathBase + "/");
return Results.Bytes(html, "text/html; charset=utf-8");
});
app.MapGet("/app.js", () => Results.Bytes(js, "text/javascript; charset=utf-8"));
app.MapGet("/app.css", () => Results.Bytes(css, "text/css; charset=utf-8"));
if (logo != null) app.MapGet("/logo.png", () => Results.Bytes(logo, "image/png"));
}
static byte[] Resource(string name)
{
using var s = Assembly.GetExecutingAssembly().GetManifestResourceStream(name);
if (s == null) return null;
using var ms = new MemoryStream();
s.CopyTo(ms);
return ms.ToArray();
}
// ---------------------------------------------------------------- API
void MapApi()
{
// --- Anmeldung
app.MapGet("/api/session", (HttpContext ctx) =>
{
var user = CurrentUser(ctx);
return Results.Json(new
{
needsSetup = users.Empty,
serverName = config.Name,
user = user == null ? null : new { name = user.Name, role = user.Role.ToString() }
});
});
app.MapPost("/api/setup", async (HttpContext ctx) =>
{
if (!CsrfOk(ctx)) return Forbidden("Missing header");
if (!users.Empty || SetupCode == null) return Error(400, "The panel is already set up");
if (RateLimited(ctx)) return Error(429, "Too many attempts - wait a few minutes");
var body = await Body(ctx);
string code = Str(body, "code").Trim().ToUpperInvariant();
if (!CryptographicOperations.FixedTimeEquals(System.Text.Encoding.UTF8.GetBytes(code), System.Text.Encoding.UTF8.GetBytes(SetupCode)))
{
RecordFailure(ctx);
return Error(403, "Wrong setup code (see the server console / docker logs)");
}
string name = Str(body, "username").Trim(), password = Str(body, "password");
string error = users.Add(name, password, PanelRole.Owner);
if (error != null) return Error(400, error);
SetupCode = null;
GameServer.Log($"[panel] Owner account '{name}' created ({Ip(ctx)})");
StartSession(ctx, name);
return Results.Json(new { ok = true });
});
app.MapPost("/api/login", async (HttpContext ctx) =>
{
if (!CsrfOk(ctx)) return Forbidden("Missing header");
if (RateLimited(ctx)) return Error(429, "Too many failed logins - wait a few minutes");
var body = await Body(ctx);
var user = users.Verify(Str(body, "username").Trim(), Str(body, "password"));
if (user == null)
{
RecordFailure(ctx);
GameServer.Log($"[panel] Failed login for '{Str(body, "username").Trim()}' from {Ip(ctx)}");
return Error(401, "Wrong username or password");
}
failures.TryRemove(Ip(ctx), out _);
StartSession(ctx, user.Name);
GameServer.Log($"[panel] {user.Name} logged in ({Ip(ctx)})");
return Results.Json(new { ok = true });
});
app.MapPost("/api/logout", (HttpContext ctx) =>
{
if (!CsrfOk(ctx)) return Forbidden("Missing header");
string token = ctx.Request.Cookies[CookieName];
if (token != null) sessions.TryRemove(token, out _);
ctx.Response.Cookies.Delete(CookieName, CookieOptions(ctx));
return Results.Json(new { ok = true });
});
app.MapPost("/api/me/password", async (HttpContext ctx) =>
{
var user = Require(ctx, PanelRole.Moderator, out var denied); if (user == null) return denied;
var body = await Body(ctx);
if (users.Verify(user.Name, Str(body, "current")) == null) return Error(403, "Current password is wrong");
string error = users.SetPassword(user.Name, Str(body, "password"));
if (error != null) return Error(400, error);
GameServer.Log($"[panel] {user.Name} changed their password");
return Ok("Password changed");
});
// --- Server
app.MapGet("/api/status", async (HttpContext ctx) =>
{
var user = Require(ctx, PanelRole.Moderator, out var denied); if (user == null) return denied;
return await Run(() => server.PanelStatus());
});
app.MapGet("/api/players", async (HttpContext ctx) =>
{
var user = Require(ctx, PanelRole.Moderator, out var denied); if (user == null) return denied;
bool withAddress = user.Role >= PanelRole.Admin; // IP-Adressen nur für Admins
return await Run(() => server.PanelPlayers(withAddress));
});
app.MapPost("/api/players/{id:int}/{action}", async (HttpContext ctx, int id, string action) =>
{
var need = action == "kick" ? PanelRole.Moderator : PanelRole.Admin;
var user = Require(ctx, need, out var denied); if (user == null) return denied;
var body = await Body(ctx);
string by = By(user);
string reason = Str(body, "reason");
return action switch
{
"kick" => await RunText(() => server.PanelKick(id, false, reason, by)),
"ban" => await RunText(() => server.PanelKick(id, true, reason, by)),
"op" => await RunText(() => server.PanelSetAdmin(id, true, by)),
"deop" => await RunText(() => server.PanelSetAdmin(id, false, by)),
_ => Error(404, "Unknown action")
};
});
app.MapPost("/api/say", async (HttpContext ctx) =>
{
var user = Require(ctx, PanelRole.Moderator, out var denied); if (user == null) return denied;
var body = await Body(ctx);
string text = Str(body, "text"), by = By(user);
return await RunText(() => server.PanelSay(text, by));
});
// --- Banns
app.MapGet("/api/bans", async (HttpContext ctx) =>
{
var user = Require(ctx, PanelRole.Moderator, out var denied); if (user == null) return denied;
return await Run(() => server.PanelBans());
});
app.MapPost("/api/bans/add", async (HttpContext ctx) =>
{
var user = Require(ctx, PanelRole.Admin, out var denied); if (user == null) return denied;
var body = await Body(ctx);
string id = Str(body, "id"), reason = Str(body, "reason"), by = By(user);
return await RunText(() => server.PanelBanId(id, reason, by));
});
app.MapPost("/api/bans/remove", async (HttpContext ctx) =>
{
var user = Require(ctx, PanelRole.Admin, out var denied); if (user == null) return denied;
var body = await Body(ctx);
string id = Str(body, "id"), by = By(user);
return await RunText(() => server.PanelUnban(id, by));
});
// --- Konsole
app.MapGet("/api/console", (HttpContext ctx) =>
{
var user = Require(ctx, PanelRole.Moderator, out var denied); if (user == null) return denied;
long.TryParse(ctx.Request.Query["after"], out long after);
var lines = LogBuffer.Since(after, 1000);
return Results.Json(new
{
last = lines.Count > 0 ? lines[^1].Seq : Math.Max(after, 0),
lines = lines.Select(l => new { seq = l.Seq, time = l.Time, text = l.Text })
});
});
app.MapPost("/api/console", async (HttpContext ctx) =>
{
var user = Require(ctx, PanelRole.Admin, out var denied); if (user == null) return denied;
var body = await Body(ctx);
string command = Str(body, "command").Trim();
if (command.Length == 0 || command.Length > 500) return Error(400, "Enter a command");
string by = By(user);
return await RunText(() => server.RunCommandCaptured(command, by));
});
// --- Welt / Backups
app.MapGet("/api/backups", async (HttpContext ctx) =>
{
var user = Require(ctx, PanelRole.Admin, out var denied); if (user == null) return denied;
return await Run(() => server.PanelBackups());
});
app.MapPost("/api/world/{action}", async (HttpContext ctx, string action) =>
{
var user = Require(ctx, PanelRole.Admin, out var denied); if (user == null) return denied;
var body = await Body(ctx);
string name = Str(body, "name"), by = By(user);
return await RunText(() => server.PanelWorld(action, name, by));
});
// --- Server steuern (Start/Stopp/Neustart)
app.MapPost("/api/server/{action}", async (HttpContext ctx, string action) =>
{
var user = Require(ctx, action == "shutdown" ? PanelRole.Owner : PanelRole.Admin, out var denied); if (user == null) return denied;
string by = By(user);
return await RunText(() => server.PanelServerAction(action, by));
});
// --- Einstellungen (server.json)
app.MapGet("/api/config", async (HttpContext ctx) =>
{
var user = Require(ctx, PanelRole.Admin, out var denied); if (user == null) return denied;
return await Run(() => server.PanelConfig());
});
app.MapPost("/api/config", async (HttpContext ctx) =>
{
var user = Require(ctx, PanelRole.Admin, out var denied); if (user == null) return denied;
var body = await Body(ctx);
bool owner = user.Role >= PanelRole.Owner;
string by = By(user);
return await RunText(() => server.PanelUpdateConfig(body, owner, by));
});
// --- Spielstand als ZIP
app.MapGet("/api/save/download", async (HttpContext ctx) =>
{
var user = Require(ctx, PanelRole.Admin, out var denied); if (user == null) return denied;
var task = server.Invoke(() => server.ExportSaveZip());
if (await Task.WhenAny(task, Task.Delay(ActionTimeout)) != task) return Error(503, "The server is busy");
GameServer.Log($"[panel] {user.Name} downloaded the save game");
return Results.File(await task, "application/zip", $"msco-save-{DateTime.Now:yyyyMMdd-HHmmss}.zip");
});
app.MapPost("/api/save/upload", async (HttpContext ctx) =>
{
var user = Require(ctx, PanelRole.Admin, out var denied); if (user == null) return denied;
// Größere Grenze nur für den Spielstand
var sizeFeature = ctx.Features.Get<Microsoft.AspNetCore.Http.Features.IHttpMaxRequestBodySizeFeature>();
if (sizeFeature != null && !sizeFeature.IsReadOnly) sizeFeature.MaxRequestBodySize = 64L * 1024 * 1024;
byte[] zip;
try
{
using var ms = new MemoryStream();
await ctx.Request.Body.CopyToAsync(ms);
zip = ms.ToArray();
}
catch (Exception e) { return Error(400, "Upload failed: " + e.Message); }
if (zip.Length == 0) return Error(400, "Choose a ZIP file");
string by = By(user);
return await RunText(() => server.ImportSaveZip(zip, by));
});
// --- Benutzer (nur Owner)
app.MapGet("/api/users", (HttpContext ctx) =>
{
var user = Require(ctx, PanelRole.Owner, out var denied); if (user == null) return denied;
return Results.Json(users.All().Select(u => new
{
name = u.Name,
role = u.Role.ToString(),
created = u.CreatedUtc.ToString("o"),
lastLogin = u.LastLoginUtc?.ToString("o")
}));
});
app.MapPost("/api/users", async (HttpContext ctx) =>
{
var user = Require(ctx, PanelRole.Owner, out var denied); if (user == null) return denied;
var body = await Body(ctx);
if (!Enum.TryParse(Str(body, "role"), true, out PanelRole role)) return Error(400, "Unknown role");
string name = Str(body, "name").Trim();
string error = users.Add(name, Str(body, "password"), role);
if (error != null) return Error(400, error);
GameServer.Log($"[panel] {user.Name} added user '{name}' ({role})");
return Ok($"User {name} created");
});
app.MapPost("/api/users/{name}/{action}", async (HttpContext ctx, string name, string action) =>
{
var user = Require(ctx, PanelRole.Owner, out var denied); if (user == null) return denied;
var body = await Body(ctx);
string error;
switch (action)
{
case "role":
if (!Enum.TryParse(Str(body, "role"), true, out PanelRole role)) return Error(400, "Unknown role");
error = users.SetRole(name, role);
if (error == null) GameServer.Log($"[panel] {user.Name} changed the role of '{name}' to {role}");
break;
case "password":
error = users.SetPassword(name, Str(body, "password"));
if (error == null)
{
EndSessionsOf(name);
GameServer.Log($"[panel] {user.Name} reset the password of '{name}'");
}
break;
case "delete":
if (string.Equals(name, user.Name, StringComparison.OrdinalIgnoreCase)) return Error(400, "You cannot delete yourself");
error = users.Remove(name);
if (error == null)
{
EndSessionsOf(name);
GameServer.Log($"[panel] {user.Name} deleted user '{name}'");
}
break;
default:
return Error(404, "Unknown action");
}
return error != null ? Error(400, error) : Ok("Done");
});
}
// ---------------------------------------------------------------- Hilfen
static string By(PanelUsers.User user) => user.Name + " (panel)";
async Task<IResult> Run<T>(Func<T> func)
{
var task = server.Invoke(func);
if (await Task.WhenAny(task, Task.Delay(ActionTimeout)) != task) return Error(503, "The server is busy or stopping");
return Results.Json(await task);
}
async Task<IResult> RunText(Func<string> func)
{
var task = server.Invoke(func);
if (await Task.WhenAny(task, Task.Delay(ActionTimeout)) != task) return Error(503, "The server is busy or stopping");
return Ok(await task);
}
static IResult Ok(string message) => Results.Json(new { ok = true, message });
static IResult Error(int status, string message) => Results.Json(new { error = message }, statusCode: status);
static IResult Forbidden(string message) => Error(403, message);
/// <summary>
/// Nur Anfragen aus der eigenen Oberfläche: sie schicken einen eigenen Header, den eine fremde Webseite ohne
/// CORS-Freigabe nicht setzen kann (Schutz vor CSRF). Zusätzlich ist das Cookie SameSite=Strict.
/// </summary>
static bool CsrfOk(HttpContext ctx) => ctx.Request.Headers[CsrfHeader] == "1";
PanelUsers.User CurrentUser(HttpContext ctx)
{
string token = ctx.Request.Cookies[CookieName];
if (token == null || !sessions.TryGetValue(token, out var session)) return null;
if (session.Expires < DateTime.UtcNow) { sessions.TryRemove(token, out _); return null; }
var user = users.Find(session.User);
if (user == null) { sessions.TryRemove(token, out _); return null; }
session.Expires = DateTime.UtcNow + SessionLifetime; // gleitend verlängern
return user;
}
PanelUsers.User Require(HttpContext ctx, PanelRole role, out IResult denied)
{
denied = null;
if (!HttpMethods.IsGet(ctx.Request.Method) && !CsrfOk(ctx)) { denied = Forbidden("Missing header"); return null; }
var user = CurrentUser(ctx);
if (user == null) { denied = Error(401, "Please log in"); return null; }
if (user.Role < role) { denied = Forbidden("Your role (" + user.Role + ") is not allowed to do this"); return null; }
return user;
}
void StartSession(HttpContext ctx, string name)
{
string token = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)).TrimEnd('=').Replace('+', '-').Replace('/', '_');
sessions[token] = new Session { User = name, Expires = DateTime.UtcNow + SessionLifetime };
ctx.Response.Cookies.Append(CookieName, token, CookieOptions(ctx));
// Alte Sitzungen aufräumen
foreach (var kv in sessions) if (kv.Value.Expires < DateTime.UtcNow) sessions.TryRemove(kv.Key, out _);
}
void EndSessionsOf(string name)
{
foreach (var kv in sessions)
if (string.Equals(kv.Value.User, name, StringComparison.OrdinalIgnoreCase)) sessions.TryRemove(kv.Key, out _);
}
static CookieOptions CookieOptions(HttpContext ctx) => new()
{
HttpOnly = true,
SameSite = SameSiteMode.Strict,
Secure = ctx.Request.IsHttps,
Path = ctx.Request.PathBase.HasValue ? ctx.Request.PathBase.Value : "/",
MaxAge = SessionLifetime
};
static string Ip(HttpContext ctx) => ctx.Connection.RemoteIpAddress?.ToString() ?? "?";
bool RateLimited(HttpContext ctx)
{
if (!failures.TryGetValue(Ip(ctx), out var list)) return false;
lock (list)
{
list.RemoveAll(t => DateTime.UtcNow - t > LoginWindow);
return list.Count >= MaxLoginFailures;
}
}
void RecordFailure(HttpContext ctx)
{
var list = failures.GetOrAdd(Ip(ctx), _ => new List<DateTime>());
lock (list) list.Add(DateTime.UtcNow);
}
static async Task<JsonElement> Body(HttpContext ctx)
{
try
{
if (ctx.Request.ContentLength is null or 0) return default;
return await JsonSerializer.DeserializeAsync<JsonElement>(ctx.Request.Body);
}
catch { return default; }
}
static string Str(JsonElement body, string name) =>
body.ValueKind == JsonValueKind.Object && body.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() ?? "" : "";
}