feat(auth): first-run password setup via web panel

When `webPassword` is null and the daemon starts headless (systemd, piped
SSH), no longer auto-generate a random password. Instead:
  - Boot normally with the gate denying everything except /api/auth/setup
  - Panel UI eagerly probes new /api/auth/state on load and renders a
    first-run setup overlay (password + confirm) when needsSetup=true
  - POST /api/auth/setup writes the chosen password and issues the auth
    cookie in the same response, so the operator lands logged in

Interactive TTY behaviour (prompt at the console) is unchanged. The gate
middleware is now registered unconditionally so first-run mode is still
locked-down instead of wide-open.
This commit is contained in:
Matt Sijbers
2026-05-18 23:20:27 +01:00
parent 372b5090cd
commit bf53b65706
3 changed files with 175 additions and 42 deletions
+95 -36
View File
@@ -36,15 +36,14 @@ public sealed class RunCommand : AsyncCommand<BaseCommandSettings>
{ {
if (Console.IsInputRedirected) if (Console.IsInputRedirected)
{ {
var generated = Convert.ToBase64String(System.Security.Cryptography.RandomNumberGenerator.GetBytes(18)) // Non-interactive (systemd, piped SSH): don't auto-generate. The
.Replace("+", "-").Replace("/", "_").Replace("=", ""); // gate below leaves /api/auth/setup open so the operator can set
config.WebPassword = generated; // a password on first visit to the panel; everything else stays
config.Save(configPath); // locked until they do.
AnsiConsole.MarkupLine(""); AnsiConsole.MarkupLine("");
AnsiConsole.MarkupLine("[bold yellow]═══════════ ADMIN PASSWORD GENERATED ═══════════[/]"); AnsiConsole.MarkupLine("[bold yellow]═══ FIRST-RUN: admin password not set ═══[/]");
AnsiConsole.MarkupLine($"[bold]Password:[/] [yellow]{generated}[/]"); AnsiConsole.MarkupLine("[grey]Open the web panel in your browser and you'll be walked through[/]");
AnsiConsole.MarkupLine($"[grey]Saved to {configPath}. Use the [/][bold]Change password[/][grey] button in the panel to set your own.[/]"); AnsiConsole.MarkupLine("[grey]setting one. Everything except /api/auth/setup is 401-gated until then.[/]");
AnsiConsole.MarkupLine("[bold yellow]════════════════════════════════════════════════[/]");
AnsiConsole.MarkupLine(""); AnsiConsole.MarkupLine("");
} }
else else
@@ -208,36 +207,49 @@ public sealed class RunCommand : AsyncCommand<BaseCommandSettings>
// those handlers short-circuit the pipeline and serve map tiles to anyone. // 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 // The UI shell at / is intentionally above this (login page must be reachable
// unauthenticated); all sensitive operations go through /api/* which is gated. // 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; if (path.StartsWithSegments("/api/auth/setup")) { await next(); return; }
// Only gate /api/* and /map/* -- everything else is the public UI shell. ctx.Response.StatusCode = 401;
var gated = path.StartsWithSegments("/api") || path.StartsWithSegments("/map"); await ctx.Response.WriteAsync("Not yet configured -- visit / in a browser to set an admin password.");
if (!gated) { await next(); return; } 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 // Normal auth check (cookie or X-Brass-Sigil-Auth header).
// separately to throttle abuse. var pwBytes = System.Text.Encoding.UTF8.GetBytes(config.WebPassword);
if (path.StartsWithSegments("/api/whitelist/request")) { await next(); return; } var auth = ctx.Request.Headers["X-Brass-Sigil-Auth"].ToString();
if (path.StartsWithSegments("/api/whitelist/status")) { await next(); return; } if (string.IsNullOrEmpty(auth))
var pwBytes = System.Text.Encoding.UTF8.GetBytes(config.WebPassword); auth = ctx.Request.Cookies["brass-sigil-auth"] ?? "";
var auth = ctx.Request.Headers["X-Brass-Sigil-Auth"].ToString(); var authBytes = System.Text.Encoding.UTF8.GetBytes(auth);
if (string.IsNullOrEmpty(auth)) var ok = authBytes.Length == pwBytes.Length
auth = ctx.Request.Cookies["brass-sigil-auth"] ?? ""; && System.Security.Cryptography.CryptographicOperations.FixedTimeEquals(authBytes, pwBytes);
var authBytes = System.Text.Encoding.UTF8.GetBytes(auth); if (!ok)
var ok = authBytes.Length == pwBytes.Length {
&& System.Security.Cryptography.CryptographicOperations.FixedTimeEquals(authBytes, pwBytes); ctx.Response.StatusCode = 401;
if (!ok) await ctx.Response.WriteAsync("Unauthorized");
{ return;
ctx.Response.StatusCode = 401; }
await ctx.Response.WriteAsync("Unauthorized"); await next();
return; });
}
await next();
});
}
// /map/.../live/players.json is generated dynamically per-request from RCON // /map/.../live/players.json is generated dynamically per-request from RCON
// -- pull-based, so RCON only fires while a browser tab is actually polling. // -- pull-based, so RCON only fires while a browser tab is actually polling.
@@ -390,6 +402,53 @@ public sealed class RunCommand : AsyncCommand<BaseCommandSettings>
return Results.Json(new { ok = true }); 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<PasswordPayload>(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) => app.MapPost("/api/auth/change-password", async (HttpContext ctx) =>
{ {
using var sr = new StreamReader(ctx.Request.Body); using var sr = new StreamReader(ctx.Request.Body);
+15
View File
@@ -374,6 +374,21 @@
</div> </div>
</div> </div>
<div id="setupOverlay" class="login-overlay" hidden>
<div class="login-box">
<h2>Brass &amp; Sigil</h2>
<p>First-run setup. Pick an admin password &mdash; this is the credential you'll use to sign in from now on.</p>
<div class="input-wrap">
<input id="setupPassword" type="password" autocomplete="new-password" placeholder="New password (min 8 chars)" />
</div>
<div class="input-wrap">
<input id="setupConfirm" type="password" autocomplete="new-password" placeholder="Confirm password" />
</div>
<button id="setupSubmit">Set password &amp; continue</button>
<div id="setupError" class="login-error"></div>
</div>
</div>
<script type="module" src="/app.js"></script> <script type="module" src="/app.js"></script>
</body> </body>
</html> </html>
+65 -6
View File
@@ -8,20 +8,42 @@
// own error messages and we want to surface them verbatim to the user. // own error messages and we want to surface them verbatim to the user.
let overlayShown = false; let overlayShown = false;
function showOverlay() { async function showOverlay(stateOverride) {
if (overlayShown) return; if (overlayShown) return;
overlayShown = true; overlayShown = true;
const overlay = document.getElementById("loginOverlay"); let state = stateOverride;
if (overlay) { if (!state) {
overlay.hidden = false; try {
document.getElementById("loginPassword")?.focus(); 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() { export function setupAuth() {
document.addEventListener("authrequired", showOverlay); document.addEventListener("authrequired", () => showOverlay());
setupLoginForm(); setupLoginForm();
setupAccountPanel(); 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() { function setupLoginForm() {
@@ -59,6 +81,43 @@ function setupLoginForm() {
input.addEventListener("keydown", e => { if (e.key === "Enter") tryLogin(); }); 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() { function setupAccountPanel() {
const logoutBtn = document.getElementById("acctLogout"); const logoutBtn = document.getElementById("acctLogout");
const changeBtn = document.getElementById("acctChangePw"); const changeBtn = document.getElementById("acctChangePw");