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
@@ -65,6 +65,32 @@ type BackupRecord struct {
|
||||
SizeBytes int64
|
||||
}
|
||||
|
||||
// StaffUser is the login-side projection of a users row that carries a password
|
||||
// (spec §B local-auth). Owner/Operator are role=admin rows WITH a bcrypt hash,
|
||||
// minted by `felis breakGlass`; players are role=user rows whose PasswordHash is
|
||||
// empty. It is loaded by username at login to verify the password and learn
|
||||
// whether a first-login change is still pending.
|
||||
type StaffUser struct {
|
||||
ID string
|
||||
Username string
|
||||
Email string
|
||||
Role string
|
||||
PasswordHash string
|
||||
MustChangePassword bool
|
||||
}
|
||||
|
||||
// SessionedUser is the projection resolved from a live session cookie: the
|
||||
// identity SessionAuth needs to build a Principal. It omits the password hash —
|
||||
// the session has already authenticated the caller — but carries the pending
|
||||
// first-login change flag so the lockdown middleware can fence a half-onboarded
|
||||
// staff account to the change-password surface.
|
||||
type SessionedUser struct {
|
||||
ID string
|
||||
Email string
|
||||
Role string
|
||||
MustChangePassword bool
|
||||
}
|
||||
|
||||
// Repo is the business-layer data access the API depends on. It is an interface
|
||||
// so handlers are tested against an in-memory fake; the Postgres implementation
|
||||
// (pgRepo) is integration-tested only — it requires a live database.
|
||||
@@ -139,4 +165,50 @@ type Repo interface {
|
||||
SeedServer(ctx context.Context, name, subdomain string) error
|
||||
// Audit appends one audit row.
|
||||
Audit(ctx context.Context, e AuditEntry) error
|
||||
|
||||
// ---- local-password auth (spec §B) ----
|
||||
|
||||
// UserByUsername loads the login projection of a staff account by its unique
|
||||
// username, or ErrNotFound. The caller compares PasswordHash itself so the
|
||||
// anti-enumeration dummy-hash compare runs even on a miss; a player row (NULL
|
||||
// password_hash → empty PasswordHash) is returned too and is rejected by the
|
||||
// caller's hash compare, never by leaking "no such user".
|
||||
UserByUsername(ctx context.Context, username string) (*StaffUser, error)
|
||||
// UserByID loads the same staff projection by user id, or ErrNotFound. The
|
||||
// change-password flow uses it to re-verify the caller's current password: the
|
||||
// session yields a user id, not a username, so this is the id-keyed counterpart
|
||||
// of UserByUsername.
|
||||
UserByID(ctx context.Context, id string) (*StaffUser, error)
|
||||
// UpsertOwner creates or resets the single Owner account direct-to-Postgres
|
||||
// (the `felis breakGlass` first-run / reset-password path). role is forced to
|
||||
// 'admin' and must_change_password to mustChange; on a username conflict the
|
||||
// existing row's email, hash and flag are overwritten so a reset is idempotent.
|
||||
UpsertOwner(ctx context.Context, id, username, email, passwordHash string, mustChange bool) error
|
||||
// SetPassword stores a new bcrypt hash for a user and clears
|
||||
// must_change_password (the panel change-password flow). ErrNotFound when no
|
||||
// row matches, so a stale session cannot silently no-op a password change.
|
||||
SetPassword(ctx context.Context, userID, passwordHash string) error
|
||||
// CreateSession records a minted session: the sha-256 of the opaque cookie
|
||||
// value, its owner, and its expiry (spec §B sessions). Only the hash is stored,
|
||||
// mirroring tokens, so a database read never yields a usable cookie.
|
||||
CreateSession(ctx context.Context, tokenHash, userID string, expiresAt time.Time) error
|
||||
// SessionUser resolves a live (unrevoked, unexpired at now) session hash to its
|
||||
// user, or ErrNotFound. It is the cookie half of SessionAuth.
|
||||
SessionUser(ctx context.Context, tokenHash string, now time.Time) (*SessionedUser, error)
|
||||
// RevokeSession marks a session revoked (logout). It is idempotent: revoking an
|
||||
// absent or already-revoked session is not an error.
|
||||
RevokeSession(ctx context.Context, tokenHash string) error
|
||||
// RevokeUserSessionsExcept revokes every live session of a user except the one
|
||||
// whose hash is keepTokenHash. The change-password flow calls it so a successful
|
||||
// password change logs out the account's other devices but not the current one.
|
||||
RevokeUserSessionsExcept(ctx context.Context, userID, keepTokenHash string) error
|
||||
|
||||
// ---- runtime platform settings (spec §B platform_settings) ----
|
||||
|
||||
// GetSetting reads a runtime setting's raw jsonb value, or ErrNotFound when the
|
||||
// key is absent. The live API reads these per-request so the break-glass TUI can
|
||||
// flip toggles (e.g. local_auth_enabled) direct-to-DB without rolling the pod.
|
||||
GetSetting(ctx context.Context, key string) ([]byte, error)
|
||||
// SetSetting upserts a runtime setting's raw jsonb value by key.
|
||||
SetSetting(ctx context.Context, key string, value []byte) error
|
||||
}
|
||||
Reference in new issue
Block a user