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:
17 files changed
+1370
-17
No files matched your search
+26
-1
@@ -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))
|
||||
|
||||
Reference in new issue
Block a user