diff --git a/server/Commands/RunCommand.cs b/server/Commands/RunCommand.cs index 8732593..c4f6e45 100644 --- a/server/Commands/RunCommand.cs +++ b/server/Commands/RunCommand.cs @@ -36,15 +36,14 @@ public sealed class RunCommand : AsyncCommand { if (Console.IsInputRedirected) { - var generated = Convert.ToBase64String(System.Security.Cryptography.RandomNumberGenerator.GetBytes(18)) - .Replace("+", "-").Replace("/", "_").Replace("=", ""); - config.WebPassword = generated; - config.Save(configPath); + // Non-interactive (systemd, piped SSH): don't auto-generate. The + // gate below leaves /api/auth/setup open so the operator can set + // a password on first visit to the panel; everything else stays + // locked until they do. AnsiConsole.MarkupLine(""); - AnsiConsole.MarkupLine("[bold yellow]═══════════ ADMIN PASSWORD GENERATED ═══════════[/]"); - AnsiConsole.MarkupLine($"[bold]Password:[/] [yellow]{generated}[/]"); - AnsiConsole.MarkupLine($"[grey]Saved to {configPath}. Use the [/][bold]Change password[/][grey] button in the panel to set your own.[/]"); - AnsiConsole.MarkupLine("[bold yellow]════════════════════════════════════════════════[/]"); + AnsiConsole.MarkupLine("[bold yellow]═══ FIRST-RUN: admin password not set ═══[/]"); + AnsiConsole.MarkupLine("[grey]Open the web panel in your browser and you'll be walked through[/]"); + AnsiConsole.MarkupLine("[grey]setting one. Everything except /api/auth/setup is 401-gated until then.[/]"); AnsiConsole.MarkupLine(""); } else @@ -208,36 +207,49 @@ public sealed class RunCommand : AsyncCommand // those handlers short-circuit the pipeline and serve map tiles to anyone. // The UI shell at / is intentionally above this (login page must be reachable // unauthenticated); all sensitive operations go through /api/* which is gated. - if (!string.IsNullOrEmpty(config.WebPassword)) + // Gate registered unconditionally so first-run mode (config.WebPassword + // == null) is still locked down to /api/auth/setup + /api/auth/state. + // Once the operator sets a password via the web panel, the same gate + // tightens automatically (config.WebPassword is updated in-place). + app.Use(async (ctx, next) => { - app.Use(async (ctx, next) => + var path = ctx.Request.Path; + // Only gate /api/* and /map/* -- everything else is the public UI shell. + var gated = path.StartsWithSegments("/api") || path.StartsWithSegments("/map"); + if (!gated) { await next(); return; } + + // Always-anonymous endpoints (login, state probe, friend whitelist requests). + if (path.StartsWithSegments("/api/auth/login")) { await next(); return; } + if (path.StartsWithSegments("/api/auth/state")) { await next(); return; } + if (path.StartsWithSegments("/api/whitelist/request")) { await next(); return; } + if (path.StartsWithSegments("/api/whitelist/status")) { await next(); return; } + + // First-run mode: only /api/auth/setup is reachable until a password + // is set. The setup endpoint refuses to overwrite an existing password. + if (string.IsNullOrEmpty(config.WebPassword)) { - var path = ctx.Request.Path; - // Only gate /api/* and /map/* -- everything else is the public UI shell. - var gated = path.StartsWithSegments("/api") || path.StartsWithSegments("/map"); - if (!gated) { await next(); return; } - if (path.StartsWithSegments("/api/auth/login")) { await next(); return; } - // Friend-side whitelist request endpoints are public on purpose so the - // launcher can hit them without holding the admin password. Rate-limited - // separately to throttle abuse. - if (path.StartsWithSegments("/api/whitelist/request")) { await next(); return; } - if (path.StartsWithSegments("/api/whitelist/status")) { await next(); return; } - var pwBytes = System.Text.Encoding.UTF8.GetBytes(config.WebPassword); - var auth = ctx.Request.Headers["X-Brass-Sigil-Auth"].ToString(); - if (string.IsNullOrEmpty(auth)) - auth = ctx.Request.Cookies["brass-sigil-auth"] ?? ""; - var authBytes = System.Text.Encoding.UTF8.GetBytes(auth); - var ok = authBytes.Length == pwBytes.Length - && System.Security.Cryptography.CryptographicOperations.FixedTimeEquals(authBytes, pwBytes); - if (!ok) - { - ctx.Response.StatusCode = 401; - await ctx.Response.WriteAsync("Unauthorized"); - return; - } - await next(); - }); - } + if (path.StartsWithSegments("/api/auth/setup")) { await next(); return; } + ctx.Response.StatusCode = 401; + await ctx.Response.WriteAsync("Not yet configured -- visit / in a browser to set an admin password."); + return; + } + + // Normal auth check (cookie or X-Brass-Sigil-Auth header). + var pwBytes = System.Text.Encoding.UTF8.GetBytes(config.WebPassword); + var auth = ctx.Request.Headers["X-Brass-Sigil-Auth"].ToString(); + if (string.IsNullOrEmpty(auth)) + auth = ctx.Request.Cookies["brass-sigil-auth"] ?? ""; + var authBytes = System.Text.Encoding.UTF8.GetBytes(auth); + var ok = authBytes.Length == pwBytes.Length + && System.Security.Cryptography.CryptographicOperations.FixedTimeEquals(authBytes, pwBytes); + if (!ok) + { + ctx.Response.StatusCode = 401; + await ctx.Response.WriteAsync("Unauthorized"); + return; + } + await next(); + }); // /map/.../live/players.json is generated dynamically per-request from RCON // -- pull-based, so RCON only fires while a browser tab is actually polling. @@ -390,6 +402,53 @@ public sealed class RunCommand : AsyncCommand return Results.Json(new { ok = true }); }); + // Anonymous probe so the panel can decide between login vs first-run + // setup vs already-authed without triggering a 401 from a "real" call. + app.MapGet("/api/auth/state", (HttpContext ctx) => + { + var pw = config.WebPassword; + var needsSetup = pw is null; + var authed = false; + if (!needsSetup && !string.IsNullOrEmpty(pw)) + { + var cookie = ctx.Request.Cookies["brass-sigil-auth"] ?? ""; + var pwBytes = System.Text.Encoding.UTF8.GetBytes(pw); + var ckBytes = System.Text.Encoding.UTF8.GetBytes(cookie); + authed = ckBytes.Length == pwBytes.Length + && System.Security.Cryptography.CryptographicOperations.FixedTimeEquals(ckBytes, pwBytes); + } + return Results.Json(new { needsSetup, authed }); + }); + + // First-run-only password setup. Refuses if a password is already set, + // so an attacker can't use this endpoint to take over an existing install. + // Rate-limited under the same bucket as /api/auth/login. + app.MapPost("/api/auth/setup", async (HttpContext ctx) => + { + if (!string.IsNullOrEmpty(config.WebPassword)) + return Results.Json(new { ok = false, error = "Password already set" }, statusCode: 409); + + using var sr = new StreamReader(ctx.Request.Body); + var payload = JsonSerializer.Deserialize(await sr.ReadToEndAsync(), JsonOpts.CaseInsensitive); + var pw = payload?.Password ?? ""; + if (pw.Length < 8) + return Results.Json(new { ok = false, error = "Password must be at least 8 characters" }, statusCode: 400); + + config.WebPassword = pw; + config.Save(configPath); + + // Issue the cookie immediately so the operator is logged in after setup + // -- no second round-trip through the login form. + ctx.Response.Cookies.Append("brass-sigil-auth", pw, new CookieOptions + { + HttpOnly = true, + Secure = ctx.Request.IsHttps, + SameSite = SameSiteMode.Strict, + Path = "/", + }); + return Results.Json(new { ok = true }); + }).RequireRateLimiting("login"); + app.MapPost("/api/auth/change-password", async (HttpContext ctx) => { using var sr = new StreamReader(ctx.Request.Body); diff --git a/server/wwwroot/index.html b/server/wwwroot/index.html index 9d91dcc..9f8d1b5 100644 --- a/server/wwwroot/index.html +++ b/server/wwwroot/index.html @@ -374,6 +374,21 @@ + + diff --git a/server/wwwroot/modules/auth.js b/server/wwwroot/modules/auth.js index 07edb5f..22bbe69 100644 --- a/server/wwwroot/modules/auth.js +++ b/server/wwwroot/modules/auth.js @@ -8,20 +8,42 @@ // own error messages and we want to surface them verbatim to the user. let overlayShown = false; -function showOverlay() { +async function showOverlay(stateOverride) { if (overlayShown) return; overlayShown = true; - const overlay = document.getElementById("loginOverlay"); - if (overlay) { - overlay.hidden = false; - document.getElementById("loginPassword")?.focus(); + let state = stateOverride; + if (!state) { + try { + const res = await fetch("/api/auth/state"); + if (res.ok) state = await res.json(); + } catch { /* network blip -- fall through to login */ } + } + state = state || { needsSetup: false }; + if (state.needsSetup) { + const overlay = document.getElementById("setupOverlay"); + if (overlay) { + overlay.hidden = false; + document.getElementById("setupPassword")?.focus(); + } + } else { + const overlay = document.getElementById("loginOverlay"); + if (overlay) { + overlay.hidden = false; + document.getElementById("loginPassword")?.focus(); + } } } export function setupAuth() { - document.addEventListener("authrequired", showOverlay); + document.addEventListener("authrequired", () => showOverlay()); setupLoginForm(); setupAccountPanel(); + setupSetupForm(); + // Eager state probe so the right overlay appears before any API call fails. + fetch("/api/auth/state") + .then(r => r.ok ? r.json() : null) + .then(state => { if (state && !state.authed) showOverlay(state); }) + .catch(() => { /* let authrequired handle it */ }); } function setupLoginForm() { @@ -59,6 +81,43 @@ function setupLoginForm() { input.addEventListener("keydown", e => { if (e.key === "Enter") tryLogin(); }); } +function setupSetupForm() { + const overlay = document.getElementById("setupOverlay"); + const pw1 = document.getElementById("setupPassword"); + const pw2 = document.getElementById("setupConfirm"); + const button = document.getElementById("setupSubmit"); + const errorEl = document.getElementById("setupError"); + if (!overlay || !pw1 || !pw2 || !button || !errorEl) return; + + async function trySetup() { + errorEl.textContent = ""; + if (pw1.value.length < 8) { errorEl.textContent = "Must be at least 8 characters."; pw1.select(); return; } + if (pw1.value !== pw2.value) { errorEl.textContent = "Passwords don't match."; pw2.select(); return; } + button.disabled = true; + try { + const res = await fetch("/api/auth/setup", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ password: pw1.value }), + }); + if (res.status === 429) { errorEl.textContent = "Too many attempts. Wait a minute."; return; } + const body = await res.json().catch(() => ({})); + if (!res.ok) { errorEl.textContent = body.error || `Error ${res.status}`; return; } + // Server has set the cookie and saved the password -- reload to enter + // the panel as a freshly-authed session. + location.reload(); + } catch (e) { + errorEl.textContent = e.message; + } finally { + button.disabled = false; + pw1.value = pw2.value = ""; + } + } + + button.addEventListener("click", trySetup); + pw2.addEventListener("keydown", e => { if (e.key === "Enter") trySetup(); }); +} + function setupAccountPanel() { const logoutBtn = document.getElementById("acctLogout"); const changeBtn = document.getElementById("acctChangePw");