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:
46 files changed
+5554
-1651
No files matched your search
+74
-59
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user