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
@@ -73,6 +73,23 @@ func (a *API) adminOnly(next http.HandlerFunc) http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// lockdownDuringPasswordChange fences a staff principal that still owes a
|
||||
// first-login password change to the change-password surface (spec §B). It is the
|
||||
// default-deny half of the lockdown: buildFace wraps every authenticated route
|
||||
// with it except the AllowDuringPasswordChange opt-outs, so a half-onboarded
|
||||
// account can do nothing but change its password, log out, or read /me. It is
|
||||
// nil-principal safe (the internal face sets no Principal), so it passes such
|
||||
// requests straight through and only ever acts on the external face.
|
||||
func (a *API) lockdownDuringPasswordChange(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if p := principalFromContext(r.Context()); p != nil && p.MustChangePassword {
|
||||
writeError(w, r, errPasswordChangeRequired)
|
||||
return
|
||||
}
|
||||
next(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
// newRequestID returns a short random hex id. crypto/rand never fails on the
|
||||
// platforms we target; on the impossible error path we fall back to a constant
|
||||
// so a request still gets a (non-unique) id rather than crashing.
|
||||
|
||||
Reference in new issue
Block a user