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

@@ -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()
);