feat(api): local-password authentication backend

Add username+password login for Owner/Operator staff accounts on
op.console, the primary web login when Zero Trust is not in front of the
API. Three handlers form the whole surface: login mints a server-side
session cookie, logout revokes it idempotently, and change-password
re-verifies the current password before rotating the hash and clearing
must_change_password.

- Session cookies are HttpOnly+Secure+SameSite=Lax, host-only, stored
  server-side as a SHA-256 hash with a 12h TTL.
- Login is anti-enumeration: every failure runs a uniform bcrypt compare
  against a dummy hash and returns the same vague error.
- Credential-bearing writes require Content-Type: application/json,
  returning 415 otherwise, to close the cross-site form-POST forgery
  vector as a belt to the SameSite cookie.
- Local auth fails closed: login is rejected unless local_auth_enabled
  is set, so a Zero-Trust-only deployment never accepts a local password.
- Extend the users table with a nullable password_hash and
  must_change_password; staff are role=admin rows with a hash, players
  are role=user rows with hash NULL.
- /me now reports must_change_password so the panel can force a
  first-login change.

Covered by Go unit tests (handlers, content-type guard, anti-enumeration,
forced-change lockdown) and the OpenAPI route-parity gate.
This commit is contained in:
flyemoji committed 2026-06-27 04:22:45 +09:00
1 parent 58fa4b0af8
commit af14f02f38
17 files changed
+1370 -17

No files matched your search

+26 -1
View File
@@ -126,6 +126,14 @@ type apiRoute struct {
// handler). Internal-face routes never set it.
Admin bool
// AllowDuringPasswordChange opts a route OUT of the must_change_password
// lockdown (spec §B). The lockdown is default-deny: every authenticated route is
// fenced off for a staff principal that still owes a first-login password change
// EXCEPT the few that let it escape the state — change-password, logout, and the
// self-identity read /me. A new authenticated route is locked down unless it
// sets this, so forgetting the flag fails safe (closed), never open.
AllowDuringPasswordChange bool
h http.HandlerFunc
}
@@ -168,6 +176,15 @@ func (a *API) externalAPIRoutes() []apiRoute {
return []apiRoute{
{Method: "GET", Pattern: "/healthz", Public: true, h: a.handleHealthz},
// Local-password auth (spec §B), the op.console login surface. login/logout
// are Public (pre-session: a caller has no principal yet, and logout reads the
// cookie directly so it works even after expiry). change-password requires a
// live session and stays reachable while must_change_password is set
// (AllowDuringPasswordChange) so a forced first-login change can complete.
{Method: "POST", Pattern: "/api/v1/auth/login", Public: true, h: a.handleLogin},
{Method: "POST", Pattern: "/api/v1/auth/logout", Public: true, h: a.handleLogout},
{Method: "POST", Pattern: "/api/v1/auth/change-password", AllowDuringPasswordChange: true, h: a.handleChangePassword},
// App-auth tier: operations on your own servers (spec §14).
{Method: "POST", Pattern: "/api/v1/servers/{name}/wake", h: a.handleWake},
{Method: "POST", Pattern: "/api/v1/servers/{name}/stop", h: a.handleStop},
@@ -195,7 +212,9 @@ func (a *API) externalAPIRoutes() []apiRoute {
// every authenticated principal may read its OWN identity. is_admin is the
// server-computed Principal.IsAdmin() (Role + admin Access path), so the client
// never re-derives the graded-ZT rule; it remains UX truth, not enforcement.
{Method: "GET", Pattern: "/api/v1/me", h: a.handleMe},
// /me is exempt from the first-login lockdown so the panel can read its own
// identity (including must_change_password) to render the change-password card.
{Method: "GET", Pattern: "/api/v1/me", AllowDuringPasswordChange: true, h: a.handleMe},
{Method: "GET", Pattern: "/api/v1/me/servers", h: a.handleMyServers},
// World backups (spec §7, §466). Both are app-tier: GET /backups is scoped
// inside the handler (admin sees all; a user sees only worlds they formerly
@@ -279,6 +298,12 @@ func (a *API) buildFace(routes []apiRoute, guard func(http.Handler) http.Handler
if rt.Admin {
h = a.adminOnly(rt.h)
}
// Default-deny first-login lockdown (spec §B): wrap every authenticated route
// unless it explicitly opts out. The wrapper is nil-principal safe, so it is
// inert on the internal face (service-token callers carry no Principal).
if !rt.AllowDuringPasswordChange {
h = a.lockdownDuringPasswordChange(h)
}
auth.HandleFunc(pattern, h)
}
mux.Handle("/api/v1/", guard(auth))