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

+13
View File
@@ -49,6 +49,19 @@ var (
errUnauthorized = newError(http.StatusUnauthorized, "unauthorized", "authentication required")
errForbidden = newError(http.StatusForbidden, "forbidden", "not permitted")
errBadRequest = newError(http.StatusBadRequest, "bad_request", "invalid request")
// errInvalidCredentials is the single, deliberately vague answer to any failed
// local-password login (spec §B): unknown username, player row, or wrong
// password all collapse to it so the response never reveals which usernames
// carry a password. The anti-enumeration dummy-hash compare keeps the timing
// uniform alongside it (handlers_auth.go).
errInvalidCredentials = newError(http.StatusUnauthorized, "invalid_credentials", "invalid username or password")
// errPasswordChangeRequired fences a staff principal that still owes a
// first-login password change to the change-password surface. The lockdown
// middleware returns it from every authenticated route except the opt-out set
// (change-password / logout / me), so a half-onboarded account cannot act until
// it sets its own password.
errPasswordChangeRequired = newError(http.StatusForbidden, "password_change_required",
"change your password before continuing")
)
// writeJSON writes v as an indented JSON body with the given status.