feat(auth): migrate console login to passwordless

Replace console password auth with a passwordless surface — the pre-session
login doors plus an identifier-first discovery endpoint — and remove the
password paths.

- Login doors (Public, pre-session): email-OTP, passkey assertion, op.console
  login with in-game approval, and setup-token redeem.
- /api/v1/auth/options: identifier-first discovery reporting which console
  methods an email can use. The single sanctioned existence oracle; methods
  are computed with no role branch, so staff and player accounts in the same
  credential state return byte-identical bodies (staffness invisible by
  construction).
- Remove password auth: drop StaffUser.PasswordHash and the /auth/login,
  /auth/change-password and /users/{id}/reset-password endpoints (and test).
- Data layer: UserByEmail, verified-email uniqueness, setup-token store
  (migration 0012).
- Reconcile docs/openapi.yaml with the served surface; the method/path/face/
  tier parity gate (TestOpenAPIMatchesServedRoutes) passes.
- felis TUI: in-game MC bind, owner/break-glass OP provisioning, version.
- Velocity /felis command suite.

Consolidates the accumulated backend migration work; the frontend (panel/)
is left untouched. Full Go tree green on WSL (go build ./... && go test ./...).
This commit is contained in:
flyemoji committed 2026-07-04 21:47:12 +09:00
1 parent 627883e89a
commit 0c1cc598c1
46 files changed
+5554 -1651

No files matched your search

+74 -59
View File
@@ -104,17 +104,6 @@ type API struct {
// a positive value. Enforced via withinRunningCap on the wake path.
MaxRunningServers int
// MaxConcurrentLogins bounds how many password logins may run their (CPU-costly)
// bcrypt compare at once on the public /auth/login route. bcrypt is deliberately
// expensive and the anti-enumeration path runs a full compare on EVERY request,
// so an unbounded flood of concurrent logins would pin every core; capping the
// simultaneous compares sheds the excess with a cheap 429 instead. Zero — the
// default — disables the cap (same "zero disables" idiom as WakeCooldown /
// MaxRunningServers); cmd/felis wires a positive value. It is a concurrency cap,
// NOT a per-account lockout, so it never fences a break-glass admin out of the
// one account they need. Enforced via loginLimiter in handleLogin.
MaxConcurrentLogins int
// MaxStreamsPerPrincipal caps how many concurrent Server-Sent Event streams
// (console + build-log relays, spec §8) a single principal may hold open at once.
// Each relay blocks for the lifetime of a client's attachment and, under a stalled
@@ -135,9 +124,6 @@ type API struct {
otpCooldownOnce sync.Once
otpCooldown *cooldownLimiter
loginCapOnce sync.Once
loginCap *concurrencyLimiter
streamCapOnce sync.Once
streamCap *streamLimiter
}
@@ -169,16 +155,6 @@ func (a *API) otpLimiter() *cooldownLimiter {
return a.otpCooldown
}
// loginLimiter lazily builds the login bcrypt concurrency cap bound to
// MaxConcurrentLogins. A zero cap yields a disabled limiter that admits every
// caller, so a deployment (or test) that leaves it unset pays nothing.
func (a *API) loginLimiter() *concurrencyLimiter {
a.loginCapOnce.Do(func() {
a.loginCap = newConcurrencyLimiter(a.MaxConcurrentLogins)
})
return a.loginCap
}
// streamGate lazily builds the per-principal SSE stream cap bound to
// MaxStreamsPerPrincipal. A zero cap yields a disabled limiter that admits every
// stream, so a deployment (or test) that leaves it unset pays nothing.
@@ -231,13 +207,9 @@ type apiRoute struct {
// on one route is harmless but redundant — an owner passes both.
Owner bool
// AllowDuringPasswordChange opts a route OUT of the must_change_password
// lockdown (spec §B). The lockdown is default-deny: every authenticated route is
// fenced off for a staff principal that still owes a first-login password change
// EXCEPT the few that let it escape the state — change-password, logout, and the
// self-identity read /me. A new authenticated route is locked down unless it
// sets this, so forgetting the flag fails safe (closed), never open.
AllowDuringPasswordChange bool
// SetupAllowed marks a route as reachable during the setup-lockdown: a session
// whose EmailVerified is false is restricted to these routes only.
SetupAllowed bool
h http.HandlerFunc
}
@@ -283,6 +255,12 @@ func (a *API) internalAPIRoutes() []apiRoute {
// Mojang player (same name, different UUID) always passes.
{Method: "POST", Pattern: "/api/v1/internal/player/reclaim", h: a.handleReclaimUsername},
{Method: "GET", Pattern: "/api/v1/internal/player/blacklist/{mc_uuid}", h: a.handleCheckBlacklist},
// Op-login (passwordless console login): an in-game op requests a login that
// the web owner/admin approves, then redeems for a session. Internal face
// carries the pending queue and the approve action (service-token auth, no
// Principal); the external face carries the start/status/finish the op drives.
{Method: "GET", Pattern: "/api/v1/internal/op-login/pending", h: a.handleOpLoginPending},
{Method: "POST", Pattern: "/api/v1/internal/op-login/{id}/approve", h: a.handleOpLoginApprove},
}
}
@@ -294,14 +272,26 @@ func (a *API) externalAPIRoutes() []apiRoute {
return []apiRoute{
{Method: "GET", Pattern: "/healthz", Public: true, h: a.handleHealthz},
// Local-password auth (spec §B), the op.console login surface. login/logout
// are Public (pre-session: a caller has no principal yet, and logout reads the
// cookie directly so it works even after expiry). change-password requires a
// live session and stays reachable while must_change_password is set
// (AllowDuringPasswordChange) so a forced first-login change can complete.
{Method: "POST", Pattern: "/api/v1/auth/login", Public: true, h: a.handleLogin},
// logout is Public: it reads the cookie directly so it works even after
// expiry. The rest of the auth surface (identifier-first options discovery,
// setup redeem/status, passkey login, email OTP login, op-login) is Public and
// pre-session: a caller has no principal yet.
{Method: "POST", Pattern: "/api/v1/auth/logout", Public: true, h: a.handleLogout},
{Method: "POST", Pattern: "/api/v1/auth/change-password", AllowDuringPasswordChange: true, h: a.handleChangePassword},
// Identifier-first discovery (#71): given an email, report which console methods
// it can use so the SPA prompts for the right authenticator. The deliberate
// counter-slice to the anti-enumeration doors — the ONE sanctioned place existence
// is disclosed — but it never reveals staffness (methods computed with no role
// branch, so a staff and a player address in the same state are indistinguishable).
{Method: "POST", Pattern: "/api/v1/auth/options", Public: true, h: a.handleAuthOptions},
{Method: "POST", Pattern: "/api/v1/auth/setup/redeem", Public: true, h: a.handleSetupRedeem},
{Method: "GET", Pattern: "/api/v1/auth/setup/status", SetupAllowed: true, h: a.handleSetupStatus},
{Method: "POST", Pattern: "/api/v1/auth/passkey/login/begin", Public: true, h: a.handlePasskeyLoginBegin},
{Method: "POST", Pattern: "/api/v1/auth/passkey/login/finish", Public: true, h: a.handlePasskeyLoginFinish},
{Method: "POST", Pattern: "/api/v1/auth/email/start", Public: true, h: a.handleLoginEmailStart},
{Method: "POST", Pattern: "/api/v1/auth/email/verify", Public: true, h: a.handleLoginEmailVerify},
{Method: "POST", Pattern: "/api/v1/auth/op-login/start", Public: true, h: a.handleOpLoginStart},
{Method: "GET", Pattern: "/api/v1/auth/op-login/status/{id}", Public: true, h: a.handleOpLoginStatus},
{Method: "POST", Pattern: "/api/v1/auth/op-login/finish", Public: true, h: a.handleOpLoginFinish},
// Player-console bootstrap (console-tier access model): the account-less
// player's door into console.<root_domain>. Public — like login there is no prior
// principal — and session-minting, but the artifact it consumes is a one-time
@@ -341,10 +331,10 @@ func (a *API) externalAPIRoutes() []apiRoute {
// every authenticated principal may read its OWN identity. is_admin is the
// server-computed Principal.IsAdmin() (Role + admin Access path), so the client
// never re-derives the graded-ZT rule; it remains UX truth, not enforcement.
// /me is exempt from the first-login lockdown so the panel can read its own
// identity (including must_change_password) to render the change-password card.
{Method: "GET", Pattern: "/api/v1/me", AllowDuringPasswordChange: true, h: a.handleMe},
{Method: "GET", Pattern: "/api/v1/me/servers", h: a.handleMyServers},
// /me is reachable during setup-lockdown so the panel can read its own
// identity (including email_verified) to drive the setup flow.
{Method: "GET", Pattern: "/api/v1/me", SetupAllowed: true, h: a.handleMe},
{Method: "GET", Pattern: "/api/v1/me/servers", SetupAllowed: true, h: a.handleMyServers},
// World backups (spec §7, §466). Both are app-tier: GET /backups is scoped
// inside the handler (admin sees all; a user sees only worlds they formerly
// owned), and restore is gated by owner-or-admin PLUS a former-owner match, so
@@ -355,26 +345,26 @@ func (a *API) externalAPIRoutes() []apiRoute {
// pointer handleClaim's 412 emits), /verify consumes the in-game code and binds
// the account. App-tier, not admin — linking your own account is an ordinary
// authenticated operation.
{Method: "POST", Pattern: "/api/v1/account/link/start", h: a.handleLinkStart},
{Method: "POST", Pattern: "/api/v1/account/link/verify", h: a.handleLinkVerify},
{Method: "POST", Pattern: "/api/v1/account/link/start", SetupAllowed: true, h: a.handleLinkStart},
{Method: "POST", Pattern: "/api/v1/account/link/verify", SetupAllowed: true, h: a.handleLinkVerify},
// Email verification (spec §B2 onboarding), web side: /start mints+delivers a
// one-time code for the caller's chosen address, /verify redeems it and flips
// email_verified. App-tier like the link routes — proving control of your own
// email is an ordinary authenticated operation, scoped to the principal.
{Method: "POST", Pattern: "/api/v1/account/email/start", h: a.handleEmailOTPStart},
{Method: "POST", Pattern: "/api/v1/account/email/verify", h: a.handleEmailOTPVerify},
{Method: "POST", Pattern: "/api/v1/account/email/start", SetupAllowed: true, h: a.handleEmailOTPStart},
{Method: "POST", Pattern: "/api/v1/account/email/verify", SetupAllowed: true, h: a.handleEmailOTPVerify},
// Passkey enrollment (spec §14 WebAuthn / Phase 6 bind), web side: /register/begin
// mints a credential-creation challenge for the caller, /register/finish verifies
// the authenticator's attestation and binds the passkey, and the credentials
// collection lists and unbinds the caller's OWN passkeys. App-tier like the email
// routes — binding a passkey to your own account is an ordinary authenticated
// operation, scoped entirely to the principal (the body never names a user). This
// is enrollment only; passkey LOGIN/assertion is a deferred slice (see migration
// 0007 and handlers_passkey.go).
{Method: "POST", Pattern: "/api/v1/account/passkey/register/begin", h: a.handlePasskeyRegisterBegin},
{Method: "POST", Pattern: "/api/v1/account/passkey/register/finish", h: a.handlePasskeyRegisterFinish},
{Method: "GET", Pattern: "/api/v1/account/passkey/credentials", h: a.handlePasskeyList},
{Method: "DELETE", Pattern: "/api/v1/account/passkey/credentials/{id}", h: a.handlePasskeyDelete},
// is the ENROLLMENT side; the passkey LOGIN/assertion door is the Public,
// pre-session /api/v1/auth/passkey/login/{begin,finish} pair above.
{Method: "POST", Pattern: "/api/v1/account/passkey/register/begin", SetupAllowed: true, h: a.handlePasskeyRegisterBegin},
{Method: "POST", Pattern: "/api/v1/account/passkey/register/finish", SetupAllowed: true, h: a.handlePasskeyRegisterFinish},
{Method: "GET", Pattern: "/api/v1/account/passkey/credentials", SetupAllowed: true, h: a.handlePasskeyList},
{Method: "DELETE", Pattern: "/api/v1/account/passkey/credentials/{id}", SetupAllowed: true, h: a.handlePasskeyDelete},
// Modpack submission (user-directed lane over §16), user side: a user files an upload for review
// and lists their own. App-tier — the submitter and the "my uploads" scope are
// both taken from the principal, never the body, so an ordinary authenticated
@@ -431,7 +421,6 @@ func (a *API) externalAPIRoutes() []apiRoute {
{Method: "PATCH", Pattern: "/api/v1/users/{id}", Owner: true, h: a.handlePatchUser},
{Method: "DELETE", Pattern: "/api/v1/users/{id}", Owner: true, h: a.handleDeleteUser},
{Method: "POST", Pattern: "/api/v1/users/{id}/disable", Owner: true, h: a.handleDisableUser},
{Method: "POST", Pattern: "/api/v1/users/{id}/reset-password", Owner: true, h: a.handleResetPassword},
{Method: "GET", Pattern: "/api/v1/users/{id}/quotas", Owner: true, h: a.handleGetQuotas},
{Method: "PUT", Pattern: "/api/v1/users/{id}/quotas", Owner: true, h: a.handleSetQuotas},
{Method: "GET", Pattern: "/api/v1/users/{id}/sessions", Owner: true, h: a.handleListUserSessions},
@@ -477,15 +466,22 @@ func (a *API) buildFace(routes []apiRoute, guard func(http.Handler) http.Handler
if rt.Admin {
h = a.adminOnly(rt.h)
}
// Default-deny first-login lockdown (spec §B): wrap every authenticated route
// unless it explicitly opts out. The wrapper is nil-principal safe, so it is
// inert on the internal face (service-token callers carry no Principal).
if !rt.AllowDuringPasswordChange {
h = a.lockdownDuringPasswordChange(h)
// Default-deny setup-lockdown: wrap every authenticated route unless it
// explicitly opts out. The wrapper is nil-principal safe, so it is inert on
// the internal face (service-token callers carry no Principal).
if !rt.SetupAllowed {
h = a.requireEmailVerified(h)
}
auth.HandleFunc(pattern, h)
}
mux.Handle("/api/v1/", guard(auth))
guarded := guard(auth)
mux.Handle("/api/v1/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if _, pattern := auth.Handler(r); pattern == "" {
http.NotFound(w, r)
return
}
guarded.ServeHTTP(w, r)
}))
return a.baseChain(mux)
}
@@ -494,6 +490,25 @@ func (a *API) baseChain(h http.Handler) http.Handler {
return withRequestID(withRecover(h))
}
// requireEmailVerified fences an authenticated route behind the setup-lockdown:
// a session whose EmailVerified is false (a freshly-onboarded principal that has
// not yet proved control of its email) is restricted to SetupAllowed routes only.
// The wrapper is nil-principal safe, so it is inert on the internal face
// (service-token callers carry no Principal) and on the external face's admin
// Zero-Trust paths (those carry an IsAdmin/IsOwner principal that has already
// passed email verification at account creation).
func (a *API) requireEmailVerified(h http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
p := principalFromContext(r.Context())
if p != nil && p.ViaSession && !p.EmailVerified {
writeError(w, r, newError(http.StatusForbidden, "setup_required",
"email verification is required before this action is available"))
return
}
h(w, r)
}
}
// ---- request context plumbing ----
type ctxKey int
+230 -43
View File
@@ -61,6 +61,12 @@ type fakeRepo struct {
// player email OTPs (spec §B2). Keyed by row id; the verify path scans for the
// newest live (user, purpose) just as the PG query does.
otps map[string]*fakeEmailOTP
// op-login requests (spec §B op-login). opLogins mirrors op_login_requests keyed
// by id; the in-game approve/finish paths mutate status/consumed in place, and
// tests plant rows directly to drive the status/finish/pending-list paths.
opLogins map[string]*fakeOpLogin
// setup tokens (spec §B setup)
setupTokens map[string]fakeSetupToken
// username-collision reclaim (spec §B3). blacklist mirrors username_blacklist
// (mc_uuid -> barred), holds mirrors player_data_holds keyed by the held
// (squatter) mc_uuid — both keyed by UUID, matching the PG UNIQUE(mc_uuid)
@@ -141,6 +147,29 @@ type fakeLinkCode struct {
expiresAt time.Time
}
// fakeOpLogin mirrors an op_login_requests row (spec §B op-login) at the granularity
// the verifiable layer exercises: status ('pending'|'approved'|'denied') is the
// projection of (approved_at, denied_at) the handler's status/finish gates read,
// consumed mirrors consumed_at (the single-use guard), and createdAt orders the
// pending list oldest-first (the PG ORDER BY created_at).
type fakeOpLogin struct {
id string
userID string
email string
status string
consumed bool
expiresAt time.Time
createdAt time.Time
}
// fakeSetupToken mirrors a setup_tokens row (spec §B setup): a one-time
// lockdown-enrollment token. ConsumedAt is zero until the /setup flow redeems it.
type fakeSetupToken struct {
TokenHash, UserID string
ExpiresAt time.Time
ConsumedAt time.Time
}
func newFakeRepo() *fakeRepo {
return &fakeRepo{
bySub: map[string]*ServerRecord{}, byName: map[string]*ServerRecord{},
@@ -151,17 +180,19 @@ func newFakeRepo() *fakeRepo {
claimOK: map[string]bool{},
seeded: map[string]bool{}, aliases: map[string]string{},
linkCodes: map[string]fakeLinkCode{}, links: map[string]string{},
linkAuthSource: map[string]string{},
staff: map[string]*StaffUser{},
sessions: map[string]*fakeSession{},
settings: map[string][]byte{},
otps: map[string]*fakeEmailOTP{},
blacklist: map[string]bool{},
holds: map[string]fakeDataHold{},
passkeyCreds: map[string]PasskeyCredential{},
passkeyChallenges: map[string]*fakePasskeyChallenge{},
fakeQuotas: map[string]*QuotaView{},
}
linkAuthSource: map[string]string{},
staff: map[string]*StaffUser{},
sessions: map[string]*fakeSession{},
settings: map[string][]byte{},
otps: map[string]*fakeEmailOTP{},
opLogins: map[string]*fakeOpLogin{},
setupTokens: map[string]fakeSetupToken{},
blacklist: map[string]bool{},
holds: map[string]fakeDataHold{},
passkeyCreds: map[string]PasskeyCredential{},
passkeyChallenges: map[string]*fakePasskeyChallenge{},
fakeQuotas: map[string]*QuotaView{},
}
}
func (f *fakeRepo) ServerBySubdomain(_ context.Context, s string) (*ServerRecord, error) {
@@ -375,14 +406,19 @@ func (f *fakeRepo) DeleteAllPasskeyCredentialsForUser(_ context.Context, userID
}
// fakePasskeyVerifier is the hermetic PasskeyVerifier: it performs no real attestation
// crypto, so it exercises the enrollment STATE MACHINE (challenge persistence, consume,
// conflict, audit) without go-webauthn. BeginRegistration returns a fixed options blob
// and an opaque session marker; FinishRegistration returns the credential the test
// preloaded, or a forced error when failErr is set (to drive the 400 path).
// or assertion crypto, so it exercises the enrollment AND login STATE MACHINES (challenge
// persistence, consume, conflict, audit, session mint) without go-webauthn.
// BeginRegistration/BeginLogin return a fixed options blob and an opaque session marker;
// FinishRegistration returns the credential the test preloaded and FinishLogin the
// assertion it preloaded, or a forced error when failErr is set (to drive the finish 400
// path). beginLoginErr drives BeginLogin's own failure branch — a user with no assertable
// credential — which the login-begin handler maps to passkey_login_failed.
type fakePasskeyVerifier struct {
options json.RawMessage
credential VerifiedCredential
failErr error
options json.RawMessage
credential VerifiedCredential
assertion VerifiedAssertion
failErr error
beginLoginErr error
// lastUser/lastSession capture what the handler passed, so a test can assert the
// stashed SessionData round-trips and the existing credentials reach the verifier.
lastUser PasskeyUser
@@ -406,6 +442,28 @@ func (v *fakePasskeyVerifier) FinishRegistration(user PasskeyUser, sessionData [
}
return v.credential, nil
}
func (v *fakePasskeyVerifier) BeginLogin(user PasskeyUser) (json.RawMessage, []byte, error) {
v.lastUser = user
if v.beginLoginErr != nil {
return nil, nil, v.beginLoginErr
}
opts := v.options
if opts == nil {
opts = json.RawMessage(`{"publicKey":{"challenge":"YXNzZXJ0"}}`)
}
return opts, []byte("login-session:" + user.ID), nil
}
func (v *fakePasskeyVerifier) FinishLogin(user PasskeyUser, sessionData []byte, _ io.Reader) (VerifiedAssertion, error) {
v.lastUser = user
v.lastSession = sessionData
if v.failErr != nil {
return VerifiedAssertion{}, v.failErr
}
return v.assertion, nil
}
func (f *fakeRepo) UserInAllowlist(_ context.Context, n, u string) (bool, error) {
return f.allowlist[n][u], nil
}
@@ -438,7 +496,7 @@ func (f *fakeRepo) IsUsernameBlacklisted(_ context.Context, mcUUID string) (bool
// IsProtectedAdminLink mirrors PGRepo's JOIN of account_links to users: linked,
// auth_source 'thirdparty', and the linked user an admin — no password-hash test, so
// an SSO Operator (role='admin', empty PasswordHash) is protected like any other.
// an SSO Operator (role='admin', with no password) is protected like any other.
func (f *fakeRepo) IsProtectedAdminLink(_ context.Context, mcUUID string) (bool, error) {
userID, ok := f.links[mcUUID]
if !ok || f.linkAuthSource[mcUUID] != authSourceThirdParty {
@@ -569,28 +627,17 @@ func (f *fakeRepo) UserByID(_ context.Context, id string) (*StaffUser, error) {
}
return nil, ErrNotFound
}
func (f *fakeRepo) UpsertOwner(_ context.Context, id, username, email, passwordHash string, mustChange bool) error {
func (f *fakeRepo) UpsertOwner(_ context.Context, id, username, email string) error {
// Mirror PG ON CONFLICT (username): preserve the existing id so live sessions
// survive a password reset.
// survive a re-bootstrap.
if existing, ok := f.staff[username]; ok {
id = existing.ID
}
f.staff[username] = &StaffUser{
ID: id, Username: username, Email: email, Role: "admin",
PasswordHash: passwordHash, MustChangePassword: mustChange,
}
return nil
}
func (f *fakeRepo) SetPassword(_ context.Context, userID, passwordHash string) error {
for _, u := range f.staff {
if u.ID == userID {
u.PasswordHash = passwordHash
u.MustChangePassword = false
return nil
}
}
return ErrNotFound
}
func (f *fakeRepo) CreateSession(_ context.Context, tokenHash, userID string, expiresAt time.Time) error {
f.sessions[tokenHash] = &fakeSession{userID: userID, expiresAt: expiresAt}
return nil
@@ -604,7 +651,6 @@ func (f *fakeRepo) SessionUser(_ context.Context, tokenHash string, now time.Tim
if u.ID == s.userID {
return &SessionedUser{
ID: u.ID, Email: u.Email, Role: u.Role,
MustChangePassword: u.MustChangePassword,
}, nil
}
}
@@ -717,7 +763,7 @@ func (f *fakeRepo) CreateUser(_ context.Context, input CreateUserInput, _ string
id := "test-" + input.Username
u := UserView{
ID: id, Username: input.Username, Email: input.Email,
Role: input.Role, MustChangePassword: input.MustChange,
Role: input.Role,
CreatedAt: time.Now(), UpdatedAt: time.Now(),
}
d := UserDetail{UserView: u}
@@ -775,15 +821,6 @@ func (f *fakeRepo) SetUserDisabled(_ context.Context, userID string, disabled bo
return ErrNotFound
}
func (f *fakeRepo) AdminResetPassword(_ context.Context, userID, passwordHash string) error {
for _, su := range f.seededUsers {
if su.view.ID == userID {
return nil
}
}
return ErrNotFound
}
// ---- quota admin fakes ----
func (f *fakeRepo) GetQuotas(_ context.Context, userID string) (*QuotaView, error) {
@@ -862,6 +899,152 @@ func (f *fakeRepo) LinkAccount(_ context.Context, userID, mcUUID, authSource str
return nil
}
// ---- op-login & setup token fakes (spec §B op-login / setup) ----
// UserByEmail mirrors PGRepo.UserByEmail: only a VERIFIED address resolves (the
// address was proven via an email OTP, not merely asserted), and the match is
// case-insensitive so the caller may type the address in any casing — the STORED
// casing is what the mailer and audit trail use. A non-verified or unknown address
// is indistinguishable from no account: both yield ErrNotFound.
func (f *fakeRepo) UserByEmail(_ context.Context, email string) (*StaffUser, error) {
for _, u := range f.staff {
if u.EmailVerified && strings.EqualFold(u.Email, email) {
su := *u
return &su, nil
}
}
return nil, ErrNotFound
}
// ConsumeLoginEmailOTP mirrors PGRepo.ConsumeLoginEmailOTP: it redeems the newest
// live code for (user, purpose) WITHOUT the identity side-effect (login already
// resolved the userID via UserByEmail, so the address is settled). It charges an
// attempt on a hash mismatch (exactly like VerifyEmailOTP) but never writes
// users.email or runs the verified-email guard. A missing/expired/consumed code →
// ErrOTPInvalid; a mismatch → ErrOTPInvalid too (and costs an attempt without
// consuming); a locked code → ErrOTPLocked; a match → consumed, nil.
func (f *fakeRepo) ConsumeLoginEmailOTP(_ context.Context, userID, purpose, codeHash string, now time.Time) error {
var live *fakeEmailOTP
for _, o := range f.otps { // newest live (user, purpose), mirroring VerifyEmailOTP
if o.userID != userID || o.purpose != purpose || o.consumed {
continue
}
if live == nil || o.createdAt.After(live.createdAt) {
live = o
}
}
if live == nil || !live.expiresAt.After(now) {
return ErrOTPInvalid
}
if live.attempts >= otpMaxAttempts {
return ErrOTPLocked
}
if live.codeHash != codeHash {
live.attempts++ // a typo costs an attempt but does not consume the code
return ErrOTPInvalid
}
live.consumed = true
return nil
}
// CreateOpLoginRequest records a fresh pending op.console login attempt. status is
// born 'pending'; createdAt orders the pending list (the PG ORDER BY created_at).
func (f *fakeRepo) CreateOpLoginRequest(_ context.Context, id, userID, email string, expiresAt time.Time) error {
f.opLogins[id] = &fakeOpLogin{
id: id, userID: userID, email: email, status: "pending",
expiresAt: expiresAt, createdAt: expiresAt, // createdAt proxy: constant TTL ⇒ later expiry == later creation
}
return nil
}
// OpLoginRequestByID loads a request by handle, projecting the fake row into the
// OpLoginRequest the status/finish paths read (Status, Consumed, ExpiresAt). Status
// is the (approved_at, denied_at) projection the handler gates on.
func (f *fakeRepo) OpLoginRequestByID(_ context.Context, id string) (*OpLoginRequest, error) {
r, ok := f.opLogins[id]
if !ok {
return nil, ErrNotFound
}
return &OpLoginRequest{
ID: r.id, UserID: r.userID, Email: r.email, ExpiresAt: r.expiresAt,
Status: r.status, Consumed: r.consumed,
}, nil
}
// ConsumeOpLoginRequest stamps consumed on an unconsumed, unexpired request (the
// finish path's single-use guard), mirroring the PG zero-rows-else UPDATE. The
// approval gate is read by the handler BEFORE this call, so consume only checks
// consumed_at and expiry (exactly as PG does).
func (f *fakeRepo) ConsumeOpLoginRequest(_ context.Context, id string, now time.Time) error {
r, ok := f.opLogins[id]
if !ok || r.consumed || !r.expiresAt.After(now) {
return ErrNotFound
}
r.consumed = true
return nil
}
// ListPendingOpLogins returns the live (pending, unconsumed, unexpired) requests
// oldest-first, mirroring the PG WHERE consumed_at IS NULL AND approved_at IS NULL
// AND expires_at > now ORDER BY created_at. Username is joined from the staff map
// (the in-game admin needs to name who is waiting), exactly as the repo.go contract
// documents — ListPendingOpLogins is the ONLY path that populates Username.
func (f *fakeRepo) ListPendingOpLogins(_ context.Context, now time.Time) ([]OpLoginRequest, error) {
var out []OpLoginRequest
for _, r := range f.opLogins {
if r.consumed || r.status != "pending" || !r.expiresAt.After(now) {
continue
}
out = append(out, OpLoginRequest{
ID: r.id, UserID: r.userID, Username: f.usernameFor(r.userID),
Email: r.email, ExpiresAt: r.expiresAt, Status: "pending", CreatedAt: r.createdAt,
})
}
sort.Slice(out, func(i, j int) bool {
if out[i].CreatedAt.Equal(out[j].CreatedAt) {
return out[i].ID < out[j].ID
}
return out[i].CreatedAt.Before(out[j].CreatedAt)
})
return out, nil
}
// ApproveOpLogin marks a pending request approved by approverUserID, atomically: it
// flips status to 'approved' only on a still-pending, unconsumed, unexpired row, else
// ErrNotFound (double approve / dead request is a no-op the caller surfaces as 404).
func (f *fakeRepo) ApproveOpLogin(_ context.Context, id, approverUserID string, now time.Time) error {
r, ok := f.opLogins[id]
if !ok || r.consumed || r.status != "pending" || !r.expiresAt.After(now) {
return ErrNotFound
}
r.status = "approved"
return nil
}
// ConsumeSetupToken atomically marks a one-time setup token consumed and returns
// its user_id, or ErrNotFound when absent, already consumed, or expired.
func (f *fakeRepo) ConsumeSetupToken(_ context.Context, tokenHash string, now time.Time) (string, error) {
tok, ok := f.setupTokens[tokenHash]
if !ok || !tok.ConsumedAt.IsZero() || !tok.ExpiresAt.After(now) {
return "", ErrNotFound
}
tok.ConsumedAt = now
f.setupTokens[tokenHash] = tok
return tok.UserID, nil
}
// usernameFor joins a userID to its staff username (the ListPendingOpLogins
// projection the in-game admin needs to name who is waiting). "" when the user is
// gone — mirroring a missing JOIN row.
func (f *fakeRepo) usernameFor(userID string) string {
for _, u := range f.staff {
if u.ID == userID {
return u.Username
}
}
return ""
}
// fakeRestorer records the restore it was asked to start and returns a canned
// error, mirroring the Restorer kick-off contract. The real restore Job is
// integration-only, so the handler is tested against this fake (spec §466).
@@ -995,6 +1178,10 @@ func do(h http.Handler, method, target, body string, headers map[string]string)
return w
}
var jsonHeader = map[string]string{"Content-Type": "application/json"}
func ctHeader(ct string) map[string]string { return map[string]string{"Content-Type": ct} }
func decodeErr(t *testing.T, w *httptest.ResponseRecorder) string {
t.Helper()
var raw map[string]map[string]string
+14 -7
View File
@@ -21,16 +21,23 @@ type Principal struct {
Role string
// ViaAdminAccess is true only when the request arrived through an admin-graded
// path: the admin.* Zero-Trust hostname (Cloudflare Access, the remote face) OR
// a local-password session presented on the op.console host (SessionAuth, the
// break-glass-enabled face). Admin-tier operations require it in addition to
// a local session presented on the op.console host (SessionAuth, the
// passwordless face). Admin-tier operations require it in addition to
// Role=="admin" (spec §14: ZT is graded by operation). A role=admin session
// arriving on the player console (console.*) never sets it.
ViaAdminAccess bool
// MustChangePassword is set only on the local-password (SessionAuth) path when
// the staff account still owes a first-login change. The JWT path leaves it
// false. The lockdown middleware fences such a principal to the change-password
// and logout surface until it is cleared.
MustChangePassword bool
// EmailVerified mirrors users.email_verified. The lockdown middleware gates
// setup-incomplete accounts (EmailVerified=false, e.g. a freshly bootstrapped
// Owner who has not yet proven control of their mailbox) to the setup-wizard
// routes only, so an intercepted setup URL cannot yield full admin access
// before the email-OTP verification step completes.
EmailVerified bool
// ViaSession is true when the principal was authenticated via a local session
// cookie (SessionAuth), not a Cloudflare-Access JWT. The setup-lockdown gate
// only applies to session-authenticated principals — a JWT caller already
// passed Zero Trust at the edge, so the local-email-verification gate is not
// the right boundary for them.
ViaSession bool
}
// IsAdmin reports whether the principal may perform admin-tier operations.
+9 -13
View File
@@ -49,6 +49,15 @@ var (
// never mints a session for an admin identity. It is distinct from ErrConflict so
// the handler answers 403 (wrong door) rather than 409 (already linked).
ErrPlayerBindForbidden = errors.New("bind code belongs to a staff account")
// ErrEmailTaken means a verified email would collide with another account's
// already-verified address (spec §B email-first login foundation, migration 0010).
// VerifyEmailOTP returns it — WITHOUT consuming the code, since the address, not
// the code, is the problem — when a DIFFERENT user has already proven the same
// address case-insensitively. It is the clean, application-level counterpart of
// the users_verified_email_unique index: a sequential double-verify meets this
// guard and gets a 409 instead of a raw unique-violation 500. Distinct from
// ErrConflict so the message can name the cause (the email is spoken for).
ErrEmailTaken = errors.New("email already verified on another account")
)
// apiError is a handler-level error carrying an HTTP status and a stable,
@@ -73,19 +82,6 @@ var (
errUnauthorized = newError(http.StatusUnauthorized, "unauthorized", "authentication required")
errForbidden = newError(http.StatusForbidden, "forbidden", "not permitted")
errBadRequest = newError(http.StatusBadRequest, "bad_request", "invalid request")
// errInvalidCredentials is the single, deliberately vague answer to any failed
// local-password login (spec §B): unknown username, player row, or wrong
// password all collapse to it so the response never reveals which usernames
// carry a password. The anti-enumeration dummy-hash compare keeps the timing
// uniform alongside it (handlers_auth.go).
errInvalidCredentials = newError(http.StatusUnauthorized, "invalid_credentials", "invalid username or password")
// errPasswordChangeRequired fences a staff principal that still owes a
// first-login password change to the change-password surface. The lockdown
// middleware returns it from every authenticated route except the opt-out set
// (change-password / logout / me), so a half-onboarded account cannot act until
// it sets its own password.
errPasswordChangeRequired = newError(http.StatusForbidden, "password_change_required",
"change your password before continuing")
)
// writeJSON writes v as an indented JSON body with the given status.
+1 -1
View File
@@ -298,7 +298,7 @@ func (a *API) handleAccessKick(w http.ResponseWriter, r *http.Request) {
// who omits the field intends. nil therefore means "default to true (grant)";
// an explicit false is a deliberate deny.
type permissionRequest struct {
Action string `json:"action"` // set | unset
Action string `json:"action"` // set | unset
Player string `json:"player"`
Node string `json:"node"`
Value *bool `json:"value,omitempty"` // set only; nil => true (grant)
+6 -232
View File
@@ -1,127 +1,12 @@
package api
import (
"net/http"
import "net/http"
"golang.org/x/crypto/bcrypt"
)
// Local-password auth handlers (spec §B). Owner/Operator log in to op.console with
// username+password when Zero Trust is not in front of the API (the demo's primary
// web login, and the always-available break-glass-enabled path). These three
// handlers are the whole surface: log in, log out, change password. `felis
// breakGlass` mints/resets the credentials direct-to-Postgres; the panel never
// creates a staff account.
// bcryptCost is the work factor for every password hash we write. It is read back
// from each stored hash on compare, so raising it later re-hashes lazily on the
// next change without invalidating existing hashes.
const bcryptCost = bcrypt.DefaultCost
// dummyPasswordHash is a real bcrypt hash, at bcryptCost, of a throwaway value. A
// failed login (unknown username, or a player row with no password) compares the
// supplied password against it anyway, so the response time matches a real
// password check and cannot be used to enumerate which usernames carry a password.
// It is computed once at init — real and same-cost, never a short-circuit — and
// the throwaway value is never a valid credential because the surrounding logic
// rejects any login whose user has no stored hash regardless of the compare.
var dummyPasswordHash = mustDummyHash()
func mustDummyHash() []byte {
h, err := bcrypt.GenerateFromPassword([]byte("felis-anti-enumeration-placeholder"), bcryptCost)
if err != nil {
panic("bcrypt dummy hash: " + err.Error())
}
return h
}
// loginRequest is the op.console login form.
type loginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
// handleLogin verifies a username+password against the users row and, on success,
// mints a server-side session cookie (spec §B). It is mounted Public — there is no
// prior principal — but still requires local auth to be enabled, so a deployment
// fronted entirely by Zero Trust never accepts a local password. Every failure
// returns the same vague errInvalidCredentials after a uniform bcrypt compare.
func (a *API) handleLogin(w http.ResponseWriter, r *http.Request) {
if !localAuthEnabled(r.Context(), a.Repo) {
writeError(w, r, newError(http.StatusForbidden, "local_auth_disabled",
"local password login is disabled"))
return
}
// Reject a non-JSON body before decoding: this is the public, credential-minting
// route, so it is the cross-site-forgery surface requireJSONContentType closes.
if err := requireJSONContentType(r); err != nil {
writeError(w, r, err)
return
}
var body loginRequest
if err := decodeJSON(w, r, &body); err != nil {
writeError(w, r, err)
return
}
if body.Username == "" || body.Password == "" {
writeError(w, r, newError(http.StatusBadRequest, "bad_request", "username and password are required"))
return
}
u, err := a.Repo.UserByUsername(r.Context(), body.Username)
if err != nil && !errIsNotFound(err) {
writeError(w, r, err)
return
}
// Anti-enumeration: always run a bcrypt compare, even on a missing user or a
// player row (empty hash), against the dummy hash. The trailing guard makes the
// missing-hash cases fail closed even if a caller supplied the dummy's plaintext.
hash := dummyPasswordHash
if u != nil && u.PasswordHash != "" {
hash = []byte(u.PasswordHash)
}
// Bound concurrent bcrypt: this public route runs a full-cost compare on every
// request (the anti-enumeration dummy included), so an unbounded flood of
// simultaneous logins would pin every core. Take one of a fixed number of compare
// slots and shed the excess with a 429 rather than adding to the CPU pile. The
// slot guards only the hash — it is released the instant the compare returns,
// before the session I/O — and being a concurrency cap (not a per-username
// lockout) it never fences the break-glass admin out. The 429 lands before any
// credential distinction, so it leaks nothing about the username either.
release, ok := a.loginLimiter().acquire()
if !ok {
writeError(w, r, newError(http.StatusTooManyRequests, "auth_busy",
"authentication is busy; retry in a moment"))
return
}
matched := bcrypt.CompareHashAndPassword(hash, []byte(body.Password)) == nil
release()
if !matched || u == nil || u.PasswordHash == "" {
writeError(w, r, errInvalidCredentials)
return
}
token, err := newSessionToken()
if err != nil {
writeError(w, r, err)
return
}
expires := a.now().Add(sessionTTL)
if err := a.Repo.CreateSession(r.Context(), hashCookie(token), u.ID, expires); err != nil {
writeError(w, r, err)
return
}
setSessionCookie(w, token, expires)
a.audit(r, u.Username, "auth.login", "")
writeJSON(w, http.StatusOK, map[string]any{
"user_id": u.ID,
"role": u.Role,
"must_change_password": u.MustChangePassword,
})
}
// Passwordless auth handlers (spec §B). Staff (Owner/Operator) authenticate via
// email-OTP / passkey + in-game approve on op.console; players via bind code or
// email-OTP on console. There is NO password login path. This file holds only the
// logout handler — the login doors live in handlers_auth_email.go (email OTP),
// handlers_onboard.go (bind code), and the deferred passkey-login slice.
// handleLogout revokes the presented session and clears the cookie (spec §B). It
// is mounted Public and idempotent: it reads the cookie directly, so it works even
@@ -133,114 +18,3 @@ func (a *API) handleLogout(w http.ResponseWriter, r *http.Request) {
clearSessionCookie(w)
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
// changePasswordRequest is the change-password form.
type changePasswordRequest struct {
CurrentPassword string `json:"current_password"`
NewPassword string `json:"new_password"`
}
// handleChangePassword re-verifies the caller's current password, stores a new
// bcrypt hash, clears must_change_password, and revokes the account's OTHER
// sessions while keeping the current one (spec §B). It is reachable while
// must_change_password is set (AllowDuringPasswordChange) so a forced first-login
// change can complete. The session itself authenticates the caller; re-asking the
// current password additionally blocks a hijacked session from silently rotating
// the credential.
func (a *API) handleChangePassword(w http.ResponseWriter, r *http.Request) {
p := principalFromContext(r.Context())
// Defense-in-depth: this route is already CSRF-safe (a session is required, the
// cookie is SameSite=Lax, and the current password is re-verified below), but the
// same content-type guard keeps every local-auth JSON write uniform.
if err := requireJSONContentType(r); err != nil {
writeError(w, r, err)
return
}
var body changePasswordRequest
if err := decodeJSON(w, r, &body); err != nil {
writeError(w, r, err)
return
}
if err := validateNewPassword(body.NewPassword); err != nil {
writeError(w, r, err)
return
}
u, err := a.Repo.UserByID(r.Context(), p.UserID)
switch {
case errIsNotFound(err):
// The session resolved a moment ago but the user is gone: treat as unauthenticated.
writeError(w, r, errUnauthorized)
return
case err != nil:
writeError(w, r, err)
return
}
if u.PasswordHash == "" {
// A link-only account has no password to change — it never reaches this path
// in practice, but fail closed rather than set a first password here.
writeError(w, r, errForbidden)
return
}
if bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(body.CurrentPassword)) != nil {
writeError(w, r, newError(http.StatusUnauthorized, "invalid_credentials", "current password is incorrect"))
return
}
// The new password must actually differ from the current one.
if bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(body.NewPassword)) == nil {
writeError(w, r, newError(http.StatusBadRequest, "password_unchanged",
"new password must differ from the current one"))
return
}
newHash, err := bcrypt.GenerateFromPassword([]byte(body.NewPassword), bcryptCost)
if err != nil {
writeError(w, r, err)
return
}
if err := a.Repo.SetPassword(r.Context(), u.ID, string(newHash)); err != nil {
writeError(w, r, err)
return
}
// Log out the account's other devices, keeping the current session. The current
// session is identified by the cookie hash; with no cookie (no live session to
// keep) every session of the user is revoked, which is the safe direction.
keep := ""
if c, cerr := r.Cookie(sessionCookieName); cerr == nil {
keep = hashCookie(c.Value)
}
if err := a.Repo.RevokeUserSessionsExcept(r.Context(), u.ID, keep); err != nil {
writeError(w, r, err)
return
}
// Revoking sessions is not enough: a passkey needs no password, so one planted
// through a transiently-hijacked session would outlive the reset as a standing login
// foothold. A password change is a possible-compromise signal, so unbind every passkey
// as part of the same remediation. The user re-enrolls afterward if they want one; the
// email-OTP factor stays available in the meantime, so this never locks anyone out.
if err := a.Repo.DeleteAllPasskeyCredentialsForUser(r.Context(), u.ID); err != nil {
writeError(w, r, err)
return
}
a.audit(r, u.Username, "auth.password_change", "")
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
// validateNewPassword enforces the minimal password policy: 8–72 bytes. The upper
// bound is bcrypt's hard limit (it errors past 72 bytes), surfaced here as a clean
// 400 rather than an opaque 500 from GenerateFromPassword.
func validateNewPassword(pw string) error {
if len(pw) < 8 {
return newError(http.StatusBadRequest, "weak_password", "password must be at least 8 characters")
}
if len(pw) > 72 {
return newError(http.StatusBadRequest, "weak_password", "password must be at most 72 bytes")
}
return nil
}
+266
View File
@@ -0,0 +1,266 @@
package api
import (
"errors"
"net/http"
"strings"
)
// Pre-session Email-OTP LOGIN (spec §B, console.<root_domain> returning-player door).
// This is the passwordless counterpart of handleLogin and the returning-player
// counterpart of handleBindRedeem: an account that already proved control of an
// email (email_verified, migration 0010) logs back in with a one-time code mailed
// to that address — no password, no in-game Bind Code. The two halves are Public,
// pre-session routes: the caller has no principal yet, so identity is resolved from
// the typed email via UserByEmail, exactly as handleBindRedeem resolves it from the
// code.
//
// Distinct from the authenticated /account/email/* onboarding pair in three ways,
// all load-bearing:
//
// - Purpose. Codes are minted under otpPurposeLogin ("login_email"), never
// otpPurposeOnboard, so a login code and an onboarding code for the same user
// never clobber or satisfy each other (the email_otps purpose column is exactly
// this separator).
// - No principal. The throttle cannot key off a user id (there is none yet); it
// keys off the typed recipient address, the same anti-bomb dimension the onboard
// start uses. Per-source (client-IP) aggregate limiting is deliberately NOT done
// here: cooldownLimiter is a one-per-window primitive, so keying it on client IP
// would false-positive on shared egress (CGNAT / office NAT), and behind
// Cloudflare RemoteAddr is the proxy anyway. The only real harm — bombing one
// mailbox — is already bounded per recipient; volumetric per-source limiting
// belongs at the edge.
// - Refuse staff. Like handleBindRedeem this public door provably never mints a
// session for an admin identity: op.console stays behind Zero Trust (and its own
// in-game approval gate). The refusal happens only AFTER a valid code is
// redeemed (see handleLoginEmailVerify), so a caller without the code cannot use
// it to enumerate which addresses are staff.
//
// Enumeration is an accepted product decision (a dedicated /auth/options oracle is a
// sibling slice), so this pair does not go out of its way to equalise timing between
// existing and unknown addresses — it only keeps the *verify* response uniform so a
// code-less caller learns nothing a wrong guess would not already reveal.
// otpPurposeLogin scopes a code to the pre-session email LOGIN flow, keeping it from
// ever colliding with or satisfying an onboarding-email code (otpPurposeOnboard) for
// the same user. VerifyEmailOTP is queried per (user, purpose), so the two flows are
// fully independent even for one account with both a live onboarding and a live
// login code.
const otpPurposeLogin = "login_email"
// loginEmailStartRequest is the start-login-by-email body: the address whose mailbox
// the returning player will read the code from.
type loginEmailStartRequest struct {
Email string `json:"email"`
}
// handleLoginEmailStart mints and mails a login code for a returning account (Public,
// pre-session). It gates on local sessions being enabled — like handleLogin and
// handleBindRedeem, minting a code toward a felis_session while SessionAuth would
// reject that cookie is pointless — reserves the per-recipient cooldown, resolves the
// address to an account, and (only if one exists) mints a code under otpPurposeLogin.
// An address with no verified account yields the SAME 202 as a successful send with
// no code minted: the response never distinguishes the two, and the reservation is
// kept on that path too so repeated probing of one address is throttled identically
// to repeated sends.
func (a *API) handleLoginEmailStart(w http.ResponseWriter, r *http.Request) {
if !localAuthEnabled(r.Context(), a.Repo) {
writeError(w, r, newError(http.StatusForbidden, "local_auth_disabled",
"session login is disabled"))
return
}
if err := requireJSONContentType(r); err != nil {
writeError(w, r, err)
return
}
var req loginEmailStartRequest
if err := decodeJSON(w, r, &req); err != nil {
writeError(w, r, err)
return
}
email := strings.TrimSpace(req.Email)
if !looksLikeEmail(email) {
writeError(w, r, newError(http.StatusBadRequest, "bad_request", "a valid email is required"))
return
}
// Atomically reserve the per-recipient cooldown BEFORE any work, so a burst of
// truly concurrent starts yields exactly one winner and each admitted send is one
// real, non-idempotent email. The key is namespaced apart from the onboard door's
// "email:" key on purpose: this door is unauthenticated, so it must not perturb
// the authenticated onboarding throttle. Both caps are 1/window, so a mailbox sees
// at most one login code plus one onboard code per window — far below any bomb.
emailKey := "login:email:" + strings.ToLower(email)
lim := a.otpLimiter()
emailAt, ok := lim.reserve(emailKey, otpResendCooldown)
if !ok {
writeError(w, r, newError(http.StatusTooManyRequests, "otp_resend_cooldown",
"a code was sent recently; wait a moment before requesting another"))
return
}
committed := false
defer func() {
if !committed {
lim.release(emailKey, emailAt)
}
}()
// Compute the expiry once so the neutral (no-account) branch and the real-send
// branch return byte-identical bodies.
expiresAt := a.now().Add(otpTTL)
u, err := a.Repo.UserByEmail(r.Context(), email)
switch {
case errors.Is(err, ErrNotFound):
// No verified account for this address. Return the same 202 as a real send
// (no code minted) and KEEP the reservation, so probing an unknown address is
// throttled exactly like resending to a known one — the throttle reveals
// nothing, and the accepted /auth/options oracle is where existence is learnt.
committed = true
writeJSON(w, http.StatusAccepted, map[string]any{"sent": true, "expires_at": expiresAt.UTC()})
return
case err != nil:
// A real read error is NOT a neutral outcome: leave committed false so the
// deferred rollback frees the window (a transient DB blip must not burn it).
writeError(w, r, err)
return
}
code, err := newEmailOTP()
if err != nil {
writeError(w, r, err)
return
}
id, err := newOTPID()
if err != nil {
writeError(w, r, err)
return
}
// Mint and deliver against the account's STORED address, not the typed string:
// UserByEmail matched case-insensitively, and the code must reach the mailbox of
// record. The login redeem (ConsumeLoginEmailOTP) never reads or writes this
// address, so the stored casing is authoritative and the row's email snapshot is
// purely for the audit trail.
if err := a.Repo.CreateEmailOTP(r.Context(), id, u.ID, u.Email, otpCodeHash(code), otpPurposeLogin, expiresAt); err != nil {
writeError(w, r, err)
return
}
if err := a.deliverOTP(r.Context(), u.Email, code); err != nil {
writeError(w, r, err)
return
}
committed = true
a.audit(r, u.Username, "auth.login_email.otp_sent", "")
writeJSON(w, http.StatusAccepted, map[string]any{"sent": true, "expires_at": expiresAt.UTC()})
}
// loginEmailVerifyRequest is the verify body: the address and the code read from it.
// Both are needed because there is no principal — the address selects the account,
// the code proves control this session.
type loginEmailVerifyRequest struct {
Email string `json:"email"`
Code string `json:"code"`
}
// handleLoginEmailVerify redeems a login code into a session (Public, pre-session).
// It resolves the address to an account, verifies the code under otpPurposeLogin, and
// on success mints the same host-only felis_session as handleLogin. A missing account,
// a wrong code, AND an attempt-exhausted (locked) code all return the IDENTICAL 400
// invalid_code, so a code-less caller cannot tell an unknown address from a bad guess
// or farm a lockout into an is-this-a-real-account oracle. Staff are refused — but only
// after a valid code is redeemed, so the refusal is reachable solely by the account
// owner and never leaks which addresses are staff.
func (a *API) handleLoginEmailVerify(w http.ResponseWriter, r *http.Request) {
if !localAuthEnabled(r.Context(), a.Repo) {
writeError(w, r, newError(http.StatusForbidden, "local_auth_disabled",
"session login is disabled"))
return
}
if err := requireJSONContentType(r); err != nil {
writeError(w, r, err)
return
}
var req loginEmailVerifyRequest
if err := decodeJSON(w, r, &req); err != nil {
writeError(w, r, err)
return
}
email := strings.TrimSpace(req.Email)
code := strings.TrimSpace(req.Code)
if !looksLikeEmail(email) {
writeError(w, r, newError(http.StatusBadRequest, "bad_request", "a valid email is required"))
return
}
if code == "" {
writeError(w, r, newError(http.StatusBadRequest, "bad_request", "code is required"))
return
}
u, err := a.Repo.UserByEmail(r.Context(), email)
switch {
case errors.Is(err, ErrNotFound):
// Uniform with a wrong code: a caller probing whether an address has an account
// gets the same invalid_code either way. (The /auth/options oracle is the
// sanctioned place to learn existence; this door does not double as one.)
writeError(w, r, newError(http.StatusBadRequest, "invalid_code", "email code is invalid or expired"))
return
case err != nil:
writeError(w, r, err)
return
}
// Consume the code BEFORE the staff check. Ordering is the whole leak-safety
// argument: a caller without a valid code always lands in the invalid_code branch
// below — identical for staff and non-staff — so only the account owner, holding a
// live code, can ever reach the staff refusal.
//
// ConsumeLoginEmailOTP, not VerifyEmailOTP: this door only re-proves control of an
// already-verified address for the session, so it must NOT rewrite users.email or
// run the onboarding taken-check. UserByEmail already guaranteed the account is
// verified; touching the row here would let a stale OTP-snapshot address overwrite
// the live one and could 500 a correct code on a spurious collision.
switch err := a.Repo.ConsumeLoginEmailOTP(r.Context(), u.ID, otpPurposeLogin, otpCodeHash(code), a.now()); {
case errors.Is(err, ErrOTPInvalid), errors.Is(err, ErrOTPLocked):
// Both a wrong/expired code and an attempt-exhausted one return the SAME 400
// invalid_code, byte-identical to the unknown-account branch above. Surfacing
// otp_locked as a distinct 429 (as the authenticated onboarding door does) would
// turn this public door into the existence oracle its no-account branch is
// careful not to be: a code-less prober could mail a code to a victim address,
// exhaust the attempt budget, and read otp_locked as "this address has an
// account." Enumeration is a product decision reserved for /auth/options, not a
// side channel of the login verify.
writeError(w, r, newError(http.StatusBadRequest, "invalid_code", "email code is invalid or expired"))
return
case err != nil:
// ConsumeLoginEmailOTP performs no users write, so ErrEmailTaken is structurally
// impossible here; anything left is a genuine fault and surfaces as a 500.
writeError(w, r, err)
return
}
// Code redeemed. Refuse staff here — never before the verify — so op.console keeps
// its Zero-Trust + in-game-approval gates and this public door provably yields only
// a role=user player session (mirrors handleBindRedeem's refuse-staff contract).
if u.Role == "admin" {
writeError(w, r, newError(http.StatusForbidden, "staff_account",
"that account is staff; sign in at the operator console"))
return
}
token, err := newSessionToken()
if err != nil {
writeError(w, r, err)
return
}
expires := a.now().Add(sessionTTL)
if err := a.Repo.CreateSession(r.Context(), hashCookie(token), u.ID, expires); err != nil {
writeError(w, r, err)
return
}
setSessionCookie(w, token, expires)
a.audit(r, u.Username, "auth.login_email", "")
writeJSON(w, http.StatusOK, map[string]any{
"user_id": u.ID,
"role": u.Role,
})
}
+612
View File
@@ -0,0 +1,612 @@
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
)
// Pre-session Email-OTP LOGIN tests (spec §B, console.<root_domain> returning-player
// door). The load-bearing properties, in the order the flow meets them:
//
// - Neutrality on start: an unknown address gets a byte-identical 202 to a real
// send AND the same cooldown reservation, so neither the response nor the
// throttle is an existence oracle.
// - Purpose separation: login codes (otpPurposeLogin) and onboarding codes
// (otpPurposeOnboard) never satisfy each other, even for one account holding
// both live at once.
// - Verify uniformity: unknown address and wrong code collapse to the same
// invalid_code envelope, so a code-less caller learns nothing.
// - Staff refusal AFTER redeem: only the mailbox owner, holding a live code, can
// ever see the staff_account refusal — and the code is spent reaching it.
// seedLoginEmailAPI wires the public email-login door: local sessions enabled and a
// single verified player "player" (id u1) whose proven address is stored in MIXED
// case, so the case-insensitivity contracts (resolve on typed lowercase, mint against
// stored casing) are exercised by default. Both routes are Public — no External
// wiring needed.
func seedLoginEmailAPI(t *testing.T) (*API, *fakeRepo, *captureMailer) {
t.Helper()
repo := newFakeRepo()
repo.settings[LocalAuthEnabledKey] = []byte("true")
repo.staff["player"] = &StaffUser{
ID: "u1", Username: "player", Email: "[email protected]",
Role: "user", EmailVerified: true,
}
mailer := &captureMailer{}
api := newTestAPI(repo, newFakeCluster())
api.Mailer = mailer
return api, repo, mailer
}
// errEnvelope decodes the standard error body into its stable (code, message) pair —
// request_id varies per request, so uniformity assertions compare these two fields,
// never raw bytes.
func errEnvelope(t *testing.T, w *httptest.ResponseRecorder) (code, msg string) {
t.Helper()
var raw map[string]map[string]string
if err := json.Unmarshal(w.Body.Bytes(), &raw); err != nil {
t.Fatalf("error body not JSON: %v (%s)", err, w.Body.String())
}
return raw["error"]["code"], raw["error"]["message"]
}
// TestLoginEmailVertical walks the whole returning-player slice: a typed lowercase
// address resolves the mixed-case stored account, the code is mailed to the account's
// STORED casing (the address of record), and redeeming it mints the same host-only
// felis_session as the password door — single-use, audited on both halves by the
// account's username. The redeem never rewrites users.email (login re-proves an
// already-verified address via ConsumeLoginEmailOTP), so the stored casing is
// untouched by definition.
func TestLoginEmailVertical(t *testing.T) {
api, repo, mailer := seedLoginEmailAPI(t)
eh := api.ExternalHandler()
// 1) start: 202 says "sent" and when it expires — never the code itself.
w := do(eh, "POST", "/api/v1/auth/email/start", `{"email":"[email protected]"}`, jsonHeader)
if w.Code != http.StatusAccepted {
t.Fatalf("start: code = %d, want 202 (%s)", w.Code, w.Body.String())
}
b := acctBody(t, w)
if b["sent"] != true {
t.Errorf("start body sent = %v, want true", b["sent"])
}
if _, leaked := b["code"]; leaked {
t.Error("start response must NEVER carry the code")
}
if s, _ := b["expires_at"].(string); s == "" {
t.Error("start must report expires_at")
}
// The mail goes to the account's STORED address, not the typed casing — the code
// is delivered to the mailbox of record regardless of how the player typed it.
if mailer.calls != 1 || mailer.email != "[email protected]" {
t.Fatalf("mailer: calls=%d email=%q, want 1 send to the STORED casing [email protected]",
mailer.calls, mailer.email)
}
code := mailer.code
if len(code) != otpCodeDigits {
t.Fatalf("delivered code %q: len = %d, want %d", code, len(code), otpCodeDigits)
}
// Exactly one row, scoped to the LOGIN purpose, holding a hash — not the digits.
if len(repo.otps) != 1 {
t.Fatalf("persisted codes = %d, want 1", len(repo.otps))
}
for _, o := range repo.otps {
if o.purpose != otpPurposeLogin {
t.Errorf("otp purpose = %q, want %q", o.purpose, otpPurposeLogin)
}
if o.codeHash == code {
t.Error("store holds the plaintext code, not its hash")
}
}
// 2) verify — typed in yet another casing, proving the verify-side resolver is
// case-insensitive too — mints the session.
w = do(eh, "POST", "/api/v1/auth/email/verify",
`{"email":"[email protected]","code":"`+code+`"}`, jsonHeader)
if w.Code != http.StatusOK {
t.Fatalf("verify: code = %d, want 200 (%s)", w.Code, w.Body.String())
}
vb := acctBody(t, w)
if vb["user_id"] != "u1" || vb["role"] != "user" {
t.Fatalf("verify body = %v, want user_id:u1 role:user", vb)
}
// The HttpOnly cookie is the whole point — same contract as handleLogin.
cookies := w.Result().Cookies()
if len(cookies) != 1 || cookies[0].Name != sessionCookieName || cookies[0].Value == "" {
t.Fatalf("want one non-empty %s cookie, got %v", sessionCookieName, cookies)
}
s, ok := repo.sessions[hashCookie(cookies[0].Value)]
if !ok {
t.Fatal("no session row for the issued cookie (must be stored hashed)")
}
if s.userID != "u1" {
t.Errorf("session userID = %q, want u1", s.userID)
}
if want := time.Unix(1_700_000_000, 0).Add(sessionTTL); !s.expiresAt.Equal(want) {
t.Errorf("session expiresAt = %v, want now+sessionTTL = %v", s.expiresAt, want)
}
// Both halves audit by the account's username (there is no principal yet).
if n := len(repo.audits); n != 2 {
t.Fatalf("want 2 audits (otp_sent, login), got %d: %+v", n, repo.audits)
}
if repo.audits[0].Action != "auth.login_email.otp_sent" || repo.audits[0].Actor != "player" {
t.Errorf("first audit = %+v, want auth.login_email.otp_sent by player", repo.audits[0])
}
if repo.audits[1].Action != "auth.login_email" || repo.audits[1].Actor != "player" {
t.Errorf("second audit = %+v, want auth.login_email by player", repo.audits[1])
}
// 3) single-use: the consumed code buys nothing a second time.
if w := do(eh, "POST", "/api/v1/auth/email/verify",
`{"email":"[email protected]","code":"`+code+`"}`, jsonHeader); w.Code != http.StatusBadRequest || decodeErr(t, w) != "invalid_code" {
t.Fatalf("replay of consumed code: code = %d body %s, want 400 invalid_code", w.Code, w.Body.String())
}
}
// TestLoginEmailStartNeutralOnUnknownAddress pins the start-side anti-enumeration
// contract: an address with no verified account yields a 202 BYTE-IDENTICAL to a
// real send (frozen clock ⇒ same expires_at), mints and mails nothing, audits
// nothing — and still burns the cooldown window, so probing is throttled exactly
// like sending.
func TestLoginEmailStartNeutralOnUnknownAddress(t *testing.T) {
// A real send for comparison.
apiK, _, _ := seedLoginEmailAPI(t)
wK := do(apiK.ExternalHandler(), "POST", "/api/v1/auth/email/start",
`{"email":"[email protected]"}`, jsonHeader)
if wK.Code != http.StatusAccepted {
t.Fatalf("known-address start: code = %d (%s)", wK.Code, wK.Body.String())
}
// The unknown address: same 202, same bytes, nothing behind it.
repoU := newFakeRepo()
repoU.settings[LocalAuthEnabledKey] = []byte("true")
mailerU := &captureMailer{}
apiU := newTestAPI(repoU, newFakeCluster())
apiU.Mailer = mailerU
ehU := apiU.ExternalHandler()
wU := do(ehU, "POST", "/api/v1/auth/email/start", `{"email":"[email protected]"}`, jsonHeader)
if wU.Code != http.StatusAccepted {
t.Fatalf("unknown-address start: code = %d, want 202 (%s)", wU.Code, wU.Body.String())
}
if wU.Body.String() != wK.Body.String() {
t.Errorf("neutral 202 differs from a real send's:\n unknown: %s\n known: %s",
wU.Body.String(), wK.Body.String())
}
if len(repoU.otps) != 0 || mailerU.calls != 0 || len(repoU.audits) != 0 {
t.Errorf("neutral path must mint/mail/audit nothing, got otps=%d mails=%d audits=%d",
len(repoU.otps), mailerU.calls, len(repoU.audits))
}
// The reservation is KEPT on the neutral path: re-probing the same unknown
// address inside the window is throttled identically to a resend.
if w := do(ehU, "POST", "/api/v1/auth/email/start", `{"email":"[email protected]"}`, jsonHeader); w.Code != http.StatusTooManyRequests || decodeErr(t, w) != "otp_resend_cooldown" {
t.Fatalf("re-probe of unknown address: code = %d body %s, want 429 otp_resend_cooldown",
w.Code, w.Body.String())
}
// An UNVERIFIED account is indistinguishable from no account: UserByEmail only
// resolves proven addresses, so the door never mails one nobody controls.
repoV := newFakeRepo()
repoV.settings[LocalAuthEnabledKey] = []byte("true")
repoV.staff["u"] = &StaffUser{ID: "u9", Username: "u", Email: "[email protected]", Role: "user"} // EmailVerified false
mailerV := &captureMailer{}
apiV := newTestAPI(repoV, newFakeCluster())
apiV.Mailer = mailerV
if w := do(apiV.ExternalHandler(), "POST", "/api/v1/auth/email/start",
`{"email":"[email protected]"}`, jsonHeader); w.Code != http.StatusAccepted {
t.Fatalf("unverified-address start: code = %d, want neutral 202 (%s)", w.Code, w.Body.String())
}
if len(repoV.otps) != 0 || mailerV.calls != 0 {
t.Errorf("unverified address must behave as absent, got otps=%d mails=%d",
len(repoV.otps), mailerV.calls)
}
}
// TestLoginEmailGates covers the shared front doors of both halves: the fail-closed
// local-auth toggle, the cross-site-forgery Content-Type guard (these are Public,
// credential-minting routes — same rationale as handleLogin), and the input gates
// that must reject before any mint or lookup.
func TestLoginEmailGates(t *testing.T) {
t.Run("local auth disabled -> 403 on both halves", func(t *testing.T) {
api := newTestAPI(newFakeRepo(), newFakeCluster()) // no LocalAuthEnabledKey: fails closed
eh := api.ExternalHandler()
if w := do(eh, "POST", "/api/v1/auth/email/start", `{"email":"[email protected]"}`, jsonHeader); w.Code != http.StatusForbidden || decodeErr(t, w) != "local_auth_disabled" {
t.Errorf("start: code = %d body %s, want 403 local_auth_disabled", w.Code, w.Body.String())
}
if w := do(eh, "POST", "/api/v1/auth/email/verify", `{"email":"[email protected]","code":"123456"}`, jsonHeader); w.Code != http.StatusForbidden || decodeErr(t, w) != "local_auth_disabled" {
t.Errorf("verify: code = %d body %s, want 403 local_auth_disabled", w.Code, w.Body.String())
}
})
t.Run("non-JSON content type -> 415 on both halves", func(t *testing.T) {
api, _, _ := seedLoginEmailAPI(t)
eh := api.ExternalHandler()
for _, ct := range []string{"", "text/plain", "application/x-www-form-urlencoded"} {
if w := do(eh, "POST", "/api/v1/auth/email/start", `{"email":"[email protected]"}`, ctHeader(ct)); w.Code != http.StatusUnsupportedMediaType {
t.Errorf("start with Content-Type %q: code = %d, want 415", ct, w.Code)
}
if w := do(eh, "POST", "/api/v1/auth/email/verify", `{"email":"[email protected]","code":"123456"}`, ctHeader(ct)); w.Code != http.StatusUnsupportedMediaType {
t.Errorf("verify with Content-Type %q: code = %d, want 415", ct, w.Code)
}
}
})
t.Run("start bad email -> 400, nothing minted or mailed", func(t *testing.T) {
bad := map[string]string{
"missing email": `{}`,
"empty email": `{"email":""}`,
"no at-sign": `{"email":"notanemail"}`,
"two at-signs": `{"email":"a@[email protected]"}`,
"unknown field": `{"email":"[email protected]","x":1}`,
}
for name, body := range bad {
api, repo, mailer := seedLoginEmailAPI(t)
w := do(api.ExternalHandler(), "POST", "/api/v1/auth/email/start", body, jsonHeader)
if w.Code != http.StatusBadRequest {
t.Errorf("%s: code = %d, want 400 (%s)", name, w.Code, w.Body.String())
}
if len(repo.otps) != 0 || mailer.calls != 0 {
t.Errorf("%s: a rejected start must not mint or mail (otps=%d mails=%d)",
name, len(repo.otps), mailer.calls)
}
}
})
t.Run("verify bad inputs -> 400 bad_request", func(t *testing.T) {
bad := map[string]string{
"bad email": `{"email":"notanemail","code":"123456"}`,
"empty code": `{"email":"[email protected]","code":""}`,
"missing code": `{"email":"[email protected]"}`,
"unknown field": `{"email":"[email protected]","code":"123456","x":1}`,
}
for name, body := range bad {
api, _, _ := seedLoginEmailAPI(t)
w := do(api.ExternalHandler(), "POST", "/api/v1/auth/email/verify", body, jsonHeader)
if w.Code != http.StatusBadRequest {
t.Errorf("%s: code = %d, want 400 (%s)", name, w.Code, w.Body.String())
}
}
})
}
// TestLoginEmailStartRateLimited closes the unauthenticated email-bomb vector on the
// public door: one send per recipient per window, keyed case-insensitively, and
// namespaced apart from the authenticated onboarding throttle so neither door can
// starve the other.
func TestLoginEmailStartRateLimited(t *testing.T) {
start := func(eh http.Handler, email string) *httptest.ResponseRecorder {
return do(eh, "POST", "/api/v1/auth/email/start", `{"email":"`+email+`"}`, jsonHeader)
}
t.Run("same recipient is throttled, then recovers after the cooldown", func(t *testing.T) {
api, repo, mailer := seedLoginEmailAPI(t)
clock := time.Unix(1_700_000_000, 0)
api.Now = func() time.Time { return clock }
eh := api.ExternalHandler()
if w := start(eh, "[email protected]"); w.Code != http.StatusAccepted {
t.Fatalf("first send: code = %d, want 202 (%s)", w.Code, w.Body.String())
}
if w := start(eh, "[email protected]"); w.Code != http.StatusTooManyRequests || decodeErr(t, w) != "otp_resend_cooldown" {
t.Fatalf("immediate resend: code = %d body %s, want 429 otp_resend_cooldown", w.Code, w.Body.String())
}
if mailer.calls != 1 || len(repo.otps) != 1 {
t.Errorf("throttled resend must not mint or mail: mails=%d otps=%d, want 1/1",
mailer.calls, len(repo.otps))
}
clock = clock.Add(otpResendCooldown + time.Second)
if w := start(eh, "[email protected]"); w.Code != http.StatusAccepted {
t.Fatalf("post-cooldown send: code = %d, want 202 (%s)", w.Code, w.Body.String())
}
})
t.Run("throttle key is case-insensitive", func(t *testing.T) {
api, _, _ := seedLoginEmailAPI(t)
eh := api.ExternalHandler()
if w := start(eh, "[email protected]"); w.Code != http.StatusAccepted {
t.Fatalf("first send: code = %d, want 202 (%s)", w.Code, w.Body.String())
}
// A recased retype is the same mailbox: it must hit the same window.
if w := start(eh, "[email protected]"); w.Code != http.StatusTooManyRequests {
t.Fatalf("recased resend: code = %d, want 429 (key must be lowercased)", w.Code)
}
})
t.Run("login and onboard cooldowns are namespaced apart", func(t *testing.T) {
// The unauthenticated login door must not perturb the authenticated
// onboarding throttle for the same mailbox — distinct keys, so both doors
// admit one send each at the same instant.
api, _, mailer := seedLoginEmailAPI(t)
api.External = staticExternal{p: &Principal{UserID: "u1", Email: "[email protected]", Role: "user"}}
eh := api.ExternalHandler()
if w := start(eh, "[email protected]"); w.Code != http.StatusAccepted {
t.Fatalf("login start: code = %d, want 202 (%s)", w.Code, w.Body.String())
}
if w := do(eh, "POST", "/api/v1/account/email/start", `{"email":"[email protected]"}`, nil); w.Code != http.StatusAccepted {
t.Fatalf("onboard start same mailbox, same instant: code = %d, want 202 — the doors must not share a throttle key (%s)",
w.Code, w.Body.String())
}
if mailer.calls != 2 {
t.Errorf("mailer calls = %d, want 2 (one per door)", mailer.calls)
}
})
}
// TestLoginEmailVerifyRejections is the redeem-side failure matrix. The anchor case
// is uniformity: an unknown address and a wrong code for a known address answer with
// the same (code, message) envelope, so the verify half never doubles as an
// existence oracle. Expired/locked rows are planted directly — the frozen clock
// makes that the only deterministic route to those branches.
func TestLoginEmailVerifyRejections(t *testing.T) {
verify := func(eh http.Handler, email, code string) *httptest.ResponseRecorder {
return do(eh, "POST", "/api/v1/auth/email/verify",
`{"email":"`+email+`","code":"`+code+`"}`, jsonHeader)
}
// liveLogin plants an unconsumed LOGIN-purpose code for u1.
liveLogin := func(repo *fakeRepo, id, codeHash string, expiresAt time.Time, attempts int) {
repo.otps[id] = &fakeEmailOTP{
id: id, userID: "u1", email: "[email protected]", codeHash: codeHash,
purpose: otpPurposeLogin, attempts: attempts,
expiresAt: expiresAt, createdAt: expiresAt,
}
}
t.Run("unknown address is indistinguishable from a wrong code", func(t *testing.T) {
// Known account, live code, wrong digits.
apiW, repoW, _ := seedLoginEmailAPI(t)
liveLogin(repoW, "lg", otpCodeHash("123456"), time.Unix(1_700_000_600, 0), 0)
wWrong := verify(apiW.ExternalHandler(), "[email protected]", "654321")
// No account at all.
apiU, _, _ := seedLoginEmailAPI(t)
wGhost := verify(apiU.ExternalHandler(), "[email protected]", "654321")
if wWrong.Code != http.StatusBadRequest || wGhost.Code != http.StatusBadRequest {
t.Fatalf("codes = %d/%d, want 400/400", wWrong.Code, wGhost.Code)
}
wc, wm := errEnvelope(t, wWrong)
gc, gm := errEnvelope(t, wGhost)
if wc != "invalid_code" || wc != gc || wm != gm {
t.Errorf("envelopes differ: known=(%s,%q) unknown=(%s,%q) — must be identical", wc, wm, gc, gm)
}
})
t.Run("known address, no live code -> 400 invalid_code", func(t *testing.T) {
api, repo, _ := seedLoginEmailAPI(t)
w := verify(api.ExternalHandler(), "[email protected]", "123456")
if w.Code != http.StatusBadRequest || decodeErr(t, w) != "invalid_code" {
t.Fatalf("code = %d body %s", w.Code, w.Body.String())
}
if len(repo.sessions) != 0 {
t.Error("no session may be minted on a failed verify")
}
})
t.Run("wrong code charges an attempt, does not consume", func(t *testing.T) {
api, repo, _ := seedLoginEmailAPI(t)
liveLogin(repo, "wr", otpCodeHash("123456"), time.Unix(1_700_000_600, 0), 0)
w := verify(api.ExternalHandler(), "[email protected]", "654321")
if w.Code != http.StatusBadRequest || decodeErr(t, w) != "invalid_code" {
t.Fatalf("code = %d body %s", w.Code, w.Body.String())
}
if repo.otps["wr"].attempts != 1 || repo.otps["wr"].consumed {
t.Errorf("attempts=%d consumed=%v, want 1/false", repo.otps["wr"].attempts, repo.otps["wr"].consumed)
}
})
t.Run("expired code -> 400 invalid_code", func(t *testing.T) {
api, repo, _ := seedLoginEmailAPI(t)
// One second before the frozen clock (time.Unix(1_700_000_000, 0)).
liveLogin(repo, "ex", otpCodeHash("123456"), time.Unix(1_699_999_999, 0), 0)
w := verify(api.ExternalHandler(), "[email protected]", "123456")
if w.Code != http.StatusBadRequest || decodeErr(t, w) != "invalid_code" {
t.Fatalf("code = %d body %s", w.Code, w.Body.String())
}
})
t.Run("exhausted code is invisible: same invalid_code as a wrong code, no locked oracle", func(t *testing.T) {
// A locked row (attempts == cap) must NOT surface as a distinct 429 otp_locked
// on this public, pre-session door: that status would be an existence oracle —
// a code-less prober could mail a victim address a code, burn its attempt budget,
// and read otp_locked as "this address has an account." It collapses to the SAME
// 400 invalid_code a wrong code returns, byte-for-byte. (The authenticated
// onboarding door keeps otp_locked as actionable feedback; this one cannot.)
api, repo, _ := seedLoginEmailAPI(t)
liveLogin(repo, "lk", otpCodeHash("123456"), time.Unix(1_700_000_600, 0), otpMaxAttempts)
wLocked := verify(api.ExternalHandler(), "[email protected]", "123456") // correct digits, but exhausted
// A plain wrong code on a fresh known account, for the byte-for-byte comparison.
apiW, repoW, _ := seedLoginEmailAPI(t)
liveLogin(repoW, "wr", otpCodeHash("123456"), time.Unix(1_700_000_600, 0), 0)
wWrong := verify(apiW.ExternalHandler(), "[email protected]", "654321")
if wLocked.Code != http.StatusBadRequest {
t.Fatalf("exhausted code: code = %d body %s, want 400 invalid_code (NOT 429 otp_locked)",
wLocked.Code, wLocked.Body.String())
}
lc, lm := errEnvelope(t, wLocked)
wc, wm := errEnvelope(t, wWrong)
if lc != "invalid_code" || lc != wc || lm != wm {
t.Errorf("locked envelope must be identical to a wrong code's: locked=(%s,%q) wrong=(%s,%q)",
lc, lm, wc, wm)
}
if len(repo.sessions) != 0 {
t.Error("no session may be minted from a locked code")
}
})
}
// TestLoginEmailPurposeSeparation proves the email_otps purpose column does its one
// job in both directions: a live ONBOARDING code cannot open the login door, a live
// LOGIN code cannot satisfy onboarding verify. Two complementary properties fall out
// and both are pinned: the OTHER flow's row is never consumed or charged (the
// per-purpose query never sees it), while the door's OWN live code IS charged one
// attempt — cross-door digits are just a wrong guess, never a free brute-force try.
func TestLoginEmailPurposeSeparation(t *testing.T) {
api, repo, _ := seedLoginEmailAPI(t)
api.External = staticExternal{p: &Principal{UserID: "u1", Email: "[email protected]", Role: "user"}}
eh := api.ExternalHandler()
// Both purposes live at once for u1, distinct digits.
repo.otps["ob"] = &fakeEmailOTP{
id: "ob", userID: "u1", email: "[email protected]", codeHash: otpCodeHash("111111"),
purpose: otpPurposeOnboard, expiresAt: time.Unix(1_700_000_600, 0), createdAt: time.Unix(1_700_000_600, 0),
}
repo.otps["lg"] = &fakeEmailOTP{
id: "lg", userID: "u1", email: "[email protected]", codeHash: otpCodeHash("222222"),
purpose: otpPurposeLogin, expiresAt: time.Unix(1_700_000_600, 0), createdAt: time.Unix(1_700_000_600, 0),
}
// Onboarding code at the LOGIN door: refused. The onboard row is untouched (the
// login-purpose query never saw it); the LIVE LOGIN code is charged one attempt —
// to the login door these are simply wrong digits.
w := do(eh, "POST", "/api/v1/auth/email/verify",
`{"email":"[email protected]","code":"111111"}`, jsonHeader)
if w.Code != http.StatusBadRequest || decodeErr(t, w) != "invalid_code" {
t.Fatalf("onboard code at login door: code = %d body %s, want 400 invalid_code", w.Code, w.Body.String())
}
if o := repo.otps["ob"]; o.consumed || o.attempts != 0 {
t.Errorf("onboard row must be untouched by a login verify: consumed=%v attempts=%d", o.consumed, o.attempts)
}
if o := repo.otps["lg"]; o.consumed || o.attempts != 1 {
t.Errorf("cross-door digits must cost the login code one attempt (never a free guess): consumed=%v attempts=%d",
o.consumed, o.attempts)
}
if len(repo.sessions) != 0 {
t.Fatal("a cross-purpose code must never mint a session")
}
// Login code at the ONBOARDING door: refused symmetrically — the login row is not
// consumed and not charged further (the onboard-purpose query never saw it; its
// one attempt above stands), while the onboard code eats the wrong guess...
w = do(eh, "POST", "/api/v1/account/email/verify", `{"code":"222222"}`, nil)
if w.Code != http.StatusBadRequest || decodeErr(t, w) != "invalid_code" {
t.Fatalf("login code at onboard door: code = %d body %s, want 400 invalid_code", w.Code, w.Body.String())
}
if o := repo.otps["lg"]; o.consumed || o.attempts != 1 {
t.Errorf("login row must not be consumed or re-charged by an onboard verify: consumed=%v attempts=%d",
o.consumed, o.attempts)
}
if o := repo.otps["ob"]; o.consumed || o.attempts != 1 {
t.Errorf("cross-door digits must cost the onboard code one attempt: consumed=%v attempts=%d",
o.consumed, o.attempts)
}
// ...and the login code is still redeemable at its own door afterwards.
w = do(eh, "POST", "/api/v1/auth/email/verify",
`{"email":"[email protected]","code":"222222"}`, jsonHeader)
if w.Code != http.StatusOK {
t.Fatalf("login code at its own door after the cross attempts: code = %d, want 200 (%s)",
w.Code, w.Body.String())
}
}
// TestLoginEmailVerifyRefusesStaff pins the staff refusal AND its ordering. The
// public door provably never mints a session for role=admin (op.console keeps its
// Zero-Trust gate) — but the refusal must be reachable only by the mailbox owner:
// a wrong code for a staff address answers the same invalid_code as for anyone, and
// the 403 costs the valid code (verify-then-refuse), so it cannot be farmed as an
// is-this-address-staff oracle.
func TestLoginEmailVerifyRefusesStaff(t *testing.T) {
repo := newFakeRepo()
repo.settings[LocalAuthEnabledKey] = []byte("true")
repo.staff["owner"] = &StaffUser{
ID: "a1", Username: "owner", Email: "[email protected]",
Role: "admin", EmailVerified: true,
}
mailer := &captureMailer{}
api := newTestAPI(repo, newFakeCluster())
api.Mailer = mailer
eh := api.ExternalHandler()
// Start happily mails a staff address — the refusal lives at verify, after the
// code proves mailbox control, so start stays neutral.
if w := do(eh, "POST", "/api/v1/auth/email/start", `{"email":"[email protected]"}`, jsonHeader); w.Code != http.StatusAccepted {
t.Fatalf("start for staff address: code = %d, want 202 (%s)", w.Code, w.Body.String())
}
good := mailer.code
if good == "000000" {
t.Skip("astronomically unlucky code collision; rerun")
}
// Without the code, staffness is invisible: plain invalid_code.
if w := do(eh, "POST", "/api/v1/auth/email/verify",
`{"email":"[email protected]","code":"000000"}`, jsonHeader); w.Code != http.StatusBadRequest || decodeErr(t, w) != "invalid_code" {
t.Fatalf("wrong code for staff: code = %d body %s, want 400 invalid_code", w.Code, w.Body.String())
}
// With the code: 403 staff_account — and no cookie, no session row, no success audit.
w := do(eh, "POST", "/api/v1/auth/email/verify",
`{"email":"[email protected]","code":"`+good+`"}`, jsonHeader)
if w.Code != http.StatusForbidden || decodeErr(t, w) != "staff_account" {
t.Fatalf("valid code for staff: code = %d body %s, want 403 staff_account", w.Code, w.Body.String())
}
if len(w.Result().Cookies()) != 0 {
t.Error("no session cookie may be set for a refused staff login")
}
if len(repo.sessions) != 0 {
t.Error("no session row may be minted for a refused staff login")
}
for _, a := range repo.audits {
if a.Action == "auth.login_email" {
t.Error("a refused staff login must not be audited as a successful login")
}
}
// Ordering pin: the refusal consumed the code (verify ran BEFORE the staff
// check), so replaying it now collapses to invalid_code.
if w := do(eh, "POST", "/api/v1/auth/email/verify",
`{"email":"[email protected]","code":"`+good+`"}`, jsonHeader); w.Code != http.StatusBadRequest || decodeErr(t, w) != "invalid_code" {
t.Fatalf("replay after staff refusal: code = %d body %s, want 400 invalid_code (code must be spent)",
w.Code, w.Body.String())
}
}
// TestLoginEmailFaceSeparation enforces that both halves are web-only: the internal
// (service-token) face must 404 them, never serve them.
func TestLoginEmailFaceSeparation(t *testing.T) {
api, _, _ := seedLoginEmailAPI(t)
ih := api.InternalHandler()
if w := do(ih, "POST", "/api/v1/auth/email/start", `{"email":"[email protected]"}`, jsonHeader); w.Code != http.StatusNotFound {
t.Errorf("start on internal face: code = %d, want 404", w.Code)
}
if w := do(ih, "POST", "/api/v1/auth/email/verify", `{"email":"[email protected]","code":"123456"}`, jsonHeader); w.Code != http.StatusNotFound {
t.Errorf("verify on internal face: code = %d, want 404", w.Code)
}
}
// TestLoginEmailStartFailedDeliveryReleasesCooldown covers the reserve→rollback
// path on the login door: a send that reserves the window but fails to deliver must
// release it, so the immediate retry is admitted instead of 429'd — a transient SMTP
// blip must not lock a returning player out for the whole window. The failure is
// also not audited as a send.
func TestLoginEmailStartFailedDeliveryReleasesCooldown(t *testing.T) {
repo := newFakeRepo()
repo.settings[LocalAuthEnabledKey] = []byte("true")
repo.staff["player"] = &StaffUser{
ID: "u1", Username: "player", Email: "[email protected]",
Role: "user", EmailVerified: true,
}
mailer := &flakyMailer{}
api := newTestAPI(repo, newFakeCluster()) // frozen clock: both attempts share one window
api.Mailer = mailer
eh := api.ExternalHandler()
if w := do(eh, "POST", "/api/v1/auth/email/start", `{"email":"[email protected]"}`, jsonHeader); w.Code < 500 {
t.Fatalf("first send (mailer fails): code = %d, want 5xx (%s)", w.Code, w.Body.String())
}
for _, a := range repo.audits {
if a.Action == "auth.login_email.otp_sent" {
t.Error("a failed delivery must not be audited as otp_sent")
}
}
if w := do(eh, "POST", "/api/v1/auth/email/start", `{"email":"[email protected]"}`, jsonHeader); w.Code != http.StatusAccepted {
t.Fatalf("retry after failed delivery: code = %d, want 202 (the failed send must release the cooldown) (%s)",
w.Code, w.Body.String())
}
if mailer.calls != 2 {
t.Errorf("mailer calls = %d, want 2 (one failed, one delivered)", mailer.calls)
}
}
+95
View File
@@ -0,0 +1,95 @@
package api
import (
"errors"
"net/http"
"strings"
)
// Pre-session identifier-first discovery (spec §B, #71). Given a typed email, this
// Public door reports which console login methods the account can use, so the SPA's
// identifier-first form can prompt for the right authenticator (a passkey assertion,
// or "we'll email you a code") instead of guessing.
//
// It is the deliberate counter-slice to the anti-enumeration login doors
// (handlers_auth_email.go, handlers_passkey.go): those refuse to disclose whether an
// address has an account precisely because THIS endpoint is the one sanctioned place
// existence is revealed. An empty methods array means "no (verified) account". That
// makes it a mass-enumeration surface by design — an accepted product decision, the
// same one the email door's header records. It is bounded only at the edge: the
// handler sends no mail and mutates nothing, so a per-recipient cooldown would merely
// block a legitimate retry, and per-source (client-IP) limiting is the edge's job
// (behind Cloudflare RemoteAddr is the proxy, and CGNAT would false-positive) — see
// the handlers_auth_email.go header for the same reasoning.
//
// It never reveals STAFFNESS. Methods are computed by the SAME rule for every resolved
// account — no role branch, no operator hint — so a staff email and a player email in
// the same credential state return byte-identical bodies. The console doors' own
// post-redemption staff refusal is not previewed here: a staff caller is told
// email_otp is available and is turned away only later, at op.console's Zero-Trust
// gate. Staffness is thus invisible by construction, with no side channel to regress.
type authOptionsRequest struct {
Email string `json:"email"`
}
// handleAuthOptions resolves the typed email and returns the console login methods it
// can use. Public, pre-session, gated on local_auth_enabled like its sibling doors.
func (a *API) handleAuthOptions(w http.ResponseWriter, r *http.Request) {
if !localAuthEnabled(r.Context(), a.Repo) {
writeError(w, r, newError(http.StatusForbidden, "local_auth_disabled",
"session login is disabled"))
return
}
if err := requireJSONContentType(r); err != nil {
writeError(w, r, err)
return
}
var req authOptionsRequest
if err := decodeJSON(w, r, &req); err != nil {
writeError(w, r, err)
return
}
email := strings.TrimSpace(req.Email)
if !looksLikeEmail(email) {
writeError(w, r, newError(http.StatusBadRequest, "bad_request", "a valid email is required"))
return
}
// methods is initialised non-nil so the no-account branch marshals as [] (not null).
methods := []string{}
u, err := a.Repo.UserByEmail(r.Context(), email)
switch {
case errors.Is(err, ErrNotFound):
// The sanctioned existence oracle: an unknown (or not-yet-verified) address is
// not disguised — it honestly reports no methods.
writeJSON(w, http.StatusOK, map[string]any{"methods": methods})
return
case err != nil:
writeError(w, r, err)
return
}
// Compute methods identically for EVERY resolved account. There is deliberately no
// branch on u.Role: a staff address must be indistinguishable from a player address
// in the same credential state, so the response carries nothing account-identifying.
//
// Advertise passkey only when a verifier is actually wired: both login halves 503
// passkey_unavailable when a.Passkey is nil regardless of enrolled credentials, so
// options must not offer a method the finish door would immediately reject.
if a.Passkey != nil {
creds, err := a.Repo.PasskeyCredentialsForUser(r.Context(), u.ID)
if err != nil {
writeError(w, r, err)
return
}
if len(creds) > 0 {
methods = append(methods, "passkey")
}
}
// Email-OTP login works for any resolved verified account (UserByEmail resolves only
// email_verified rows), so it is always on offer.
methods = append(methods, "email_otp")
writeJSON(w, http.StatusOK, map[string]any{"methods": methods})
}
+190
View File
@@ -0,0 +1,190 @@
package api
import (
"net/http"
"net/http/httptest"
"reflect"
"strings"
"testing"
)
// Pre-session identifier-first discovery tests (spec §B, #71). Load-bearing properties:
//
// - Methods reflect real state: email_otp for any resolved verified account, plus
// passkey when a verifier is wired AND the account has >=1 enrolled credential.
// - Existence IS disclosed: an unknown address returns an empty methods array. This
// endpoint is the deliberate, sanctioned counter-slice to the anti-enumeration
// login doors, so it does not disguise non-existence.
// - Staffness is NOT disclosed: a staff email and a player email in the same
// credential state return BYTE-IDENTICAL bodies — the highest-value guard, because a
// role branch here would out which addresses are operators.
// - passkey is gated on a wired verifier: options never advertises a method the finish
// door would immediately 503.
// seedAuthOptionsAPI wires the discovery door: local sessions enabled, a verified player
// (u1) and a verified staff account (a1), and a passkey verifier wired by default.
// Callers seed passkey credentials per-test to set the credential state.
func seedAuthOptionsAPI(t *testing.T) (*API, *fakeRepo) {
t.Helper()
repo := newFakeRepo()
repo.settings[LocalAuthEnabledKey] = []byte("true")
repo.staff["player"] = &StaffUser{ID: "u1", Username: "player", Email: "[email protected]", Role: "user", EmailVerified: true}
repo.staff["boss"] = &StaffUser{ID: "a1", Username: "boss", Email: "[email protected]", Role: "admin", EmailVerified: true}
api := newTestAPI(repo, newFakeCluster())
api.Passkey = &fakePasskeyVerifier{}
return api, repo
}
const authOptionsPath = "/api/v1/auth/options"
// optionsMethods pulls the methods array out of a 200 body as []string.
func optionsMethods(t *testing.T, w *httptest.ResponseRecorder) []string {
t.Helper()
raw, ok := acctBody(t, w)["methods"].([]any)
if !ok {
t.Fatalf("body has no methods array: %s", w.Body.String())
}
out := make([]string, len(raw))
for i, m := range raw {
out[i], _ = m.(string)
}
return out
}
func TestAuthOptionsMethodsByState(t *testing.T) {
t.Run("account with no passkey -> email_otp only", func(t *testing.T) {
api, _ := seedAuthOptionsAPI(t)
w := do(api.ExternalHandler(), "POST", authOptionsPath, `{"email":"[email protected]"}`, jsonHeader)
if w.Code != http.StatusOK {
t.Fatalf("code = %d, want 200 (%s)", w.Code, w.Body.String())
}
if got := optionsMethods(t, w); !reflect.DeepEqual(got, []string{"email_otp"}) {
t.Errorf("methods = %v, want [email_otp]", got)
}
})
t.Run("account with a passkey (verifier wired) -> passkey + email_otp", func(t *testing.T) {
api, repo := seedAuthOptionsAPI(t)
repo.passkeyCreds["row1"] = PasskeyCredential{ID: "row1", UserID: "u1", CredentialID: "cred-1", PublicKey: "k", CreatedAt: frozenNow}
w := do(api.ExternalHandler(), "POST", authOptionsPath, `{"email":"[email protected]"}`, jsonHeader)
if w.Code != http.StatusOK {
t.Fatalf("code = %d, want 200 (%s)", w.Code, w.Body.String())
}
// Deterministic order (passkey before email_otp) so clients and this assertion
// can compare without sorting.
if got := optionsMethods(t, w); !reflect.DeepEqual(got, []string{"passkey", "email_otp"}) {
t.Errorf("methods = %v, want [passkey email_otp]", got)
}
})
}
// TestAuthOptionsUnknownEmail pins the sanctioned-oracle contract: an address with no
// verified account is not disguised — it returns an explicit empty array (not null), so
// the client can trust "no methods" as "no account".
func TestAuthOptionsUnknownEmail(t *testing.T) {
api, _ := seedAuthOptionsAPI(t)
w := do(api.ExternalHandler(), "POST", authOptionsPath, `{"email":"[email protected]"}`, jsonHeader)
if w.Code != http.StatusOK {
t.Fatalf("code = %d, want 200 (%s)", w.Code, w.Body.String())
}
if got := optionsMethods(t, w); len(got) != 0 {
t.Errorf("methods = %v, want []", got)
}
if body := w.Body.String(); !strings.Contains(body, `"methods":[]`) {
t.Errorf("unknown-email body = %s, want an explicit \"methods\":[] (not null)", body)
}
}
// TestAuthOptionsDoesNotRevealStaffness is the security anchor. For each credential
// state, a staff address and a player address in the SAME state must return
// byte-identical bodies. A role branch in the handler — even one that only reordered or
// relabelled — would out which addresses are operators; this is the guard that such a
// branch can never be introduced without a red test.
func TestAuthOptionsDoesNotRevealStaffness(t *testing.T) {
states := []struct {
name string
withPasskey bool
}{
{"neither has a passkey", false},
{"both have a passkey", true},
}
for _, st := range states {
t.Run(st.name, func(t *testing.T) {
api, repo := seedAuthOptionsAPI(t)
if st.withPasskey {
repo.passkeyCreds["p"] = PasskeyCredential{ID: "p", UserID: "u1", CredentialID: "c-u1", PublicKey: "k", CreatedAt: frozenNow}
repo.passkeyCreds["a"] = PasskeyCredential{ID: "a", UserID: "a1", CredentialID: "c-a1", PublicKey: "k", CreatedAt: frozenNow}
}
eh := api.ExternalHandler()
wPlayer := do(eh, "POST", authOptionsPath, `{"email":"[email protected]"}`, jsonHeader)
wStaff := do(eh, "POST", authOptionsPath, `{"email":"[email protected]"}`, jsonHeader)
if wPlayer.Code != http.StatusOK || wStaff.Code != http.StatusOK {
t.Fatalf("codes = %d/%d, want 200/200", wPlayer.Code, wStaff.Code)
}
if wPlayer.Body.String() != wStaff.Body.String() {
t.Errorf("staff/player bodies differ — options reveals staffness:\n player: %s\n staff: %s",
wPlayer.Body.String(), wStaff.Body.String())
}
})
}
}
// TestAuthOptionsPasskeyRequiresWiredVerifier: the account HAS an enrolled passkey, but
// no verifier is wired (a.Passkey == nil). Both login halves 503 passkey_unavailable in
// that state, so options must NOT advertise passkey — it would be a dead offer.
func TestAuthOptionsPasskeyRequiresWiredVerifier(t *testing.T) {
api, repo := seedAuthOptionsAPI(t)
repo.passkeyCreds["row1"] = PasskeyCredential{ID: "row1", UserID: "u1", CredentialID: "cred-1", PublicKey: "k", CreatedAt: frozenNow}
api.Passkey = nil
w := do(api.ExternalHandler(), "POST", authOptionsPath, `{"email":"[email protected]"}`, jsonHeader)
if w.Code != http.StatusOK {
t.Fatalf("code = %d, want 200 (%s)", w.Code, w.Body.String())
}
if got := optionsMethods(t, w); !reflect.DeepEqual(got, []string{"email_otp"}) {
t.Errorf("methods = %v, want [email_otp] (passkey must not be offered without a wired verifier)", got)
}
}
func TestAuthOptionsGates(t *testing.T) {
t.Run("local auth disabled -> 403", func(t *testing.T) {
api := newTestAPI(newFakeRepo(), newFakeCluster()) // no LocalAuthEnabledKey: fails closed
api.Passkey = &fakePasskeyVerifier{}
w := do(api.ExternalHandler(), "POST", authOptionsPath, `{"email":"[email protected]"}`, jsonHeader)
if w.Code != http.StatusForbidden || decodeErr(t, w) != "local_auth_disabled" {
t.Errorf("code = %d body %s, want 403 local_auth_disabled", w.Code, w.Body.String())
}
})
t.Run("non-JSON content type -> 415", func(t *testing.T) {
api, _ := seedAuthOptionsAPI(t)
eh := api.ExternalHandler()
for _, ct := range []string{"", "text/plain", "application/x-www-form-urlencoded"} {
if w := do(eh, "POST", authOptionsPath, `{"email":"[email protected]"}`, ctHeader(ct)); w.Code != http.StatusUnsupportedMediaType {
t.Errorf("Content-Type %q: code = %d, want 415", ct, w.Code)
}
}
})
t.Run("bad or unknown-field body -> 400", func(t *testing.T) {
bad := map[string]string{
"missing email": `{}`,
"empty email": `{"email":""}`,
"no at-sign": `{"email":"notanemail"}`,
"unknown field": `{"email":"[email protected]","x":1}`,
}
api, _ := seedAuthOptionsAPI(t)
eh := api.ExternalHandler()
for name, body := range bad {
if w := do(eh, "POST", authOptionsPath, body, jsonHeader); w.Code != http.StatusBadRequest {
t.Errorf("%s: code = %d, want 400 (%s)", name, w.Code, w.Body.String())
}
}
})
}
// TestAuthOptionsFaceSeparation: the route is external-only (registered in
// externalAPIRoutes), so the internal face must 404 it.
func TestAuthOptionsFaceSeparation(t *testing.T) {
api, _ := seedAuthOptionsAPI(t)
ih := api.InternalHandler()
if w := do(ih, "POST", authOptionsPath, `{"email":"[email protected]"}`, jsonHeader); w.Code != http.StatusNotFound {
t.Errorf("options on internal face: code = %d, want 404 (it is external-only)", w.Code)
}
}
-321
View File
@@ -1,321 +0,0 @@
package api
import (
"encoding/json"
"net/http"
"testing"
"time"
"golang.org/x/crypto/bcrypt"
)
// Local-password auth handler tests (spec §B). These exercise the three-route
// surface — login, logout, change-password — against the in-memory fakeRepo, which
// mirrors the PG fail-closed contract. The load-bearing cases are the anti-
// enumeration uniformity (an unknown user and a wrong password are indistinguishable)
// and the requireJSONContentType guard that closes the cross-site login-forgery
// vector: a forged HTML-form POST cannot set application/json, so it is rejected
// before any credential check.
// seedAuthAPI returns an API whose repo has local auth enabled and a single admin
// "owner" (id u1) whose password is the given plaintext. Login is Public, so these
// tests need no External wiring.
func seedAuthAPI(t *testing.T, password string, mustChange bool) (*API, *fakeRepo) {
t.Helper()
repo := newFakeRepo()
repo.settings[LocalAuthEnabledKey] = []byte("true")
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost)
if err != nil {
t.Fatalf("hash seed password: %v", err)
}
repo.staff["owner"] = &StaffUser{
ID: "u1", Username: "owner", Email: "owner@" + testRoot,
Role: "admin", PasswordHash: string(hash), MustChangePassword: mustChange,
}
return newTestAPI(repo, newFakeCluster()), repo
}
// seedAuthedAPI extends seedAuthAPI with an injected session principal so the
// authenticated change-password route resolves a caller. change-password opts out of
// the first-login lockdown (AllowDuringPasswordChange), so a must-change principal
// still reaches the handler.
func seedAuthedAPI(t *testing.T, password string, mustChange bool) (*API, *fakeRepo) {
t.Helper()
api, repo := seedAuthAPI(t, password, mustChange)
api.External = staticExternal{p: &Principal{
UserID: "u1", Email: "owner@" + testRoot, Role: "admin", MustChangePassword: mustChange,
}}
return api, repo
}
// ctHeader builds a headers map carrying the given Content-Type, or nil for the
// absent-header case (do() then sets no Content-Type at all).
func ctHeader(ct string) map[string]string {
if ct == "" {
return nil
}
return map[string]string{"Content-Type": ct}
}
var jsonHeader = map[string]string{"Content-Type": "application/json"}
func TestHandleLoginSuccess(t *testing.T) {
api, _ := seedAuthAPI(t, "correct-horse-battery", true)
w := do(api.ExternalHandler(), "POST", "/api/v1/auth/login",
`{"username":"owner","password":"correct-horse-battery"}`, jsonHeader)
if w.Code != http.StatusOK {
t.Fatalf("code = %d, want 200 (%s)", w.Code, w.Body.String())
}
// The HttpOnly session cookie is the login's whole point — the panel never reads
// it, the browser just carries it back.
cookies := w.Result().Cookies()
if len(cookies) != 1 || cookies[0].Name != sessionCookieName || cookies[0].Value == "" {
t.Fatalf("want one non-empty %s cookie, got %v", sessionCookieName, cookies)
}
if !cookies[0].HttpOnly {
t.Fatalf("session cookie must be HttpOnly")
}
var got map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
t.Fatalf("body not JSON: %v (%s)", err, w.Body.String())
}
if got["user_id"] != "u1" || got["role"] != "admin" || got["must_change_password"] != true {
t.Fatalf("got %v, want user_id=u1 role=admin must_change_password=true", got)
}
}
// TestHandleLoginContentTypeGuard pins the confirmed login-CSRF fix: a body whose
// Content-Type is anything an HTML form (or a default cross-site fetch) can emit is
// rejected 415 BEFORE the credential check, so a forged off-origin login never even
// reaches bcrypt. Local auth is enabled and the credentials are valid here, proving
// the rejection is the content-type, not a bad password.
func TestHandleLoginContentTypeGuard(t *testing.T) {
api, _ := seedAuthAPI(t, "correct-horse-battery", false)
h := api.ExternalHandler()
body := `{"username":"owner","password":"correct-horse-battery"}`
for _, ct := range []string{
"application/x-www-form-urlencoded",
"multipart/form-data; boundary=x",
"text/plain;charset=UTF-8",
"", // header absent entirely
} {
w := do(h, "POST", "/api/v1/auth/login", body, ctHeader(ct))
if w.Code != http.StatusUnsupportedMediaType {
t.Fatalf("Content-Type %q: code = %d, want 415", ct, w.Code)
}
if code := decodeErr(t, w); code != "unsupported_media_type" {
t.Fatalf("Content-Type %q: error code = %q, want unsupported_media_type", ct, code)
}
if len(w.Result().Cookies()) != 0 {
t.Fatalf("Content-Type %q: no session cookie may be set on a rejected login", ct)
}
}
// A JSON content-type with a charset parameter is still JSON and must pass.
if w := do(h, "POST", "/api/v1/auth/login", body,
map[string]string{"Content-Type": "application/json; charset=utf-8"}); w.Code != http.StatusOK {
t.Fatalf("application/json; charset=utf-8: code = %d, want 200 (%s)", w.Code, w.Body.String())
}
}
// TestHandleLoginConcurrencyCap pins the audit-hardening bound on the public login
// route: bcrypt is CPU-costly and runs on every request (the anti-enumeration dummy
// included), so at most MaxConcurrentLogins compares may be in flight at once and the
// excess is shed with a 429 rather than piling more onto every core. Holding the sole
// slot makes the next login — with otherwise-valid credentials — return 429 auth_busy
// with no cookie BEFORE any credential check; releasing it lets the identical request
// succeed, proving the 429 was the cap, not the password. A concurrency cap, not a
// per-account lockout: the same account gets in the moment the burst clears.
func TestHandleLoginConcurrencyCap(t *testing.T) {
api, _ := seedAuthAPI(t, "correct-horse-battery", false)
api.MaxConcurrentLogins = 1
h := api.ExternalHandler()
body := `{"username":"owner","password":"correct-horse-battery"}`
// Occupy the one compare slot so the handler finds the cap full. loginLimiter is
// lazily built from MaxConcurrentLogins (set just above), so this and the handler
// share the same one-token limiter.
release, ok := api.loginLimiter().acquire()
if !ok {
t.Fatal("could not acquire the sole login slot in test setup")
}
w := do(h, "POST", "/api/v1/auth/login", body, jsonHeader)
if w.Code != http.StatusTooManyRequests {
t.Fatalf("with the slot held: code = %d, want 429 (%s)", w.Code, w.Body.String())
}
if code := decodeErr(t, w); code != "auth_busy" {
t.Fatalf("error code = %q, want auth_busy", code)
}
if len(w.Result().Cookies()) != 0 {
t.Fatal("no session cookie may be set on a shed login")
}
// Release the slot: the identical request now runs the compare and succeeds.
release()
if w := do(h, "POST", "/api/v1/auth/login", body, jsonHeader); w.Code != http.StatusOK {
t.Fatalf("after releasing the slot: code = %d, want 200 (%s)", w.Code, w.Body.String())
}
}
// TestHandleLoginInvalidCredentials proves the anti-enumeration uniformity: a wrong
// password and an unknown username return the SAME 401 invalid_credentials with no
// cookie, so a caller cannot learn which usernames carry a password.
func TestHandleLoginInvalidCredentials(t *testing.T) {
api, _ := seedAuthAPI(t, "correct-horse-battery", false)
h := api.ExternalHandler()
for _, tc := range []struct{ name, body string }{
{"wrong password", `{"username":"owner","password":"wrong"}`},
{"unknown user", `{"username":"ghost","password":"whatever"}`},
} {
t.Run(tc.name, func(t *testing.T) {
w := do(h, "POST", "/api/v1/auth/login", tc.body, jsonHeader)
if w.Code != http.StatusUnauthorized {
t.Fatalf("code = %d, want 401 (%s)", w.Code, w.Body.String())
}
if code := decodeErr(t, w); code != "invalid_credentials" {
t.Fatalf("error code = %q, want invalid_credentials", code)
}
if len(w.Result().Cookies()) != 0 {
t.Fatalf("no session cookie may be set on a failed login")
}
})
}
}
// TestHandleLoginLocalAuthDisabled proves a deployment with no local_auth_enabled
// setting refuses every local login (403), so a Zero-Trust-only console never
// accepts a password.
func TestHandleLoginLocalAuthDisabled(t *testing.T) {
repo := newFakeRepo() // local_auth_enabled never set → fail closed
api := newTestAPI(repo, newFakeCluster())
w := do(api.ExternalHandler(), "POST", "/api/v1/auth/login",
`{"username":"owner","password":"x"}`, jsonHeader)
if w.Code != http.StatusForbidden {
t.Fatalf("code = %d, want 403 (%s)", w.Code, w.Body.String())
}
if code := decodeErr(t, w); code != "local_auth_disabled" {
t.Fatalf("error code = %q, want local_auth_disabled", code)
}
}
func TestHandleLoginMissingFields(t *testing.T) {
api, _ := seedAuthAPI(t, "correct-horse-battery", false)
w := do(api.ExternalHandler(), "POST", "/api/v1/auth/login",
`{"username":"","password":""}`, jsonHeader)
if w.Code != http.StatusBadRequest {
t.Fatalf("code = %d, want 400 (%s)", w.Code, w.Body.String())
}
}
// TestHandleLogout is idempotent: it clears the cookie and returns 200 even with no
// live session, and revokes the presented one when there is.
func TestHandleLogout(t *testing.T) {
api, repo := seedAuthAPI(t, "correct-horse-battery", false)
h := api.ExternalHandler()
// No cookie: still 200, still clears.
if w := do(h, "POST", "/api/v1/auth/logout", "", nil); w.Code != http.StatusOK {
t.Fatalf("logout without session: code = %d, want 200", w.Code)
}
// With a live session cookie: the matching session is revoked.
token, err := newSessionToken()
if err != nil {
t.Fatalf("token: %v", err)
}
repo.sessions[hashCookie(token)] = &fakeSession{userID: "u1", expiresAt: api.now().Add(time.Hour)}
w := do(h, "POST", "/api/v1/auth/logout", "",
map[string]string{"Cookie": sessionCookieName + "=" + token})
if w.Code != http.StatusOK {
t.Fatalf("logout with session: code = %d, want 200", w.Code)
}
if !repo.sessions[hashCookie(token)].revoked {
t.Fatalf("presented session should be revoked")
}
}
func TestHandleChangePasswordSuccess(t *testing.T) {
api, repo := seedAuthedAPI(t, "old-password", true)
// A second live session for u1: the change must revoke it. This request carries
// no felis_session cookie, so keep="" and every session of u1 is revoked — the
// safe direction the handler documents.
repo.sessions["other-device"] = &fakeSession{userID: "u1", expiresAt: api.now().Add(time.Hour)}
// A bound passkey for u1: the change must unbind it too. A passkey planted through a
// hijacked session needs no password, so it would otherwise survive the reset as a
// standing login foothold.
repo.passkeyCreds["pk1"] = PasskeyCredential{ID: "pk1", UserID: "u1", CredentialID: "cred-1", PublicKey: "k"}
w := do(api.ExternalHandler(), "POST", "/api/v1/auth/change-password",
`{"current_password":"old-password","new_password":"brand-new-password"}`, jsonHeader)
if w.Code != http.StatusOK {
t.Fatalf("code = %d, want 200 (%s)", w.Code, w.Body.String())
}
u := repo.staff["owner"]
if u.MustChangePassword {
t.Fatalf("must_change_password should be cleared after a change")
}
if bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte("brand-new-password")) != nil {
t.Fatalf("the new password does not verify against the stored hash")
}
if !repo.sessions["other-device"].revoked {
t.Fatalf("other sessions should be revoked on a password change")
}
if len(repo.passkeyCreds) != 0 {
t.Fatalf("password change left %d passkeys, want 0 — a planted passkey must not survive remediation", len(repo.passkeyCreds))
}
}
// TestHandleChangePasswordContentTypeGuard pins the defense-in-depth guard on the
// authenticated change-password route.
func TestHandleChangePasswordContentTypeGuard(t *testing.T) {
api, _ := seedAuthedAPI(t, "old-password", false)
w := do(api.ExternalHandler(), "POST", "/api/v1/auth/change-password",
`{"current_password":"old-password","new_password":"brand-new-password"}`,
map[string]string{"Content-Type": "text/plain"})
if w.Code != http.StatusUnsupportedMediaType {
t.Fatalf("code = %d, want 415 (%s)", w.Code, w.Body.String())
}
}
func TestHandleChangePasswordRejections(t *testing.T) {
t.Run("weak new password", func(t *testing.T) {
api, _ := seedAuthedAPI(t, "old-password", false)
w := do(api.ExternalHandler(), "POST", "/api/v1/auth/change-password",
`{"current_password":"old-password","new_password":"short"}`, jsonHeader)
if w.Code != http.StatusBadRequest {
t.Fatalf("code = %d, want 400", w.Code)
}
if code := decodeErr(t, w); code != "weak_password" {
t.Fatalf("error code = %q, want weak_password", code)
}
})
t.Run("unchanged password", func(t *testing.T) {
api, _ := seedAuthedAPI(t, "old-password", false)
w := do(api.ExternalHandler(), "POST", "/api/v1/auth/change-password",
`{"current_password":"old-password","new_password":"old-password"}`, jsonHeader)
if w.Code != http.StatusBadRequest {
t.Fatalf("code = %d, want 400", w.Code)
}
if code := decodeErr(t, w); code != "password_unchanged" {
t.Fatalf("error code = %q, want password_unchanged", code)
}
})
t.Run("wrong current password", func(t *testing.T) {
api, _ := seedAuthedAPI(t, "old-password", false)
w := do(api.ExternalHandler(), "POST", "/api/v1/auth/change-password",
`{"current_password":"wrong","new_password":"brand-new-password"}`, jsonHeader)
if w.Code != http.StatusUnauthorized {
t.Fatalf("code = %d, want 401", w.Code)
}
if code := decodeErr(t, w); code != "invalid_credentials" {
t.Fatalf("error code = %q, want invalid_credentials", code)
}
})
}
+1 -1
View File
@@ -596,7 +596,7 @@ func newDeadlineStallWriter() *deadlineStallWriter {
func (s *deadlineStallWriter) Header() http.Header { return s.hdr }
func (s *deadlineStallWriter) WriteHeader(int) {}
func (s *deadlineStallWriter) Write(p []byte) (int, error) { return len(p), nil } // buffered: never blocks
func (s *deadlineStallWriter) Flush() {} // header flush: instant, best-effort
func (s *deadlineStallWriter) Flush() {} // header flush: instant, best-effort
// FlushError is where the stalled socket bites: it blocks until the deadline the relay
// set via SetWriteDeadline, then returns the same error a real write reports when that
+2 -2
View File
@@ -73,8 +73,8 @@ func TestBindRedeemBootstrapsPlayer(t *testing.T) {
}
// A fresh role=user player row was created and bound; the code was consumed.
if u := repo.staff[bindTestUUID]; u == nil || u.Role != "user" || u.PasswordHash != "" || u.ID != userID {
t.Fatalf("created row = %+v, want role=user, NULL hash, id=%s", u, userID)
if u := repo.staff[bindTestUUID]; u == nil || u.Role != "user" || u.ID != userID {
t.Fatalf("created row = %+v, want role=user, id=%s", u, userID)
}
if repo.links[bindTestUUID] != userID {
t.Fatalf("account_links[%s] = %q, want %q", bindTestUUID, repo.links[bindTestUUID], userID)
+408
View File
@@ -0,0 +1,408 @@
package api
import (
"encoding/json"
"errors"
"net/http"
"strings"
)
// op.console STAFF login (spec §B op-login): the two-factor door for the most
// sensitive tier. Unlike the console.<root_domain> player doors (email OTP / bind
// code), a staff web session is never minted from a single factor. The flow is a
// three-call state machine over op_login_requests (migration 0012), all Public
// pre-session routes (the caller has no principal yet), plus two internal-face routes
// velocity drives on behalf of online admins:
//
// POST /api/v1/auth/op-login/start (public) — mint a request + mail an OTP
// GET /api/v1/auth/op-login/status/{id} (public) — poll until an admin approves
// POST /api/v1/auth/op-login/finish (public) — redeem code+approval → session
// GET /api/v1/internal/op-login/pending (internal) — the online-admin push list
// POST /api/v1/internal/op-login/{id}/approve (internal) — an in-game admin vouches
//
// The two factors:
//
// - Possession of the staff mailbox — an email-OTP under purpose op_login, minted by
// start and redeemed by finish, reusing the email_otps lifecycle (the purpose
// column keeps it from ever colliding with a console login_email or onboard code).
// - An in-game vouch — an already-trusted admin who is ONLINE approves the pending
// request via velocity's /felis command (internal approve). Only a linked
// role=admin account may approve; velocity additionally gates the command on
// in-game op, so the API check is defence in depth over its own user table.
//
// finish mints the session only when BOTH have landed. Neither factor alone — a mailed
// code without an approval, or an approval without the code — yields a session.
//
// Anti-enumeration. op.console sits behind Cloudflare Zero-Trust at the edge, but the
// external API is hostname-agnostic at the route level, so these Public routes are
// reachable from console.<root_domain> too and must not become a staff oracle:
//
// - start resolves the typed email; a non-staff or unknown address gets the SAME 202
// with a plausible (non-persisted, random) request_id and mails nothing, so a
// caller cannot tell a staff address from any other.
// - status returns approved:false for an unknown/expired/denied/consumed id exactly
// as for a live-but-unapproved one; only a genuinely approved live request reads
// approved:true, and driving an id to that state REQUIRES an in-game admin vouch a
// fabricated id can never obtain.
// - finish collapses unknown id, not-yet-approved, wrong code, locked, and lost-race
// into one uniform failure, and (like the console door) never reveals staffness.
// otpPurposeOpLogin scopes an email code to the op.console staff door, keeping it from
// ever colliding with or satisfying a console login_email or onboarding code for the
// same account. VerifyEmailOTP/ConsumeLoginEmailOTP are queried per (user, purpose),
// so the op-login factor is fully independent of the player-console doors.
const otpPurposeOpLogin = "op_login"
// opLoginStartRequest is the start body: the staff address the code is mailed to.
type opLoginStartRequest struct {
Email string `json:"email"`
}
// handleOpLoginStart begins a staff op.console login (Public, pre-session): it mints an
// op_login_requests row for the resolved staff account and mails an email-OTP under
// otpPurposeOpLogin, returning the request handle the browser polls. A non-staff or
// unknown address yields the SAME 202 with a random, non-persisted handle and no mail,
// so this never doubles as a staff-enumeration oracle (op.console's own Zero-Trust is
// the edge gate; this app-layer neutrality covers the hostname-agnostic route).
func (a *API) handleOpLoginStart(w http.ResponseWriter, r *http.Request) {
if !localAuthEnabled(r.Context(), a.Repo) {
writeError(w, r, newError(http.StatusForbidden, "local_auth_disabled",
"session login is disabled"))
return
}
if err := requireJSONContentType(r); err != nil {
writeError(w, r, err)
return
}
var req opLoginStartRequest
if err := decodeJSON(w, r, &req); err != nil {
writeError(w, r, err)
return
}
email := strings.TrimSpace(req.Email)
if !looksLikeEmail(email) {
writeError(w, r, newError(http.StatusBadRequest, "bad_request", "a valid email is required"))
return
}
// Per-recipient cooldown reserved BEFORE any work, identical to the console email
// door: one winner per window, and the neutral (non-staff) branch keeps the
// reservation too so probing an address is throttled exactly like a real send. The
// key is namespaced apart from the console door's "login:email:" so the two
// unauthenticated doors never perturb each other's throttle.
emailKey := "oplogin:email:" + strings.ToLower(email)
lim := a.otpLimiter()
emailAt, ok := lim.reserve(emailKey, otpResendCooldown)
if !ok {
writeError(w, r, newError(http.StatusTooManyRequests, "otp_resend_cooldown",
"a code was sent recently; wait a moment before requesting another"))
return
}
committed := false
defer func() {
if !committed {
lim.release(emailKey, emailAt)
}
}()
// Compute expiry once so the neutral and real branches return identical-shaped
// bodies and (real branch) the request row and its OTP are coterminous.
expiresAt := a.now().Add(otpTTL)
// neutral returns the indistinguishable no-op success: a plausible but non-persisted
// handle that status(id) reads approved:false forever (no row, never approvable). It
// mints nothing and mails nothing, and KEEPS the reservation so probing is throttled
// exactly like a real send.
neutral := func() {
fakeID, err := newOTPID()
if err != nil {
writeError(w, r, err) // committed stays false → deferred rollback frees the window
return
}
committed = true
writeJSON(w, http.StatusAccepted, map[string]any{
"request_id": fakeID, "expires_at": expiresAt.UTC(),
})
}
u, err := a.Repo.UserByEmail(r.Context(), email)
switch {
case errors.Is(err, ErrNotFound):
neutral()
return
case err != nil:
// Real read fault: leave committed false so the deferred rollback frees the
// window (a transient DB blip must not burn it).
writeError(w, r, err)
return
}
// op.console is the STAFF door: a non-admin who typed their address here (they belong
// on console.<root_domain>) gets the neutral response, never a request or a code.
if u.Role != "admin" {
neutral()
return
}
id, err := newOTPID()
if err != nil {
writeError(w, r, err)
return
}
if err := a.Repo.CreateOpLoginRequest(r.Context(), id, u.ID, u.Email, expiresAt); err != nil {
writeError(w, r, err)
return
}
code, err := newEmailOTP()
if err != nil {
writeError(w, r, err)
return
}
otpID, err := newOTPID()
if err != nil {
writeError(w, r, err)
return
}
// Mint+mail against the STORED staff address (UserByEmail matched case-insensitively);
// the request row snapshots the same address for its audit trail.
if err := a.Repo.CreateEmailOTP(r.Context(), otpID, u.ID, u.Email, otpCodeHash(code), otpPurposeOpLogin, expiresAt); err != nil {
writeError(w, r, err)
return
}
if err := a.deliverOTP(r.Context(), u.Email, code); err != nil {
writeError(w, r, err)
return
}
committed = true
a.audit(r, u.Username, "auth.op_login.otp_sent", "")
writeJSON(w, http.StatusAccepted, map[string]any{
"request_id": id, "expires_at": expiresAt.UTC(),
})
}
// handleOpLoginStatus reports whether a staff login request has been approved in-game
// (Public, pre-session). It is a pure read the browser polls after start: it returns
// approved:true only for a genuinely approved, live, unconsumed request, and
// approved:false for everything else — including an unknown, expired, denied, or
// already-consumed id — so a fabricated handle polls as approved:false forever and the
// endpoint is not a staff-enumeration oracle (only an in-game admin vouch, impossible
// against a fake id, flips it true).
func (a *API) handleOpLoginStatus(w http.ResponseWriter, r *http.Request) {
if !localAuthEnabled(r.Context(), a.Repo) {
writeError(w, r, newError(http.StatusForbidden, "local_auth_disabled",
"session login is disabled"))
return
}
id := r.PathValue("id")
approved := false
switch req, err := a.Repo.OpLoginRequestByID(r.Context(), id); {
case errors.Is(err, ErrNotFound):
// Unknown handle: neutral approved:false (never 404), uniform with a real request
// still awaiting approval.
case err != nil:
writeError(w, r, err)
return
default:
approved = req.Status == "approved" && !req.Consumed && req.ExpiresAt.After(a.now())
}
writeJSON(w, http.StatusOK, map[string]any{"approved": approved})
}
// opLoginFinishRequest is the finish body: the request handle from start and the code
// read from the staff mailbox. The handle selects the account (there is no principal);
// the code proves possession of the mailbox this session.
type opLoginFinishRequest struct {
RequestID string `json:"request_id"`
Code string `json:"code"`
}
// handleOpLoginFinish redeems an approved request plus its mailed code into a staff
// session (Public, pre-session). It mints the session only when BOTH factors have
// landed: the request is approved-and-live AND the code verifies. Every failure mode —
// unknown handle, not-yet-approved, wrong or locked code, lost race — collapses into
// ONE uniform 400, so a code-less caller learns nothing (not staffness, not approval
// state). The approval is read BEFORE the code is consumed so a valid code submitted
// early (before an admin approves) is preserved for a retry rather than burned.
func (a *API) handleOpLoginFinish(w http.ResponseWriter, r *http.Request) {
if !localAuthEnabled(r.Context(), a.Repo) {
writeError(w, r, newError(http.StatusForbidden, "local_auth_disabled",
"session login is disabled"))
return
}
if err := requireJSONContentType(r); err != nil {
writeError(w, r, err)
return
}
var req opLoginFinishRequest
if err := decodeJSON(w, r, &req); err != nil {
writeError(w, r, err)
return
}
requestID := strings.TrimSpace(req.RequestID)
code := strings.TrimSpace(req.Code)
if requestID == "" || code == "" {
writeError(w, r, newError(http.StatusBadRequest, "bad_request", "request_id and code are required"))
return
}
// The single uniform failure every "cannot complete" branch returns, so unknown
// handle / not-approved / wrong code / locked / lost-race are indistinguishable.
invalid := newError(http.StatusBadRequest, "op_login_invalid",
"this operator login could not be completed; restart the sign-in")
now := a.now()
loginReq, err := a.Repo.OpLoginRequestByID(r.Context(), requestID)
switch {
case errors.Is(err, ErrNotFound):
writeError(w, r, invalid)
return
case err != nil:
writeError(w, r, err)
return
}
// Read the approval state BEFORE touching the code: an early finish (user typed the
// code before an admin approved) must not consume the code. Not-approved collapses
// into the same uniform failure as a bad code, so the ordering leaks nothing.
if loginReq.Status != "approved" || loginReq.Consumed || !loginReq.ExpiresAt.After(now) {
writeError(w, r, invalid)
return
}
// Consume the mailed code (op_login purpose). A wrong/expired/locked code charges an
// attempt without minting anything and returns the uniform failure — the code, not
// the request, is the problem, and the request stays approved for a retry.
switch err := a.Repo.ConsumeLoginEmailOTP(r.Context(), loginReq.UserID, otpPurposeOpLogin, otpCodeHash(code), now); {
case errors.Is(err, ErrOTPInvalid), errors.Is(err, ErrOTPLocked):
writeError(w, r, invalid)
return
case err != nil:
writeError(w, r, err)
return
}
// Both factors proven. Atomically spend the request (approved→consumed, single-use):
// this serialises against a concurrent finish and records which request completed.
switch err := a.Repo.ConsumeOpLoginRequest(r.Context(), requestID, now); {
case errors.Is(err, ErrNotFound):
// Lost a race (another finish consumed it) or it expired between the checks —
// uniform failure. The code was already spent by the winner.
writeError(w, r, invalid)
return
case err != nil:
writeError(w, r, err)
return
}
// Load the staff account for the session + response. Re-assert admin as defence in
// depth: only admins ever get a request minted, but the session must never be issued
// to a non-admin identity even if the row were somehow otherwise.
u, err := a.Repo.UserByID(r.Context(), loginReq.UserID)
if err != nil {
writeError(w, r, err)
return
}
if u.Role != "admin" {
writeError(w, r, newError(http.StatusForbidden, "staff_account", "that account is not an operator"))
return
}
token, err := newSessionToken()
if err != nil {
writeError(w, r, err)
return
}
expires := now.Add(sessionTTL)
if err := a.Repo.CreateSession(r.Context(), hashCookie(token), u.ID, expires); err != nil {
writeError(w, r, err)
return
}
setSessionCookie(w, token, expires)
a.audit(r, u.Username, "auth.op_login", "")
writeJSON(w, http.StatusOK, map[string]any{"user_id": u.ID, "role": u.Role})
}
// handleOpLoginPending lists live pending staff login requests, oldest first (internal
// face). Velocity polls it and pushes the waiting requests to online admins, who
// approve one with /felis web op approve <id>. Internal-only: velocity holds a service
// token and no pending request is secret to the operator crew.
func (a *API) handleOpLoginPending(w http.ResponseWriter, r *http.Request) {
reqs, err := a.Repo.ListPendingOpLogins(r.Context(), a.now())
if err != nil {
writeError(w, r, err)
return
}
out := make([]map[string]any, 0, len(reqs))
for _, req := range reqs {
out = append(out, map[string]any{
"request_id": req.ID,
"username": req.Username,
"email": req.Email,
"created_at": req.CreatedAt.UTC(),
})
}
writeJSON(w, http.StatusOK, map[string]any{"pending": out})
}
// opLoginApproveRequest is the internal approve body: the online-mode UUID of the
// in-game admin running /felis web op approve. The API resolves it to a linked account
// and refuses unless that account is role=admin — defence in depth over velocity's own
// in-game op gate, checked against the API's authoritative user table.
type opLoginApproveRequest struct {
ApproverUUID string `json:"approver_uuid"`
}
// handleOpLoginApprove records an in-game admin's vouch for a pending staff login
// (internal face), supplying the second factor. It resolves the approver UUID to a
// linked role=admin account (else 403), then flips the request approved. A missing or
// no-longer-pending request is 404. Self-approval is allowed: a staff member online as
// their own admin identity supplies a genuine second factor (in-game session control)
// distinct from the mailbox factor.
func (a *API) handleOpLoginApprove(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
var req opLoginApproveRequest
if err := decodeJSON(w, r, &req); err != nil {
writeError(w, r, err)
return
}
approverUUID := strings.TrimSpace(req.ApproverUUID)
if approverUUID == "" {
writeError(w, r, newError(http.StatusBadRequest, "bad_request", "approver_uuid is required"))
return
}
// Resolve the in-game approver to a linked account and require admin. An unlinked
// UUID or a non-admin player may never vouch for an op.console login. All three
// refusals share one response so a caller cannot tell "not linked" from "linked but
// not staff".
notAdmin := newError(http.StatusForbidden, "not_admin", "only a linked administrator may approve an operator login")
approverID, err := a.Repo.UserByMCUUID(r.Context(), approverUUID)
switch {
case errors.Is(err, ErrNotFound):
writeError(w, r, notAdmin)
return
case err != nil:
writeError(w, r, err)
return
}
approver, err := a.Repo.UserByID(r.Context(), approverID)
switch {
case errors.Is(err, ErrNotFound):
writeError(w, r, notAdmin)
return
case err != nil:
writeError(w, r, err)
return
}
if approver.Role != "admin" {
writeError(w, r, notAdmin)
return
}
switch err := a.Repo.ApproveOpLogin(r.Context(), id, approverID, a.now()); {
case errors.Is(err, ErrNotFound):
writeError(w, r, newError(http.StatusNotFound, "op_login_not_found", "no pending operator login with that id"))
return
case err != nil:
writeError(w, r, err)
return
}
payload, _ := json.Marshal(map[string]string{"request_id": id, "approver_user_id": approverID})
_ = a.Repo.Audit(r.Context(), AuditEntry{
Actor: approver.Username, Source: "internal", Action: "auth.op_login.approved",
RequestID: requestIDFromContext(r.Context()), Payload: payload,
})
writeJSON(w, http.StatusOK, map[string]any{"approved": true})
}
+509
View File
@@ -0,0 +1,509 @@
package api
import (
"net/http"
"net/http/httptest"
"testing"
"time"
)
// op.console STAFF login tests (spec §B op-login). The two-factor door's load-bearing
// properties, in the order the flow meets them:
//
// - Two factors, both required. finish mints a session only when the mailed op_login
// code verifies AND an in-game admin has approved the request; neither alone works.
// - Neutral start. A non-staff or unknown address gets a 202 with a plausible but
// non-persisted request_id and nothing mailed, so start is not a staff oracle.
// - Neutral status. An unknown/expired/consumed handle reads approved:false exactly
// like a real request awaiting approval, so a fabricated handle is not an oracle.
// - Uniform finish failure. Unknown handle / not-approved / wrong code / lost race
// all collapse to one op_login_invalid envelope; an early-but-correct code is
// preserved (approval is read before the code is consumed), and a wrong code costs
// an attempt without burning the approval.
// - Admin-only approval. Only a linked role=admin UUID may vouch; the check is the
// API's own user table, defence in depth over velocity's in-game op gate.
const opUUID = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" // the seeded admin's linked in-game UUID
// seedOpLoginAPI wires the op.console door: local sessions enabled and a single staff
// admin "op" (id a1) whose proven address is stored in MIXED case (so the mint-against-
// stored-casing contract is exercised by default) and whose in-game UUID opUUID is
// linked, so the admin can act as an in-game approver.
func seedOpLoginAPI(t *testing.T) (*API, *fakeRepo, *captureMailer) {
t.Helper()
repo := newFakeRepo()
repo.settings[LocalAuthEnabledKey] = []byte("true")
repo.staff["op"] = &StaffUser{
ID: "a1", Username: "op", Email: "[email protected]",
Role: "admin", EmailVerified: true,
}
repo.links[opUUID] = "a1"
mailer := &captureMailer{}
api := newTestAPI(repo, newFakeCluster())
api.Mailer = mailer
return api, repo, mailer
}
// startOp / statusOp / finishOp drive the three public browser calls; approveOp drives
// the internal in-game vouch. They return the recorder so each test asserts its own
// codes and bodies.
func startOp(eh http.Handler, email string) *httptest.ResponseRecorder {
return do(eh, "POST", "/api/v1/auth/op-login/start", `{"email":"`+email+`"}`, jsonHeader)
}
func statusOp(eh http.Handler, id string) *httptest.ResponseRecorder {
return do(eh, "GET", "/api/v1/auth/op-login/status/"+id, "", nil)
}
func finishOp(eh http.Handler, id, code string) *httptest.ResponseRecorder {
return do(eh, "POST", "/api/v1/auth/op-login/finish",
`{"request_id":"`+id+`","code":"`+code+`"}`, jsonHeader)
}
func approveOp(ih http.Handler, id, approverUUID string) *httptest.ResponseRecorder {
return do(ih, "POST", "/api/v1/internal/op-login/"+id+"/approve",
`{"approver_uuid":"`+approverUUID+`"}`, nil)
}
// TestOpLoginVertical walks the whole two-factor slice end to end: start mails a code
// (purpose op_login) to the staff address of record and mints a pending request; the
// browser polls status until an in-game admin approves; finish redeems code+approval
// into the same host-only session the other doors mint. All three legs audit by the
// account's username, and the request is single-use.
func TestOpLoginVertical(t *testing.T) {
api, repo, mailer := seedOpLoginAPI(t)
eh := api.ExternalHandler()
ih := api.InternalHandler()
// 1) start: 202 with a request handle + expiry, never the code itself.
w := startOp(eh, "[email protected]")
if w.Code != http.StatusAccepted {
t.Fatalf("start: code = %d, want 202 (%s)", w.Code, w.Body.String())
}
b := acctBody(t, w)
reqID, _ := b["request_id"].(string)
if reqID == "" {
t.Fatal("start must return a request_id")
}
if _, leaked := b["code"]; leaked {
t.Error("start response must NEVER carry the code")
}
if s, _ := b["expires_at"].(string); s == "" {
t.Error("start must report expires_at")
}
// The code goes to the STORED casing (address of record), under the op_login purpose.
if mailer.calls != 1 || mailer.email != "[email protected]" {
t.Fatalf("mailer: calls=%d email=%q, want 1 send to the STORED casing [email protected]",
mailer.calls, mailer.email)
}
code := mailer.code
if len(repo.otps) != 1 {
t.Fatalf("persisted codes = %d, want 1", len(repo.otps))
}
for _, o := range repo.otps {
if o.purpose != otpPurposeOpLogin {
t.Errorf("otp purpose = %q, want %q", o.purpose, otpPurposeOpLogin)
}
}
// Exactly one pending request row, owned by the staff account.
if len(repo.opLogins) != 1 {
t.Fatalf("op_login_requests rows = %d, want 1", len(repo.opLogins))
}
if got := repo.opLogins[reqID]; got == nil || got.userID != "a1" || got.status != "pending" {
t.Fatalf("request row = %+v, want {userID:a1, status:pending}", got)
}
// 2) status before approval: not approved yet.
if sb := acctBody(t, statusOp(eh, reqID)); sb["approved"] != false {
t.Fatalf("status before approval: approved = %v, want false", sb["approved"])
}
// 3) finish before approval is REFUSED and must NOT burn the code (approval is read
// before the code is consumed).
if w := finishOp(eh, reqID, code); w.Code != http.StatusBadRequest || decodeErr(t, w) != "op_login_invalid" {
t.Fatalf("finish before approval: code = %d body %s, want 400 op_login_invalid", w.Code, w.Body.String())
}
if len(repo.sessions) != 0 {
t.Fatal("no session may be minted before approval")
}
// 4) an in-game admin approves via the internal face.
if w := approveOp(ih, reqID, opUUID); w.Code != http.StatusOK {
t.Fatalf("approve: code = %d, want 200 (%s)", w.Code, w.Body.String())
}
if ab := acctBody(t, statusOp(eh, reqID)); ab["approved"] != true {
t.Fatalf("status after approval: approved = %v, want true", ab["approved"])
}
// 5) finish with the preserved code: session minted, role=admin.
w = finishOp(eh, reqID, code)
if w.Code != http.StatusOK {
t.Fatalf("finish: code = %d, want 200 (%s)", w.Code, w.Body.String())
}
vb := acctBody(t, w)
if vb["user_id"] != "a1" || vb["role"] != "admin" {
t.Fatalf("finish body = %v, want user_id:a1 role:admin", vb)
}
cookies := w.Result().Cookies()
if len(cookies) != 1 || cookies[0].Name != sessionCookieName || cookies[0].Value == "" {
t.Fatalf("want one non-empty %s cookie, got %v", sessionCookieName, cookies)
}
if s, ok := repo.sessions[hashCookie(cookies[0].Value)]; !ok || s.userID != "a1" {
t.Fatalf("session row for the cookie = %+v (ok=%v), want userID a1", s, ok)
}
// Three audits by "op": otp_sent (start), approved (in-game vouch), op_login (finish).
if n := len(repo.audits); n != 3 {
t.Fatalf("want 3 audits, got %d: %+v", n, repo.audits)
}
wantActions := []string{"auth.op_login.otp_sent", "auth.op_login.approved", "auth.op_login"}
for i, want := range wantActions {
if repo.audits[i].Action != want || repo.audits[i].Actor != "op" {
t.Errorf("audit[%d] = %+v, want action %q by op", i, repo.audits[i], want)
}
}
// 6) single-use: the consumed request finishes no second time, and status flips back
// to approved:false (consumed).
if w := finishOp(eh, reqID, code); w.Code != http.StatusBadRequest || decodeErr(t, w) != "op_login_invalid" {
t.Fatalf("replay finish: code = %d body %s, want 400 op_login_invalid", w.Code, w.Body.String())
}
if sb := acctBody(t, statusOp(eh, reqID)); sb["approved"] != false {
t.Errorf("status after consume: approved = %v, want false", sb["approved"])
}
}
// TestOpLoginStartNeutral pins the start-side anti-enumeration contract: op.console is
// the STAFF door, so a non-admin account AND an unknown address both get a 202 carrying
// a request_id + expires_at, mint/mail nothing, and still burn the per-recipient
// cooldown — so neither the response nor the throttle tells a caller who is staff.
func TestOpLoginStartNeutral(t *testing.T) {
check := func(t *testing.T, seed func(*fakeRepo), email string) {
t.Helper()
repo := newFakeRepo()
repo.settings[LocalAuthEnabledKey] = []byte("true")
if seed != nil {
seed(repo)
}
mailer := &captureMailer{}
api := newTestAPI(repo, newFakeCluster())
api.Mailer = mailer
eh := api.ExternalHandler()
w := startOp(eh, email)
if w.Code != http.StatusAccepted {
t.Fatalf("neutral start: code = %d, want 202 (%s)", w.Code, w.Body.String())
}
b := acctBody(t, w)
if id, _ := b["request_id"].(string); id == "" {
t.Error("neutral start must still return a plausible request_id")
}
if s, _ := b["expires_at"].(string); s == "" {
t.Error("neutral start must still return expires_at")
}
if len(repo.opLogins) != 0 || len(repo.otps) != 0 || mailer.calls != 0 || len(repo.audits) != 0 {
t.Errorf("neutral start must mint/mail/audit nothing: reqs=%d otps=%d mails=%d audits=%d",
len(repo.opLogins), len(repo.otps), mailer.calls, len(repo.audits))
}
// The reservation is KEPT: re-probing the same address is throttled like a resend.
if w := startOp(eh, email); w.Code != http.StatusTooManyRequests || decodeErr(t, w) != "otp_resend_cooldown" {
t.Fatalf("re-probe: code = %d body %s, want 429 otp_resend_cooldown", w.Code, w.Body.String())
}
}
t.Run("unknown address", func(t *testing.T) {
check(t, nil, "[email protected]")
})
t.Run("non-staff (role=user) address is ignored by the staff door", func(t *testing.T) {
check(t, func(repo *fakeRepo) {
repo.staff["p"] = &StaffUser{ID: "u9", Username: "p", Email: "[email protected]", Role: "user", EmailVerified: true}
}, "[email protected]")
})
}
// TestOpLoginStatusNeutral proves status is never an enumeration oracle: it returns
// approved:true ONLY for a genuinely approved, live, unconsumed request, and
// approved:false (never 404) for an unknown, expired, denied, or consumed handle — all
// indistinguishable from a real request still awaiting approval.
func TestOpLoginStatusNeutral(t *testing.T) {
api, repo, _ := seedOpLoginAPI(t)
eh := api.ExternalHandler()
future := time.Unix(1_700_000_600, 0)
past := time.Unix(1_699_999_999, 0)
repo.opLogins["pending"] = &fakeOpLogin{id: "pending", userID: "a1", email: "[email protected]", status: "pending", expiresAt: future, createdAt: future}
repo.opLogins["expired"] = &fakeOpLogin{id: "expired", userID: "a1", email: "[email protected]", status: "approved", expiresAt: past, createdAt: past}
repo.opLogins["consumed"] = &fakeOpLogin{id: "consumed", userID: "a1", email: "[email protected]", status: "approved", consumed: true, expiresAt: future, createdAt: future}
repo.opLogins["denied"] = &fakeOpLogin{id: "denied", userID: "a1", email: "[email protected]", status: "denied", expiresAt: future, createdAt: future}
repo.opLogins["live"] = &fakeOpLogin{id: "live", userID: "a1", email: "[email protected]", status: "approved", expiresAt: future, createdAt: future}
for _, id := range []string{"unknown-handle", "pending", "expired", "consumed", "denied"} {
w := statusOp(eh, id)
if w.Code != http.StatusOK {
t.Fatalf("status %q: code = %d, want 200", id, w.Code)
}
if acctBody(t, w)["approved"] != false {
t.Errorf("status %q: approved = true, want false (must not be an oracle)", id)
}
}
// Only the genuinely-approved live request reads true.
if acctBody(t, statusOp(eh, "live"))["approved"] != true {
t.Error("status of an approved live request must read approved:true")
}
}
// TestOpLoginFinishUniform is the redeem-side failure matrix. The anchor is uniformity:
// an unknown handle and a wrong code for an approved request answer with the SAME
// (code, message) envelope, so finish never doubles as an oracle. Two lifecycle
// invariants are pinned alongside: a correct code submitted BEFORE approval is
// preserved (approval read before consume), and a wrong code costs an attempt without
// burning the approval.
func TestOpLoginFinishUniform(t *testing.T) {
t.Run("unknown handle and wrong code are indistinguishable", func(t *testing.T) {
api, _, mailer := seedOpLoginAPI(t)
eh, ih := api.ExternalHandler(), api.InternalHandler()
reqID := acctBody(t, startOp(eh, "[email protected]"))["request_id"].(string)
code := mailer.code
if w := approveOp(ih, reqID, opUUID); w.Code != http.StatusOK {
t.Fatalf("approve: %d (%s)", w.Code, w.Body.String())
}
// Wrong code for a real, approved request.
wWrong := finishOp(eh, reqID, code+"x")
// Unknown handle.
wGhost := finishOp(eh, "deadbeefdeadbeefdeadbeefdeadbeef", code)
if wWrong.Code != http.StatusBadRequest || wGhost.Code != http.StatusBadRequest {
t.Fatalf("codes = %d/%d, want 400/400", wWrong.Code, wGhost.Code)
}
wc, wm := errEnvelope(t, wWrong)
gc, gm := errEnvelope(t, wGhost)
if wc != "op_login_invalid" || wc != gc || wm != gm {
t.Errorf("envelopes differ: wrong=(%s,%q) unknown=(%s,%q) — must be identical", wc, wm, gc, gm)
}
})
t.Run("correct code before approval is preserved, not burned", func(t *testing.T) {
api, repo, mailer := seedOpLoginAPI(t)
eh, ih := api.ExternalHandler(), api.InternalHandler()
reqID := acctBody(t, startOp(eh, "[email protected]"))["request_id"].(string)
code := mailer.code
// Finish before approval: refused, and the code is NOT consumed.
if w := finishOp(eh, reqID, code); w.Code != http.StatusBadRequest || decodeErr(t, w) != "op_login_invalid" {
t.Fatalf("early finish: code = %d body %s, want 400 op_login_invalid", w.Code, w.Body.String())
}
for _, o := range repo.otps {
if o.consumed || o.attempts != 0 {
t.Errorf("early finish must not touch the code: consumed=%v attempts=%d", o.consumed, o.attempts)
}
}
// Approve, then the same code completes.
if w := approveOp(ih, reqID, opUUID); w.Code != http.StatusOK {
t.Fatalf("approve: %d (%s)", w.Code, w.Body.String())
}
if w := finishOp(eh, reqID, code); w.Code != http.StatusOK {
t.Fatalf("finish with preserved code: code = %d, want 200 (%s)", w.Code, w.Body.String())
}
})
t.Run("wrong code charges an attempt without burning the approval", func(t *testing.T) {
api, repo, mailer := seedOpLoginAPI(t)
eh, ih := api.ExternalHandler(), api.InternalHandler()
reqID := acctBody(t, startOp(eh, "[email protected]"))["request_id"].(string)
code := mailer.code
if w := approveOp(ih, reqID, opUUID); w.Code != http.StatusOK {
t.Fatalf("approve: %d (%s)", w.Code, w.Body.String())
}
// Wrong code: refused, one attempt charged, request still approved+unconsumed.
if w := finishOp(eh, reqID, code+"x"); w.Code != http.StatusBadRequest || decodeErr(t, w) != "op_login_invalid" {
t.Fatalf("wrong code: code = %d body %s, want 400 op_login_invalid", w.Code, w.Body.String())
}
for _, o := range repo.otps {
if o.consumed || o.attempts != 1 {
t.Errorf("wrong code must charge one attempt, not consume: consumed=%v attempts=%d", o.consumed, o.attempts)
}
}
if r := repo.opLogins[reqID]; r.status != "approved" || r.consumed {
t.Errorf("a wrong code must not burn the approval: status=%q consumed=%v", r.status, r.consumed)
}
// The right code still completes.
if w := finishOp(eh, reqID, code); w.Code != http.StatusOK {
t.Fatalf("retry with right code: code = %d, want 200 (%s)", w.Code, w.Body.String())
}
})
}
// TestOpLoginApproveGate pins the in-game approval gate: only a linked role=admin UUID
// may vouch (all refusals share one 403 not_admin), a missing/no-longer-pending request
// is 404, and a bare request without an approver UUID is 400.
func TestOpLoginApproveGate(t *testing.T) {
plantPending := func(repo *fakeRepo) string {
repo.opLogins["r1"] = &fakeOpLogin{
id: "r1", userID: "a1", email: "[email protected]", status: "pending",
expiresAt: time.Unix(1_700_000_600, 0), createdAt: time.Unix(1_700_000_000, 0),
}
return "r1"
}
t.Run("unlinked approver UUID -> 403, request stays pending", func(t *testing.T) {
api, repo, _ := seedOpLoginAPI(t)
id := plantPending(repo)
if w := approveOp(api.InternalHandler(), id, "ffffffff-ffff-ffff-ffff-ffffffffffff"); w.Code != http.StatusForbidden || decodeErr(t, w) != "not_admin" {
t.Fatalf("unlinked approver: code = %d body %s, want 403 not_admin", w.Code, w.Body.String())
}
if repo.opLogins[id].status != "pending" {
t.Error("a refused approval must leave the request pending")
}
})
t.Run("linked non-admin approver -> 403", func(t *testing.T) {
api, repo, _ := seedOpLoginAPI(t)
id := plantPending(repo)
repo.staff["p"] = &StaffUser{ID: "u9", Username: "p", Email: "[email protected]", Role: "user", EmailVerified: true}
repo.links["bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"] = "u9"
if w := approveOp(api.InternalHandler(), id, "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"); w.Code != http.StatusForbidden || decodeErr(t, w) != "not_admin" {
t.Fatalf("non-admin approver: code = %d body %s, want 403 not_admin", w.Code, w.Body.String())
}
})
t.Run("missing approver_uuid -> 400", func(t *testing.T) {
api, repo, _ := seedOpLoginAPI(t)
id := plantPending(repo)
if w := do(api.InternalHandler(), "POST", "/api/v1/internal/op-login/"+id+"/approve", `{}`, nil); w.Code != http.StatusBadRequest || decodeErr(t, w) != "bad_request" {
t.Fatalf("missing approver_uuid: code = %d body %s, want 400 bad_request", w.Code, w.Body.String())
}
})
t.Run("unknown request id -> 404", func(t *testing.T) {
api, _, _ := seedOpLoginAPI(t)
if w := approveOp(api.InternalHandler(), "nosuchrequest", opUUID); w.Code != http.StatusNotFound || decodeErr(t, w) != "op_login_not_found" {
t.Fatalf("unknown request: code = %d body %s, want 404 op_login_not_found", w.Code, w.Body.String())
}
})
t.Run("re-approving an approved request -> 404 (first approval stands)", func(t *testing.T) {
api, repo, _ := seedOpLoginAPI(t)
id := plantPending(repo)
if w := approveOp(api.InternalHandler(), id, opUUID); w.Code != http.StatusOK {
t.Fatalf("first approve: code = %d, want 200 (%s)", w.Code, w.Body.String())
}
if w := approveOp(api.InternalHandler(), id, opUUID); w.Code != http.StatusNotFound {
t.Fatalf("second approve: code = %d, want 404 (no longer pending)", w.Code)
}
if repo.opLogins[id].status != "approved" {
t.Error("the request must remain approved after a redundant re-approval")
}
})
}
// TestOpLoginPendingList covers the internal push list: live pending requests are
// returned oldest first, carrying the joined username, and an approved or expired
// request is absent.
func TestOpLoginPendingList(t *testing.T) {
api, repo, _ := seedOpLoginAPI(t)
ih := api.InternalHandler()
future := time.Unix(1_700_000_600, 0)
// Two pending (distinct createdAt so ordering is deterministic), one approved, one
// expired.
repo.opLogins["r2"] = &fakeOpLogin{id: "r2", userID: "a1", email: "[email protected]", status: "pending", expiresAt: future, createdAt: time.Unix(1_700_000_200, 0)}
repo.opLogins["r1"] = &fakeOpLogin{id: "r1", userID: "a1", email: "[email protected]", status: "pending", expiresAt: future, createdAt: time.Unix(1_700_000_100, 0)}
repo.opLogins["ap"] = &fakeOpLogin{id: "ap", userID: "a1", email: "[email protected]", status: "approved", expiresAt: future, createdAt: time.Unix(1_700_000_150, 0)}
repo.opLogins["ex"] = &fakeOpLogin{id: "ex", userID: "a1", email: "[email protected]", status: "pending", expiresAt: time.Unix(1_699_999_999, 0), createdAt: time.Unix(1_700_000_050, 0)}
w := do(ih, "GET", "/api/v1/internal/op-login/pending", "", nil)
if w.Code != http.StatusOK {
t.Fatalf("pending: code = %d, want 200 (%s)", w.Code, w.Body.String())
}
body := acctBody(t, w)
pending, _ := body["pending"].([]any)
if len(pending) != 2 {
t.Fatalf("pending count = %d, want 2 (only live pending rows) — %v", len(pending), body["pending"])
}
// Oldest first: r1 (created earlier) before r2.
first, _ := pending[0].(map[string]any)
second, _ := pending[1].(map[string]any)
if first["request_id"] != "r1" || second["request_id"] != "r2" {
t.Errorf("order = [%v, %v], want [r1, r2] (oldest first)", first["request_id"], second["request_id"])
}
if first["username"] != "op" || first["email"] != "[email protected]" {
t.Errorf("row projection = %v, want username op / email [email protected]", first)
}
}
// TestOpLoginGates covers the shared front doors: the fail-closed local-auth toggle on
// all three public legs, the CSRF Content-Type guard on the credential-minting POSTs,
// and the input gates that must reject before any lookup or mint.
func TestOpLoginGates(t *testing.T) {
t.Run("local auth disabled -> 403 on start/status/finish", func(t *testing.T) {
api := newTestAPI(newFakeRepo(), newFakeCluster()) // no LocalAuthEnabledKey: fails closed
eh := api.ExternalHandler()
if w := startOp(eh, "[email protected]"); w.Code != http.StatusForbidden || decodeErr(t, w) != "local_auth_disabled" {
t.Errorf("start: code = %d body %s, want 403 local_auth_disabled", w.Code, w.Body.String())
}
if w := statusOp(eh, "anything"); w.Code != http.StatusForbidden || decodeErr(t, w) != "local_auth_disabled" {
t.Errorf("status: code = %d body %s, want 403 local_auth_disabled", w.Code, w.Body.String())
}
if w := finishOp(eh, "anything", "123456"); w.Code != http.StatusForbidden || decodeErr(t, w) != "local_auth_disabled" {
t.Errorf("finish: code = %d body %s, want 403 local_auth_disabled", w.Code, w.Body.String())
}
})
t.Run("non-JSON content type -> 415 on the minting POSTs", func(t *testing.T) {
api, _, _ := seedOpLoginAPI(t)
eh := api.ExternalHandler()
for _, ct := range []string{"", "text/plain", "application/x-www-form-urlencoded"} {
if w := do(eh, "POST", "/api/v1/auth/op-login/start", `{"email":"[email protected]"}`, ctHeader(ct)); w.Code != http.StatusUnsupportedMediaType {
t.Errorf("start Content-Type %q: code = %d, want 415", ct, w.Code)
}
if w := do(eh, "POST", "/api/v1/auth/op-login/finish", `{"request_id":"x","code":"1"}`, ctHeader(ct)); w.Code != http.StatusUnsupportedMediaType {
t.Errorf("finish Content-Type %q: code = %d, want 415", ct, w.Code)
}
}
})
t.Run("start bad email -> 400, nothing minted", func(t *testing.T) {
for _, body := range []string{`{}`, `{"email":""}`, `{"email":"notanemail"}`, `{"email":"[email protected]","x":1}`} {
api, repo, mailer := seedOpLoginAPI(t)
w := do(api.ExternalHandler(), "POST", "/api/v1/auth/op-login/start", body, jsonHeader)
if w.Code != http.StatusBadRequest {
t.Errorf("start %q: code = %d, want 400 (%s)", body, w.Code, w.Body.String())
}
if len(repo.opLogins) != 0 || len(repo.otps) != 0 || mailer.calls != 0 {
t.Errorf("start %q: a rejected start must mint nothing", body)
}
}
})
t.Run("finish missing request_id or code -> 400 bad_request", func(t *testing.T) {
api, _, _ := seedOpLoginAPI(t)
eh := api.ExternalHandler()
for _, body := range []string{`{"code":"123456"}`, `{"request_id":"x"}`, `{"request_id":"","code":""}`} {
if w := do(eh, "POST", "/api/v1/auth/op-login/finish", body, jsonHeader); w.Code != http.StatusBadRequest || decodeErr(t, w) != "bad_request" {
t.Errorf("finish %q: code = %d body %s, want 400 bad_request", body, w.Code, w.Body.String())
}
}
})
}
// TestOpLoginFaceSeparation enforces the two-face split: the three public browser legs
// must 404 on the internal (service-token) face, and the two internal in-game legs must
// 404 on the external (Access-JWT) face.
func TestOpLoginFaceSeparation(t *testing.T) {
api, _, _ := seedOpLoginAPI(t)
eh, ih := api.ExternalHandler(), api.InternalHandler()
// Public legs must not appear on the internal face.
if w := do(ih, "POST", "/api/v1/auth/op-login/start", `{"email":"[email protected]"}`, jsonHeader); w.Code != http.StatusNotFound {
t.Errorf("start on internal face: code = %d, want 404", w.Code)
}
if w := do(ih, "GET", "/api/v1/auth/op-login/status/x", "", nil); w.Code != http.StatusNotFound {
t.Errorf("status on internal face: code = %d, want 404", w.Code)
}
if w := do(ih, "POST", "/api/v1/auth/op-login/finish", `{"request_id":"x","code":"1"}`, jsonHeader); w.Code != http.StatusNotFound {
t.Errorf("finish on internal face: code = %d, want 404", w.Code)
}
// Internal legs must not appear on the external face.
if w := do(eh, "GET", "/api/v1/internal/op-login/pending", "", nil); w.Code != http.StatusNotFound {
t.Errorf("pending on external face: code = %d, want 404", w.Code)
}
if w := do(eh, "POST", "/api/v1/internal/op-login/x/approve", `{"approver_uuid":"`+opUUID+`"}`, nil); w.Code != http.StatusNotFound {
t.Errorf("approve on external face: code = %d, want 404", w.Code)
}
}
+288 -35
View File
@@ -8,46 +8,47 @@ import (
"errors"
"io"
"net/http"
"strings"
"time"
)
// Passkey enrollment (spec §14 WebAuthn / Phase 6 bind). An already-authenticated
// principal binds a passkey to their account — the WebAuthn credential-creation
// ceremony — and manages the credentials they have bound. Email-OTP (handlers_email_otp.go)
// stays the fallback factor, so a player with no passkey is never locked out.
// Passkey (spec §14 WebAuthn). Two slices live in this file: ENROLLMENT — an already-
// authenticated principal binds a passkey to their account (the WebAuthn credential-
// creation ceremony) and manages the credentials they have bound — and the public LOGIN
// (assertion) door, which resolves an account by email, proves one of its bound passkeys,
// and mints a session from an UNauthenticated state (handlePasskeyLoginBegin/Finish, near
// the end of this file). Email-OTP (handlers_email_otp.go) stays the fallback factor, so a
// player with no passkey is never locked out.
//
// Scope of the HANDLERS in this file: ENROLLMENT only. Every ceremony here rides on a
// known principal — the challenge is bound to the caller's user_id and the finish
// verifies against the server-stashed SessionData, never a client-echoed challenge. The
// login/assertion path (proving a passkey to mint a session from an UNauthenticated
// state) has its cryptographic half built and Oracle-verified in the adapter
// (internal/passkey BeginLogin/FinishLogin, against a virtual authenticator) and its
// persist-ready output shape is VerifiedAssertion below — but the login HTTP handler is
// a DELIBERATELY deferred slice. Its design checkpoint (task #36) resolved two questions
// and then deferred, for reasons that outlive this comment:
// Every ceremony rides on a challenge bound to a user_id whose finish verifies against the
// server-stashed SessionData, never a client-echoed challenge. The login door's
// cryptographic half is built and Oracle-verified in the adapter (internal/passkey
// BeginLogin/FinishLogin, against a virtual authenticator); its persist-ready output shape
// is VerifiedAssertion below. The login door's design checkpoint (task #36) resolved two
// questions that still frame it:
//
// - RP boundary (RESOLVED): felis-api is the app-login relying party (panel.*); the
// WebAuthn-as-security-gate lives at the Cloudflare Access EDGE, not here. Spec §14
// ties WebAuthn/posture to admin.* (Access), while panel.* is plain app login with
// no WebAuthn requirement — so there is neither a spec-required assertion handler
// nor a backend step-up consumer for one (the role-switcher step-up UX is frontend).
// - Identifier (BLOCKING): a from-zero login needs a unique, human-typable handle to
// resolve the account before its passkeys can be offered. users.email is nullable
// and NOT unique (0001_init.sql), and a player's users.username IS their Minecraft
// uuid (pgrepo.go RedeemPlayerBindCode mints a uuid-derived unique username) —
// opaque, never typed into a form. The username-first assertion the non-resident
// credentials + user-keyed challenge store support therefore has nothing to key on.
// no WebAuthn requirement — so this door is a login convenience, not a spec-required
// backend step-up consumer (the role-switcher step-up UX is frontend).
// - Identifier (RESOLVED by #69/#70): a from-zero login needs a unique, human-typable
// handle to resolve the account before its passkeys can be offered. users.email was
// nullable and NOT unique (0001_init.sql), and a player's users.username IS their
// Minecraft uuid (pgrepo.go RedeemPlayerBindCode mints a uuid-derived unique username)
// — opaque, never typed into a form. The verified-email uniqueness invariant
// (0010_verified_email_unique.sql) plus UserByEmail gave the door the typable handle
// it keys on: begin resolves email → account → its bound passkeys.
//
// The system's returning-player door is already re-link (control of the in-game identity
// is the root of trust — handlers_onboard.go re-mints a session through the bind-code
// flow even after passkey/OTP are bound); passkey and email-OTP are factors on an
// ALREADY-authenticated principal here, not from-zero login methods. The real enabler
// for a from-zero passkey login is discoverable ("usernameless") credentials, which
// sidestep the identifier gap but reshape enrollment (residentKey) and need a
// non-user-keyed challenge store — a future migration and its own checkpoint (that door
// partly bypasses the in-game-identity root of trust). The adapter crypto is verified
// now so that slice inherits correct crypto; this file adds no unauthenticated login
// route until then.
// This is an EMAIL-first assertion, not a usernameless one. The system's returning-player
// root of trust is still re-link (control of the in-game identity — handlers_onboard.go
// re-mints a session through the bind-code flow even after passkey/OTP are bound); the
// email and passkey login doors are convenience layered on top, never the root. The real
// enabler for a TRULY from-zero passkey login (no identifier typed at all) is discoverable
// ("usernameless") credentials, which sidestep even the email handle but reshape enrollment
// (residentKey) and need a non-user-keyed challenge store — a future migration and its own
// checkpoint, task #40 (that door partly bypasses the in-game-identity root of trust). The
// adapter crypto is verified now so that slice inherits correct crypto.
//
// The cryptographic half is a seam (PasskeyVerifier) so this package never imports
// go-webauthn: ceremony state crosses the boundary as opaque bytes, the attestation
@@ -87,6 +88,20 @@ type PasskeyVerifier interface {
// blob BeginRegistration returned. A failed verification returns a non-nil error;
// the handler maps it to 400 (the ceremony state exists; the attestation is bad).
FinishRegistration(user PasskeyUser, sessionData []byte, attestation io.Reader) (VerifiedCredential, error)
// BeginLogin starts an assertion (login) ceremony for a known user. It returns the
// {"publicKey": {...}} request options for navigator.credentials.get() and the
// opaque SessionData the handler stashes and replays at finish. user.Credentials
// carries the passkeys already bound so the authenticator can be told which to
// offer. A user with no bound credential yields an error (nothing to assert); the
// handler treats that as "offer the email-OTP fallback instead", never a server
// fault.
BeginLogin(user PasskeyUser) (options json.RawMessage, sessionData []byte, err error)
// FinishLogin verifies the browser's assertion against the stashed SessionData and
// reports which of the user's credentials signed and the signature counter the
// authenticator reported. assertion is the raw navigator.credentials.get() result
// the browser posts back; sessionData is the blob BeginLogin returned. A failed
// verification returns a non-nil error; the handler maps it to 400.
FinishLogin(user PasskeyUser, sessionData []byte, assertion io.Reader) (VerifiedAssertion, error)
}
// PasskeyUser is the relying-party view of the enrolling principal the verifier needs:
@@ -126,12 +141,19 @@ type VerifiedCredential struct {
// signal — the verifier deliberately does not, so clone policy lives in one place with
// the stored state. SignCount is legitimately 0 for authenticators that keep no counter.
//
// The login handlers do not exist yet (see the file header): this is the stable seam
// output the production adapter (internal/passkey) already produces and its Oracle test
// already asserts on, so wiring the handlers later needs no reshaping here.
// The login handler below (handlePasskeyLoginFinish) obtains this from FinishLogin but
// currently checks only that the assertion verified — the SignCount/UserVerified consumer
// the note above anticipates is still future. It is the stable seam output the production
// adapter (internal/passkey) produces and its Oracle test asserts on, so handler and
// adapter agree on shape without either reshaping the other.
type VerifiedAssertion struct {
CredentialID string // base64url(raw credential id) — which bound credential signed
SignCount uint32
// UserVerified records that a PIN/biometric (not mere presence) was performed
// during the assertion ceremony. The verifier enforces UV=required at BeginLogin,
// so this is always true for a successful assertion; persisting it makes the
// guarantee auditable and survives a future policy that permits UV=preferred.
UserVerified bool
}
// errPasskeyUnavailable is returned when the WebAuthn verifier is not configured on
@@ -222,6 +244,11 @@ func (a *API) handlePasskeyRegisterFinish(w http.ResponseWriter, r *http.Request
writeError(w, r, newError(http.StatusBadRequest, "bad_request", "attestation is required"))
return
}
name := strings.TrimSpace(req.Name)
if len(name) > 100 {
writeError(w, r, newError(http.StatusBadRequest, "bad_request", "passkey name must be at most 100 characters"))
return
}
sessionData, err := a.Repo.ConsumePasskeyChallengeByUser(r.Context(), p.UserID, passkeyPurposeRegister, a.now())
if err != nil {
if errors.Is(err, ErrPasskeyChallengeInvalid) {
@@ -250,7 +277,7 @@ func (a *API) handlePasskeyRegisterFinish(w http.ResponseWriter, r *http.Request
PublicKey: vc.PublicKey,
SignCount: vc.SignCount,
AAGUID: vc.AAGUID,
Name: req.Name,
Name: name,
CreatedAt: a.now(),
UserVerified: vc.UserVerified,
BackupEligible: vc.BackupEligible,
@@ -328,3 +355,229 @@ func (a *API) handlePasskeyDelete(w http.ResponseWriter, r *http.Request) {
a.audit(r, auditActor(p), "account.passkey.removed", id)
w.WriteHeader(http.StatusNoContent)
}
// ---- passkey login (assertion) ----
// passkeyPurposeLogin scopes a challenge to the login (assertion) flow, keeping it
// from ever colliding with an enrollment challenge (passkeyPurposeRegister) for the
// same user. The challenge store is queried per (user, purpose), so the two flows
// are fully independent even for one account with both a live enrollment and a live
// login challenge.
const passkeyPurposeLogin = "passkey_login"
// passkeyLoginBeginRequest is the begin body: the email that resolves the account
// before its passkeys can be offered. There is no principal yet (this is a
// pre-session route), so the email is the identifier — the same role the typed
// email plays in the email-OTP and op-login doors.
type passkeyLoginBeginRequest struct {
Email string `json:"email"`
}
// handlePasskeyLoginBegin starts a passkey assertion ceremony for a returning user
// (Public, pre-session). It resolves the typed email to an account, loads the
// passkeys that account has bound, and asks the verifier for the assertion options
// + opaque SessionData the browser needs for navigator.credentials.get(). The
// SessionData is stashed under a short TTL, keyed to the user so the finish step
// can consume it. Requires local sessions to be enabled (like the other pre-session
// doors). A user with no bound passkey, an unknown email, and a real account with
// passkeys are distinguished by status code (400 vs 200) — this is an accepted
// enumeration trade-off (the /auth/options oracle is the sanctioned place to learn
// existence), but the per-recipient cooldown below makes probing impractical.
func (a *API) handlePasskeyLoginBegin(w http.ResponseWriter, r *http.Request) {
if !localAuthEnabled(r.Context(), a.Repo) {
writeError(w, r, newError(http.StatusForbidden, "local_auth_disabled",
"session login is disabled"))
return
}
if a.Passkey == nil {
writeError(w, r, errPasskeyUnavailable)
return
}
if err := requireJSONContentType(r); err != nil {
writeError(w, r, err)
return
}
var req passkeyLoginBeginRequest
if err := decodeJSON(w, r, &req); err != nil {
writeError(w, r, err)
return
}
email := strings.TrimSpace(req.Email)
if !looksLikeEmail(email) {
writeError(w, r, newError(http.StatusBadRequest, "bad_request", "a valid email is required"))
return
}
// Per-recipient cooldown reserved BEFORE any work, identical to the email-OTP and
// op-login doors: one winner per window, so a burst of probes is throttled. The
// key is namespaced apart from the other pre-session doors so they never perturb
// each other's throttle.
emailKey := "passkey:login:" + strings.ToLower(email)
lim := a.otpLimiter()
emailAt, ok := lim.reserve(emailKey, otpResendCooldown)
if !ok {
writeError(w, r, newError(http.StatusTooManyRequests, "otp_resend_cooldown",
"a passkey login was started recently; wait a moment before requesting another"))
return
}
committed := false
defer func() {
if !committed {
lim.release(emailKey, emailAt)
}
}()
u, err := a.Repo.UserByEmail(r.Context(), email)
if err != nil {
if errors.Is(err, ErrNotFound) {
committed = true // keep the reservation so probing is throttled
writeError(w, r, newError(http.StatusBadRequest, "no_passkey",
"no passkey enrolled for this account; use email or operator login"))
return
}
writeError(w, r, err)
return
}
creds, err := a.Repo.PasskeyCredentialsForUser(r.Context(), u.ID)
if err != nil {
writeError(w, r, err)
return
}
if len(creds) == 0 {
committed = true
writeError(w, r, newError(http.StatusBadRequest, "no_passkey",
"no passkey enrolled for this account; use email or operator login"))
return
}
user := PasskeyUser{
ID: u.ID,
Name: email,
DisplayName: u.Username,
Credentials: creds,
}
options, sessionData, err := a.Passkey.BeginLogin(user)
if err != nil {
writeError(w, r, newError(http.StatusBadRequest, "passkey_login_failed",
"could not start passkey login"))
return
}
id, err := newPasskeyID()
if err != nil {
writeError(w, r, err)
return
}
expiresAt := a.now().Add(passkeyChallengeTTL)
if err := a.Repo.CreatePasskeyChallenge(r.Context(), id, u.ID, passkeyPurposeLogin, sessionData, expiresAt); err != nil {
writeError(w, r, err)
return
}
committed = true
writeJSON(w, http.StatusOK, options)
}
// passkeyLoginFinishRequest is the finish body: the email (to resolve the account,
// as in the begin step) and the raw navigator.credentials.get() assertion response.
// Attestation is captured as RawMessage so the handler hands the exact bytes the
// browser produced to the verifier without re-encoding.
type passkeyLoginFinishRequest struct {
Email string `json:"email"`
Assertion json.RawMessage `json:"assertion"`
}
// handlePasskeyLoginFinish verifies a passkey assertion and mints a session (Public,
// pre-session). It resolves the email to the account, atomically consumes the
// stashed login challenge (a missing or expired one → 400), verifies the assertion
// against the SessionData, and mints a felis_session. Both players and staff may
// log in this way — the passkey is a two-factor authenticator (possession +
// biometric/PIN), strong enough to stand alone without the in-game approval the
// op-login flow requires. The session cookie is host-only, so a session minted on
// console.<root_domain> cannot reach op.console, and ViaAdminAccess is host-checked
// so admin operations are gated regardless.
func (a *API) handlePasskeyLoginFinish(w http.ResponseWriter, r *http.Request) {
if !localAuthEnabled(r.Context(), a.Repo) {
writeError(w, r, newError(http.StatusForbidden, "local_auth_disabled",
"session login is disabled"))
return
}
if a.Passkey == nil {
writeError(w, r, errPasskeyUnavailable)
return
}
if err := requireJSONContentType(r); err != nil {
writeError(w, r, err)
return
}
var req passkeyLoginFinishRequest
if err := decodeJSON(w, r, &req); err != nil {
writeError(w, r, err)
return
}
email := strings.TrimSpace(req.Email)
if !looksLikeEmail(email) {
writeError(w, r, newError(http.StatusBadRequest, "bad_request", "a valid email is required"))
return
}
if len(req.Assertion) == 0 {
writeError(w, r, newError(http.StatusBadRequest, "bad_request", "assertion is required"))
return
}
u, err := a.Repo.UserByEmail(r.Context(), email)
if err != nil {
if errors.Is(err, ErrNotFound) {
writeError(w, r, newError(http.StatusBadRequest, "passkey_login_invalid",
"passkey login could not be completed; begin again"))
return
}
writeError(w, r, err)
return
}
sessionData, err := a.Repo.ConsumePasskeyChallengeByUser(r.Context(), u.ID, passkeyPurposeLogin, a.now())
if err != nil {
if errors.Is(err, ErrPasskeyChallengeInvalid) {
writeError(w, r, newError(http.StatusBadRequest, "passkey_login_invalid",
"passkey login could not be completed; begin again"))
return
}
writeError(w, r, err)
return
}
creds, err := a.Repo.PasskeyCredentialsForUser(r.Context(), u.ID)
if err != nil {
writeError(w, r, err)
return
}
user := PasskeyUser{
ID: u.ID,
Name: email,
DisplayName: u.Username,
Credentials: creds,
}
_, err = a.Passkey.FinishLogin(user, sessionData, bytes.NewReader(req.Assertion))
if err != nil {
writeError(w, r, newError(http.StatusBadRequest, "passkey_login_invalid",
"passkey login could not be completed; begin again"))
return
}
token, err := newSessionToken()
if err != nil {
writeError(w, r, err)
return
}
expires := a.now().Add(sessionTTL)
if err := a.Repo.CreateSession(r.Context(), hashCookie(token), u.ID, expires); err != nil {
writeError(w, r, err)
return
}
setSessionCookie(w, token, expires)
a.audit(r, u.Username, "auth.passkey_login", "")
writeJSON(w, http.StatusOK, map[string]any{
"user_id": u.ID,
"role": u.Role,
})
}
+460
View File
@@ -0,0 +1,460 @@
package api
import (
"bytes"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
"time"
)
// Pre-session Passkey (assertion) LOGIN tests (spec §B, console.<root_domain>
// returning-player door — the public sibling of the email-OTP login door). These drive
// the two Public routes against the fakeRepo challenge state machine and a fake
// PasskeyVerifier, so what they PROVE is the handler + login state machine (challenge
// stash → consume → session mint), not the pgrepo SQL nor the cryptographic assertion
// verification (both mirrored, not run here). The load-bearing properties, in flow order:
//
// - Session-data round-trip: the finish body carries only email+assertion, so the only
// path for the stashed blob into FinishLogin is store-stash → consume — the challenge
// is never client-echoed.
// - Anti-enumeration on finish: unknown email, no live challenge, expired challenge and
// a bad assertion all collapse to ONE passkey_login_invalid envelope, so the finish
// half is never an existence/state oracle.
// - Cooldown seals the accepted begin-side trade-off: has-passkey (200) vs no_passkey
// (400) is a status oracle, but one begin per recipient per window throttles probing.
// - Staff admitted: unlike the email door's staff_account refusal, a passkey stands
// alone (possession + user-verification), so role=admin mints a session here.
// seedLoginPasskeyAPI wires the public passkey-login door: local sessions enabled, a
// single verified player "player" (id u1) whose proven address is stored in MIXED case
// (so the resolve-on-typed-lowercase contract is exercised by default), one passkey
// credential bound to that account, and a verifier primed with fixed options + a verified
// assertion. Both routes are Public — no External principal wired, proving they are truly
// pre-session.
func seedLoginPasskeyAPI(t *testing.T) (*API, *fakeRepo, *fakePasskeyVerifier) {
t.Helper()
repo := newFakeRepo()
repo.settings[LocalAuthEnabledKey] = []byte("true")
repo.staff["player"] = &StaffUser{
ID: "u1", Username: "player", Email: "[email protected]",
Role: "user", EmailVerified: true,
}
repo.passkeyCreds["row1"] = PasskeyCredential{
ID: "row1", UserID: "u1", CredentialID: "cred-1", PublicKey: "k", CreatedAt: frozenNow,
}
v := &fakePasskeyVerifier{
options: json.RawMessage(`{"publicKey":{"challenge":"YXNzZXJ0"}}`),
assertion: VerifiedAssertion{CredentialID: "cred-1", UserVerified: true},
}
api := newTestAPI(repo, newFakeCluster())
api.Passkey = v
return api, repo, v
}
// plantLoginChallenge seeds a stashed LOGIN-purpose challenge for u1 directly, so the
// finish-side branches (expired, verification failure) are reachable under the frozen
// clock without running begin first. Mirrors plantPasskeyChallenge, but scoped to
// passkeyPurposeLogin so it is only ever consumed by the login door.
func plantLoginChallenge(repo *fakeRepo, id string, expiresAt time.Time) {
repo.passkeyChallenges[id] = &fakePasskeyChallenge{
id: id, userID: "u1", purpose: passkeyPurposeLogin,
sessionData: []byte("login-session:u1"), expiresAt: expiresAt, createdAt: expiresAt,
}
}
// TestPasskeyLoginVertical walks the whole returning-player slice across the external
// face: begin resolves the mixed-case account from a lowercase-typed email, hands its
// bound credential to the verifier, returns the assertion options verbatim and stashes
// one login challenge; finish verifies the assertion against the SERVER-STASHED session
// data and mints the same host-only felis_session as the email door. The decisive
// assertion is the session-data round-trip: the finish body carries only email+assertion,
// so the only path for the stashed blob into FinishLogin is store-stash → consume,
// proving the challenge is never client-echoed.
func TestPasskeyLoginVertical(t *testing.T) {
api, repo, v := seedLoginPasskeyAPI(t)
eh := api.ExternalHandler()
// 1) begin: options verbatim, exactly one login-purpose challenge stashed for u1, and
// the account's bound credential handed to the verifier (so the authenticator can be
// asked to assert with a known key).
w := do(eh, "POST", "/api/v1/auth/passkey/login/begin", `{"email":"[email protected]"}`, jsonHeader)
if w.Code != http.StatusOK {
t.Fatalf("begin: code = %d, want 200 (%s)", w.Code, w.Body.String())
}
if b := acctBody(t, w); b["publicKey"] == nil {
t.Errorf("begin must return the assertion options verbatim, got %s", w.Body.String())
}
if v.lastUser.ID != "u1" {
t.Errorf("begin passed user id %q, want u1", v.lastUser.ID)
}
if len(v.lastUser.Credentials) != 1 || v.lastUser.Credentials[0].CredentialID != "cred-1" {
t.Errorf("begin must hand the account's bound credential to the verifier, got %+v", v.lastUser.Credentials)
}
if len(repo.passkeyChallenges) != 1 {
t.Fatalf("begin must stash exactly one challenge, got %d", len(repo.passkeyChallenges))
}
for _, c := range repo.passkeyChallenges {
if c.userID != "u1" || c.purpose != passkeyPurposeLogin {
t.Errorf("stashed challenge = %+v, want user u1 purpose %q", c, passkeyPurposeLogin)
}
}
// 2) finish — typed in yet another casing, proving the finish-side resolver is
// case-insensitive too — verifies the assertion and mints the session. The body
// carries NO challenge, only email+assertion.
w = do(eh, "POST", "/api/v1/auth/passkey/login/finish",
`{"email":"[email protected]","assertion":{"id":"cred-1","type":"public-key"}}`, jsonHeader)
if w.Code != http.StatusOK {
t.Fatalf("finish: code = %d, want 200 (%s)", w.Code, w.Body.String())
}
// THE security assertion: the blob the verifier saw at finish is exactly what begin
// stashed — it travelled store-stash → consume, never the client.
if !bytes.Equal(v.lastSession, []byte("login-session:u1")) {
t.Fatalf("finish session data = %q, want the server-stashed %q (challenge must not be client-echoed)",
v.lastSession, "login-session:u1")
}
vb := acctBody(t, w)
if vb["user_id"] != "u1" || vb["role"] != "user" {
t.Fatalf("finish body = %v, want user_id:u1 role:user", vb)
}
// The host-only HttpOnly cookie is the whole point — same contract as the email door.
cookies := w.Result().Cookies()
if len(cookies) != 1 || cookies[0].Name != sessionCookieName || cookies[0].Value == "" {
t.Fatalf("want one non-empty %s cookie, got %v", sessionCookieName, cookies)
}
s, ok := repo.sessions[hashCookie(cookies[0].Value)]
if !ok {
t.Fatal("no session row for the issued cookie (must be stored hashed)")
}
if s.userID != "u1" {
t.Errorf("session userID = %q, want u1", s.userID)
}
if want := frozenNow.Add(sessionTTL); !s.expiresAt.Equal(want) {
t.Errorf("session expiresAt = %v, want now+sessionTTL = %v", s.expiresAt, want)
}
// Audited once, by the account's username (there is no principal yet); begin is silent.
if n := len(repo.audits); n != 1 {
t.Fatalf("want exactly 1 audit (passkey_login), got %d: %+v", n, repo.audits)
}
if repo.audits[0].Action != "auth.passkey_login" || repo.audits[0].Actor != "player" {
t.Errorf("audit = %+v, want auth.passkey_login by player", repo.audits[0])
}
// 3) single-use: the consumed challenge buys nothing a second time.
if w := do(eh, "POST", "/api/v1/auth/passkey/login/finish",
`{"email":"[email protected]","assertion":{"id":"cred-1","type":"public-key"}}`, jsonHeader); w.Code != http.StatusBadRequest || decodeErr(t, w) != "passkey_login_invalid" {
t.Fatalf("replay of consumed challenge: code = %d body %s, want 400 passkey_login_invalid", w.Code, w.Body.String())
}
}
// TestPasskeyLoginBeginNoPasskey pins the begin-side anti-enumeration floor: an unknown
// email and a KNOWN verified account that has enrolled no passkey answer the SAME
// no_passkey envelope, so the two are indistinguishable. (The remaining has-passkey-vs-not
// status split is the documented, accepted trade-off; the cooldown below makes probing
// it impractical.) Neither path stashes a challenge, and both KEEP the reservation.
func TestPasskeyLoginBeginNoPasskey(t *testing.T) {
begin := func(eh http.Handler, email string) *httptest.ResponseRecorder {
return do(eh, "POST", "/api/v1/auth/passkey/login/begin", `{"email":"`+email+`"}`, jsonHeader)
}
t.Run("unknown email and a passkey-less account answer the same no_passkey", func(t *testing.T) {
// Unknown email: no account at all.
apiU, repoU, _ := seedLoginPasskeyAPI(t)
wGhost := begin(apiU.ExternalHandler(), "[email protected]")
// Known verified account that has enrolled NO passkey.
apiN, repoN, _ := seedLoginPasskeyAPI(t)
delete(repoN.passkeyCreds, "row1")
wNone := begin(apiN.ExternalHandler(), "[email protected]")
if wGhost.Code != http.StatusBadRequest || wNone.Code != http.StatusBadRequest {
t.Fatalf("codes = %d/%d, want 400/400", wGhost.Code, wNone.Code)
}
gc, gm := errEnvelope(t, wGhost)
nc, nm := errEnvelope(t, wNone)
if gc != "no_passkey" || gc != nc || gm != nm {
t.Errorf("envelopes differ: unknown=(%s,%q) no-cred=(%s,%q) — must be identical no_passkey", gc, gm, nc, nm)
}
// Neither may stash a challenge or reach BeginLogin.
if len(repoU.passkeyChallenges) != 0 || len(repoN.passkeyChallenges) != 0 {
t.Errorf("no_passkey paths must stash nothing, got unknown=%d no-cred=%d",
len(repoU.passkeyChallenges), len(repoN.passkeyChallenges))
}
})
t.Run("the no_passkey path KEEPS the reservation so probing is throttled", func(t *testing.T) {
api, _, _ := seedLoginPasskeyAPI(t)
eh := api.ExternalHandler()
if w := begin(eh, "[email protected]"); w.Code != http.StatusBadRequest || decodeErr(t, w) != "no_passkey" {
t.Fatalf("first probe: code = %d body %s, want 400 no_passkey", w.Code, w.Body.String())
}
// Re-probing the same unknown address inside the window is throttled identically to
// a real begin — the response is not the only channel; the throttle is sealed too.
if w := begin(eh, "[email protected]"); w.Code != http.StatusTooManyRequests || decodeErr(t, w) != "otp_resend_cooldown" {
t.Fatalf("re-probe: code = %d body %s, want 429 otp_resend_cooldown", w.Code, w.Body.String())
}
})
}
// TestPasskeyLoginBeginVerifierError pins the reserve→rollback path: a BeginLogin failure
// is a server-side fault, not a probe signal, so it answers passkey_login_failed AND
// RELEASES the reservation — the immediate retry is admitted, not 429'd. Distinguishing
// 400-not-429 on the retry is what proves the release: a kept reservation would 429 before
// ever reaching BeginLogin.
func TestPasskeyLoginBeginVerifierError(t *testing.T) {
api, repo, v := seedLoginPasskeyAPI(t)
v.beginLoginErr = errors.New("no assertable credential")
eh := api.ExternalHandler()
if w := do(eh, "POST", "/api/v1/auth/passkey/login/begin", `{"email":"[email protected]"}`, jsonHeader); w.Code != http.StatusBadRequest || decodeErr(t, w) != "passkey_login_failed" {
t.Fatalf("verifier error: code = %d body %s, want 400 passkey_login_failed", w.Code, w.Body.String())
}
if len(repo.passkeyChallenges) != 0 {
t.Errorf("a failed begin must stash no challenge, got %d", len(repo.passkeyChallenges))
}
if w := do(eh, "POST", "/api/v1/auth/passkey/login/begin", `{"email":"[email protected]"}`, jsonHeader); w.Code != http.StatusBadRequest || decodeErr(t, w) != "passkey_login_failed" {
t.Fatalf("retry after verifier error: code = %d body %s, want 400 passkey_login_failed (reservation must be released, not 429)", w.Code, w.Body.String())
}
}
// TestPasskeyLoginGates covers the shared front doors of both halves: the fail-closed
// local-auth toggle, graceful degradation when no verifier is wired, the CSRF Content-Type
// guard (these are Public, credential-minting routes), and the input gates that must
// reject before any lookup or stash.
func TestPasskeyLoginGates(t *testing.T) {
const beginPath = "/api/v1/auth/passkey/login/begin"
const finishPath = "/api/v1/auth/passkey/login/finish"
const goodBegin = `{"email":"[email protected]"}`
const goodFinish = `{"email":"[email protected]","assertion":{"id":"cred-1"}}`
t.Run("local auth disabled -> 403 on both halves", func(t *testing.T) {
api := newTestAPI(newFakeRepo(), newFakeCluster()) // no LocalAuthEnabledKey: fails closed
api.Passkey = &fakePasskeyVerifier{}
eh := api.ExternalHandler()
if w := do(eh, "POST", beginPath, goodBegin, jsonHeader); w.Code != http.StatusForbidden || decodeErr(t, w) != "local_auth_disabled" {
t.Errorf("begin: code = %d body %s, want 403 local_auth_disabled", w.Code, w.Body.String())
}
if w := do(eh, "POST", finishPath, goodFinish, jsonHeader); w.Code != http.StatusForbidden || decodeErr(t, w) != "local_auth_disabled" {
t.Errorf("finish: code = %d body %s, want 403 local_auth_disabled", w.Code, w.Body.String())
}
})
t.Run("no verifier wired -> 503 passkey_unavailable on both halves", func(t *testing.T) {
api, _, _ := seedLoginPasskeyAPI(t)
api.Passkey = nil // unwire it: the degraded path must be a clean 503, not a panic
eh := api.ExternalHandler()
if w := do(eh, "POST", beginPath, goodBegin, jsonHeader); w.Code != http.StatusServiceUnavailable || decodeErr(t, w) != "passkey_unavailable" {
t.Errorf("begin: code = %d body %s, want 503 passkey_unavailable", w.Code, w.Body.String())
}
if w := do(eh, "POST", finishPath, goodFinish, jsonHeader); w.Code != http.StatusServiceUnavailable || decodeErr(t, w) != "passkey_unavailable" {
t.Errorf("finish: code = %d body %s, want 503 passkey_unavailable", w.Code, w.Body.String())
}
})
t.Run("non-JSON content type -> 415 on both halves", func(t *testing.T) {
api, _, _ := seedLoginPasskeyAPI(t)
eh := api.ExternalHandler()
for _, ct := range []string{"", "text/plain", "application/x-www-form-urlencoded"} {
if w := do(eh, "POST", beginPath, goodBegin, ctHeader(ct)); w.Code != http.StatusUnsupportedMediaType {
t.Errorf("begin with Content-Type %q: code = %d, want 415", ct, w.Code)
}
if w := do(eh, "POST", finishPath, goodFinish, ctHeader(ct)); w.Code != http.StatusUnsupportedMediaType {
t.Errorf("finish with Content-Type %q: code = %d, want 415", ct, w.Code)
}
}
})
t.Run("begin bad email -> 400, nothing stashed", func(t *testing.T) {
bad := map[string]string{
"missing email": `{}`,
"empty email": `{"email":""}`,
"no at-sign": `{"email":"notanemail"}`,
"two at-signs": `{"email":"a@[email protected]"}`,
"unknown field": `{"email":"[email protected]","x":1}`,
}
for name, body := range bad {
api, repo, _ := seedLoginPasskeyAPI(t)
w := do(api.ExternalHandler(), "POST", beginPath, body, jsonHeader)
if w.Code != http.StatusBadRequest {
t.Errorf("%s: code = %d, want 400 (%s)", name, w.Code, w.Body.String())
}
if len(repo.passkeyChallenges) != 0 {
t.Errorf("%s: a rejected begin must stash nothing (%d)", name, len(repo.passkeyChallenges))
}
}
})
t.Run("finish bad inputs -> 400", func(t *testing.T) {
// An assertion key of {} is non-empty (2 bytes), so it clears the len==0 gate and
// fails later at passkey_login_invalid — the "missing assertion" gate is only the
// absent key. These are the cases the input gate itself must catch.
cases := []struct{ name, body, wantCode string }{
{"bad email", `{"email":"notanemail","assertion":{"id":"x"}}`, "bad_request"},
{"missing assertion", `{"email":"[email protected]"}`, "bad_request"},
{"unknown field", `{"email":"[email protected]","assertion":{"id":"x"},"z":1}`, ""},
}
for _, c := range cases {
api, _, _ := seedLoginPasskeyAPI(t)
w := do(api.ExternalHandler(), "POST", finishPath, c.body, jsonHeader)
if w.Code != http.StatusBadRequest {
t.Errorf("%s: code = %d, want 400 (%s)", c.name, w.Code, w.Body.String())
}
if c.wantCode != "" && decodeErr(t, w) != c.wantCode {
t.Errorf("%s: error code = %q, want %q", c.name, decodeErr(t, w), c.wantCode)
}
}
})
}
// TestPasskeyLoginFinishRejections is the redeem-side failure matrix and the anchor for
// anti-enumeration: an unknown email, a known account with no live challenge, an expired
// challenge and an assertion that fails verification must ALL answer the byte-identical
// passkey_login_invalid envelope (code AND message) and mint no session — so the finish
// half never doubles as an existence or ceremony-state oracle. Expired state is planted
// directly: the frozen clock makes that the only deterministic route to that branch.
func TestPasskeyLoginFinishRejections(t *testing.T) {
const finishPath = "/api/v1/auth/passkey/login/finish"
finish := func(eh http.Handler, email string) *httptest.ResponseRecorder {
return do(eh, "POST", finishPath,
`{"email":"`+email+`","assertion":{"id":"cred-1","type":"public-key"}}`, jsonHeader)
}
cases := []struct {
name string
email string
setup func(repo *fakeRepo, v *fakePasskeyVerifier)
}{
{"unknown email", "[email protected]", func(repo *fakeRepo, v *fakePasskeyVerifier) {}},
{"known account, no live challenge", "[email protected]", func(repo *fakeRepo, v *fakePasskeyVerifier) {}},
{"expired challenge", "[email protected]", func(repo *fakeRepo, v *fakePasskeyVerifier) {
plantLoginChallenge(repo, "ex", frozenNow.Add(-time.Second))
}},
{"assertion fails verification", "[email protected]", func(repo *fakeRepo, v *fakePasskeyVerifier) {
plantLoginChallenge(repo, "live", frozenNow.Add(passkeyChallengeTTL))
v.failErr = errors.New("bad assertion")
}},
}
var envelopes [][2]string
for _, c := range cases {
api, repo, v := seedLoginPasskeyAPI(t)
c.setup(repo, v)
w := finish(api.ExternalHandler(), c.email)
if w.Code != http.StatusBadRequest {
t.Fatalf("%s: code = %d, want 400 (%s)", c.name, w.Code, w.Body.String())
}
code, msg := errEnvelope(t, w)
if code != "passkey_login_invalid" {
t.Errorf("%s: error code = %q, want passkey_login_invalid", c.name, code)
}
if len(repo.sessions) != 0 {
t.Errorf("%s: a rejected finish must mint no session (got %d)", c.name, len(repo.sessions))
}
if len(w.Result().Cookies()) != 0 {
t.Errorf("%s: a rejected finish must set no cookie", c.name)
}
envelopes = append(envelopes, [2]string{code, msg})
}
// The anchor: every envelope is identical (code AND message), so no branch is
// distinguishable from another.
for i := 1; i < len(envelopes); i++ {
if envelopes[i] != envelopes[0] {
t.Errorf("envelope for %q %v differs from %q %v — all rejections must be identical",
cases[i].name, envelopes[i], cases[0].name, envelopes[0])
}
}
}
// TestPasskeyLoginBeginRateLimited closes the unauthenticated probing/DoS vector on the
// public door: one begin per recipient per window, keyed case-insensitively (a recased
// retype is the same mailbox), recovering after the window elapses. This is what makes the
// accepted has-passkey-vs-not status oracle impractical to farm.
func TestPasskeyLoginBeginRateLimited(t *testing.T) {
begin := func(eh http.Handler, email string) *httptest.ResponseRecorder {
return do(eh, "POST", "/api/v1/auth/passkey/login/begin", `{"email":"`+email+`"}`, jsonHeader)
}
t.Run("same recipient is throttled, then recovers after the cooldown", func(t *testing.T) {
api, _, _ := seedLoginPasskeyAPI(t)
clock := frozenNow
api.Now = func() time.Time { return clock }
eh := api.ExternalHandler()
if w := begin(eh, "[email protected]"); w.Code != http.StatusOK {
t.Fatalf("first begin: code = %d, want 200 (%s)", w.Code, w.Body.String())
}
if w := begin(eh, "[email protected]"); w.Code != http.StatusTooManyRequests || decodeErr(t, w) != "otp_resend_cooldown" {
t.Fatalf("immediate re-begin: code = %d body %s, want 429 otp_resend_cooldown", w.Code, w.Body.String())
}
clock = clock.Add(otpResendCooldown + time.Second)
if w := begin(eh, "[email protected]"); w.Code != http.StatusOK {
t.Fatalf("post-cooldown begin: code = %d, want 200 (%s)", w.Code, w.Body.String())
}
})
t.Run("throttle key is case-insensitive", func(t *testing.T) {
api, _, _ := seedLoginPasskeyAPI(t)
eh := api.ExternalHandler()
if w := begin(eh, "[email protected]"); w.Code != http.StatusOK {
t.Fatalf("first begin: code = %d, want 200 (%s)", w.Code, w.Body.String())
}
if w := begin(eh, "[email protected]"); w.Code != http.StatusTooManyRequests {
t.Fatalf("recased re-begin: code = %d, want 429 (key must be lowercased)", w.Code)
}
})
}
// TestPasskeyLoginAllowsStaff pins the deliberate contrast with the email door: that door
// refuses role != user with 403 staff_account (op.console keeps its Zero-Trust in-game
// gate), but the passkey door ADMITS staff — a passkey is a strong two-factor authenticator
// (possession + user-verification), enough to stand alone. This guards against a future
// "make the doors consistent" change silently locking admins out of passkey login.
func TestPasskeyLoginAllowsStaff(t *testing.T) {
repo := newFakeRepo()
repo.settings[LocalAuthEnabledKey] = []byte("true")
repo.staff["boss"] = &StaffUser{
ID: "a1", Username: "boss", Email: "[email protected]",
Role: "admin", EmailVerified: true,
}
repo.passkeyCreds["row1"] = PasskeyCredential{
ID: "row1", UserID: "a1", CredentialID: "cred-a1", PublicKey: "k", CreatedAt: frozenNow,
}
v := &fakePasskeyVerifier{
options: json.RawMessage(`{"publicKey":{"challenge":"YXNzZXJ0"}}`),
assertion: VerifiedAssertion{CredentialID: "cred-a1", UserVerified: true},
}
api := newTestAPI(repo, newFakeCluster())
api.Passkey = v
eh := api.ExternalHandler()
if w := do(eh, "POST", "/api/v1/auth/passkey/login/begin", `{"email":"[email protected]"}`, jsonHeader); w.Code != http.StatusOK {
t.Fatalf("begin for staff: code = %d, want 200 (%s)", w.Code, w.Body.String())
}
w := do(eh, "POST", "/api/v1/auth/passkey/login/finish",
`{"email":"[email protected]","assertion":{"id":"cred-a1","type":"public-key"}}`, jsonHeader)
if w.Code != http.StatusOK {
t.Fatalf("finish for staff: code = %d, want 200 (passkey admits staff) (%s)", w.Code, w.Body.String())
}
if b := acctBody(t, w); b["user_id"] != "a1" || b["role"] != "admin" {
t.Fatalf("finish body = %v, want user_id:a1 role:admin", b)
}
if len(repo.sessions) != 1 {
t.Errorf("a staff passkey login must mint a session, got %d", len(repo.sessions))
}
}
// TestPasskeyLoginFaceSeparation enforces that both halves are web-only: the internal
// (service-token) face must 404 them, never serve them.
func TestPasskeyLoginFaceSeparation(t *testing.T) {
api, _, _ := seedLoginPasskeyAPI(t)
ih := api.InternalHandler()
if w := do(ih, "POST", "/api/v1/auth/passkey/login/begin", `{"email":"[email protected]"}`, jsonHeader); w.Code != http.StatusNotFound {
t.Errorf("begin on internal face: code = %d, want 404", w.Code)
}
if w := do(ih, "POST", "/api/v1/auth/passkey/login/finish", `{"email":"[email protected]","assertion":{"id":"x"}}`, jsonHeader); w.Code != http.StatusNotFound {
t.Errorf("finish on internal face: code = %d, want 404", w.Code)
}
}
+8 -9
View File
@@ -100,7 +100,7 @@ func TestReclaimProtectsAdminOnYggdrasil(t *testing.T) {
const adminUUID = "0a11dead-0000-0000-0000-00000000ad11"
repo := newFakeRepo()
// An Operator who linked in-game through the third-party Yggdrasil (auth_source).
repo.staff["operator1"] = &StaffUser{ID: "op-1", Username: "operator1", Role: "admin", PasswordHash: "$2a$10$VnJ5kZqZ9bQmsCp1uoQ3qO"}
repo.staff["operator1"] = &StaffUser{ID: "op-1", Username: "operator1", Role: "admin"}
repo.links[adminUUID] = "op-1"
repo.linkAuthSource[adminUUID] = authSourceThirdParty
@@ -151,26 +151,25 @@ func TestReclaimProtectsAdminOnYggdrasil(t *testing.T) {
// - a Mojang-authenticated admin is still reclaimed (pins auth_source='thirdparty') —
// an admin's Mojang identity has no Login-Server name to protect (and Mojang names
// are unique, so this is operationally moot, but it locks the conjunct);
// - an SSO Operator with NO local password is still protected (pins the deliberate
// ABSENCE of a password_hash test) — signing in via Cloudflare Access (§14) leaves
// role='admin' with a NULL hash, and that holder must be protected all the same.
// - an SSO Operator authenticated through the third-party Yggdrasil is protected even
// with no local login secret at all — protection turns on role + auth_source, so an
// admin who signs in via Cloudflare Access (§14) is covered just the same.
func TestReclaimAdminProtectionScope(t *testing.T) {
const squatter = "0a11dead-0000-0000-0000-00000000ad11"
cases := []struct {
name string
role string
auth string
passHash string
protected bool // true: reclaim refused (409); false: reclaim succeeds (200, barred)
}{
{"thirdparty non-admin is reclaimed", "user", authSourceThirdParty, "", false},
{"mojang admin is reclaimed", "admin", authSourceMojang, "$2a$10$VnJ5kZqZ9bQmsCp1uoQ3qO", false},
{"sso admin without local password is protected", "admin", authSourceThirdParty, "", true},
{"thirdparty non-admin is reclaimed", "user", authSourceThirdParty, false},
{"mojang admin is reclaimed", "admin", authSourceMojang, false},
{"sso admin without local password is protected", "admin", authSourceThirdParty, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
repo := newFakeRepo()
repo.staff["holder"] = &StaffUser{ID: "h-1", Username: "holder", Role: tc.role, PasswordHash: tc.passHash}
repo.staff["holder"] = &StaffUser{ID: "h-1", Username: "holder", Role: tc.role}
repo.links[squatter] = "h-1"
repo.linkAuthSource[squatter] = tc.auth
api := newTestAPI(repo, newFakeCluster())
+135
View File
@@ -0,0 +1,135 @@
package api
import (
"crypto/sha256"
"encoding/hex"
"errors"
"net/http"
"strings"
)
// Setup-token redemption (spec §B setup bootstrap). The `felis setup` MC-bind
// flow mints a one-time token and prints a URL like:
//
// https://op.console.<root>/setup?token=<raw>
//
// The Owner opens that URL in a browser; the SPA reads the token from the query
// string and POSTs it here. This handler consumes the token (single-use, hashed
// at rest like session cookies), mints a felis_session, and returns the caller's
// setup state so the frontend can guide email verification + passkey enrollment
// before unlocking the admin console.
//
// The minted session is a "lockdown" session in product terms: the Owner has not
// yet proven control of an email or enrolled a passkey, so the frontend restricts
// it to the setup wizard. Backend enforcement of the lockdown is a separate
// middleware concern (checking email_verified on the principal); this handler's
// job is the one-time token→session swap and reporting what setup remains.
// setupRedeemRequest is the redeem body: the raw one-time token from the setup URL.
type setupRedeemRequest struct {
Token string `json:"token"`
}
// handleSetupRedeem consumes a one-time setup token and mints a lockdown session
// (Public, pre-session). The token is hashed (sha-256) before lookup — only the
// hash is persisted, mirroring session-cookie storage. On success the caller
// receives a felis_session cookie and a JSON body describing the remaining setup
// steps (email set? verified? passkey enrolled?) so the SPA can drive the wizard.
func (a *API) handleSetupRedeem(w http.ResponseWriter, r *http.Request) {
if !localAuthEnabled(r.Context(), a.Repo) {
writeError(w, r, newError(http.StatusForbidden, "local_auth_disabled",
"session login is disabled"))
return
}
if err := requireJSONContentType(r); err != nil {
writeError(w, r, err)
return
}
var req setupRedeemRequest
if err := decodeJSON(w, r, &req); err != nil {
writeError(w, r, err)
return
}
token := strings.TrimSpace(req.Token)
if token == "" {
writeError(w, r, newError(http.StatusBadRequest, "bad_request", "token is required"))
return
}
// Hash the raw token — only the hash is stored (mirroring session cookies and
// setup token creation in performSetupMCBind).
sum := sha256.Sum256([]byte(token))
tokenHash := hex.EncodeToString(sum[:])
now := a.now()
userID, err := a.Repo.ConsumeSetupToken(r.Context(), tokenHash, now)
if err != nil {
// Unknown, already-consumed, or expired — uniform 400 so the token cannot
// be used as an oracle.
writeError(w, r, newError(http.StatusBadRequest, "setup_token_invalid",
"this setup link is invalid or has already been used"))
return
}
u, err := a.Repo.UserByID(r.Context(), userID)
if err != nil {
writeError(w, r, err)
return
}
// Mint the session — a regular felis_session; the lockdown is a product-level
// restriction the frontend enforces until email is verified / a passkey is bound.
sessionToken, err := newSessionToken()
if err != nil {
writeError(w, r, err)
return
}
expires := now.Add(sessionTTL)
if err := a.Repo.CreateSession(r.Context(), hashCookie(sessionToken), u.ID, expires); err != nil {
writeError(w, r, err)
return
}
setSessionCookie(w, sessionToken, expires)
// Report the setup state so the SPA knows which wizard steps remain.
creds, _ := a.Repo.PasskeyCredentialsForUser(r.Context(), u.ID)
hasPasskey := len(creds) > 0
a.audit(r, u.Username, "auth.setup_redeem", "")
writeJSON(w, http.StatusOK, map[string]any{
"user_id": u.ID,
"username": u.Username,
"role": u.Role,
"email": u.Email,
"email_verified": u.EmailVerified,
"has_passkey": hasPasskey,
"setup_required": !u.EmailVerified || !hasPasskey,
})
}
// handleSetupStatus reports the caller's setup progress (app-tier). The SPA polls
// it after each wizard step (email verify, passkey enroll) to decide whether the
// lockdown can lift. It reads only the principal's own state.
func (a *API) handleSetupStatus(w http.ResponseWriter, r *http.Request) {
p := principalFromContext(r.Context())
u, err := a.Repo.UserByID(r.Context(), p.UserID)
if err != nil {
if errors.Is(err, ErrNotFound) {
writeError(w, r, newError(http.StatusNotFound, "not_found", "user not found"))
return
}
writeError(w, r, err)
return
}
creds, _ := a.Repo.PasskeyCredentialsForUser(r.Context(), u.ID)
hasPasskey := len(creds) > 0
writeJSON(w, http.StatusOK, map[string]any{
"user_id": u.ID,
"username": u.Username,
"role": u.Role,
"email": u.Email,
"email_verified": u.EmailVerified,
"has_passkey": hasPasskey,
"setup_required": !u.EmailVerified || !hasPasskey,
})
}
+6 -10
View File
@@ -180,16 +180,12 @@ func (a *API) handleMe(w http.ResponseWriter, r *http.Request) {
emailVerified = u.EmailVerified
}
writeJSON(w, http.StatusOK, map[string]any{
"user_id": p.UserID,
"email": p.Email,
"role": p.Role,
"is_admin": p.IsAdmin(),
"is_owner": p.IsOwner(),
"email_verified": emailVerified,
// must_change_password is meaningful only on the local-password path; the JWT
// path leaves it false. The panel uses it to route a freshly-provisioned staff
// account straight to the change-password card before any other surface.
"must_change_password": p.MustChangePassword,
"user_id": p.UserID,
"email": p.Email,
"role": p.Role,
"is_admin": p.IsAdmin(),
"is_owner": p.IsOwner(),
"email_verified": emailVerified,
})
}
+6 -106
View File
@@ -2,15 +2,10 @@ package api
import (
"context"
"crypto/rand"
"errors"
"log"
"math/big"
"net/http"
"strconv"
"strings"
"golang.org/x/crypto/bcrypt"
)
// ResetMailer delivers a freshly-generated admin-reset password to the user's
@@ -74,11 +69,9 @@ func (a *API) handleGetUser(w http.ResponseWriter, r *http.Request) {
// createUserRequest is the admin create-user form.
type createUserRequest struct {
Username string `json:"username"`
Email string `json:"email,omitempty"`
Role string `json:"role"`
Password string `json:"password"`
MustChange bool `json:"must_change_password"`
Username string `json:"username"`
Email string `json:"email,omitempty"`
Role string `json:"role"`
}
// handleCreateUser is the admin-tier create-user endpoint (POST /users).
@@ -104,30 +97,10 @@ func (a *API) handleCreateUser(w http.ResponseWriter, r *http.Request) {
return
}
// Validate password: 8–72 bytes (bcrypt limit).
if len(body.Password) < 8 {
writeError(w, r, newError(http.StatusBadRequest, "weak_password",
"password must be at least 8 characters"))
return
}
if len(body.Password) > 72 {
writeError(w, r, newError(http.StatusBadRequest, "bad_request",
"password must be at most 72 characters"))
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(body.Password), bcrypt.DefaultCost)
if err != nil {
writeError(w, r, err)
return
}
u, err := a.Repo.CreateUser(r.Context(), CreateUserInput{
Username: body.Username,
Email: body.Email,
Role: body.Role,
PasswordHash: string(hash),
MustChange: body.MustChange,
Username: body.Username,
Email: body.Email,
Role: body.Role,
}, p.Email)
if err != nil {
if errors.Is(err, ErrConflict) {
@@ -283,79 +256,6 @@ func (a *API) handleDisableUser(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"id": id, "disabled": body.Disabled})
}
// handleResetPassword generates a high-entropy random password, stores its hash,
// forces must_change_password, and delivers the plaintext to the user's email
// (server-side log when no mailer is wired). The password is never returned to the
// admin caller — the response carries only the target email, not the password.
// (POST /users/{id}/reset-password). No request body — the server owns entropy.
func (a *API) handleResetPassword(w http.ResponseWriter, r *http.Request) {
p := principalFromContext(r.Context())
id := r.PathValue("id")
if id == "" {
writeError(w, r, errBadRequest)
return
}
// Load user to get their email.
u, err := a.Repo.UserByID(r.Context(), id)
if err != nil {
if errors.Is(err, ErrNotFound) {
writeError(w, r, newError(http.StatusNotFound, "not_found", "user not found"))
return
}
writeError(w, r, err)
return
}
password, err := generateResetPassword()
if err != nil {
writeError(w, r, err)
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
writeError(w, r, err)
return
}
if err := a.Repo.AdminResetPassword(r.Context(), id, string(hash)); err != nil {
writeError(w, r, err)
return
}
if a.ResetMailer != nil && u.Email != "" {
if err := a.ResetMailer.SendPasswordReset(r.Context(), u.Email, password); err != nil {
log.Printf("reset-password: mail delivery failed for %s: %v", u.Email, err)
}
} else {
log.Printf("reset-password: no ResetMailer configured; password for %s (%s): %s",
u.Username, id, password)
}
a.audit(r, p.Email, "user.reset_password", id)
writeJSON(w, http.StatusOK, map[string]any{
"ok": true,
"email": u.Email,
})
}
// generateResetPassword produces a 20-character, high-entropy random password
// drawn from alphanumerics plus a safe symbol set.
func generateResetPassword() (string, error) {
const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*-_+=?"
const n = 20
b := make([]byte, n)
for i := range b {
idx, err := rand.Int(rand.Reader, big.NewInt(int64(len(chars))))
if err != nil {
return "", err
}
b[i] = chars[idx.Int64()]
}
return string(b), nil
}
// ---- quota admin ----
// handleGetQuotas is the admin-tier quotas read (GET /users/{id}/quotas).
-17
View File
@@ -122,23 +122,6 @@ func (a *API) ownerOnly(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.
+315 -118
View File
@@ -189,6 +189,73 @@ func (p *PGRepo) RedeemPlayerBindCode(ctx context.Context, newUserID, code strin
return userID, mcUUID, authSource, nil
}
// RedeemLinkCodeForOwner consumes an in-game link code and creates-or-promotes the
// bound account to the passwordless Owner (role='admin'). It is the `felis setup`
// MC-bind path: the operator enters limbo, runs /link, and types the code here.
// Unlike RedeemPlayerBindCode — which refuses an already-staff account so a game
// login can never self-elevate — this DELIBERATELY elevates: an unlinked UUID is
// born directly as staff, and an already-linked account (player OR staff) is
// promoted in place, preserving its id so any live sessions and its username
// survive. The elevation is gated by the caller's local-root break-glass
// authority, not by anything in-band. Returns the Owner's (userID, mcUUID,
// authSource); an absent or expired code is ErrLinkCodeInvalid and consumes
// nothing.
func (p *PGRepo) RedeemLinkCodeForOwner(ctx context.Context, newUserID, code string, now time.Time) (string, string, string, error) {
tx, err := p.db.BeginTx(ctx, nil)
if err != nil {
return "", "", "", err
}
defer tx.Rollback() //nolint:errcheck // no-op after commit
var mcUUID, authSource string
switch err := tx.QueryRowContext(ctx,
`SELECT mc_uuid, auth_source FROM account_link_codes WHERE code = $1 AND expires_at > $2`,
code, now).Scan(&mcUUID, &authSource); {
case errors.Is(err, sql.ErrNoRows):
return "", "", "", ErrLinkCodeInvalid
case err != nil:
return "", "", "", err
}
// Create-or-promote keyed on the verified UUID. An unlinked UUID births a fresh
// staff row (role='admin') with a uuid-derived username; an already-linked
// account is promoted to role='admin' in place (idempotent when it is already
// staff), keeping its id and username. Setup elevates on purpose, so there is no
// staff refusal here — that guard belongs to the player path only.
userID := newUserID
switch err := tx.QueryRowContext(ctx,
`SELECT user_id FROM account_links WHERE mc_uuid = $1`, mcUUID).Scan(&userID); {
case errors.Is(err, sql.ErrNoRows):
if _, err := tx.ExecContext(ctx,
`INSERT INTO users (id, username, role) VALUES ($1, $2, 'admin')`,
newUserID, mcUUID); err != nil {
return "", "", "", fmt.Errorf("create owner: %w", err)
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO account_links (user_id, mc_uuid, auth_source) VALUES ($1, $2, $3)`,
newUserID, mcUUID, authSource); err != nil {
return "", "", "", fmt.Errorf("write account link: %w", err)
}
userID = newUserID
case err != nil:
return "", "", "", err
default:
if _, err := tx.ExecContext(ctx,
`UPDATE users SET role = 'admin' WHERE id = $1`, userID); err != nil {
return "", "", "", fmt.Errorf("promote owner: %w", err)
}
}
if _, err := tx.ExecContext(ctx,
`DELETE FROM account_link_codes WHERE code = $1`, code); err != nil {
return "", "", "", fmt.Errorf("consume link code: %w", err)
}
if err := tx.Commit(); err != nil {
return "", "", "", err
}
return userID, mcUUID, authSource, nil
}
// QuotaAvailable treats a missing quota row or a NULL max_servers as unlimited;
// otherwise it compares the live owned-server count against the cap (spec §9.3).
//
@@ -657,17 +724,15 @@ func (p *PGRepo) IsProtectedAdminLink(ctx context.Context, mcUUID string) (bool,
// ---- local-password auth (spec §B) ----
// UserByUsername loads a staff login projection by username, or ErrNotFound. A
// player row (NULL password_hash) is returned with an empty PasswordHash, never
// hidden — the caller rejects it by the hash compare, so login cannot be used to
// enumerate which usernames carry a password.
// UserByUsername loads a staff login projection by username, or ErrNotFound.
// The account is passwordless — staff authenticate via email-OTP / passkey, so
// no password column is read.
func (p *PGRepo) UserByUsername(ctx context.Context, username string) (*StaffUser, error) {
const q = `SELECT id, username, COALESCE(email, ''), role::text,
COALESCE(password_hash, ''), must_change_password, email_verified
const q = `SELECT id, username, COALESCE(email, ''), role::text, email_verified
FROM users WHERE username = $1`
var u StaffUser
switch err := p.db.QueryRowContext(ctx, q, username).Scan(
&u.ID, &u.Username, &u.Email, &u.Role, &u.PasswordHash, &u.MustChangePassword, &u.EmailVerified); {
&u.ID, &u.Username, &u.Email, &u.Role, &u.EmailVerified); {
case errors.Is(err, sql.ErrNoRows):
return nil, ErrNotFound
case err != nil:
@@ -676,32 +741,31 @@ func (p *PGRepo) UserByUsername(ctx context.Context, username string) (*StaffUse
return &u, nil
}
// AdminExists reports whether any authenticatable staff account already exists —
// an admin row WITH a bcrypt password hash. It is the break-glass console's
// bootstrap-vs-recovery switch: false means the typed credential mints the first
// Owner (no prior identity to verify against), true means the operator must
// identify against an existing admin for accountability. It is not on the Repo
// interface because only the break-glass CLI consults it.
// AdminExists reports whether any admin account already exists. It is the
// break-glass console's bootstrap-vs-recovery switch: false means the typed
// credential mints the first Owner (no prior identity to verify against), true
// means the operator must identify against an existing admin for accountability.
// It is not on the Repo interface because only the break-glass CLI consults it.
func (p *PGRepo) AdminExists(ctx context.Context) (bool, error) {
const q = `SELECT EXISTS (
SELECT 1 FROM users WHERE role = 'admin' AND password_hash IS NOT NULL)`
var exists bool
if err := p.db.QueryRowContext(ctx, q).Scan(&exists); err != nil {
const q = `SELECT 1 FROM users WHERE role = 'admin' LIMIT 1`
var one int
switch err := p.db.QueryRowContext(ctx, q).Scan(&one); {
case errors.Is(err, sql.ErrNoRows):
return false, nil
case err != nil:
return false, err
}
return exists, nil
return true, nil
}
// UserByID loads the same staff projection by id, or ErrNotFound. The
// change-password flow re-verifies the caller's current password with it: the
// session yields a user id, not a username.
// account is passwordless — no password column is read.
func (p *PGRepo) UserByID(ctx context.Context, id string) (*StaffUser, error) {
const q = `SELECT id, username, COALESCE(email, ''), role::text,
COALESCE(password_hash, ''), must_change_password, email_verified
const q = `SELECT id, username, COALESCE(email, ''), role::text, email_verified
FROM users WHERE id = $1`
var u StaffUser
switch err := p.db.QueryRowContext(ctx, q, id).Scan(
&u.ID, &u.Username, &u.Email, &u.Role, &u.PasswordHash, &u.MustChangePassword, &u.EmailVerified); {
&u.ID, &u.Username, &u.Email, &u.Role, &u.EmailVerified); {
case errors.Is(err, sql.ErrNoRows):
return nil, ErrNotFound
case err != nil:
@@ -711,66 +775,30 @@ func (p *PGRepo) UserByID(ctx context.Context, id string) (*StaffUser, error) {
}
// UpsertOwner creates or resets the Owner account direct-to-Postgres (the
// break-glass first-run / reset-password path). role is forced to 'owner' —
// the platform-level identity one level above admin. On a username conflict the
// email, hash and must_change_password flag are overwritten while the existing
// id is preserved, so live sessions referencing it survive a password reset.
// The empty email is stored as NULL (users.email is nullable).
func (p *PGRepo) UpsertOwner(ctx context.Context, id, username, email, passwordHash string, mustChange bool) error {
// break-glass first-run / recovery path). role is forced to 'admin' — the
// platform-level identity. On a username conflict the email is overwritten
// while the existing id is preserved, so live sessions referencing it survive
// a reset. The account is passwordless by design. The empty email is stored
// as NULL (users.email is nullable).
func (p *PGRepo) UpsertOwner(ctx context.Context, id, username, email string) error {
_, err := p.db.ExecContext(ctx,
`INSERT INTO users (id, username, email, role, password_hash, must_change_password)
VALUES ($1, $2, NULLIF($3, ''), 'owner', $4, $5)
ON CONFLICT (username) DO UPDATE SET
email = NULLIF($3, ''), role = 'owner',
password_hash = $4, must_change_password = $5`,
id, username, email, passwordHash, mustChange)
`INSERT INTO users (id, username, email, role) VALUES ($1, $2, NULLIF($3, ''), 'admin')
ON CONFLICT (username) DO UPDATE SET email = EXCLUDED.email`,
id, username, email)
return err
}
// InsertOperator mints a NEW Operator (additional staff admin) account
// direct-to-Postgres. role is forced to 'admin' — Felis has a separate 'owner'
// role (migration 0011) for the single platform owner; Operators are below
// that. UNLIKE UpsertOwner this is insert-only: a username conflict is
// left untouched (ON CONFLICT DO NOTHING) and reported as ErrConflict via a zero
// RowsAffected, so adding an Operator can never silently reset the Owner's or
// another Operator's credential. The empty email is stored as NULL.
func (p *PGRepo) InsertOperator(ctx context.Context, id, username, email, passwordHash string, mustChange bool) error {
res, err := p.db.ExecContext(ctx,
`INSERT INTO users (id, username, email, role, password_hash, must_change_password)
VALUES ($1, $2, NULLIF($3, ''), 'admin', $4, $5)
ON CONFLICT (username) DO NOTHING`,
id, username, email, passwordHash, mustChange)
if err != nil {
return err
}
n, err := res.RowsAffected()
if err != nil {
return err
}
if n == 0 {
return ErrConflict
}
return nil
}
// SetPassword stores a new hash and clears must_change_password (the panel
// change-password flow). ErrNotFound when no row matches so a stale session
// cannot silently no-op the change.
func (p *PGRepo) SetPassword(ctx context.Context, userID, passwordHash string) error {
res, err := p.db.ExecContext(ctx,
`UPDATE users SET password_hash = $2, must_change_password = false WHERE id = $1`,
userID, passwordHash)
if err != nil {
return err
}
n, err := res.RowsAffected()
if err != nil {
return err
}
if n == 0 {
return ErrNotFound
}
return nil
// direct-to-Postgres. role is forced to 'admin'. UNLIKE UpsertOwner this is
// insert-only: a username conflict is left untouched and surfaces as a driver
// error, so adding an Operator can never silently reset the Owner's or another
// Operator's row. The account is passwordless by design. The empty email is
// stored as NULL.
func (p *PGRepo) InsertOperator(ctx context.Context, id, username, email string) error {
_, err := p.db.ExecContext(ctx,
`INSERT INTO users (id, username, email, role) VALUES ($1, $2, NULLIF($3, ''), 'admin')`,
id, username, email)
return err
}
// CreateSession records a minted session by the sha-256 of its cookie value
@@ -785,12 +813,12 @@ func (p *PGRepo) CreateSession(ctx context.Context, tokenHash, userID string, ex
// SessionUser resolves a live (unrevoked, unexpired at now) session hash to its
// user, or ErrNotFound.
func (p *PGRepo) SessionUser(ctx context.Context, tokenHash string, now time.Time) (*SessionedUser, error) {
const q = `SELECT u.id, COALESCE(u.email, ''), u.role::text, u.must_change_password
const q = `SELECT u.id, COALESCE(u.email, ''), u.role::text, COALESCE(u.email_verified, false)
FROM sessions s JOIN users u ON u.id = s.user_id
WHERE s.token_hash = $1 AND s.revoked_at IS NULL AND s.expires_at > $2`
var u SessionedUser
switch err := p.db.QueryRowContext(ctx, q, tokenHash, now).Scan(
&u.ID, &u.Email, &u.Role, &u.MustChangePassword); {
&u.ID, &u.Email, &u.Role, &u.EmailVerified); {
case errors.Is(err, sql.ErrNoRows):
return nil, ErrNotFound
case err != nil:
@@ -1057,7 +1085,7 @@ func (p *PGRepo) ListUsers(ctx context.Context, opts ListUsersOpts) ([]UserView,
}
q := `SELECT u.id, u.username, COALESCE(u.email, ''), u.role::text,
u.disabled, u.email_verified, u.must_change_password,
u.disabled, u.email_verified,
u.created_at, u.updated_at,
COALESCE((SELECT count(*) FROM servers s WHERE s.owner_id = u.id AND s.deleted_at IS NULL), 0)
FROM users u` + where
@@ -1075,7 +1103,7 @@ func (p *PGRepo) ListUsers(ctx context.Context, opts ListUsersOpts) ([]UserView,
for rows.Next() {
var v UserView
if err := rows.Scan(&v.ID, &v.Username, &v.Email, &v.Role,
&v.Disabled, &v.EmailVerified, &v.MustChangePassword,
&v.Disabled, &v.EmailVerified,
&v.CreatedAt, &v.UpdatedAt, &v.ServerCount); err != nil {
return nil, 0, err
}
@@ -1087,14 +1115,14 @@ func (p *PGRepo) ListUsers(ctx context.Context, opts ListUsersOpts) ([]UserView,
// UserDetail loads one user with its linked MC accounts, or ErrNotFound.
func (p *PGRepo) UserDetail(ctx context.Context, userID string) (*UserDetail, error) {
const q = `SELECT u.id, u.username, COALESCE(u.email, ''), u.role::text,
u.disabled, u.email_verified, u.must_change_password,
u.disabled, u.email_verified,
u.created_at, u.updated_at, u.deleted_at,
COALESCE((SELECT count(*) FROM servers s WHERE s.owner_id = u.id AND s.deleted_at IS NULL), 0)
FROM users u WHERE u.id = $1`
var d UserDetail
switch err := p.db.QueryRowContext(ctx, q, userID).Scan(
&d.ID, &d.Username, &d.Email, &d.Role,
&d.Disabled, &d.EmailVerified, &d.MustChangePassword,
&d.Disabled, &d.EmailVerified,
&d.CreatedAt, &d.UpdatedAt, &d.DeletedAt, &d.ServerCount); {
case errors.Is(err, sql.ErrNoRows):
return nil, ErrNotFound
@@ -1120,19 +1148,18 @@ func (p *PGRepo) UserDetail(ctx context.Context, userID string) (*UserDetail, er
return &d, linkRows.Err()
}
// CreateUser mints a new user row with an initial password hash. A username
// conflict → ErrConflict.
// CreateUser mints a new user row. A username conflict → ErrConflict.
func (p *PGRepo) CreateUser(ctx context.Context, input CreateUserInput, _ string) (*UserView, error) {
const q = `INSERT INTO users (id, username, email, role, password_hash, must_change_password)
VALUES (gen_random_uuid()::text, $1, NULLIF($2, ''), $3::user_role, $4, $5)
const q = `INSERT INTO users (id, username, email, role)
VALUES (gen_random_uuid()::text, $1, NULLIF($2, ''), $3::user_role)
ON CONFLICT (username) DO NOTHING
RETURNING id, username, COALESCE(email, ''), role::text, disabled, email_verified,
must_change_password, created_at, updated_at, 0`
created_at, updated_at, 0`
var v UserView
switch err := p.db.QueryRowContext(ctx, q,
input.Username, input.Email, input.Role, input.PasswordHash, input.MustChange).Scan(
input.Username, input.Email, input.Role).Scan(
&v.ID, &v.Username, &v.Email, &v.Role,
&v.Disabled, &v.EmailVerified, &v.MustChangePassword,
&v.Disabled, &v.EmailVerified,
&v.CreatedAt, &v.UpdatedAt, &v.ServerCount); {
case errors.Is(err, sql.ErrNoRows):
return nil, ErrConflict
@@ -1181,12 +1208,12 @@ func (p *PGRepo) UpdateUser(ctx context.Context, userID string, patch UpdateUser
}
q += fmt.Sprintf(` WHERE id = $%d AND deleted_at IS NULL`, argn)
q += ` RETURNING id, username, COALESCE(email, ''), role::text, disabled,
email_verified, must_change_password, created_at, updated_at,
email_verified, created_at, updated_at,
(SELECT count(*) FROM servers WHERE owner_id = users.id AND deleted_at IS NULL)`
var v UserView
switch err := p.db.QueryRowContext(ctx, q, args...).Scan(
&v.ID, &v.Username, &v.Email, &v.Role,
&v.Disabled, &v.EmailVerified, &v.MustChangePassword,
&v.Disabled, &v.EmailVerified,
&v.CreatedAt, &v.UpdatedAt, &v.ServerCount); {
case errors.Is(err, sql.ErrNoRows):
return nil, ErrNotFound
@@ -1205,13 +1232,13 @@ func (p *PGRepo) UpdateUser(ctx context.Context, userID string, patch UpdateUser
// shared by UpdateUser (no-op return) and several other paths.
func (p *PGRepo) userView(ctx context.Context, userID string) (*UserView, error) {
const q = `SELECT id, username, COALESCE(email, ''), role::text, disabled,
email_verified, must_change_password, created_at, updated_at,
email_verified, created_at, updated_at,
(SELECT count(*) FROM servers WHERE owner_id = users.id AND deleted_at IS NULL)
FROM users WHERE id = $1 AND deleted_at IS NULL`
var v UserView
switch err := p.db.QueryRowContext(ctx, q, userID).Scan(
&v.ID, &v.Username, &v.Email, &v.Role,
&v.Disabled, &v.EmailVerified, &v.MustChangePassword,
&v.Disabled, &v.EmailVerified,
&v.CreatedAt, &v.UpdatedAt, &v.ServerCount); {
case errors.Is(err, sql.ErrNoRows):
return nil, ErrNotFound
@@ -1294,29 +1321,6 @@ func (p *PGRepo) SetUserDisabled(ctx context.Context, userID string, disabled bo
return nil
}
// AdminResetPassword stores a new hash and forces must_change_password so the
// admin-set password is replaced on first login.
func (p *PGRepo) AdminResetPassword(ctx context.Context, userID, passwordHash string) error {
res, err := p.db.ExecContext(ctx,
`UPDATE users SET password_hash = $2, must_change_password = true WHERE id = $1 AND deleted_at IS NULL`,
userID, passwordHash)
if err != nil {
return err
}
n, err := res.RowsAffected()
if err != nil {
return err
}
if n == 0 {
return ErrNotFound
}
// Revoke every session so the old password cannot be used via a retained cookie.
_, _ = p.db.ExecContext(ctx,
`UPDATE sessions SET revoked_at = now() WHERE user_id = $1 AND revoked_at IS NULL`,
userID)
return nil
}
// ---- quota admin ----
// GetQuotas returns the quotas row for a user, or a zero-value view when no
@@ -1473,6 +1477,199 @@ func (p *PGRepo) LinkAccount(ctx context.Context, userID, mcUUID, authSource str
return nil
}
// ---- pre-session email login (spec §B) ----
// UserByEmail resolves a VERIFIED email address to its login projection, or
// ErrNotFound. Only a proven (email_verified true) address resolves, so a
// merely-asserted address never reaches a session-mintable identity. The
// account is passwordless — no password column is read.
func (p *PGRepo) UserByEmail(ctx context.Context, email string) (*StaffUser, error) {
const q = `SELECT id, username, COALESCE(email, ''), role::text, email_verified
FROM users WHERE email = $1 AND email_verified = true`
var u StaffUser
switch err := p.db.QueryRowContext(ctx, q, email).Scan(
&u.ID, &u.Username, &u.Email, &u.Role, &u.EmailVerified); {
case errors.Is(err, sql.ErrNoRows):
return nil, ErrNotFound
case err != nil:
return nil, err
}
return &u, nil
}
// ConsumeLoginEmailOTP redeems a live code for the PRE-SESSION email login door.
// Unlike VerifyEmailOTP it has no identity side-effects: it neither writes
// users.email nor runs the verified-email uniqueness guard — login already
// resolved the userID via UserByEmail, which requires email_verified, so the
// address is settled. Zero rows affected (no live code, expired, consumed, or
// hash mismatch) → ErrNotFound.
func (p *PGRepo) ConsumeLoginEmailOTP(ctx context.Context, userID, purpose, codeHash string, now time.Time) error {
res, err := p.db.ExecContext(ctx,
`UPDATE email_otps SET consumed_at = $4
WHERE user_id = $1 AND purpose = $2 AND code_hash = $3
AND consumed_at IS NULL AND expires_at > $4`,
userID, purpose, codeHash, now)
if err != nil {
return err
}
n, err := res.RowsAffected()
if err != nil {
return err
}
if n == 0 {
return ErrNotFound
}
return nil
}
// ---- op.console staff login: in-game approval state machine (spec §B op-login) ----
// CreateOpLoginRequest records a fresh pending op.console login attempt for a staff
// account. It writes the SECOND factor only — the email-OTP is minted separately
// under purpose 'op_login' — so a row here means this staff account is waiting for
// an in-game admin to vouch. email is a snapshot for the audit trail.
func (p *PGRepo) CreateOpLoginRequest(ctx context.Context, id, userID, email string, expiresAt time.Time) error {
_, err := p.db.ExecContext(ctx,
`INSERT INTO op_login_requests (id, user_id, email, expires_at) VALUES ($1, $2, $3, $4)`,
id, userID, email, expiresAt)
return err
}
// OpLoginRequestByID loads a request by its handle, or ErrNotFound. The status poll
// and the finish path both use it; finish additionally checks Status=='approved',
// !Consumed, and ExpiresAt>now before minting a session. Username is left empty (no
// join needed here). Status is derived from approved_at: 'approved' once set, else
// 'pending'.
func (p *PGRepo) OpLoginRequestByID(ctx context.Context, id string) (*OpLoginRequest, error) {
const q = `SELECT id, user_id, email, expires_at, consumed_at, approved_at, approved_by
FROM op_login_requests WHERE id = $1`
var (
r OpLoginRequest
consumedAt sql.NullTime
approvedAt sql.NullTime
approvedBy sql.NullString
)
switch err := p.db.QueryRowContext(ctx, q, id).Scan(
&r.ID, &r.UserID, &r.Email, &r.ExpiresAt, &consumedAt, &approvedAt, &approvedBy); {
case errors.Is(err, sql.ErrNoRows):
return nil, ErrNotFound
case err != nil:
return nil, err
}
r.Consumed = consumedAt.Valid
if approvedAt.Valid {
r.Status = "approved"
} else {
r.Status = "pending"
}
return &r, nil
}
// ListPendingOpLogins returns the live (pending, unconsumed, unexpired at now)
// requests oldest-first, for the in-game admin's approval prompt. A resolved or
// expired request drops out of the list, so an admin only ever sees actionable
// attempts.
func (p *PGRepo) ListPendingOpLogins(ctx context.Context, now time.Time) ([]OpLoginRequest, error) {
const q = `SELECT id, user_id, email, expires_at
FROM op_login_requests
WHERE consumed_at IS NULL AND approved_at IS NULL AND expires_at > $1
ORDER BY created_at`
rows, err := p.db.QueryContext(ctx, q, now)
if err != nil {
return nil, err
}
defer rows.Close()
var out []OpLoginRequest
for rows.Next() {
var r OpLoginRequest
if err := rows.Scan(&r.ID, &r.UserID, &r.Email, &r.ExpiresAt); err != nil {
return nil, err
}
r.Status = "pending"
out = append(out, r)
}
return out, rows.Err()
}
// ApproveOpLogin marks a pending request approved by approverUserID (the in-game
// admin), atomically: it stamps approved_at and approved_by only WHERE the row is
// still pending, unconsumed, and unexpired at now. Zero rows affected (gone,
// already resolved, or expired) → ErrNotFound, so a double approval or an
// approval of a dead request is a no-op the caller can surface.
func (p *PGRepo) ApproveOpLogin(ctx context.Context, id, approverUserID string, now time.Time) error {
res, err := p.db.ExecContext(ctx,
`UPDATE op_login_requests SET approved_at = $3, approved_by = $2
WHERE id = $1 AND consumed_at IS NULL AND approved_at IS NULL AND expires_at > $3`,
id, approverUserID, now)
if err != nil {
return err
}
n, err := res.RowsAffected()
if err != nil {
return err
}
if n == 0 {
return ErrNotFound
}
return nil
}
// ConsumeOpLoginRequest stamps consumed_at on an APPROVED, unconsumed, unexpired
// request, atomically, so it can be exchanged for a session exactly once. Zero
// rows affected (pending, already consumed, or expired) → ErrNotFound. This is the
// finish path's single-use guard; the email-OTP is consumed separately, so a lost
// race here never silently mints a second session.
func (p *PGRepo) ConsumeOpLoginRequest(ctx context.Context, id string, now time.Time) error {
res, err := p.db.ExecContext(ctx,
`UPDATE op_login_requests SET consumed_at = $2
WHERE id = $1 AND consumed_at IS NULL AND expires_at > $2`,
id, now)
if err != nil {
return err
}
n, err := res.RowsAffected()
if err != nil {
return err
}
if n == 0 {
return ErrNotFound
}
return nil
}
// ---- setup token redemption (spec §B) ----
// ConsumeSetupToken atomically marks a one-time setup token consumed and returns
// its user_id, or ErrNotFound when the token is absent, already consumed, or
// expired. The /setup?token=... web flow redeems it for a lockdown session.
func (p *PGRepo) ConsumeSetupToken(ctx context.Context, tokenHash string, now time.Time) (string, error) {
var userID string
switch err := p.db.QueryRowContext(ctx,
`UPDATE setup_tokens SET consumed_at = $2
WHERE token_hash = $1 AND consumed_at IS NULL AND expires_at > $2
RETURNING user_id`,
tokenHash, now).Scan(&userID); {
case errors.Is(err, sql.ErrNoRows):
return "", ErrNotFound
case err != nil:
return "", err
}
return userID, nil
}
// CreateSetupToken persists a one-time setup token for the first-web-login
// bootstrap, storing only its hash (the raw value rides in the /setup?token=...
// URL). `felis setup` mints it after binding the Owner's Minecraft account; it is
// redeemed exactly once by ConsumeSetupToken. The caller supplies a 256-bit
// random token, so a token_hash collision is not a case worth special-handling —
// any insert error (including an unknown user_id) surfaces to the caller.
func (p *PGRepo) CreateSetupToken(ctx context.Context, tokenHash, userID string, expiresAt time.Time) error {
_, err := p.db.ExecContext(ctx,
`INSERT INTO setup_tokens (token_hash, user_id, expires_at) VALUES ($1, $2, $3)`,
tokenHash, userID, expiresAt)
return err
}
// joinStr joins a slice of strings with ", ".
func joinStr(vals []string) string {
if len(vals) == 0 {
+119 -53
View File
@@ -71,21 +71,19 @@ 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.
// StaffUser is the login-side projection of a users row (spec §B passwordless
// auth). Owner/Operator are role=admin rows, minted by `felis setup` (MC link)
// and recovered by `felis breakGlass` (email OTP); players are role=user rows.
// There is no password column — staff authenticate via email-OTP / passkey +
// in-game approve, never a password.
type StaffUser struct {
ID string
Username string
Email string
Role string
PasswordHash string
MustChangePassword bool
ID string
Username string
Email string
Role string
// EmailVerified mirrors users.email_verified (spec §B2): the address was proven
// via an email OTP, not merely asserted. Players carry it through onboarding;
// staff rows seeded by break-glass leave it false until a code is redeemed.
// staff rows seeded by setup leave it false until a code is redeemed.
EmailVerified bool
}
@@ -117,15 +115,30 @@ type PasskeyCredential struct {
}
// 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.
// identity SessionAuth needs to build a Principal. EmailVerified mirrors
// users.email_verified so the lockdown middleware can gate setup-incomplete
// accounts without a second DB read.
type SessionedUser struct {
ID string
Email string
Role string
MustChangePassword bool
ID string
Email string
Role string
EmailVerified bool
}
// OpLoginRequest is one op.console staff-login attempt (spec §B op-login): the
// durable second factor (in-game approval) that pairs with an email_otps code under
// purpose 'op_login'. Username is populated only by ListPendingOpLogins (the join the
// in-game admin needs to name who is waiting); Consumed reflects consumed_at, so the
// finish path can refuse an already-spent request without a second query.
type OpLoginRequest struct {
ID string
UserID string
Username string // joined for the in-game pending list; "" elsewhere
Email string
Status string // 'pending' | 'approved' | 'denied'
Consumed bool // consumed_at IS NOT NULL (single-use guard)
ExpiresAt time.Time
CreatedAt time.Time
}
// Repo is the business-layer data access the API depends on. It is an interface
@@ -257,7 +270,55 @@ type Repo interface {
// code (so a typo does not burn it). On a match the code is consumed and the
// user row is flipped to email=<the proven address>, email_verified=true; the
// proven email is returned. now is the API clock so expiry is testable.
//
// This is the ONBOARDING primitive: verifying the code is the moment the address
// becomes proven, so the write is load-bearing. The pre-session LOGIN door must
// NOT use it — see ConsumeLoginEmailOTP.
VerifyEmailOTP(ctx context.Context, userID, purpose, codeHash string, now time.Time) (email string, err error)
// ConsumeLoginEmailOTP redeems the newest live code for (userID, purpose) against
// codeHash for the PRE-SESSION email LOGIN door, with the SAME code lifecycle as
// VerifyEmailOTP (FOR UPDATE, expiry+lockout before hash compare, mismatch charges
// one attempt without consuming) but with NO identity side-effects: it neither
// writes users.email nor runs the verified-email uniqueness guard. Login resolved
// userID via UserByEmail, which already requires email_verified, so the address is
// settled — re-proving control of a code this session must not re-touch the row.
// Returning only an error is deliberate: unlike onboarding, login has nothing to
// prove about the address, so there is no email to hand back. Errors are exactly
// ErrOTPInvalid / ErrOTPLocked (ErrEmailTaken is structurally impossible here).
ConsumeLoginEmailOTP(ctx context.Context, userID, purpose, codeHash string, now time.Time) error
// ---- op.console staff login: in-game approval state machine (spec §B op-login) ----
// CreateOpLoginRequest records a fresh pending op.console login attempt for a staff
// account (spec §B op-login). id is the opaque handle the browser polls; email is a
// snapshot for the audit trail. It writes the SECOND factor only — the email-OTP
// itself is minted separately under purpose 'op_login' (CreateEmailOTP) — so a row
// here means "this staff account is waiting for an in-game admin to vouch". expiresAt
// is the API clock + TTL so expiry is driven by one authoritative clock.
CreateOpLoginRequest(ctx context.Context, id, userID, email string, expiresAt time.Time) error
// OpLoginRequestByID loads a request by its handle, or ErrNotFound. The status poll
// and the finish path both use it: finish additionally checks Status=='approved',
// !Consumed, and ExpiresAt>now before it will mint a session, so a pending, spent, or
// expired request can never be exchanged. Username is left empty (no join needed here).
OpLoginRequestByID(ctx context.Context, id string) (*OpLoginRequest, error)
// ListPendingOpLogins returns the live (pending, unconsumed, unexpired at now)
// requests oldest-first, each joined to its staff username, for the in-game admin's
// approval prompt (Velocity polls this on the internal face). A resolved or expired
// request drops out of the list, so an admin only ever sees actionable attempts.
ListPendingOpLogins(ctx context.Context, now time.Time) ([]OpLoginRequest, error)
// ApproveOpLogin marks a pending request approved by approverUserID (the in-game
// admin, resolved from their online-mode UUID via UserByMCUUID), atomically: it
// stamps status='approved', approved_by, approved_at only WHERE the row is still
// pending, unconsumed, and unexpired at now. A request that is gone, already
// resolved, or expired affects zero rows and returns ErrNotFound, so a double
// approval or an approval of a dead request is a no-op the caller can surface.
ApproveOpLogin(ctx context.Context, id, approverUserID string, now time.Time) error
// ConsumeOpLoginRequest stamps consumed_at on an APPROVED, unconsumed, unexpired
// request, atomically, so it can be exchanged for a session exactly once. Zero rows
// affected (pending, already consumed, or expired) → ErrNotFound. This is the finish
// path's single-use guard; the email-OTP is consumed separately, so a lost race here
// never silently mints a second session.
ConsumeOpLoginRequest(ctx context.Context, id string, now time.Time) error
// ---- player passkey enrollment (spec §14 WebAuthn / Phase 6 bind) ----
@@ -350,15 +411,23 @@ type Repo interface {
// 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)
// UserByEmail resolves a VERIFIED email address to its login projection,
// case-insensitively, or ErrNotFound (spec §B email-first login). It is the
// entry point every email-first web login shares: the address must be proven
// (email_verified true), so a merely-asserted or unverified address never
// resolves to a session-mintable identity — an attacker cannot claim someone
// else's login by typing their email. Matching is on lower(email) to align with
// the users_verified_email_unique partial index (migration 0010), which
// guarantees at most one verified row per normalized address, so the result is
// unambiguous. A player row (empty PasswordHash) resolves too — email-first
// login is passwordless and does not consult the hash — unlike the password
// path, which this deliberately does not gate on.
UserByEmail(ctx context.Context, email 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
// (the `felis setup` / `felis breakGlass` recovery path). role is forced to
// 'admin'; on a username conflict the existing row's email is overwritten so
// a reset is idempotent. The account is passwordless by design.
UpsertOwner(ctx context.Context, id, username, email 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.
@@ -370,10 +439,15 @@ type Repo interface {
// 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.
// whose hash is keepTokenHash. Used to log out other devices on a security event.
RevokeUserSessionsExcept(ctx context.Context, userID, keepTokenHash string) error
// ConsumeSetupToken atomically marks a one-time setup token consumed and returns
// its user_id, or ErrNotFound when the token is absent, already consumed, or
// expired. The /setup?token=... web flow redeems it for a lockdown session that
// can only complete passwordless login setup (verify email / enroll passkey).
ConsumeSetupToken(ctx context.Context, tokenHash string, now time.Time) (userID string, err error)
// ---- runtime platform settings (spec §B platform_settings) ----
// GetSetting reads a runtime setting's raw jsonb value, or ErrNotFound when the
@@ -392,9 +466,8 @@ type Repo interface {
// UserDetail loads one user with its linked MC accounts, or ErrNotFound.
// A deleted user is returned (the row lives for audit) but flagged.
UserDetail(ctx context.Context, userID string) (*UserDetail, error)
// CreateUser mints a new user row (role forced to either 'admin' or 'user')
// with an initial password hash. createdBy is the actor email for audit. A
// username conflict → ErrConflict.
// CreateUser mints a new user row (role forced to either 'admin' or 'user').
// createdBy is the actor email for audit. A username conflict → ErrConflict.
CreateUser(ctx context.Context, input CreateUserInput, createdBy string) (*UserView, error)
// UpdateUser applies the non-nil fields of patch to the user identified by
// userID and returns the updated view. A username conflict → ErrConflict;
@@ -410,10 +483,6 @@ type Repo interface {
// disabled account is immediately locked out. A non-existent user →
// ErrNotFound; a deleted user → ErrNotFound.
SetUserDisabled(ctx context.Context, userID string, disabled bool) error
// AdminResetPassword stores a new bcrypt hash for a user and forces
// must_change_password, so the admin-set password is replaced on first
// login. ErrNotFound when no live row matches.
AdminResetPassword(ctx context.Context, userID, passwordHash string) error
// ---- quota admin (spec §6 quotas, admin-only) ----
@@ -462,22 +531,21 @@ type ListUsersOpts struct {
// UserView is one row of the admin user list.
type UserView struct {
ID string `json:"id"`
Username string `json:"username"`
Email string `json:"email,omitempty"`
Role string `json:"role"`
Disabled bool `json:"disabled"`
EmailVerified bool `json:"email_verified"`
ServerCount int `json:"server_count"`
MustChangePassword bool `json:"must_change_password"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ID string `json:"id"`
Username string `json:"username"`
Email string `json:"email,omitempty"`
Role string `json:"role"`
Disabled bool `json:"disabled"`
EmailVerified bool `json:"email_verified"`
ServerCount int `json:"server_count"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// UserDetail is the full admin view of one user, including linked MC accounts.
type UserDetail struct {
UserView
DeletedAt *time.Time `json:"deleted_at,omitempty"`
DeletedAt *time.Time `json:"deleted_at,omitempty"`
LinkedAccounts []LinkedAccount `json:"linked_accounts,omitempty"`
}
@@ -490,11 +558,9 @@ type LinkedAccount struct {
// CreateUserInput is the admin create-user form.
type CreateUserInput struct {
Username string `json:"username"`
Email string `json:"email,omitempty"`
Role string `json:"role"`
PasswordHash string `json:"-"`
MustChange bool `json:"must_change_password"`
Username string `json:"username"`
Email string `json:"email,omitempty"`
Role string `json:"role"`
}
// UpdateUserInput is the admin patch-user form. Every field is a pointer so
+6 -5
View File
@@ -156,11 +156,12 @@ func (s SessionAuth) Authenticate(r *http.Request) (*Principal, error) {
return nil, fmt.Errorf("invalid session: %w", err)
}
return &Principal{
UserID: u.ID,
Email: u.Email,
Role: u.Role,
ViaAdminAccess: u.Role == "admin" && hostIsAdminConsole(r, s.RootDomain, s.AdminHostname),
MustChangePassword: u.MustChangePassword,
UserID: u.ID,
Email: u.Email,
Role: u.Role,
ViaAdminAccess: u.Role == "admin" && hostIsAdminConsole(r, s.RootDomain, s.AdminHostname),
EmailVerified: u.EmailVerified,
ViaSession: true,
}, nil
}