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
@@ -0,0 +1,33 @@
|
||||
-- Phase B local-password auth + sessions + runtime settings.
|
||||
-- The web (op.console) authenticates Owner/Operator via username+password;
|
||||
-- `felis breakGlass` (the sudo-only emergency TUI) mints/resets these directly
|
||||
-- against Postgres so recovery works even when the API is down. Players keep
|
||||
-- password_hash NULL (account-link identity only — see account_links).
|
||||
|
||||
-- Owner/Operator credentials live on the existing users row, not a separate
|
||||
-- table: role=admin WITH a hash is staff; role=user with NULL hash is a player.
|
||||
ALTER TABLE users
|
||||
ADD COLUMN password_hash text, -- bcrypt; NULL for link-only players
|
||||
ADD COLUMN must_change_password boolean NOT NULL DEFAULT false; -- force change-on-first-login
|
||||
|
||||
-- Server-set httpOnly session cookies. The remote path authenticates with a
|
||||
-- stateless Cloudflare-Access JWT (no cookie); local-password auth needs its
|
||||
-- own session. Store only the hash of the opaque cookie value, mirroring tokens.
|
||||
CREATE TABLE sessions (
|
||||
token_hash text PRIMARY KEY, -- sha-256(cookie value)
|
||||
user_id text NOT NULL REFERENCES users(id),
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
expires_at timestamptz NOT NULL,
|
||||
revoked_at timestamptz -- non-NULL once invalidated
|
||||
);
|
||||
CREATE INDEX sessions_user_id_idx ON sessions (user_id);
|
||||
|
||||
-- Runtime security/platform settings as jsonb. The live API reads these from
|
||||
-- Postgres per-request (NOT the read-only felis-config Secret), so the
|
||||
-- break-glass TUI can flip toggles (e.g. local_auth_enabled) direct-to-DB
|
||||
-- without patching the Secret and rolling the pod.
|
||||
CREATE TABLE platform_settings (
|
||||
key text PRIMARY KEY, -- e.g. 'local_auth_enabled'
|
||||
value jsonb NOT NULL,
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
Reference in new issue
Block a user