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
@@ -2,12 +2,33 @@ package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"mime"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// maxBodyBytes caps request bodies; the API only accepts small JSON documents.
|
||||
const maxBodyBytes = 1 << 20 // 1 MiB
|
||||
|
||||
// requireJSONContentType rejects a request whose body is not declared
|
||||
// application/json, returning 415 before any decode. It guards the credential-bearing
|
||||
// auth writes (login, change-password) against a cross-site forgery: an HTML form can
|
||||
// only POST as application/x-www-form-urlencoded, multipart/form-data, or text/plain
|
||||
// — never JSON — and a cross-site fetch that forces application/json triggers a CORS
|
||||
// preflight this API never answers, so neither form can be forged off-origin. The
|
||||
// session cookie's SameSite=Lax already blocks the bearing of credentials cross-site;
|
||||
// this is the belt to that suspenders, and it costs a legitimate same-origin caller
|
||||
// nothing (the panel always sends application/json on a bodied request). Media-type
|
||||
// parameters (e.g. "; charset=utf-8") are ignored — only the type/subtype must match.
|
||||
func requireJSONContentType(r *http.Request) error {
|
||||
mt, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
|
||||
if err != nil || !strings.EqualFold(mt, "application/json") {
|
||||
return newError(http.StatusUnsupportedMediaType, "unsupported_media_type",
|
||||
"Content-Type must be application/json")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// decodeJSON strictly decodes a small request body into v, rejecting unknown
|
||||
// fields and trailing data so malformed callers fail fast with 400.
|
||||
func decodeJSON(w http.ResponseWriter, r *http.Request, v any) error {
|
||||
|
||||
Reference in new issue
Block a user