From 0c1cc598c196aea91eb7805d2ca3381373b50a91 Mon Sep 17 00:00:00 2001 From: Minseong Choi Date: Sat, 4 Jul 2026 20:05:16 +0900 Subject: [PATCH] feat(auth): migrate console login to passwordless MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ./...). --- AGENTS.md | 41 + cmd/felis/api.go | 16 +- cmd/felis/breakglass.go | 269 +++---- cmd/felis/breakglass_test.go | 445 +++++------ cmd/felis/setup.go | 27 +- cmd/felis/tui_height_measure_test.go | 2 +- cmd/felis/tui_mc_bind.go | 202 +++++ cmd/felis/tui_menu_test.go | 8 +- cmd/felis/tui_owner.go | 85 +-- cmd/felis/tui_root.go | 23 +- cmd/felis/tui_root_test.go | 26 +- cmd/felis/tui_summary.go | 8 +- cmd/felis/version.go | 58 ++ docs/openapi.yaml | 712 +++++++++++++++--- internal/api/api.go | 133 ++-- internal/api/api_test.go | 273 +++++-- internal/api/auth.go | 21 +- internal/api/errors.go | 22 +- internal/api/handlers_access.go | 2 +- internal/api/handlers_auth.go | 238 +----- internal/api/handlers_auth_email.go | 266 +++++++ internal/api/handlers_auth_email_test.go | 612 +++++++++++++++ internal/api/handlers_auth_options.go | 95 +++ internal/api/handlers_auth_options_test.go | 190 +++++ internal/api/handlers_auth_test.go | 321 -------- internal/api/handlers_logstream_test.go | 2 +- internal/api/handlers_onboard_test.go | 4 +- internal/api/handlers_op_login.go | 408 ++++++++++ internal/api/handlers_op_login_test.go | 509 +++++++++++++ internal/api/handlers_passkey.go | 323 +++++++- internal/api/handlers_passkey_login_test.go | 460 +++++++++++ internal/api/handlers_player_reclaim_test.go | 17 +- internal/api/handlers_setup.go | 135 ++++ internal/api/handlers_user.go | 16 +- internal/api/handlers_users.go | 112 +-- internal/api/middleware.go | 17 - internal/api/pgrepo.go | 433 ++++++++--- internal/api/repo.go | 172 +++-- internal/api/session.go | 11 +- internal/panel/panel.go | 108 ++- internal/panel/panel_test.go | 11 +- internal/panel/webview_test.go | 2 +- .../store/migrations/0012_setup_tokens.sql | 19 + .../lolicon/felis/link/FelisApiClient.java | 27 + .../felis/velocity/FelisVelocityPlugin.java | 311 +++++++- .../lolicon/felis/velocity/WaitingRouter.java | 13 + 46 files changed, 5554 insertions(+), 1651 deletions(-) create mode 100644 AGENTS.md create mode 100644 cmd/felis/tui_mc_bind.go create mode 100644 cmd/felis/version.go create mode 100644 internal/api/handlers_auth_email.go create mode 100644 internal/api/handlers_auth_email_test.go create mode 100644 internal/api/handlers_auth_options.go create mode 100644 internal/api/handlers_auth_options_test.go delete mode 100644 internal/api/handlers_auth_test.go create mode 100644 internal/api/handlers_op_login.go create mode 100644 internal/api/handlers_op_login_test.go create mode 100644 internal/api/handlers_passkey_login_test.go create mode 100644 internal/api/handlers_setup.go create mode 100644 internal/store/migrations/0012_setup_tokens.sql diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..0b26e18 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,41 @@ +# AGENTS.md + +This file helps Autohand understand how to work with this project. + +## Project Overview + +- **Language**: Go +- **Package Manager**: go + +## Commands + +- **Build**: `go build` +- **Run**: `go run .` +- **Test**: `go test ./...` +- **Format**: `go fmt ./...` +- **Vet**: `go vet ./...` + +## Instruction Sources + +- Check saved memories and preferences before implementation work. +- Follow this AGENTS.md file for repository-specific guidance. +- AGENTS.md takes precedence over CLAUDE.md when both files provide instructions. + +## Code Style + +- Follow Go idioms and conventions +- Use short variable names in small scopes +- Handle errors explicitly +- Follow existing patterns in the codebase +- Use meaningful variable and function names +- Add comments for complex logic +- Keep functions focused and small + +## Constraints + +- Do not modify files outside the project directory +- Ask before making breaking changes +- Prefer editing existing files over creating new ones +- Do not delete files without confirmation +- Keep dependencies minimal - avoid adding new ones without good reason +- Do not commit sensitive data (API keys, secrets, credentials) diff --git a/cmd/felis/api.go b/cmd/felis/api.go index e6334cd..b57aab9 100644 --- a/cmd/felis/api.go +++ b/cmd/felis/api.go @@ -8,7 +8,6 @@ import ( "net/http" "os" "regexp" - goruntime "runtime" "strings" "time" @@ -169,14 +168,6 @@ func cmdAPI(args []string, stdout, stderr io.Writer) int { // agree on what local auth knows. repo := api.NewPGRepo(drv.DB()) - // Bound concurrent login bcrypt to roughly the core count (floored so even a 1–2 - // vCPU demo box tolerates a handful of simultaneous staff logins). bcrypt is - // CPU-costly and the public login route runs a full compare on every request, so - // this caps the work a login flood can pile on the scheduler; the excess is shed - // as a cheap 429. Staff password logins are rare (players never use this path), so - // the cap never bites legitimate use. - loginBcryptCap := max(goruntime.NumCPU(), 4) - a := &api.API{ Repo: repo, Cluster: api.NewK8sCluster(cl, cfg.K8s.Namespace), @@ -201,9 +192,8 @@ func cmdAPI(args []string, stdout, stderr io.Writer) int { RootDomain: cfg.Server.RootDomain, AdminHostname: cfg.Auth.AdminHostname, }, - RootDomain: cfg.Server.RootDomain, - WakeCooldown: 30 * time.Second, - MaxConcurrentLogins: loginBcryptCap, + RootDomain: cfg.Server.RootDomain, + WakeCooldown: 30 * time.Second, // Bound concurrent console/build-log SSE streams per principal. Generous enough // for legitimate multi-tab / multi-server watching, while capping how many // upstream follow connections a single caller can tie up if their streams stall. @@ -232,7 +222,7 @@ func cmdAPI(args []string, stdout, stderr io.Writer) int { fmt.Fprintln(stderr, "felis api: passkey verifier disabled (auth.panel_hostname unset) — passkey endpoints return 503") } - externalHandler := panel.Handler(a.ExternalHandler(), cfg.Server.RootDomain) + externalHandler := panel.Handler(a.ExternalHandler(), cfg.Server.RootDomain, cfg.Auth.PanelHostname, cfg.Auth.AdminHostname, resolvedVersion()) internalSrv := newAPIServer(*internalAddr, a.InternalHandler()) externalSrv := newAPIServer(cfg.Server.Listen, externalHandler) diff --git a/cmd/felis/breakglass.go b/cmd/felis/breakglass.go index 0b32f73..707a6a4 100644 --- a/cmd/felis/breakglass.go +++ b/cmd/felis/breakglass.go @@ -3,6 +3,8 @@ package main import ( "context" "crypto/rand" + "crypto/sha256" + "encoding/base64" "encoding/hex" "encoding/json" "errors" @@ -11,13 +13,13 @@ import ( "io" "os" "strings" + "time" "felis.lolicon.best/internal/api" "felis.lolicon.best/internal/config" "felis.lolicon.best/internal/store" tea "github.com/charmbracelet/bubbletea" - "golang.org/x/crypto/bcrypt" ) // `felis breakGlass` is the local break-glass emergency console (spec §B). Its @@ -64,27 +66,31 @@ import ( // bare Enter) keeps the unverified root override from happening by reflex. const breakGlassOverrideToken = "OVERRIDE" -// bootstrapPasswordAlphabet excludes visually ambiguous glyphs (0/O, 1/I/l) so a -// human can transcribe a generated one-time password off a terminal without error. -const bootstrapPasswordAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789" - -// ownerStore is the minimal repo surface the break-glass console needs. +// ownerStore is the minimal repo surface the break-glass / setup console needs. // *api.PGRepo satisfies it; the unit tests drive a fake, so the core logic -// (authentication, provisioning, accountability audit) is exercised without a +// (recovery, provisioning, accountability audit) is exercised without a // database or a terminal. type ownerStore interface { - // AdminExists reports whether any authenticatable staff account already exists. + // AdminExists reports whether any admin account already exists. // It is the bootstrap-vs-recovery switch. AdminExists(ctx context.Context) (bool, error) - // UserByUsername loads a staff login projection for credential verification. + // UserByUsername loads a staff login projection. UserByUsername(ctx context.Context, username string) (*api.StaffUser, error) - UpsertOwner(ctx context.Context, id, username, email, passwordHash string, mustChange bool) error + UpsertOwner(ctx context.Context, id, username, email string) error // InsertOperator mints a NEW Operator staff account. Unlike UpsertOwner it is // insert-only: a username already taken is a conflict (api.ErrConflict), never a // silent reset, so adding an Operator can never clobber the Owner or an existing // Operator. The row is role=admin, identical in shape to the Owner — Felis has no - // separate operator DB role (migration 0003: staff = role=admin WITH a hash). - InsertOperator(ctx context.Context, id, username, email, passwordHash string, mustChange bool) error + // separate operator DB role (migration 0003: staff = role=admin). + InsertOperator(ctx context.Context, id, username, email string) error + // RedeemLinkCodeForOwner consumes an in-game link code and creates-or-promotes + // the bound user to role='admin' (Owner). It is the `felis setup` MC-bind path: + // the operator enters limbo, runs /link, types the code here, and the bound + // account becomes the passwordless Owner. Unlike RedeemPlayerBindCode it does NOT + // refuse staff — setup deliberately elevates the bound account. + RedeemLinkCodeForOwner(ctx context.Context, newUserID, code string, now time.Time) (userID, mcUUID, authSource string, err error) + // CreateSetupToken mints a one-time setup token for first-web-login bootstrap. + CreateSetupToken(ctx context.Context, tokenHash, userID string, expiresAt time.Time) error SetSetting(ctx context.Context, key string, value []byte) error // Audit records the break-glass accountability row. Audit(ctx context.Context, e api.AuditEntry) error @@ -164,20 +170,14 @@ func cmdBreakGlass(args []string, stdout, stderr io.Writer) int { fmt.Fprintf(stdout, "\nfelis breakGlass: Owner account %q provisioned; local-password login is ENABLED.\n", res.username) } fmt.Fprintf(stdout, "Recorded as %q (mode: %s, os user: %s).\n", res.accountable, res.mode, res.osUser) - if res.displayPassword != "" { - // A one-time password was generated (recovery / root override). It is shown, - // never persisted: only the bcrypt hash reached the database. - fmt.Fprintf(stdout, "One-time password (you MUST change it on first login):\n\n %s\n\n", res.displayPassword) - } else { - // Bootstrap: the operator typed the password themselves, so we do NOT echo it - // back into scrollback. - fmt.Fprintln(stdout, "Log in with the password you just entered (you MUST change it on first login).") + if res.setupTokenURL != "" { + fmt.Fprintf(stdout, "One-time setup URL (opens a lockdown session to verify email / enroll passkey):\n\n %s\n\n", res.setupTokenURL) } if res.auditWarning != "" { fmt.Fprintf(stdout, "WARNING: the accountability audit row was NOT written: %s\n", res.auditWarning) } if url := adminLoginURL(res.rootDomain, res.adminHostname); url != "" { - fmt.Fprintf(stdout, "Log in at %s with that username and password.\n", url) + fmt.Fprintf(stdout, "Admin console: %s\n", url) } } @@ -229,54 +229,12 @@ func newOwnerID() string { return "usr-" + hex.EncodeToString(b[:]) } -// generateBootstrapPassword returns a fresh one-time password from the unambiguous -// alphabet. It rejection-samples to avoid modulo bias, so every position is uniform -// over the alphabet. 20 chars over a 57-symbol alphabet is ~116 bits — far more than -// the must-change credential needs, and it is rotated on first login regardless. -func generateBootstrapPassword() (string, error) { - const n = 20 - // Largest multiple of the alphabet size that fits in a byte; bytes at or above it - // are discarded so the surviving values map uniformly (no modulo bias). - limit := byte(256 - (256 % len(bootstrapPasswordAlphabet))) - out := make([]byte, 0, n) - var b [1]byte - for len(out) < n { - if _, err := rand.Read(b[:]); err != nil { - return "", fmt.Errorf("generate bootstrap password: %w", err) - } - if b[0] >= limit { - continue - } - out = append(out, bootstrapPasswordAlphabet[int(b[0])%len(bootstrapPasswordAlphabet)]) - } - return string(out), nil -} - -// validateOwnerPassword mirrors api.validateNewPassword (handlers_auth.go): a -// break-glass credential must satisfy the SAME 8–72-byte rule the panel's own -// change-password enforces, so an operator can never set a password here that the -// web change-password flow would later reject. 72 is bcrypt's hard input limit. -func validateOwnerPassword(pw string) error { - if len(pw) < 8 { - return errors.New("password must be at least 8 characters") - } - if len(pw) > 72 { - return errors.New("password must be at most 72 bytes") - } - return nil -} - -// authenticateAdmin verifies a typed credential against an existing admin account -// for recovery-mode attribution. matched is the stored username on success. -// -// ok==false with err==nil is NOT a failure to surface — it means the credential did -// not match any admin password. The caller offers an explicit root override instead -// of refusing, because break-glass must still recover when no admin credential can -// be produced (a forgotten password is the canonical reason the web login is -// unreachable in the first place). Only a real datastore fault returns err. -func authenticateAdmin(ctx context.Context, s ownerStore, username, password string) (matched string, ok bool, err error) { +// authenticateAdmin resolves a typed admin username for recovery-mode attribution. +// Password verification is gone (passwordless design); Phase 3 replaces this with +// email-OTP recovery. For now it confirms the named admin exists. +func authenticateAdmin(ctx context.Context, s ownerStore, username string) (matched string, ok bool, err error) { username = strings.TrimSpace(username) - if username == "" || password == "" { + if username == "" { return "", false, nil } u, err := s.UserByUsername(ctx, username) @@ -286,70 +244,47 @@ func authenticateAdmin(ctx context.Context, s ownerStore, username, password str if err != nil { return "", false, err } - // Only an admin row carrying a bcrypt hash is an authenticatable staff identity; - // a player row (role=user, hash NULL → empty PasswordHash) can never attribute a - // break-glass action. - if u.Role != "admin" || u.PasswordHash == "" { - return "", false, nil - } - if bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)) != nil { + if u.Role != "admin" { return "", false, nil } return u.Username, true, nil } -// provisionOwner mints or resets the single Owner account direct-to-Postgres with -// the given (already-validated-by-the-caller) password. The account is created with -// must_change_password=true, which is load-bearing: it is what arms the API's -// lockdown middleware so the Owner can do nothing but change the password on first -// login. Only the bcrypt hash reaches the database; the plaintext never does. -func provisionOwner(ctx context.Context, s ownerStore, username, email, password string) error { +// provisionOwner mints or resets the single Owner account direct-to-Postgres, +// passwordless. The account is role=admin with no password — the Owner completes +// passwordless login setup via the web setup-token flow after `felis setup`. +func provisionOwner(ctx context.Context, s ownerStore, username, email string) error { username = strings.TrimSpace(username) if username == "" { return errors.New("owner username is required") } - if err := validateOwnerPassword(password); err != nil { - return err - } id := newOwnerID() if id == "" { return errors.New("generate owner id: entropy source failed") } - hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) - if err != nil { - return fmt.Errorf("hash owner password: %w", err) - } - if err := s.UpsertOwner(ctx, id, username, strings.TrimSpace(email), string(hash), true); err != nil { + if err := s.UpsertOwner(ctx, id, username, strings.TrimSpace(email)); err != nil { return fmt.Errorf("write owner: %w", err) } return nil } // provisionOperator mints a NEW Operator staff account direct-to-Postgres. Like the -// Owner it requires must_change_password=true but carries role='admin' (the single -// above-admin 'owner' role was added in migration 0011 and is exclusive to the first -// account — every subsequent staff is a plain admin). UNLIKE provisionOwner, which -// username conflict, this is insert-only: a username already taken returns -// api.ErrConflict rather than overwriting a live account, so adding an Operator can -// never silently clobber the Owner's or another Operator's credential. Only the -// bcrypt hash reaches the database; the plaintext never does. -func provisionOperator(ctx context.Context, s ownerStore, username, email, password string) error { +// Owner it is role=admin and passwordless — Felis has no separate operator DB role, +// so an Operator is simply an additional staff admin (migration 0003). UNLIKE +// provisionOwner, which upserts the single Owner and resets it on a username +// conflict, this is insert-only: a username already taken returns api.ErrConflict +// rather than overwriting a live account, so adding an Operator can never silently +// clobber the Owner's or another Operator's account. +func provisionOperator(ctx context.Context, s ownerStore, username, email string) error { username = strings.TrimSpace(username) if username == "" { return errors.New("operator username is required") } - if err := validateOwnerPassword(password); err != nil { - return err - } id := newOwnerID() if id == "" { return errors.New("generate operator id: entropy source failed") } - hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) - if err != nil { - return fmt.Errorf("hash operator password: %w", err) - } - if err := s.InsertOperator(ctx, id, username, strings.TrimSpace(email), string(hash), true); err != nil { + if err := s.InsertOperator(ctx, id, username, strings.TrimSpace(email)); err != nil { if errors.Is(err, api.ErrConflict) { // Wrap %w so errors.Is(err, api.ErrConflict) still holds — the TUI can render // a "name already taken" message — while keeping a clear human string. @@ -381,50 +316,84 @@ type breakGlassOp struct { osUser string // $SUDO_USER (or "root"); recorded in the payload ownerUsername string ownerEmail string - ownerPassword string // typed (bootstrap); "" => generate a one-time password attemptedAdmin string // recovery / override: the admin username the operator typed } // breakGlassOutcome is what performBreakGlass reports back to the TUI. type breakGlassOutcome struct { - displayPassword string // non-empty only when a one-time password was generated - auditErr error // non-nil if the accountability row could not be written + setupTokenURL string // non-empty when setup minted a one-time first-login URL + auditErr error // non-nil if the accountability row could not be written } // performBreakGlass executes a resolved break-glass operation: provision (or reset) // the Owner, enable local-password login, then record a best-effort accountability -// audit row. A typed ownerPassword (bootstrap) is used as-is; an empty one (recovery -// / root override) is replaced with a generated one-time password returned for -// one-time display. The audit write is best-effort: a logging failure is reported -// via auditErr but does NOT fail the recovery — break-glass must still work when the -// audit sink is unhappy. +// audit row. The Owner is passwordless — the setup-token flow handles first-login +// setup. The audit write is best-effort: a logging failure is reported via auditErr +// but does NOT fail the recovery — break-glass must still work when the audit sink +// is unhappy. func performBreakGlass(ctx context.Context, s ownerStore, op breakGlassOp) (breakGlassOutcome, error) { - password := op.ownerPassword - generated := false - if password == "" { - p, err := generateBootstrapPassword() - if err != nil { - return breakGlassOutcome{}, err - } - password, generated = p, true - } - if err := provisionOwner(ctx, s, op.ownerUsername, op.ownerEmail, password); err != nil { + if err := provisionOwner(ctx, s, op.ownerUsername, op.ownerEmail); err != nil { return breakGlassOutcome{}, err } - // Record accountability the instant the credential changes — BEFORE enabling - // local auth, which can still fail. Auditing only after both writes would let a - // failed enableLocalAuth leave a just-reset credential with no "who did it" row; - // the audit is best-effort, so doing it first never blocks the recovery. + // Record accountability the instant the account is written — BEFORE enabling + // local auth, which can still fail. The audit is best-effort, so doing it first + // never blocks the recovery. out := breakGlassOutcome{auditErr: auditBreakGlass(ctx, s, op)} - if generated { - out.displayPassword = password - } if err := enableLocalAuth(ctx, s); err != nil { return breakGlassOutcome{}, err } return out, nil } +// setupTokenTTL bounds how long a one-time setup URL is valid. The operator opens +// it right after setup completes, so a generous-but-bounded window is enough. +const setupTokenTTL = 30 * time.Minute + +// newSetupToken returns a fresh opaque setup token (256 bits, URL-safe) and its +// sha-256 hex hash. Only the hash is persisted; the raw value rides in the URL. +func newSetupToken() (raw, hash string, err error) { + var b [32]byte + if _, err := rand.Read(b[:]); err != nil { + return "", "", fmt.Errorf("generate setup token: %w", err) + } + raw = base64.RawURLEncoding.EncodeToString(b[:]) + sum := sha256.Sum256([]byte(raw)) + return raw, hex.EncodeToString(sum[:]), nil +} + +// performSetupMCBind is the `felis setup` Owner-establishment path: the operator +// binds their Minecraft account via an in-game /link code, the bound user is +// promoted to role='admin' (passwordless Owner), and a one-time setup URL is +// minted for the first web login where the Owner verifies email / enrolls a +// passkey. adminHostname is the op.console host the URL points at. +func performSetupMCBind(ctx context.Context, s ownerStore, code, adminHostname string) (breakGlassOutcome, error) { + code = strings.TrimSpace(strings.ToUpper(code)) + if code == "" { + return breakGlassOutcome{}, errors.New("link code is required") + } + newID := newOwnerID() + if newID == "" { + return breakGlassOutcome{}, errors.New("generate owner id: entropy source failed") + } + userID, _, _, err := s.RedeemLinkCodeForOwner(ctx, newID, code, time.Now()) + if err != nil { + return breakGlassOutcome{}, fmt.Errorf("bind minecraft account: %w", err) + } + raw, hash, err := newSetupToken() + if err != nil { + return breakGlassOutcome{}, err + } + if err := s.CreateSetupToken(ctx, hash, userID, time.Now().Add(setupTokenTTL)); err != nil { + return breakGlassOutcome{}, fmt.Errorf("mint setup token: %w", err) + } + host := strings.TrimSpace(adminHostname) + if host == "" { + host = "op.console.localhost" + } + url := "https://" + host + "/setup?token=" + raw + return breakGlassOutcome{setupTokenURL: url}, nil +} + // auditBreakGlass writes the break-glass accountability row. The actor is the // resolved human identity (a verified admin in recovery, the OS user otherwise); // the payload carries the full who/what/how so an after-the-fact reader can tell a @@ -454,9 +423,7 @@ func auditBreakGlass(ctx context.Context, s ownerStore, op breakGlassOp) error { } // performAddOperator mints a NEW Operator account and records a best-effort -// accountability row. It mirrors performBreakGlass — a typed password is used as-is, -// an empty one is replaced with a generated one-time password returned for one-time -// display (the common case: hand a fresh credential to the new operator) — with two +// accountability row. It mirrors performBreakGlass — passwordless — with two // deliberate differences. (1) It provisions insert-only (provisionOperator), so it // can never reset an existing account the way the Owner upsert does. (2) It does NOT // touch local_auth_enabled: adding an Operator presupposes an already-configured, @@ -465,22 +432,10 @@ func auditBreakGlass(ctx context.Context, s ownerStore, op breakGlassOp) error { // the Owner break-glass thread alone. The audit is best-effort and written only after // a successful provision; a conflict mints nothing, so there is nothing to attribute. func performAddOperator(ctx context.Context, s ownerStore, op breakGlassOp) (breakGlassOutcome, error) { - password := op.ownerPassword - generated := false - if password == "" { - p, err := generateBootstrapPassword() - if err != nil { - return breakGlassOutcome{}, err - } - password, generated = p, true - } - if err := provisionOperator(ctx, s, op.ownerUsername, op.ownerEmail, password); err != nil { + if err := provisionOperator(ctx, s, op.ownerUsername, op.ownerEmail); err != nil { return breakGlassOutcome{}, err } out := breakGlassOutcome{auditErr: auditAddOperator(ctx, s, op)} - if generated { - out.displayPassword = password - } return out, nil } @@ -514,17 +469,17 @@ func auditAddOperator(ctx context.Context, s ownerStore, op breakGlassOp) error // breakGlassResult is what the TUI hands back to cmdBreakGlass for the durable // post-exit summary. provisioned is false on cancel. type breakGlassResult struct { - provisioned bool - isOperator bool // an Operator was added rather than the Owner provisioned - mode string - accountable string - osUser string - username string - displayPassword string // empty when the operator typed their own bootstrap password - auditWarning string - rootDomain string - adminHostname string - panelURL string + provisioned bool + isOperator bool // an Operator was added rather than the Owner provisioned + mode string + accountable string + osUser string + username string + setupTokenURL string // non-empty when setup minted a one-time first-login URL + auditWarning string + rootDomain string + adminHostname string + panelURL string // connection outcome (independent of provisioned) connectMethod connectMethod diff --git a/cmd/felis/breakglass_test.go b/cmd/felis/breakglass_test.go index 6f562c5..c2940cc 100644 --- a/cmd/felis/breakglass_test.go +++ b/cmd/felis/breakglass_test.go @@ -2,38 +2,65 @@ package main import ( "context" + "crypto/sha256" + "encoding/hex" "encoding/json" "errors" "strings" "testing" + "time" "felis.lolicon.best/internal/api" - - "golang.org/x/crypto/bcrypt" ) -// fakeOwnerStore records what break-glass provisioning writes and answers the -// identity lookups, so the core logic (authentication, provisioning, accountability -// audit) is exercised without a database or a terminal. +// fakeOwnerStore records what break-glass / setup provisioning writes and answers +// the identity lookups, so the core logic (admin resolution, provisioning, +// accountability audit, setup-token mint) is exercised without a database or a +// terminal. The design is passwordless: accounts carry no credential, and the +// Owner completes first-login through the setup-token web flow. type fakeOwnerStore struct { upserts []upsertCall inserts []upsertCall settings map[string][]byte audits []api.AuditEntry + tokens []setupTokenCall + redeems []redeemCall users map[string]*api.StaffUser // keyed by username admins bool // AdminExists answer - upsertErr error - insertErr error - setErr error - auditErr error - userErr error // non-not-found error from UserByUsername - adminErr error + // RedeemLinkCodeForOwner's success result. redeemUserID defaults to the fresh id + // the caller passes (the unlinked-UUID case) when left empty. + redeemUserID string + redeemMCUUID string + redeemAuthSource string + + upsertErr error + insertErr error + setErr error + auditErr error + userErr error // non-not-found error from UserByUsername + adminErr error + redeemErr error + createTokenErr error } +// upsertCall is a recorded owner/operator provision. Passwordless: the row is pure +// identity (id, username, email) with an implied role=admin. type upsertCall struct { - id, username, email, passwordHash string - mustChange bool + id, username, email string +} + +// setupTokenCall is a recorded CreateSetupToken write. Only the hash is persisted. +type setupTokenCall struct { + tokenHash string + userID string + expiresAt time.Time +} + +// redeemCall records the inputs RedeemLinkCodeForOwner was called with. +type redeemCall struct { + newUserID string + code string } func (f *fakeOwnerStore) AdminExists(_ context.Context) (bool, error) { @@ -53,33 +80,55 @@ func (f *fakeOwnerStore) UserByUsername(_ context.Context, username string) (*ap return nil, api.ErrNotFound } -func (f *fakeOwnerStore) UpsertOwner(_ context.Context, id, username, email, passwordHash string, mustChange bool) error { +func (f *fakeOwnerStore) UpsertOwner(_ context.Context, id, username, email string) error { if f.upsertErr != nil { return f.upsertErr } - f.upserts = append(f.upserts, upsertCall{id, username, email, passwordHash, mustChange}) + f.upserts = append(f.upserts, upsertCall{id, username, email}) return nil } // InsertOperator records an insert-only Operator provision. A username already in -// the users map is a conflict (api.ErrConflict), mirroring the PGRepo ON CONFLICT -// DO NOTHING + zero-RowsAffected contract; a fresh one is recorded and reflected -// into users so a later lookup — or a second insert of the same name — sees it. -func (f *fakeOwnerStore) InsertOperator(_ context.Context, id, username, email, passwordHash string, mustChange bool) error { +// the users map is a conflict (api.ErrConflict), mirroring the PGRepo insert-only +// contract; a fresh one is recorded and reflected into users so a later lookup — or +// a second insert of the same name — sees it. The row is passwordless (role=admin). +func (f *fakeOwnerStore) InsertOperator(_ context.Context, id, username, email string) error { if f.insertErr != nil { return f.insertErr } if _, taken := f.users[username]; taken { return api.ErrConflict } - f.inserts = append(f.inserts, upsertCall{id, username, email, passwordHash, mustChange}) + f.inserts = append(f.inserts, upsertCall{id, username, email}) if f.users == nil { f.users = map[string]*api.StaffUser{} } - f.users[username] = &api.StaffUser{ - ID: id, Username: username, Email: email, - Role: "admin", PasswordHash: passwordHash, MustChangePassword: mustChange, + f.users[username] = &api.StaffUser{ID: id, Username: username, Email: email, Role: "admin"} + return nil +} + +// RedeemLinkCodeForOwner records the call and returns the configured Owner identity +// (or the injected error). The real method consumes a link code and promotes the +// bound account; the fake models only its inputs and outputs. +func (f *fakeOwnerStore) RedeemLinkCodeForOwner(_ context.Context, newUserID, code string, _ time.Time) (string, string, string, error) { + if f.redeemErr != nil { + return "", "", "", f.redeemErr } + f.redeems = append(f.redeems, redeemCall{newUserID, code}) + userID := f.redeemUserID + if userID == "" { + userID = newUserID // unlinked UUID → the fresh id becomes the Owner + } + return userID, f.redeemMCUUID, f.redeemAuthSource, nil +} + +// CreateSetupToken records a minted setup token (hash only), or fails with the +// injected error without recording it. +func (f *fakeOwnerStore) CreateSetupToken(_ context.Context, tokenHash, userID string, expiresAt time.Time) error { + if f.createTokenErr != nil { + return f.createTokenErr + } + f.tokens = append(f.tokens, setupTokenCall{tokenHash, userID, expiresAt}) return nil } @@ -102,49 +151,18 @@ func (f *fakeOwnerStore) Audit(_ context.Context, e api.AuditEntry) error { return nil } -// mkAdmin builds an authenticatable admin row (role=admin, real bcrypt hash) for the -// fake. MinCost keeps the hash fast — these tests are about wiring, not bcrypt. -func mkAdmin(t *testing.T, username, password string) *api.StaffUser { - t.Helper() - h, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.MinCost) - if err != nil { - t.Fatalf("hash: %v", err) - } - return &api.StaffUser{ID: "usr-admin", Username: username, Role: "admin", PasswordHash: string(h)} -} - -func TestValidateOwnerPassword(t *testing.T) { - cases := []struct { - name string - pw string - ok bool - }{ - {"too short", "1234567", false}, - {"minimum", "12345678", true}, - {"comfortable", "Mid-Range-1", true}, - {"at the bcrypt limit", strings.Repeat("a", 72), true}, - {"past the bcrypt limit", strings.Repeat("a", 73), false}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - err := validateOwnerPassword(tc.pw) - if tc.ok && err != nil { - t.Errorf("validateOwnerPassword(%d bytes) = %v, want nil", len(tc.pw), err) - } - if !tc.ok && err == nil { - t.Errorf("validateOwnerPassword(%d bytes) = nil, want error", len(tc.pw)) - } - }) - } +// mkAdmin builds a resolvable staff row (role=admin). The design is passwordless, +// so a staff account is identity + role — there is no credential to attach. +func mkAdmin(username string) *api.StaffUser { + return &api.StaffUser{ID: "usr-admin", Username: username, Role: "admin"} } func TestProvisionOwner(t *testing.T) { ctx := context.Background() - t.Run("happy path mints a must-change admin with a verifiable hash", func(t *testing.T) { + t.Run("mints a passwordless owner row", func(t *testing.T) { f := &fakeOwnerStore{} - const pw = "valid-test-pw" - if err := provisionOwner(ctx, f, "owner", "me@example.com", pw); err != nil { + if err := provisionOwner(ctx, f, "owner", "me@example.com"); err != nil { t.Fatalf("provisionOwner: %v", err) } if len(f.upserts) != 1 { @@ -157,26 +175,14 @@ func TestProvisionOwner(t *testing.T) { if got.email != "me@example.com" { t.Errorf("email = %q, want me@example.com", got.email) } - // must_change_password=true is load-bearing: it arms the API lockdown so the - // Owner can do nothing but change the password on first login. - if !got.mustChange { - t.Error("mustChange = false, want true (forced first-login change)") - } if !strings.HasPrefix(got.id, "usr-") { t.Errorf("id = %q, want usr- prefix", got.id) } - // Only the hash is stored; the typed plaintext must verify against it. - if bcrypt.CompareHashAndPassword([]byte(got.passwordHash), []byte(pw)) != nil { - t.Error("typed password does not verify against the stored hash") - } - if got.passwordHash == pw { - t.Error("stored hash equals plaintext — password was not hashed") - } }) t.Run("trims surrounding whitespace", func(t *testing.T) { f := &fakeOwnerStore{} - if err := provisionOwner(ctx, f, " owner ", " e@x.io ", "valid-test-pw"); err != nil { + if err := provisionOwner(ctx, f, " owner ", " e@x.io "); err != nil { t.Fatalf("provisionOwner: %v", err) } if f.upserts[0].username != "owner" || f.upserts[0].email != "e@x.io" { @@ -186,7 +192,7 @@ func TestProvisionOwner(t *testing.T) { t.Run("rejects an empty username before any write", func(t *testing.T) { f := &fakeOwnerStore{} - if err := provisionOwner(ctx, f, " ", "", "valid-test-pw"); err == nil { + if err := provisionOwner(ctx, f, " ", ""); err == nil { t.Fatal("want error for empty username") } if len(f.upserts) != 0 { @@ -194,19 +200,9 @@ func TestProvisionOwner(t *testing.T) { } }) - t.Run("rejects a weak password before any write", func(t *testing.T) { - f := &fakeOwnerStore{} - if err := provisionOwner(ctx, f, "owner", "", "short"); err == nil { - t.Fatal("want error for a sub-8-byte password") - } - if len(f.upserts) != 0 { - t.Errorf("want no upsert on weak password, got %d", len(f.upserts)) - } - }) - t.Run("propagates a store error", func(t *testing.T) { f := &fakeOwnerStore{upsertErr: errors.New("boom")} - if err := provisionOwner(ctx, f, "owner", "", "valid-test-pw"); err == nil { + if err := provisionOwner(ctx, f, "owner", ""); err == nil { t.Fatal("want error when the store fails") } }) @@ -233,66 +229,32 @@ func TestEnableLocalAuth(t *testing.T) { } } -func TestGenerateBootstrapPassword(t *testing.T) { - const want = 20 - pw, err := generateBootstrapPassword() - if err != nil { - t.Fatalf("generateBootstrapPassword: %v", err) - } - if len(pw) != want { - t.Errorf("length = %d, want %d", len(pw), want) - } - for _, c := range pw { - if !strings.ContainsRune(bootstrapPasswordAlphabet, c) { - t.Errorf("password contains out-of-alphabet rune %q", c) - } - } - // A generated password must satisfy the same rule provisionOwner enforces. - if err := validateOwnerPassword(pw); err != nil { - t.Errorf("generated password fails validateOwnerPassword: %v", err) - } - other, err := generateBootstrapPassword() - if err != nil { - t.Fatal(err) - } - if pw == other { - t.Error("two calls produced the same password") - } -} - func TestAuthenticateAdmin(t *testing.T) { ctx := context.Background() - t.Run("verifies a matching admin credential", func(t *testing.T) { - f := &fakeOwnerStore{users: map[string]*api.StaffUser{"root": mkAdmin(t, "root", "correct horse")}} - matched, ok, err := authenticateAdmin(ctx, f, "root", "correct horse") + // Password verification is gone (passwordless design): authenticateAdmin now only + // resolves the named admin so recovery can attribute the audit to a real identity. + // The security boundary is the break-glass root gate, not a typed secret. + + t.Run("resolves an existing admin for attribution", func(t *testing.T) { + f := &fakeOwnerStore{users: map[string]*api.StaffUser{"root": mkAdmin("root")}} + matched, ok, err := authenticateAdmin(ctx, f, "root") if err != nil { t.Fatalf("authenticateAdmin: %v", err) } if !ok { - t.Fatal("ok = false, want true for the correct password") + t.Fatal("ok = false, want true for an existing admin") } if matched != "root" { t.Errorf("matched = %q, want root", matched) } }) - t.Run("a wrong password is a non-match, not an error", func(t *testing.T) { - f := &fakeOwnerStore{users: map[string]*api.StaffUser{"root": mkAdmin(t, "root", "correct horse")}} - _, ok, err := authenticateAdmin(ctx, f, "root", "wrong") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if ok { - t.Error("ok = true, want false for a wrong password") - } - }) - t.Run("a non-admin role can never attribute a break-glass", func(t *testing.T) { - player := mkAdmin(t, "alice", "correct horse") - player.Role = "user" // a player row, even with a hash, is not staff + player := mkAdmin("alice") + player.Role = "user" // a player row is not staff f := &fakeOwnerStore{users: map[string]*api.StaffUser{"alice": player}} - _, ok, err := authenticateAdmin(ctx, f, "alice", "correct horse") + _, ok, err := authenticateAdmin(ctx, f, "alice") if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -301,22 +263,9 @@ func TestAuthenticateAdmin(t *testing.T) { } }) - t.Run("a hashless admin row is a non-match", func(t *testing.T) { - f := &fakeOwnerStore{users: map[string]*api.StaffUser{ - "ghost": {Username: "ghost", Role: "admin", PasswordHash: ""}, - }} - _, ok, err := authenticateAdmin(ctx, f, "ghost", "anything") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if ok { - t.Error("ok = true, want false when no hash is set") - } - }) - t.Run("an unknown user is a non-match, not an error", func(t *testing.T) { f := &fakeOwnerStore{} - _, ok, err := authenticateAdmin(ctx, f, "nobody", "pw") + _, ok, err := authenticateAdmin(ctx, f, "nobody") if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -325,19 +274,16 @@ func TestAuthenticateAdmin(t *testing.T) { } }) - t.Run("empty input is a non-match with no store call", func(t *testing.T) { + t.Run("an empty username is a non-match with no store call", func(t *testing.T) { f := &fakeOwnerStore{userErr: errors.New("must not be called")} - if _, ok, err := authenticateAdmin(ctx, f, "", "pw"); ok || err != nil { + if _, ok, err := authenticateAdmin(ctx, f, ""); ok || err != nil { t.Errorf("empty username: ok=%v err=%v, want false,nil", ok, err) } - if _, ok, err := authenticateAdmin(ctx, f, "root", ""); ok || err != nil { - t.Errorf("empty password: ok=%v err=%v, want false,nil", ok, err) - } }) t.Run("a datastore fault is surfaced", func(t *testing.T) { f := &fakeOwnerStore{userErr: errors.New("db down")} - if _, _, err := authenticateAdmin(ctx, f, "root", "pw"); err == nil { + if _, _, err := authenticateAdmin(ctx, f, "root"); err == nil { t.Fatal("want error when the store fails") } }) @@ -360,7 +306,7 @@ func auditOf(t *testing.T, f *fakeOwnerStore) (api.AuditEntry, map[string]any) { func TestPerformBreakGlass(t *testing.T) { ctx := context.Background() - t.Run("bootstrap uses the typed password and never echoes it", func(t *testing.T) { + t.Run("bootstrap provisions the owner, enables local auth, and audits", func(t *testing.T) { f := &fakeOwnerStore{} op := breakGlassOp{ mode: "bootstrap", @@ -368,21 +314,16 @@ func TestPerformBreakGlass(t *testing.T) { osUser: "deploybot", ownerUsername: "owner", ownerEmail: "owner@example.com", - ownerPassword: "valid-test-pw", } out, err := performBreakGlass(ctx, f, op) if err != nil { t.Fatalf("performBreakGlass: %v", err) } - // The operator typed their own password, so it must NOT be surfaced for display. - if out.displayPassword != "" { - t.Errorf("displayPassword = %q, want empty for a typed bootstrap password", out.displayPassword) - } if out.auditErr != nil { t.Errorf("auditErr = %v, want nil", out.auditErr) } - if len(f.upserts) != 1 || bcrypt.CompareHashAndPassword([]byte(f.upserts[0].passwordHash), []byte("valid-test-pw")) != nil { - t.Error("owner was not provisioned with the typed password") + if len(f.upserts) != 1 || f.upserts[0].username != "owner" { + t.Errorf("owner was not provisioned: %+v", f.upserts) } if _, ok := f.settings[api.LocalAuthEnabledKey]; !ok { t.Error("local auth was not enabled — login would 403") @@ -402,7 +343,7 @@ func TestPerformBreakGlass(t *testing.T) { } }) - t.Run("recovery generates a one-time password and records a verified row", func(t *testing.T) { + t.Run("recovery provisions the owner and records a verified row", func(t *testing.T) { f := &fakeOwnerStore{} op := breakGlassOp{ mode: "recovery", @@ -415,12 +356,14 @@ func TestPerformBreakGlass(t *testing.T) { if err != nil { t.Fatalf("performBreakGlass: %v", err) } - if out.displayPassword == "" { - t.Fatal("displayPassword empty, want a generated one-time password") + if out.auditErr != nil { + t.Errorf("auditErr = %v, want nil", out.auditErr) } - // The shown password must be the one actually stored (as a hash). - if bcrypt.CompareHashAndPassword([]byte(f.upserts[0].passwordHash), []byte(out.displayPassword)) != nil { - t.Error("displayed password does not match the stored hash") + if len(f.upserts) != 1 { + t.Fatalf("want 1 upsert, got %d", len(f.upserts)) + } + if _, ok := f.settings[api.LocalAuthEnabledKey]; !ok { + t.Error("local auth was not enabled") } e, payload := auditOf(t, f) if e.Actor != "root" || e.Action != "break_glass.recovery" { @@ -443,13 +386,9 @@ func TestPerformBreakGlass(t *testing.T) { ownerUsername: "owner", attemptedAdmin: "typo-admin", } - out, err := performBreakGlass(ctx, f, op) - if err != nil { + if _, err := performBreakGlass(ctx, f, op); err != nil { t.Fatalf("performBreakGlass: %v", err) } - if out.displayPassword == "" { - t.Error("displayPassword empty, want a generated one-time password") - } e, payload := auditOf(t, f) if e.Actor != "alice" || e.Action != "break_glass.root_override" { t.Errorf("audit envelope = %+v, want actor=alice action=break_glass.root_override", e) @@ -484,7 +423,7 @@ func TestPerformBreakGlass(t *testing.T) { t.Run("does not enable local auth or audit if the owner write fails", func(t *testing.T) { f := &fakeOwnerStore{upsertErr: errors.New("boom")} - op := breakGlassOp{mode: "bootstrap", accountable: "root", osUser: "root", ownerUsername: "owner", ownerPassword: "valid-test-pw"} + op := breakGlassOp{mode: "bootstrap", accountable: "root", osUser: "root", ownerUsername: "owner"} if _, err := performBreakGlass(ctx, f, op); err == nil { t.Fatal("want error when the owner write fails") } @@ -497,9 +436,9 @@ func TestPerformBreakGlass(t *testing.T) { }) t.Run("records accountability before enabling local auth, surviving an enableLocalAuth failure", func(t *testing.T) { - // The credential is reset by provisionOwner; if the audit were written only - // after enableLocalAuth, a failed toggle write would leave that reset with no - // "who did it" row. Order guarantees the accountability row lands first. + // The Owner is written by provisionOwner; if the audit were written only after + // enableLocalAuth, a failed toggle write would leave that write with no "who did + // it" row. Order guarantees the accountability row lands first. f := &fakeOwnerStore{setErr: errors.New("settings write down")} op := breakGlassOp{mode: "recovery", accountable: "root", osUser: "alice", ownerUsername: "owner", attemptedAdmin: "root"} if _, err := performBreakGlass(ctx, f, op); err == nil { @@ -535,10 +474,9 @@ func TestAccountableOSUser(t *testing.T) { func TestProvisionOperator(t *testing.T) { ctx := context.Background() - t.Run("happy path mints a must-change admin with a verifiable hash", func(t *testing.T) { + t.Run("mints a passwordless operator row (insert-only)", func(t *testing.T) { f := &fakeOwnerStore{} - const pw = "valid-test-pw" - if err := provisionOperator(ctx, f, "ops-jordan", "jordan@example.com", pw); err != nil { + if err := provisionOperator(ctx, f, "ops-jordan", "jordan@example.com"); err != nil { t.Fatalf("provisionOperator: %v", err) } // Insert-only: it records an insert and never touches the Owner upsert path. @@ -555,29 +493,18 @@ func TestProvisionOperator(t *testing.T) { if got.email != "jordan@example.com" { t.Errorf("email = %q, want jordan@example.com", got.email) } - // must_change_password=true arms the API lockdown for the new operator too. - if !got.mustChange { - t.Error("mustChange = false, want true (forced first-login change)") - } if !strings.HasPrefix(got.id, "usr-") { t.Errorf("id = %q, want usr- prefix", got.id) } - // Only the hash is stored; the typed plaintext must verify against it. - if bcrypt.CompareHashAndPassword([]byte(got.passwordHash), []byte(pw)) != nil { - t.Error("typed password does not verify against the stored hash") - } - if got.passwordHash == pw { - t.Error("stored hash equals plaintext — password was not hashed") - } }) t.Run("a taken username is a conflict, not a silent reset", func(t *testing.T) { // The Owner already holds this username. Operator-add must refuse rather than // overwrite it the way UpsertOwner would. f := &fakeOwnerStore{users: map[string]*api.StaffUser{ - "owner": {ID: "usr-owner", Username: "owner", Role: "admin", PasswordHash: "x"}, + "owner": {ID: "usr-owner", Username: "owner", Role: "admin"}, }} - err := provisionOperator(ctx, f, "owner", "", "valid-test-pw") + err := provisionOperator(ctx, f, "owner", "") if err == nil { t.Fatal("want error when the username is already taken") } @@ -589,14 +516,14 @@ func TestProvisionOperator(t *testing.T) { t.Errorf("want no insert on conflict, got %d", len(f.inserts)) } // The pre-existing account must be untouched. - if f.users["owner"].PasswordHash != "x" { - t.Error("conflicting insert clobbered the existing account's hash") + if f.users["owner"].ID != "usr-owner" { + t.Error("conflicting insert clobbered the existing account") } }) t.Run("trims surrounding whitespace", func(t *testing.T) { f := &fakeOwnerStore{} - if err := provisionOperator(ctx, f, " ops ", " e@x.io ", "valid-test-pw"); err != nil { + if err := provisionOperator(ctx, f, " ops ", " e@x.io "); err != nil { t.Fatalf("provisionOperator: %v", err) } if f.inserts[0].username != "ops" || f.inserts[0].email != "e@x.io" { @@ -606,7 +533,7 @@ func TestProvisionOperator(t *testing.T) { t.Run("rejects an empty username before any write", func(t *testing.T) { f := &fakeOwnerStore{} - if err := provisionOperator(ctx, f, " ", "", "valid-test-pw"); err == nil { + if err := provisionOperator(ctx, f, " ", ""); err == nil { t.Fatal("want error for empty username") } if len(f.inserts) != 0 { @@ -614,19 +541,9 @@ func TestProvisionOperator(t *testing.T) { } }) - t.Run("rejects a weak password before any write", func(t *testing.T) { - f := &fakeOwnerStore{} - if err := provisionOperator(ctx, f, "ops", "", "short"); err == nil { - t.Fatal("want error for a sub-8-byte password") - } - if len(f.inserts) != 0 { - t.Errorf("want no insert on weak password, got %d", len(f.inserts)) - } - }) - t.Run("propagates a non-conflict store error without mislabeling it", func(t *testing.T) { f := &fakeOwnerStore{insertErr: errors.New("boom")} - err := provisionOperator(ctx, f, "ops", "", "valid-test-pw") + err := provisionOperator(ctx, f, "ops", "") if err == nil { t.Fatal("want error when the store fails") } @@ -640,7 +557,7 @@ func TestProvisionOperator(t *testing.T) { func TestPerformAddOperator(t *testing.T) { ctx := context.Background() - t.Run("typed password is used as-is, never echoed, and never flips local auth", func(t *testing.T) { + t.Run("provisions an operator, audits, and never flips local auth", func(t *testing.T) { f := &fakeOwnerStore{} op := breakGlassOp{ mode: "recovery", @@ -648,19 +565,17 @@ func TestPerformAddOperator(t *testing.T) { osUser: "alice", ownerUsername: "ops-jordan", ownerEmail: "jordan@example.com", - ownerPassword: "valid-test-pw", attemptedAdmin: "root", } out, err := performAddOperator(ctx, f, op) if err != nil { t.Fatalf("performAddOperator: %v", err) } - // The operator's password was typed, so it must NOT be surfaced for display. - if out.displayPassword != "" { - t.Errorf("displayPassword = %q, want empty for a typed password", out.displayPassword) + if out.auditErr != nil { + t.Errorf("auditErr = %v, want nil", out.auditErr) } - if len(f.inserts) != 1 || bcrypt.CompareHashAndPassword([]byte(f.inserts[0].passwordHash), []byte("valid-test-pw")) != nil { - t.Error("operator was not provisioned with the typed password") + if len(f.inserts) != 1 || f.inserts[0].username != "ops-jordan" { + t.Errorf("operator was not provisioned: %+v", f.inserts) } // Adding an Operator must NOT flip the global local-auth gate (Owner-only). if _, ok := f.settings[api.LocalAuthEnabledKey]; ok { @@ -682,19 +597,14 @@ func TestPerformAddOperator(t *testing.T) { } }) - t.Run("an empty password generates a one-time credential matching the stored hash", func(t *testing.T) { + t.Run("root override records an unverified operator row", func(t *testing.T) { f := &fakeOwnerStore{} op := breakGlassOp{mode: "root_override", accountable: "alice", osUser: "alice", ownerUsername: "ops", attemptedAdmin: "typo-admin"} - out, err := performAddOperator(ctx, f, op) - if err != nil { + if _, err := performAddOperator(ctx, f, op); err != nil { t.Fatalf("performAddOperator: %v", err) } - if out.displayPassword == "" { - t.Fatal("displayPassword empty, want a generated one-time password to hand off") - } - // The shown password must be the one actually stored (as a hash). - if bcrypt.CompareHashAndPassword([]byte(f.inserts[0].passwordHash), []byte(out.displayPassword)) != nil { - t.Error("displayed password does not match the stored hash") + if len(f.inserts) != 1 { + t.Fatalf("want 1 insert, got %d", len(f.inserts)) } _, payload := auditOf(t, f) if payload["verified"] != false { @@ -719,9 +629,9 @@ func TestPerformAddOperator(t *testing.T) { t.Run("a conflict mints nothing and writes no audit row", func(t *testing.T) { f := &fakeOwnerStore{users: map[string]*api.StaffUser{ - "owner": {ID: "usr-owner", Username: "owner", Role: "admin", PasswordHash: "x"}, + "owner": {ID: "usr-owner", Username: "owner", Role: "admin"}, }} - op := breakGlassOp{mode: "recovery", accountable: "root", osUser: "alice", ownerUsername: "owner", ownerPassword: "valid-test-pw", attemptedAdmin: "root"} + op := breakGlassOp{mode: "recovery", accountable: "root", osUser: "alice", ownerUsername: "owner", attemptedAdmin: "root"} if _, err := performAddOperator(ctx, f, op); err == nil { t.Fatal("want error when the operator username is already taken") } @@ -733,3 +643,94 @@ func TestPerformAddOperator(t *testing.T) { } }) } + +func TestPerformSetupMCBind(t *testing.T) { + ctx := context.Background() + + t.Run("binds the owner and mints a setup URL whose token hash is what is stored", func(t *testing.T) { + f := &fakeOwnerStore{redeemUserID: "usr-owner-1"} + out, err := performSetupMCBind(ctx, f, " abc-123 ", "op.console.example.com") + if err != nil { + t.Fatalf("performSetupMCBind: %v", err) + } + const prefix = "https://op.console.example.com/setup?token=" + if !strings.HasPrefix(out.setupTokenURL, prefix) { + t.Fatalf("setup URL = %q, want prefix %q", out.setupTokenURL, prefix) + } + // The link code is trimmed and upper-cased before redemption. + if len(f.redeems) != 1 { + t.Fatalf("want 1 redeem, got %d", len(f.redeems)) + } + if f.redeems[0].code != "ABC-123" { + t.Errorf("redeemed code = %q, want ABC-123 (trimmed + upper-cased)", f.redeems[0].code) + } + if !strings.HasPrefix(f.redeems[0].newUserID, "usr-") { + t.Errorf("redeem newUserID = %q, want usr- prefix", f.redeems[0].newUserID) + } + // Exactly one token minted, for the redeemed user, and only its hash stored — + // the stored hash must be sha-256 of the raw token carried in the URL. + if len(f.tokens) != 1 { + t.Fatalf("want 1 setup token, got %d", len(f.tokens)) + } + tok := f.tokens[0] + if tok.userID != "usr-owner-1" { + t.Errorf("token userID = %q, want usr-owner-1 (the redeemed owner)", tok.userID) + } + raw := strings.TrimPrefix(out.setupTokenURL, prefix) + sum := sha256.Sum256([]byte(raw)) + if tok.tokenHash != hex.EncodeToString(sum[:]) { + t.Error("stored token hash is not sha-256 of the raw token in the URL") + } + if tok.tokenHash == raw || tok.tokenHash == "" { + t.Error("the raw token (or nothing) was stored instead of its hash") + } + // The token is short-lived and in the future. + if !tok.expiresAt.After(time.Now()) { + t.Errorf("token expiresAt = %v, want a future time", tok.expiresAt) + } + }) + + t.Run("an empty link code mints nothing", func(t *testing.T) { + f := &fakeOwnerStore{} + if _, err := performSetupMCBind(ctx, f, " ", "op.console.example.com"); err == nil { + t.Fatal("want error for an empty link code") + } + if len(f.redeems) != 0 || len(f.tokens) != 0 { + t.Errorf("want no redeem/token on an empty code, got redeems=%d tokens=%d", len(f.redeems), len(f.tokens)) + } + }) + + t.Run("a link-code redemption failure mints no token", func(t *testing.T) { + f := &fakeOwnerStore{redeemErr: errors.New("code expired")} + if _, err := performSetupMCBind(ctx, f, "abc-123", "op.console.example.com"); err == nil { + t.Fatal("want error when the link code cannot be redeemed") + } + if len(f.tokens) != 0 { + t.Errorf("want no token minted on a redeem failure, got %d", len(f.tokens)) + } + }) + + t.Run("a token-store failure surfaces after the bind", func(t *testing.T) { + f := &fakeOwnerStore{redeemUserID: "usr-owner-1", createTokenErr: errors.New("db down")} + if _, err := performSetupMCBind(ctx, f, "abc-123", "op.console.example.com"); err == nil { + t.Fatal("want error when the setup token cannot be stored") + } + if len(f.redeems) != 1 { + t.Errorf("want the redeem to have happened before the token write, got %d", len(f.redeems)) + } + if len(f.tokens) != 0 { + t.Errorf("want no recorded token when the store fails, got %d", len(f.tokens)) + } + }) + + t.Run("defaults the op.console host when adminHostname is empty", func(t *testing.T) { + f := &fakeOwnerStore{redeemUserID: "usr-owner-1"} + out, err := performSetupMCBind(ctx, f, "abc-123", " ") + if err != nil { + t.Fatalf("performSetupMCBind: %v", err) + } + if !strings.HasPrefix(out.setupTokenURL, "https://op.console.localhost/setup?token=") { + t.Errorf("setup URL = %q, want the op.console.localhost default host", out.setupTokenURL) + } + }) +} diff --git a/cmd/felis/setup.go b/cmd/felis/setup.go index 4372031..563f04c 100644 --- a/cmd/felis/setup.go +++ b/cmd/felis/setup.go @@ -24,6 +24,15 @@ const hostBootstrapKubeconfigPath = "/etc/rancher/k3s/k3s.yaml" var errHostBootstrapCancelled = errors.New("host bootstrap cancelled") +// channelName maps the --dev flag to the release channel deploy/bootstrap.sh +// understands. Release is the default so a bare `felis setup` is production. +func channelName(dev bool) string { + if dev { + return "dev" + } + return "release" +} + // cmdSetup is the normal first-run operator console. It is intentionally separate // from breakGlass: setup creates the initial Owner and optional web edge; breakGlass // is reserved for emergency local recovery/reset. @@ -31,12 +40,20 @@ func cmdSetup(args []string, stdout, stderr io.Writer) int { fs := flag.NewFlagSet("setup", flag.ContinueOnError) fs.SetOutput(stderr) cfgPath := fs.String("config", defaultSetupConfigPath, "path to felis.toml") + dev := fs.Bool("dev", false, "install the dev channel (felis:dev, main HEAD) instead of the default release channel (felis:release, newest tag)") if err := fs.Parse(args); err != nil { if errors.Is(err, flag.ErrHelp) { return 0 } return 2 } + // The channel governs which image tag/source ref the host bootstrap builds. + // runBootstrap forwards the whole environment, so exporting it here is enough + // to reach deploy/bootstrap.sh without threading a parameter through the TUI. + if err := os.Setenv("FELIS_CHANNEL", channelName(*dev)); err != nil { + fmt.Fprintf(stderr, "felis setup: %v\n", err) + return 1 + } configFlagSet := false fs.Visit(func(f *flag.Flag) { if f.Name == "config" { @@ -112,18 +129,16 @@ func cmdSetup(args []string, stdout, stderr io.Writer) int { } if res.provisioned { - fmt.Fprintf(stdout, "\nfelis setup: Owner account %q provisioned; local-password login is ENABLED.\n", res.username) + fmt.Fprintf(stdout, "\nfelis setup: Owner account %q provisioned (passwordless).\n", res.username) fmt.Fprintf(stdout, "Recorded as %q (mode: %s, os user: %s).\n", res.accountable, res.mode, res.osUser) - if res.displayPassword != "" { - fmt.Fprintf(stdout, "One-time password (you MUST change it on first login):\n\n %s\n\n", res.displayPassword) - } else { - fmt.Fprintln(stdout, "Log in with the password you just entered (you MUST change it on first login).") + if res.setupTokenURL != "" { + fmt.Fprintf(stdout, "Open this URL to complete passwordless login setup (verify email / enroll passkey):\n\n %s\n\n", res.setupTokenURL) } if res.auditWarning != "" { fmt.Fprintf(stdout, "WARNING: the accountability audit row was NOT written: %s\n", res.auditWarning) } if panelURL != "" { - fmt.Fprintf(stdout, "Log in at %s with that username and password.\n", panelURL) + fmt.Fprintf(stdout, "Admin console: %s\n", panelURL) fmt.Fprintln(stdout, "The local HTTPS certificate is self-signed; your browser may ask for confirmation on first visit.") } } diff --git a/cmd/felis/tui_height_measure_test.go b/cmd/felis/tui_height_measure_test.go index e4f7d76..49f27f7 100644 --- a/cmd/felis/tui_height_measure_test.go +++ b/cmd/felis/tui_height_measure_test.go @@ -19,7 +19,7 @@ func TestWizardViewsFitTerminal(t *testing.T) { msg tea.Msg }{ {"owner", preflightDoneMsg{}}, - {"connect", ownerResultMsg{username: "owner", displayPassword: "hunter2pw"}}, + {"connect", ownerResultMsg{username: "owner", setupTokenURL: "https://op.console.example.com/setup?token=t0ken"}}, {"summary", connectResultMsg{method: connectLocal, panelHostname: "panel.example.com"}}, } diff --git a/cmd/felis/tui_mc_bind.go b/cmd/felis/tui_mc_bind.go new file mode 100644 index 0000000..6e84afc --- /dev/null +++ b/cmd/felis/tui_mc_bind.go @@ -0,0 +1,202 @@ +package main + +import ( + "context" + "strings" + + "github.com/charmbracelet/bubbles/spinner" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/huh" +) + +// mcBindModel is the `felis setup` Owner-establishment screen: the operator +// joins the server, runs /link to get a one-time code, and types it here. The +// bound Minecraft account is promoted to the passwordless Owner, and a one-time +// setup URL is minted for the first web login. It replaces the old ownerModel +// bootstrap form in setup mode — no username/email/password is typed here, the +// MC identity is the root of trust. +type mcBindModel struct { + ctx context.Context + store ownerStore + adminHost string + osUser string + + step mcBindStep + form *huh.Form + sp spinner.Model + working string + + linkCode string + setupTokenURL string + + width, height int +} + +type mcBindStep int + +const ( + mcBindForm mcBindStep = iota + mcBindWorking + mcBindDone +) + +type mcBindMsg struct { + outcome breakGlassOutcome + err error +} + +func newMCBindModel(ctx context.Context, store ownerStore, adminHost, osUser string) *mcBindModel { + sp := spinner.New() + sp.Spinner = spinner.Dot + sp.Style = tuiLabel + m := &mcBindModel{ + ctx: ctx, + store: store, + adminHost: adminHost, + osUser: osUser, + sp: sp, + step: mcBindForm, + } + m.form = m.buildForm() + return m +} + +func (m *mcBindModel) buildForm() *huh.Form { + return m.sized(newFelisForm(huh.NewGroup( + huh.NewNote(). + Title("Bind your Minecraft account"). + Description("Join the server and run /link to get a one-time code,\nthen type it here. Your bound account becomes the\npasswordless Owner."), + huh.NewInput(). + Title("Link code"). + Placeholder("ABCD12"). + Value(&m.linkCode). + Validate(requiredField("link code")), + ))) +} + +func (m *mcBindModel) sized(f *huh.Form) *huh.Form { + if m.width > 0 { + return f.WithWidth(m.width).WithHeight(m.height) + } + return f +} + +func (m *mcBindModel) setSize(w, h int) { + m.width, m.height = w, h + if m.form != nil { + m.form = m.form.WithWidth(w).WithHeight(h) + } +} + +func (m *mcBindModel) Init() tea.Cmd { return m.form.Init() } + +func (m *mcBindModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case mcBindMsg: + if msg.err != nil { + return m, m.failCmd(msg.err) + } + m.step = mcBindDone + m.setupTokenURL = msg.outcome.setupTokenURL + return m, nil + + case spinner.TickMsg: + if m.step == mcBindWorking { + var cmd tea.Cmd + m.sp, cmd = m.sp.Update(msg) + return m, cmd + } + return m, nil + + case tea.KeyMsg: + switch m.step { + case mcBindDone: + switch msg.String() { + case "ctrl+c", "esc", "enter": + return m, m.resultCmd() + } + return m, nil + case mcBindWorking: + if msg.String() == "ctrl+c" { + return m, tea.Quit + } + return m, nil + default: + switch msg.String() { + case "ctrl+c", "esc": + return m, tea.Quit + } + } + } + + if m.step == mcBindForm && m.form != nil { + form, cmd := m.form.Update(msg) + if f, ok := form.(*huh.Form); ok { + m.form = f + } + switch m.form.State { + case huh.StateCompleted: + return m.onFormComplete() + case huh.StateAborted: + return m, tea.Quit + } + return m, cmd + } + return m, nil +} + +func (m *mcBindModel) onFormComplete() (tea.Model, tea.Cmd) { + m.step = mcBindWorking + m.working = "Binding Minecraft account…" + code := strings.TrimSpace(strings.ToUpper(m.linkCode)) + return m, tea.Batch(m.sp.Tick, func() tea.Msg { + out, err := performSetupMCBind(m.ctx, m.store, code, m.adminHost) + return mcBindMsg{outcome: out, err: err} + }) +} + +func (m *mcBindModel) failCmd(err error) tea.Cmd { + return func() tea.Msg { return ownerResultMsg{err: err} } +} + +func (m *mcBindModel) resultCmd() tea.Cmd { + return func() tea.Msg { + return ownerResultMsg{ + setupTokenURL: m.setupTokenURL, + mode: "setup", + accountable: m.osUser, + } + } +} + +func (m *mcBindModel) View() string { + switch m.step { + case mcBindWorking: + msg := m.working + if msg == "" { + msg = "Working…" + } + return " " + m.sp.View() + " " + tuiHint.Render(msg) + "\n" + case mcBindDone: + return m.doneView() + default: + if m.form == nil { + return "" + } + return m.form.View() + } +} + +func (m *mcBindModel) doneView() string { + var b strings.Builder + b.WriteString(tuiSuccessBanner("Owner account is ready.") + "\n\n") + + var box strings.Builder + if m.setupTokenURL != "" { + box.WriteString(tuiLabel.Render("setup URL ") + "\n" + tuiPassword.Render(m.setupTokenURL) + "\n\n") + box.WriteString(tuiWarn.Render("Open this URL to complete passwordless login setup.\nIt is shown only once.")) + } + b.WriteString(tuiCardStyle.Render(box.String()) + "\n\n") + b.WriteString(tuiAction("enter", "continue")) + return b.String() +} diff --git a/cmd/felis/tui_menu_test.go b/cmd/felis/tui_menu_test.go index 23b8126..b925321 100644 --- a/cmd/felis/tui_menu_test.go +++ b/cmd/felis/tui_menu_test.go @@ -76,9 +76,9 @@ func TestProvisionCmdSelectsPathByOperation(t *testing.T) { if _, ok := f.settings[api.LocalAuthEnabledKey]; ok { t.Error("the operator path flipped local auth — only the Owner thread may") } - // No password was typed, so a one-time credential is surfaced to hand off. - if msg.outcome.displayPassword == "" { - t.Error("want a generated one-time password to hand to the new operator") + // The provision is fully done: the audit row landed (no recoverable audit error). + if msg.outcome.auditErr != nil { + t.Errorf("operator provision recorded an audit error: %v", msg.outcome.auditErr) } }) @@ -171,7 +171,7 @@ func TestOwnerResultCmdCarriesIsOperator(t *testing.T) { ctx := context.Background() op := newOperatorModel(ctx, &fakeOwnerStore{}, "root") - op.username, op.displayPassword, op.mode, op.accountable = "ops", "pw", "recovery", "root" + op.username, op.mode, op.accountable = "ops", "recovery", "root" if res := op.ownerResultCmd()().(ownerResultMsg); !res.isOperator { t.Error("operator result.isOperator = false, want true") } diff --git a/cmd/felis/tui_owner.go b/cmd/felis/tui_owner.go index 4f1ea11..a116591 100644 --- a/cmd/felis/tui_owner.go +++ b/cmd/felis/tui_owner.go @@ -65,17 +65,14 @@ type ownerModel struct { width, height int // huh-bound form values - authUser string - authPass string - overrideTok string - ownerUser string - ownerEmail string - ownerPass string - ownerConfirm string + authUser string + overrideTok string + ownerUser string + ownerEmail string - username string - displayPassword string - auditWarning string + username string + setupTokenURL string + auditWarning string } func newOwnerModel(ctx context.Context, store ownerStore, osUser string, adminExists bool) *ownerModel { @@ -191,7 +188,7 @@ func (m *ownerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, m.failCmd(msg.err) } m.step = owDone - m.displayPassword = msg.outcome.displayPassword + m.setupTokenURL = msg.outcome.setupTokenURL if msg.outcome.auditErr != nil { m.auditWarning = msg.outcome.auditErr.Error() } @@ -255,10 +252,10 @@ func (m *ownerModel) onFormComplete() (tea.Model, tea.Cmd) { case owAuth: m.attempt = strings.TrimSpace(m.authUser) m.step = owWorking - m.working = "Verifying admin credential…" - user, pass := m.authUser, m.authPass + m.working = "Verifying admin…" + user := m.authUser return m, tea.Batch(m.sp.Tick, func() tea.Msg { - matched, ok, err := authenticateAdmin(m.ctx, m.store, user, pass) + matched, ok, err := authenticateAdmin(m.ctx, m.store, user) return owAuthMsg{matched: matched, ok: ok, err: err} }) case owOverride: @@ -277,17 +274,12 @@ func (m *ownerModel) onFormComplete() (tea.Model, tea.Cmd) { } func (m *ownerModel) provisionCmd() tea.Cmd { - password := "" - if m.mode == "bootstrap" { - password = m.ownerPass - } op := breakGlassOp{ mode: m.mode, accountable: m.accountable, osUser: m.osUser, ownerUsername: m.username, ownerEmail: m.ownerEmail, - ownerPassword: password, attemptedAdmin: m.attempt, } // performAddOperator and performBreakGlass share a signature; the operation @@ -311,12 +303,12 @@ func (m *ownerModel) failCmd(err error) tea.Cmd { func (m *ownerModel) ownerResultCmd() tea.Cmd { return func() tea.Msg { return ownerResultMsg{ - username: m.username, - displayPassword: m.displayPassword, - mode: m.mode, - accountable: m.accountable, - auditWarning: m.auditWarning, - isOperator: m.operation == bgAddOperator, + username: m.username, + setupTokenURL: m.setupTokenURL, + mode: m.mode, + accountable: m.accountable, + auditWarning: m.auditWarning, + isOperator: m.operation == bgAddOperator, } } } @@ -332,11 +324,6 @@ func (m *ownerModel) buildAuthForm() *huh.Form { Title("Admin username"). Value(&m.authUser). Validate(requiredField("admin username")), - huh.NewInput(). - Title("Admin password"). - EchoMode(huh.EchoModePassword). - Value(&m.authPass). - Validate(requiredField("admin password")), ))) } @@ -364,18 +351,16 @@ func (m *ownerModel) buildProvisionForm() *huh.Form { desc := fmt.Sprintf("Create the first Owner — recorded as OS user %q.", m.osUser) switch m.mode { case "recovery": - desc = fmt.Sprintf("Authenticated as %q — a one-time password will be generated.", m.accountable) + desc = fmt.Sprintf("Authenticated as %q.", m.accountable) case "root_override": - desc = "Root override — a one-time password will be generated." + desc = "Root override — the Owner will be reset." } if m.operation == bgAddOperator { - // Operator-add never bootstraps (an admin is already present to authorize it), - // so it is always one of the generated-password modes. switch m.mode { case "recovery": - desc = fmt.Sprintf("Add an Operator — authenticated as %q; a one-time password will be generated.", m.accountable) + desc = fmt.Sprintf("Add an Operator — authenticated as %q.", m.accountable) case "root_override": - desc = "Add an Operator (root override) — a one-time password will be generated." + desc = "Add an Operator (root override)." } } if m.provisionErr != nil { @@ -396,26 +381,6 @@ func (m *ownerModel) buildProvisionForm() *huh.Form { Placeholder("you@example.com"). Value(&m.ownerEmail), } - if m.mode == "bootstrap" { - fields = append(fields, - huh.NewInput(). - Title("Owner password"). - Description("at least 8 characters"). - EchoMode(huh.EchoModePassword). - Value(&m.ownerPass). - Validate(validateOwnerPassword), - huh.NewInput(). - Title("Confirm password"). - EchoMode(huh.EchoModePassword). - Value(&m.ownerConfirm). - Validate(func(s string) error { - if s != m.ownerPass { - return errors.New("the two passwords do not match") - } - return nil - }), - ) - } return m.sized(newFelisForm(huh.NewGroup(fields...))) } @@ -454,11 +419,9 @@ func (m *ownerModel) doneView() string { var box strings.Builder box.WriteString(tuiLabel.Render("username ") + m.username + "\n") - if m.displayPassword != "" { - box.WriteString(tuiLabel.Render("password ") + tuiPassword.Render(m.displayPassword) + "\n\n") - box.WriteString(tuiWarn.Render("Record this password — it is shown only once.")) - } else { - box.WriteString(tuiHint.Render("Log in with the password you entered.")) + if m.setupTokenURL != "" { + box.WriteString("\n" + tuiLabel.Render("setup URL ") + "\n" + tuiPassword.Render(m.setupTokenURL) + "\n\n") + box.WriteString(tuiWarn.Render("Open this URL to complete passwordless login setup. It is shown only once.")) } if m.auditWarning != "" { box.WriteString("\n\n" + tuiWarn.Render("Audit warning: "+m.auditWarning)) diff --git a/cmd/felis/tui_root.go b/cmd/felis/tui_root.go index 9742f34..4c7d114 100644 --- a/cmd/felis/tui_root.go +++ b/cmd/felis/tui_root.go @@ -52,13 +52,13 @@ func connectMethodLabel(m connectMethod) string { type preflightDoneMsg struct{} type ownerResultMsg struct { - username string - displayPassword string - mode string - accountable string - auditWarning string - isOperator bool // true when an Operator was added rather than the Owner provisioned - err error + username string + setupTokenURL string + mode string + accountable string + auditWarning string + isOperator bool // true when an Operator was added rather than the Owner provisioned + err error } // connectResultMsg is emitted by every connection method (the chooser for @@ -207,6 +207,9 @@ func (m *rootModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m.showStatus() } m.stage = stageOwner + if m.mode == consoleModeSetup { + return m.adopt(newMCBindModel(m.ctx, m.store, m.adminHost, m.osUser)) + } return m.adopt(newOwnerModel(m.ctx, m.store, m.osUser, false)) case menuChoiceMsg: @@ -228,7 +231,7 @@ func (m *rootModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.result.provisioned = true m.result.isOperator = msg.isOperator m.result.username = msg.username - m.result.displayPassword = msg.displayPassword + m.result.setupTokenURL = msg.setupTokenURL m.result.mode = msg.mode m.result.accountable = msg.accountable m.result.auditWarning = msg.auditWarning @@ -363,7 +366,7 @@ func (m *rootModel) reviewBody(stage int) string { if m.result.username != "" { b.WriteString(tuiLabel.Render("username ") + m.result.username + "\n") } - b.WriteString(tuiHint.Render("Created and recorded. The one-time password was shown on the Owner step.")) + b.WriteString(tuiHint.Render("Created and recorded. The one-time setup URL was shown on the Owner step.")) case stageConnect: b.WriteString(tuiOK.Render("✓ Connection") + "\n") b.WriteString(tuiLabel.Render("method ") + connectMethodLabel(m.result.connectMethod) + "\n") @@ -488,7 +491,7 @@ func (m *rootModel) showSummary() (tea.Model, tea.Cmd) { return m.adopt(&summaryModel{ panelURL: m.result.panelURL, ownerUsername: m.result.username, - ownerPassword: m.result.displayPassword, + setupTokenURL: m.result.setupTokenURL, accessLabel: connectMethodLabel(m.result.connectMethod), storageLabel: m.result.storageDetail, routedHosts: routed, diff --git a/cmd/felis/tui_root_test.go b/cmd/felis/tui_root_test.go index d6ab8c4..45b9c9d 100644 --- a/cmd/felis/tui_root_test.go +++ b/cmd/felis/tui_root_test.go @@ -52,24 +52,26 @@ func TestRootSetupHappyPath(t *testing.T) { t.Fatalf("initial screen = %T, want *preflightModel", m.screen) } - // Preflight done → Owner. + // Preflight done → MC-bind (setup mode establishes the Owner by binding a + // Minecraft account, not by typing a username/password). The stage label is + // still stageOwner; only the screen differs by mode. m = drive(t, m, preflightDoneMsg{}) if m.stage != stageOwner { t.Fatalf("after preflight, stage = %v, want stageOwner", m.stage) } - if _, ok := m.screen.(*ownerModel); !ok { - t.Fatalf("after preflight, screen = %T, want *ownerModel", m.screen) + if _, ok := m.screen.(*mcBindModel); !ok { + t.Fatalf("after preflight, screen = %T, want *mcBindModel", m.screen) } // Owner provisioned → Connection chooser. - m = drive(t, m, ownerResultMsg{username: "owner", displayPassword: "hunter2"}) + m = drive(t, m, ownerResultMsg{username: "owner", setupTokenURL: "https://op.console.example.com/setup?token=t0ken"}) if m.stage != stageConnect { t.Fatalf("after owner, stage = %v, want stageConnect", m.stage) } if _, ok := m.screen.(*connectChooserModel); !ok { t.Fatalf("after owner, screen = %T, want *connectChooserModel", m.screen) } - if !m.result.provisioned || m.result.username != "owner" || m.result.displayPassword != "hunter2" { + if !m.result.provisioned || m.result.username != "owner" || m.result.setupTokenURL != "https://op.console.example.com/setup?token=t0ken" { t.Fatalf("owner result not recorded: %+v", m.result) } @@ -115,8 +117,8 @@ func TestRootSetupHappyPath(t *testing.T) { if want := "https://panel.felis.example.com"; sum.panelURL != want { t.Fatalf("summary panelURL = %q, want %q", sum.panelURL, want) } - if sum.ownerPassword != "hunter2" { - t.Fatalf("summary ownerPassword = %q, want %q", sum.ownerPassword, "hunter2") + if want := "https://op.console.example.com/setup?token=t0ken"; sum.setupTokenURL != want { + t.Fatalf("summary setupTokenURL = %q, want %q", sum.setupTokenURL, want) } if sum.alreadySetUp { t.Fatalf("first-run summary should not be marked alreadySetUp") @@ -150,7 +152,7 @@ func TestRootSetupLocalSummary(t *testing.T) { func TestRootReconfigureConnectSkipsStorage(t *testing.T) { m := newTestRoot(false, consoleModeSetup, "") m = drive(t, m, preflightDoneMsg{}) - m = drive(t, m, ownerResultMsg{username: "owner", displayPassword: "hunter2"}) + m = drive(t, m, ownerResultMsg{username: "owner", setupTokenURL: "https://op.console.example.com/setup?token=t0ken"}) m = drive(t, m, connectResultMsg{method: connectLocal, panelHostname: "panel.felis.example.com"}) m = drive(t, m, storageResultMsg{method: storageS3, detail: "s3://bucket"}) if _, ok := m.screen.(*summaryModel); !ok { @@ -257,7 +259,7 @@ func TestRootBreakGlassQuitsAfterOwner(t *testing.T) { t.Fatalf("after the menu choice, screen = %T, want *ownerModel", m.screen) } - next, cmd := m.Update(ownerResultMsg{username: "owner", displayPassword: "pw", mode: "recovery"}) + next, cmd := m.Update(ownerResultMsg{username: "owner", setupTokenURL: "https://op.console.example.com/setup?token=t0ken", mode: "recovery"}) rm := next.(*rootModel) if _, ok := rm.screen.(*connectChooserModel); ok { t.Fatalf("break-glass must not enter the connection chooser") @@ -292,7 +294,7 @@ func TestRootRailReviewNavigation(t *testing.T) { } // Advance to the Connection chooser (a select — it yields ←/→). - m = drive(t, m, ownerResultMsg{username: "owner", displayPassword: "hunter2"}) + m = drive(t, m, ownerResultMsg{username: "owner", setupTokenURL: "https://op.console.example.com/setup?token=t0ken"}) if m.reviewing != -1 { t.Fatalf("fresh chooser should start live, reviewing = %d", m.reviewing) } @@ -352,8 +354,8 @@ func TestSetupRailSpansBootstrap(t *testing.T) { m := newTestRoot(false, consoleModeSetup, "") m = drive(t, m, tea.WindowSizeMsg{Width: 90, Height: 30}) m = drive(t, m, preflightDoneMsg{}) - if _, ok := m.screen.(*ownerModel); !ok { - t.Fatalf("expected owner screen after preflight, got %T", m.screen) + if _, ok := m.screen.(*mcBindModel); !ok { + t.Fatalf("expected MC-bind screen after preflight, got %T", m.screen) } if v := m.View(); !strings.Contains(v, "✓ Bootstrap") { t.Fatalf("wizard rail should carry Bootstrap as a completed step, got:\n%s", v) diff --git a/cmd/felis/tui_summary.go b/cmd/felis/tui_summary.go index d54019d..a9850b5 100644 --- a/cmd/felis/tui_summary.go +++ b/cmd/felis/tui_summary.go @@ -14,7 +14,7 @@ import ( type summaryModel struct { panelURL string ownerUsername string - ownerPassword string // one-time; shown once + setupTokenURL string // one-time first-login URL; shown once accessLabel string storageLabel string // build-context storage backend recap; empty to omit routedHosts []string @@ -55,9 +55,9 @@ func (m *summaryModel) View() string { if m.ownerUsername != "" { card.WriteString(tuiLabel.Render("owner ") + m.ownerUsername + "\n") } - if m.ownerPassword != "" { - card.WriteString(tuiLabel.Render("password ") + tuiPassword.Render(m.ownerPassword) + "\n") - card.WriteString(" " + tuiWarn.Render("shown only once — record it now") + "\n") + if m.setupTokenURL != "" { + card.WriteString(tuiLabel.Render("setup URL ") + tuiPassword.Render(m.setupTokenURL) + "\n") + card.WriteString(" " + tuiWarn.Render("one-time link — open it to finish login setup") + "\n") } if m.accessLabel != "" { card.WriteString(tuiLabel.Render("access ") + m.accessLabel + "\n") diff --git a/cmd/felis/version.go b/cmd/felis/version.go new file mode 100644 index 0000000..a21e82d --- /dev/null +++ b/cmd/felis/version.go @@ -0,0 +1,58 @@ +package main + +import ( + "fmt" + "io" + "runtime" + "runtime/debug" +) + +// version is the build stamp injected at link time via +// +// -ldflags "-X main.version=" +// +// deploy/bootstrap.sh computes it from the checked-out source with +// `git describe --tags --always --dirty`: the release channel builds the newest +// vX.Y.Z tag (a clean name like v1.0.0-earlyAccess), the dev channel builds main +// HEAD (a tag+distance+gSHA string). It stays "dev" for an un-stamped local +// `go build`, where ReadBuildInfo below still surfaces the vcs revision. +var version = "dev" + +// cmdVersion prints the build stamp. It takes no flags and never touches the +// cluster, so it is safe to run as any user (unlike setup/breakGlass). +func cmdVersion(args []string, stdout, stderr io.Writer) int { + fmt.Fprintf(stdout, "felis %s\n", resolvedVersion()) + fmt.Fprintf(stdout, " go: %s %s/%s\n", runtime.Version(), runtime.GOOS, runtime.GOARCH) + if rev, ok := vcsRevision(); ok { + fmt.Fprintf(stdout, " revision: %s\n", rev) + } + return 0 +} + +// resolvedVersion prefers the ldflag stamp, then the module version recorded by +// `go install`, and only reports "unknown" when neither is present. +func resolvedVersion() string { + if version != "" { + return version + } + if bi, ok := debug.ReadBuildInfo(); ok && bi.Main.Version != "" { + return bi.Main.Version + } + return "unknown" +} + +// vcsRevision returns the git commit the binary was built from when the build +// carried VCS stamping (local `go build` in a checkout; the docker build strips +// .git, so there the ldflag version carries the identity instead). +func vcsRevision() (string, bool) { + bi, ok := debug.ReadBuildInfo() + if !ok { + return "", false + } + for _, s := range bi.Settings { + if s.Key == "vcs.revision" && s.Value != "" { + return s.Value, true + } + } + return "", false +} diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 3852a0a..b777ff0 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -879,6 +879,94 @@ paths: '401': $ref: '#/components/responses/Unauthorized' + /api/v1/internal/op-login/pending: + get: + tags: [account-internal] + operationId: opLoginPending + summary: List live pending op.console login requests, oldest first (spec §B). + description: > + Internal-only. Velocity polls it and pushes waiting requests to online admins, + who approve one with /felis web op approve . No pending request is secret + to the operator crew. + x-felis-face: [internal] + x-felis-tier: service + security: [{ serviceToken: [] }] + responses: + '200': + description: The pending requests awaiting an in-game vouch. + content: + application/json: + schema: + type: object + required: [pending] + properties: + pending: + type: array + items: + type: object + required: [request_id, username, email, created_at] + properties: + request_id: { type: string } + username: { type: string } + email: { type: string } + created_at: { type: string, format: date-time } + '401': + $ref: '#/components/responses/Unauthorized' + + /api/v1/internal/op-login/{id}/approve: + post: + tags: [account-internal] + operationId: opLoginApprove + summary: Record an in-game admin's vouch for a pending op.console login (spec §B). + description: > + Internal-only second factor: velocity submits the online-mode UUID of the + in-game admin running /felis web op approve. The API resolves it to a linked + role=admin account (else 403 not_admin) and flips the request approved. A + missing or no-longer-pending request is 404. Self-approval is allowed — an + online staff member vouching as their own admin identity is a genuine second + factor distinct from the mailbox. + x-felis-face: [internal] + x-felis-tier: service + security: [{ serviceToken: [] }] + parameters: + - { name: id, in: path, required: true, schema: { type: string } } + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [approver_uuid] + properties: + approver_uuid: { type: string, format: uuid } + responses: + '200': + description: The vouch was recorded; the request is now approved. + content: + application/json: + schema: + type: object + required: [approved] + properties: + approved: { type: boolean, const: true } + '400': + description: approver_uuid is required (bad_request). + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + '401': + $ref: '#/components/responses/Unauthorized' + '403': + description: The approver is not a linked administrator (not_admin). + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + '404': + description: No pending operator login with that id (op_login_not_found). + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + # ----------------------------------------------------- external: servers --- /api/v1/servers/{name}/wake: post: @@ -1431,18 +1519,22 @@ paths: $ref: '#/components/responses/NotFound' # -------------------------------------------------- external: local auth --- - /api/v1/auth/login: + /api/v1/auth/options: post: tags: [auth] - operationId: login - summary: Log in with a local username + password (op.console). + operationId: authOptions + summary: Identifier-first login discovery — which methods can this email use (spec §B, #71). description: >- - Verifies a username+password against the users row and, on success, mints - a host-only session cookie (spec §B). Mounted Public — there is no prior - principal — but local auth must be enabled (local_auth_enabled), so a - deployment fronted entirely by Zero Trust never accepts a local password. - Every failure returns the same vague invalid_credentials after a uniform - bcrypt compare, so usernames cannot be enumerated by response or timing. + Public, pre-session discovery for the SPA's identifier-first form: given a typed + email, report which console login methods the account can use (passkey and/or + email-OTP) so the UI prompts for the right authenticator. This is the deliberate + counter-slice to the anti-enumeration login doors — the ONE sanctioned place + account existence is disclosed, so an unknown address returns an empty methods + array. It never reveals staffness: methods are computed identically for every + resolved account (no role branch), so a staff and a player address in the same + credential state return byte-identical bodies. passkey is offered only when a + verifier is wired. Sends no mail and mutates nothing; not rate-limited at the app + layer (volumetric abuse is bounded at the edge). Gated on local_auth_enabled. x-felis-face: [external] x-felis-tier: public security: [] @@ -1452,37 +1544,521 @@ paths: application/json: schema: type: object - required: [username, password] + required: [email] properties: - username: { type: string } - password: { type: string, format: password } + email: { type: string, format: email } responses: '200': - description: Session established; the cookie is set on the response. + description: >- + The login methods available for the address, in a deterministic order + (passkey before email_otp). An empty array means no verified account. content: application/json: schema: type: object - required: [user_id, role, must_change_password] + required: [methods] properties: - user_id: { type: string } - role: - type: string - enum: [user, admin] - must_change_password: - type: boolean - description: >- - True when this account still owes its first-login password - change; the panel routes straight to the change-password card. + methods: + type: array + items: { type: string, enum: [passkey, email_otp] } '400': - $ref: '#/components/responses/BadRequest' - '401': - description: Invalid username or password (vague by design). + description: A valid email is required (bad_request). content: application/json: schema: { $ref: '#/components/schemas/Error' } '403': - description: Local password login is disabled on this deployment. + description: Local session login is disabled on this deployment (local_auth_disabled). + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + '415': + description: Request body was not application/json. + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + + /api/v1/auth/passkey/login/begin: + post: + tags: [auth] + operationId: passkeyLoginBegin + summary: Begin a passwordless passkey (WebAuthn) login (spec §14, §B). + description: >- + First leg of the public, pre-session passkey assertion door: the caller + supplies the email that selects the account and, on success, receives the raw + PublicKeyCredentialRequestOptions to hand to navigator.credentials.get(). The + matching challenge is stashed server-side and redeemed by finish. Mounted + Public (no prior principal) and gated on local_auth_enabled. An unknown + address and a known account with no enrolled passkey both return the SAME 400 + no_passkey, so the door is not an existence oracle; a per-recipient cooldown + (shared shape with the email-OTP and op-login doors) throttles probing. + x-felis-face: [external] + x-felis-tier: public + security: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [email] + properties: + email: { type: string, format: email } + responses: + '200': + description: >- + The WebAuthn assertion options (PublicKeyCredentialRequestOptions), passed + through verbatim from the authenticator library for the browser to consume. + The body is the WebAuthn standard shape and is not modelled here. + content: + application/json: + schema: { type: object, additionalProperties: true } + '400': + description: >- + Invalid email (bad_request); or no passkey is enrolled for the account, or + the address is unknown — indistinguishable by design (no_passkey); or the + authenticator library could not start the ceremony (passkey_login_failed). + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + '403': + description: Local session login is disabled on this deployment (local_auth_disabled). + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + '415': + description: Request body was not application/json. + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + '429': + description: A passkey login for this recipient was started too recently (otp_resend_cooldown). + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + '503': + description: No passkey verifier is wired on this deployment (passkey_unavailable). + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + + /api/v1/auth/passkey/login/finish: + post: + tags: [auth] + operationId: passkeyLoginFinish + summary: Complete a passkey (WebAuthn) login and mint a session (spec §14, §B). + description: >- + Second leg of the public passkey door: the caller returns the email (to + re-select the account) and the raw navigator.credentials.get() assertion. The + stashed login challenge is consumed atomically and the assertion is verified + against it; on success a host-only felis_session cookie is minted. Both players + and staff may log in this way — a passkey is a two-factor authenticator + (possession + user verification), strong enough to stand alone without the + in-game approval op-login requires. Every failure mode (unknown address, no + live challenge, expired challenge, bad assertion) collapses into one uniform + passkey_login_invalid, so the door reveals nothing. + x-felis-face: [external] + x-felis-tier: public + security: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [email, assertion] + properties: + email: { type: string, format: email } + assertion: + type: object + additionalProperties: true + description: >- + The raw PublicKeyCredential from navigator.credentials.get(), + passed to the verifier verbatim (WebAuthn standard shape). + responses: + '200': + description: Assertion verified; a host-only session cookie is set on the response. + content: + application/json: + schema: + type: object + required: [user_id, role] + properties: + user_id: { type: string } + role: { type: string, enum: [user, admin] } + '400': + description: >- + Invalid email or missing assertion (bad_request); or the login could not be + completed — unknown address, no live or expired challenge, or a failed + assertion, all uniform (passkey_login_invalid). + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + '403': + description: Local session login is disabled on this deployment (local_auth_disabled). + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + '415': + description: Request body was not application/json. + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + '503': + description: No passkey verifier is wired on this deployment (passkey_unavailable). + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + + /api/v1/auth/email/start: + post: + tags: [auth] + operationId: loginEmailStart + summary: Begin a passwordless email-OTP login — mail a one-time code (spec §B). + description: >- + Public, pre-session console door: the caller supplies an email and, if it + resolves to a verified account, a one-time code is mailed under the login + purpose. An address with no account returns the SAME 202 with no code minted, + and the per-recipient cooldown is kept on that path too, so probing reveals + nothing (existence is learnt only at the sanctioned /auth/options oracle). + Gated on local_auth_enabled. + x-felis-face: [external] + x-felis-tier: public + security: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [email] + properties: + email: { type: string, format: email } + responses: + '202': + description: >- + Accepted (neutral): a code was mailed if the address has a verified + account; the response is identical either way. + content: + application/json: + schema: + type: object + required: [sent, expires_at] + properties: + sent: { type: boolean, const: true } + expires_at: { type: string, format: date-time } + '400': + description: A valid email is required (bad_request). + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + '403': + description: Local session login is disabled on this deployment (local_auth_disabled). + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + '415': + description: Request body was not application/json. + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + '429': + description: A code for this recipient was requested too recently (otp_resend_cooldown). + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + + /api/v1/auth/email/verify: + post: + tags: [auth] + operationId: loginEmailVerify + summary: Redeem an email-OTP login code into a session (spec §B). + description: >- + Public, pre-session: resolves the address to an account, verifies the code + under the login purpose, and on success mints a host-only felis_session. An + unknown address, a wrong or expired code, and an attempt-exhausted code all + return the IDENTICAL 400 invalid_code, so the door is not an existence or + lockout oracle. Staff are refused (403) — but only AFTER a valid code is + redeemed, so only the account owner can ever reach that refusal. + x-felis-face: [external] + x-felis-tier: public + security: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [email, code] + properties: + email: { type: string, format: email } + code: { type: string } + responses: + '200': + description: Code accepted; a host-only session cookie is set on the response. + content: + application/json: + schema: + type: object + required: [user_id, role] + properties: + user_id: { type: string } + role: { type: string, enum: [user, admin] } + '400': + description: >- + A valid email and code are required (bad_request); or the code is wrong, + expired, or exhausted (invalid_code, uniform with an unknown address). + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + '403': + description: >- + Local session login is disabled (local_auth_disabled), or the account is + staff and must sign in at the operator console (staff_account). + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + '415': + description: Request body was not application/json. + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + + /api/v1/auth/op-login/start: + post: + tags: [auth] + operationId: opLoginStart + summary: Begin an op.console staff login — mail an OTP, open an approval request (spec §B). + description: >- + Public, pre-session first leg of the two-factor operator door: resolves the + staff address, opens an op_login request, and mails a one-time code under the + op_login purpose, returning the request handle the browser polls. A non-staff + or unknown address gets the SAME 202 with a random, non-persisted handle and no + mail, so this never becomes a staff-enumeration oracle. Gated on + local_auth_enabled. + x-felis-face: [external] + x-felis-tier: public + security: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [email] + properties: + email: { type: string, format: email } + responses: + '202': + description: >- + Accepted (neutral): a request handle to poll. For a staff address a code + was mailed and the handle is real; otherwise the handle is a random no-op. + content: + application/json: + schema: + type: object + required: [request_id, expires_at] + properties: + request_id: { type: string } + expires_at: { type: string, format: date-time } + '400': + description: A valid email is required (bad_request). + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + '403': + description: Local session login is disabled on this deployment (local_auth_disabled). + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + '415': + description: Request body was not application/json. + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + '429': + description: A code for this recipient was requested too recently (otp_resend_cooldown). + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + + /api/v1/auth/op-login/status/{id}: + get: + tags: [auth] + operationId: opLoginStatus + summary: Poll whether an op.console login request has been approved in-game (spec §B). + description: >- + Public, pre-session read the browser polls after start. Returns approved:true + only for a genuinely approved, live, unconsumed request; every other case — + unknown, expired, denied, or already-consumed handle — reads approved:false, so + a fabricated handle polls false forever and only an in-game admin vouch can flip + it true. + x-felis-face: [external] + x-felis-tier: public + security: [] + parameters: + - { name: id, in: path, required: true, schema: { type: string } } + responses: + '200': + description: The approval state of the request handle. + content: + application/json: + schema: + type: object + required: [approved] + properties: + approved: { type: boolean } + '403': + description: Local session login is disabled on this deployment (local_auth_disabled). + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + + /api/v1/auth/op-login/finish: + post: + tags: [auth] + operationId: opLoginFinish + summary: Redeem an approved op.console request plus its mailed code into a staff session (spec §B). + description: >- + Public, pre-session final leg: mints a host-only staff session only when BOTH + factors have landed — the request is approved-and-live AND the mailed code + verifies. Every failure (unknown handle, not-yet-approved, wrong or locked code, + lost race) collapses into one uniform 400 op_login_invalid, so a code-less + caller learns nothing. Admin is re-asserted before the session is issued. + x-felis-face: [external] + x-felis-tier: public + security: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [request_id, code] + properties: + request_id: { type: string } + code: { type: string } + responses: + '200': + description: Both factors proven; a host-only session cookie is set on the response. + content: + application/json: + schema: + type: object + required: [user_id, role] + properties: + user_id: { type: string } + role: { type: string, enum: [user, admin] } + '400': + description: >- + request_id and code are required (bad_request); or the login could not be + completed — unknown handle, not approved, wrong or locked code, or lost + race, all uniform (op_login_invalid). + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + '403': + description: >- + Local session login is disabled (local_auth_disabled), or the resolved + account is not an operator (staff_account). + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + '415': + description: Request body was not application/json. + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + + /api/v1/auth/setup/redeem: + post: + tags: [auth] + operationId: setupRedeem + summary: Redeem a one-time setup token into a lockdown session (spec §B). + description: >- + Public, pre-session first-run door: consumes the one-time setup token minted by + the felis TUI (stored and looked up by SHA-256 hash, like session cookies), + mints a host-only felis_session, and returns the remaining setup steps so the + SPA can drive the wizard. An unknown, consumed, or expired token returns a + uniform 400 setup_token_invalid. Gated on local_auth_enabled. + x-felis-face: [external] + x-felis-tier: public + security: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [token] + properties: + token: { type: string } + responses: + '200': + description: Token redeemed; a session cookie is set and the setup state is returned. + content: + application/json: + schema: + type: object + required: [user_id, username, role, email, email_verified, has_passkey, setup_required] + properties: + user_id: { type: string } + username: { type: string } + role: { type: string, enum: [user, admin] } + email: { type: string } + email_verified: { type: boolean } + has_passkey: { type: boolean } + setup_required: { type: boolean } + '400': + description: >- + A token is required (bad_request), or it is unknown, already used, or + expired (setup_token_invalid). + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + '403': + description: Local session login is disabled on this deployment (local_auth_disabled). + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + '415': + description: Request body was not application/json. + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + + /api/v1/auth/setup/status: + get: + tags: [auth] + operationId: setupStatus + summary: Report the caller's own setup progress (spec §B). + description: >- + App-tier read the SPA polls after each setup wizard step (email verify, passkey + enroll) to decide whether the first-run lockdown can lift. It reads only the + principal's own state and is reachable during setup lockdown (the rest of the + API is fenced until setup completes). + x-felis-face: [external] + x-felis-tier: app + security: [{ sessionCookie: [] }] + responses: + '200': + description: The caller's current setup state. + content: + application/json: + schema: + type: object + required: [user_id, username, role, email, email_verified, has_passkey, setup_required] + properties: + user_id: { type: string } + username: { type: string } + role: { type: string, enum: [user, admin] } + email: { type: string } + email_verified: { type: boolean } + has_passkey: { type: boolean } + setup_required: { type: boolean } + '401': + $ref: '#/components/responses/Unauthorized' + '404': + description: The principal's user row was not found (not_found). content: application/json: schema: { $ref: '#/components/schemas/Error' } @@ -1510,57 +2086,6 @@ paths: properties: ok: { type: boolean, const: true } - /api/v1/auth/change-password: - post: - tags: [auth] - operationId: changePassword - summary: Change the caller's local password (forced first-login or rotation). - description: >- - 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). Reachable while must_change_password is set, so a - forced first-login change can complete — the rest of the API is fenced off - until it does. The session authenticates the caller; re-asking the current - password additionally blocks a hijacked session from silently rotating the - credential. - x-felis-face: [external] - x-felis-tier: app - security: [{ sessionCookie: [] }] - requestBody: - required: true - content: - application/json: - schema: - type: object - required: [current_password, new_password] - properties: - current_password: { type: string, format: password } - new_password: - type: string - format: password - minLength: 8 - maxLength: 72 - description: 8–72 bytes; 72 is bcrypt's hard input limit. - responses: - '200': - description: Password changed; other sessions revoked. - content: - application/json: - schema: - type: object - required: [ok] - properties: - ok: { type: boolean, const: true } - '400': - description: Weak password, or the new password equals the current one. - content: - application/json: - schema: { $ref: '#/components/schemas/Error' } - '401': - $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/Forbidden' - /api/v1/auth/bind: post: tags: [auth] @@ -2061,37 +2586,6 @@ paths: '404': $ref: '#/components/responses/NotFound' - /api/v1/users/{id}/reset-password: - post: - tags: [users] - operationId: resetPassword - summary: >- - Generate a high-entropy random password, deliver it to the user's email, and - force a first-login change (admin only). No request body — the server owns - entropy. The password is never returned to the admin; only the target email is echoed. - x-felis-face: [external] - x-felis-tier: owner - security: [{ accessJWT: [] }] - parameters: - - { name: id, in: path, required: true, schema: { type: string } } - responses: - '200': - description: Password reset. All existing sessions revoked. Password sent to the user's email. - content: - application/json: - schema: - type: object - required: [ok, email] - properties: - ok: { type: boolean, const: true } - email: { type: string, description: "The recipient email (empty if the user has none)." } - '401': - $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/Forbidden' - '404': - $ref: '#/components/responses/NotFound' - /api/v1/users/{id}/quotas: get: tags: [users] diff --git a/internal/api/api.go b/internal/api/api.go index af691e2..895fbcb 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -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.. 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 diff --git a/internal/api/api_test.go b/internal/api/api_test.go index 2dd8aa0..a869cf0 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -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 diff --git a/internal/api/auth.go b/internal/api/auth.go index cba3738..f244cab 100644 --- a/internal/api/auth.go +++ b/internal/api/auth.go @@ -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. diff --git a/internal/api/errors.go b/internal/api/errors.go index 647513c..40b4ed2 100644 --- a/internal/api/errors.go +++ b/internal/api/errors.go @@ -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. diff --git a/internal/api/handlers_access.go b/internal/api/handlers_access.go index b49e07f..aa30141 100644 --- a/internal/api/handlers_access.go +++ b/internal/api/handlers_access.go @@ -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) diff --git a/internal/api/handlers_auth.go b/internal/api/handlers_auth.go index 63da54f..30d7595 100644 --- a/internal/api/handlers_auth.go +++ b/internal/api/handlers_auth.go @@ -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 -} diff --git a/internal/api/handlers_auth_email.go b/internal/api/handlers_auth_email.go new file mode 100644 index 0000000..7ed449b --- /dev/null +++ b/internal/api/handlers_auth_email.go @@ -0,0 +1,266 @@ +package api + +import ( + "errors" + "net/http" + "strings" +) + +// Pre-session Email-OTP LOGIN (spec §B, console. 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, + }) +} diff --git a/internal/api/handlers_auth_email_test.go b/internal/api/handlers_auth_email_test.go new file mode 100644 index 0000000..20dfec1 --- /dev/null +++ b/internal/api/handlers_auth_email_test.go @@ -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. 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: "Player@Example.NET", + 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":"player@example.net"}`, 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 != "Player@Example.NET" { + t.Fatalf("mailer: calls=%d email=%q, want 1 send to the STORED casing Player@Example.NET", + 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":"PLAYER@example.NET","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":"player@example.net","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":"player@example.net"}`, 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":"ghost@example.net"}`, 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":"ghost@example.net"}`, 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: "half@example.net", 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":"half@example.net"}`, 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":"a@example.net"}`, 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":"a@example.net","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":"a@example.net"}`, 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":"a@example.net","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@b@example.net"}`, + "unknown field": `{"email":"a@example.net","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":"a@example.net","code":""}`, + "missing code": `{"email":"a@example.net"}`, + "unknown field": `{"email":"a@example.net","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, "player@example.net"); w.Code != http.StatusAccepted { + t.Fatalf("first send: code = %d, want 202 (%s)", w.Code, w.Body.String()) + } + if w := start(eh, "player@example.net"); 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, "player@example.net"); 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, "Player@Example.NET"); 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, "player@example.net"); 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: "u1@example.net", Role: "user"}} + eh := api.ExternalHandler() + + if w := start(eh, "player@example.net"); 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":"player@example.net"}`, 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: "player@example.net", 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(), "player@example.net", "654321") + // No account at all. + apiU, _, _ := seedLoginEmailAPI(t) + wGhost := verify(apiU.ExternalHandler(), "ghost@example.net", "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(), "player@example.net", "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(), "player@example.net", "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(), "player@example.net", "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(), "player@example.net", "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(), "player@example.net", "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: "u1@example.net", Role: "user"}} + eh := api.ExternalHandler() + + // Both purposes live at once for u1, distinct digits. + repo.otps["ob"] = &fakeEmailOTP{ + id: "ob", userID: "u1", email: "player@example.net", 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: "Player@Example.NET", 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":"player@example.net","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":"player@example.net","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: "boss@example.net", + 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":"boss@example.net"}`, 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":"boss@example.net","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":"boss@example.net","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":"boss@example.net","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":"a@example.net"}`, 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":"a@example.net","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: "player@example.net", + 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":"player@example.net"}`, 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":"player@example.net"}`, 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) + } +} diff --git a/internal/api/handlers_auth_options.go b/internal/api/handlers_auth_options.go new file mode 100644 index 0000000..dea7853 --- /dev/null +++ b/internal/api/handlers_auth_options.go @@ -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}) +} diff --git a/internal/api/handlers_auth_options_test.go b/internal/api/handlers_auth_options_test.go new file mode 100644 index 0000000..43208a6 --- /dev/null +++ b/internal/api/handlers_auth_options_test.go @@ -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: "player@example.net", Role: "user", EmailVerified: true} + repo.staff["boss"] = &StaffUser{ID: "a1", Username: "boss", Email: "boss@example.net", 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":"player@example.net"}`, 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":"player@example.net"}`, 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":"ghost@example.net"}`, 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":"player@example.net"}`, jsonHeader) + wStaff := do(eh, "POST", authOptionsPath, `{"email":"boss@example.net"}`, 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":"player@example.net"}`, 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":"player@example.net"}`, 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":"player@example.net"}`, 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":"player@example.net","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":"player@example.net"}`, jsonHeader); w.Code != http.StatusNotFound { + t.Errorf("options on internal face: code = %d, want 404 (it is external-only)", w.Code) + } +} diff --git a/internal/api/handlers_auth_test.go b/internal/api/handlers_auth_test.go deleted file mode 100644 index 3dc7ea1..0000000 --- a/internal/api/handlers_auth_test.go +++ /dev/null @@ -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) - } - }) -} diff --git a/internal/api/handlers_logstream_test.go b/internal/api/handlers_logstream_test.go index f53aae4..a99485d 100644 --- a/internal/api/handlers_logstream_test.go +++ b/internal/api/handlers_logstream_test.go @@ -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 diff --git a/internal/api/handlers_onboard_test.go b/internal/api/handlers_onboard_test.go index be6fb8a..710edf9 100644 --- a/internal/api/handlers_onboard_test.go +++ b/internal/api/handlers_onboard_test.go @@ -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) diff --git a/internal/api/handlers_op_login.go b/internal/api/handlers_op_login.go new file mode 100644 index 0000000..551de42 --- /dev/null +++ b/internal/api/handlers_op_login.go @@ -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. 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. 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.) 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 . 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}) +} diff --git a/internal/api/handlers_op_login_test.go b/internal/api/handlers_op_login_test.go new file mode 100644 index 0000000..1bb3f8f --- /dev/null +++ b/internal/api/handlers_op_login_test.go @@ -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: "Op@Example.NET", + 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, "op@example.net") + 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 != "Op@Example.NET" { + t.Fatalf("mailer: calls=%d email=%q, want 1 send to the STORED casing Op@Example.NET", + 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, "ghost@example.net") + }) + 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: "player@example.net", Role: "user", EmailVerified: true} + }, "player@example.net") + }) +} + +// 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: "op@example.net", status: "pending", expiresAt: future, createdAt: future} + repo.opLogins["expired"] = &fakeOpLogin{id: "expired", userID: "a1", email: "op@example.net", status: "approved", expiresAt: past, createdAt: past} + repo.opLogins["consumed"] = &fakeOpLogin{id: "consumed", userID: "a1", email: "op@example.net", status: "approved", consumed: true, expiresAt: future, createdAt: future} + repo.opLogins["denied"] = &fakeOpLogin{id: "denied", userID: "a1", email: "op@example.net", status: "denied", expiresAt: future, createdAt: future} + repo.opLogins["live"] = &fakeOpLogin{id: "live", userID: "a1", email: "op@example.net", 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, "op@example.net"))["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, "op@example.net"))["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, "op@example.net"))["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: "op@example.net", 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: "player@example.net", 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: "op@example.net", status: "pending", expiresAt: future, createdAt: time.Unix(1_700_000_200, 0)} + repo.opLogins["r1"] = &fakeOpLogin{id: "r1", userID: "a1", email: "op@example.net", status: "pending", expiresAt: future, createdAt: time.Unix(1_700_000_100, 0)} + repo.opLogins["ap"] = &fakeOpLogin{id: "ap", userID: "a1", email: "op@example.net", status: "approved", expiresAt: future, createdAt: time.Unix(1_700_000_150, 0)} + repo.opLogins["ex"] = &fakeOpLogin{id: "ex", userID: "a1", email: "op@example.net", 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"] != "op@example.net" { + t.Errorf("row projection = %v, want username op / email op@example.net", 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, "op@example.net"); 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":"op@example.net"}`, 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":"op@example.net","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":"op@example.net"}`, 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) + } +} diff --git a/internal/api/handlers_passkey.go b/internal/api/handlers_passkey.go index e2c04dc..db62bcc 100644 --- a/internal/api/handlers_passkey.go +++ b/internal/api/handlers_passkey.go @@ -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. 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, + }) +} diff --git a/internal/api/handlers_passkey_login_test.go b/internal/api/handlers_passkey_login_test.go new file mode 100644 index 0000000..c64d0b9 --- /dev/null +++ b/internal/api/handlers_passkey_login_test.go @@ -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. +// 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: "Player@Example.NET", + 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":"player@example.net"}`, 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":"PLAYER@example.NET","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":"player@example.net","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(), "ghost@example.net") + + // Known verified account that has enrolled NO passkey. + apiN, repoN, _ := seedLoginPasskeyAPI(t) + delete(repoN.passkeyCreds, "row1") + wNone := begin(apiN.ExternalHandler(), "player@example.net") + + 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, "ghost@example.net"); 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, "ghost@example.net"); 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":"player@example.net"}`, 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":"player@example.net"}`, 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":"player@example.net"}` + const goodFinish = `{"email":"player@example.net","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@b@example.net"}`, + "unknown field": `{"email":"a@example.net","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":"player@example.net"}`, "bad_request"}, + {"unknown field", `{"email":"player@example.net","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", "ghost@example.net", func(repo *fakeRepo, v *fakePasskeyVerifier) {}}, + {"known account, no live challenge", "player@example.net", func(repo *fakeRepo, v *fakePasskeyVerifier) {}}, + {"expired challenge", "player@example.net", func(repo *fakeRepo, v *fakePasskeyVerifier) { + plantLoginChallenge(repo, "ex", frozenNow.Add(-time.Second)) + }}, + {"assertion fails verification", "player@example.net", 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, "player@example.net"); w.Code != http.StatusOK { + t.Fatalf("first begin: code = %d, want 200 (%s)", w.Code, w.Body.String()) + } + if w := begin(eh, "player@example.net"); 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, "player@example.net"); 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, "Player@Example.NET"); w.Code != http.StatusOK { + t.Fatalf("first begin: code = %d, want 200 (%s)", w.Code, w.Body.String()) + } + if w := begin(eh, "player@example.net"); 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: "boss@example.net", + 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":"boss@example.net"}`, 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":"boss@example.net","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":"player@example.net"}`, 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":"player@example.net","assertion":{"id":"x"}}`, jsonHeader); w.Code != http.StatusNotFound { + t.Errorf("finish on internal face: code = %d, want 404", w.Code) + } +} diff --git a/internal/api/handlers_player_reclaim_test.go b/internal/api/handlers_player_reclaim_test.go index ca6396a..9b1a2e3 100644 --- a/internal/api/handlers_player_reclaim_test.go +++ b/internal/api/handlers_player_reclaim_test.go @@ -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()) diff --git a/internal/api/handlers_setup.go b/internal/api/handlers_setup.go new file mode 100644 index 0000000..27970c3 --- /dev/null +++ b/internal/api/handlers_setup.go @@ -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./setup?token= +// +// 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, + }) +} diff --git a/internal/api/handlers_user.go b/internal/api/handlers_user.go index 7cfca40..1eb7578 100644 --- a/internal/api/handlers_user.go +++ b/internal/api/handlers_user.go @@ -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, }) } diff --git a/internal/api/handlers_users.go b/internal/api/handlers_users.go index 8773bb9..97928dc 100644 --- a/internal/api/handlers_users.go +++ b/internal/api/handlers_users.go @@ -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). diff --git a/internal/api/middleware.go b/internal/api/middleware.go index 0759b94..d2ecd4f 100644 --- a/internal/api/middleware.go +++ b/internal/api/middleware.go @@ -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. diff --git a/internal/api/pgrepo.go b/internal/api/pgrepo.go index 0a7d672..da0f4a8 100644 --- a/internal/api/pgrepo.go +++ b/internal/api/pgrepo.go @@ -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 { diff --git a/internal/api/repo.go b/internal/api/repo.go index 90bcefe..cbe5461 100644 --- a/internal/api/repo.go +++ b/internal/api/repo.go @@ -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=, 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 diff --git a/internal/api/session.go b/internal/api/session.go index 315fc6d..9a56c83 100644 --- a/internal/api/session.go +++ b/internal/api/session.go @@ -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 } diff --git a/internal/panel/panel.go b/internal/panel/panel.go index 909bd9c..7c5884c 100644 --- a/internal/panel/panel.go +++ b/internal/panel/panel.go @@ -10,6 +10,7 @@ import ( "io/fs" "net/http" "path" + "regexp" "strings" ) @@ -17,29 +18,104 @@ import ( var static embed.FS type runtimeConfig struct { - APIBase string `json:"apiBase"` - RootDomain string `json:"rootDomain"` + APIBase string `json:"apiBase"` + RootDomain string `json:"rootDomain"` + PanelHostname string `json:"panelHostname,omitempty"` + AdminHostname string `json:"adminHostname,omitempty"` + Build buildInfo `json:"build"` } -// Handler wraps the external API handler with the panel SPA. -func Handler(api http.Handler, rootDomain string) http.Handler { +// buildInfo is the resolved build stamp the panel renders in its version badge. +// It is derived once, server-side, from the binary's `main.version` (a +// `git describe --tags --always --dirty` string) so the panel needs no brittle +// string parsing — it just renders `Release`, appending `+Commit` when `Dev`. +type buildInfo struct { + // Version is the raw resolved stamp (e.g. "v1.0.0-earlyAccess-3-g1a2b3c4"). + Version string `json:"version"` + // Release is the "big version" — the newest tag with any git-describe suffix + // stripped (e.g. "v1.0.0-earlyAccess"). It is what the release channel shows. + Release string `json:"release"` + // Commit is the short commit the dev channel was built from (e.g. "1a2b3c4"), + // empty for a clean release build. + Commit string `json:"commit,omitempty"` + // Dev is true for a non-release build — the dev channel (main past the tag), an + // untagged/bare-SHA build, a dirty tree, or an un-stamped local `go build`. + Dev bool `json:"dev"` +} + +// describeSuffix matches the trailing "--g" that `git describe` +// appends to the newest tag once HEAD is past it — the shape the dev channel +// (main HEAD) produces. The release channel builds the exact tag, so its stamp +// carries no such suffix. Match is anchored at end so a tag whose prerelease part +// itself contains hyphens (v1.0.0-earlyAccess) keeps that part in Release. +var describeSuffix = regexp.MustCompile(`-([0-9]+)-g([0-9a-f]+)$`) + +// releaseTag matches a clean released semantic-version tag (vMAJOR.MINOR.PATCH +// with an optional -prerelease), i.e. the name the release channel builds. It is +// permissive on the prerelease so tags like v1.0.0-earlyAccess qualify. +var releaseTag = regexp.MustCompile(`^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$`) + +// parseBuildVersion splits a `git describe` build stamp into the fields the panel +// version badge renders. Cases: a dev stamp "-N-gSHA" → Release=, +// Commit=SHA, Dev=true; a clean release tag "vX.Y.Z[-pre]" → Release=tag, Dev=false; +// anything else ("dev", "unknown", a bare short SHA, a dirty tree) → best-effort +// Release with Dev=true. +func parseBuildVersion(raw string) buildInfo { + raw = strings.TrimSpace(raw) + if raw == "" { + raw = "dev" + } + bi := buildInfo{Version: raw} + core := strings.TrimSuffix(raw, "-dirty") + dirty := core != raw + + switch { + case describeSuffix.MatchString(core): + m := describeSuffix.FindStringSubmatch(core) + bi.Release = core[:len(core)-len(m[0])] + bi.Commit = m[2] + bi.Dev = true + case releaseTag.MatchString(core) && !dirty: + bi.Release = core + bi.Dev = false + default: + bi.Release = core + bi.Dev = true + } + return bi +} + +// Handler wraps the external API handler with the panel SPA. version is the +// binary's resolved build stamp (cmd/felis resolvedVersion()); it is surfaced to +// the SPA via /config.json for the version badge. panelHost and adminHost are the +// configured console. and op.console. hostnames (either +// may be empty when that face is not deployed); they let the SPA detect which home +// it is being served from by comparing location.host, so one bundle can render the +// right surface (player console vs SysAdmin console) without a rebuild. +func Handler(api http.Handler, rootDomain, panelHost, adminHost, version string) http.Handler { files, err := fs.Sub(static, "static") if err != nil { panic(err) } return &handler{ - api: api, - rootDomain: rootDomain, - files: files, - fileServer: http.FileServer(http.FS(files)), + api: api, + rootDomain: rootDomain, + panelHostname: panelHost, + adminHostname: adminHost, + build: parseBuildVersion(version), + files: files, + fileServer: http.FileServer(http.FS(files)), } } type handler struct { - api http.Handler - rootDomain string - files fs.FS - fileServer http.Handler + api http.Handler + rootDomain string + panelHostname string + adminHostname string + build buildInfo + files fs.FS + fileServer http.Handler } func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { @@ -55,7 +131,13 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { case r.URL.Path == "/config.json": w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "no-store") - _ = json.NewEncoder(w).Encode(runtimeConfig{APIBase: "/api/v1", RootDomain: h.rootDomain}) + _ = json.NewEncoder(w).Encode(runtimeConfig{ + APIBase: "/api/v1", + RootDomain: h.rootDomain, + PanelHostname: h.panelHostname, + AdminHostname: h.adminHostname, + Build: h.build, + }) case h.hasStaticFile(r.URL.Path): h.fileServer.ServeHTTP(w, r) default: diff --git a/internal/panel/panel_test.go b/internal/panel/panel_test.go index b8a0f12..233933f 100644 --- a/internal/panel/panel_test.go +++ b/internal/panel/panel_test.go @@ -15,7 +15,7 @@ func TestHandlerServesPanelAndConfig(t *testing.T) { } w.WriteHeader(http.StatusTeapot) }) - h := Handler(api, "example.test") + h := Handler(api, "example.test", "console.example.test", "op.console.example.test", "v1.2.3") w := httptest.NewRecorder() h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/", nil)) @@ -41,6 +41,15 @@ func TestHandlerServesPanelAndConfig(t *testing.T) { if cfg.APIBase != "/api/v1" || cfg.RootDomain != "example.test" { t.Fatalf("config = %+v", cfg) } + // The tiering plumbing surfaces the two console hostnames so one bundle can + // detect which face it is being served from, plus a resolved build stamp for + // the version badge. A clean release tag resolves to a non-dev build. + if cfg.PanelHostname != "console.example.test" || cfg.AdminHostname != "op.console.example.test" { + t.Fatalf("config tiering hostnames = %+v", cfg) + } + if cfg.Build.Version != "v1.2.3" || cfg.Build.Release != "v1.2.3" || cfg.Build.Dev { + t.Fatalf("config build stamp = %+v", cfg.Build) + } w = httptest.NewRecorder() h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/v1/me", nil)) diff --git a/internal/panel/webview_test.go b/internal/panel/webview_test.go index b376df0..83f0352 100644 --- a/internal/panel/webview_test.go +++ b/internal/panel/webview_test.go @@ -40,7 +40,7 @@ func newPanelHandler(t *testing.T) http.Handler { api := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusTeapot) }) - return Handler(api, "example.test") + return Handler(api, "example.test", "", "", "") } func TestGuardServesInterstitialForWeChatNavigation(t *testing.T) { diff --git a/internal/store/migrations/0012_setup_tokens.sql b/internal/store/migrations/0012_setup_tokens.sql new file mode 100644 index 0000000..6910c44 --- /dev/null +++ b/internal/store/migrations/0012_setup_tokens.sql @@ -0,0 +1,19 @@ +-- One-time setup tokens for the first-web-login bootstrap (spec §B). +-- `felis setup` binds the Owner's Minecraft account, promotes it to the +-- passwordless Owner (role='admin'), and mints one of these — the raw token +-- rides in the op.console /setup?token=... URL while only its sha-256 hash is +-- stored here, mirroring sessions and account_link_codes. Opening the URL +-- redeems the token once (ConsumeSetupToken) for a lockdown session in which the +-- Owner verifies their email and enrols a passkey instead of setting a password. +-- Tokens are single-use (consumed_at) and short-lived (expires_at, 30 min); a +-- redeemed row is spent, not deleted, so a replayed URL is a clean miss rather +-- than a fresh mint. ON DELETE CASCADE keeps pending tokens from outliving the +-- user they bootstrap (mirrors webauthn_credentials, migration 0008). +CREATE TABLE setup_tokens ( + token_hash text PRIMARY KEY, -- sha-256(raw token); the raw value only ever lives in the URL + user_id text NOT NULL REFERENCES users(id) ON DELETE CASCADE, + expires_at timestamptz NOT NULL, -- redemption refused once passed (ConsumeSetupToken) + consumed_at timestamptz, -- non-NULL once redeemed; the single-use gate + created_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX setup_tokens_user_id_idx ON setup_tokens (user_id); diff --git a/plugins/shared/src/main/java/best/lolicon/felis/link/FelisApiClient.java b/plugins/shared/src/main/java/best/lolicon/felis/link/FelisApiClient.java index a8269da..6da6d50 100644 --- a/plugins/shared/src/main/java/best/lolicon/felis/link/FelisApiClient.java +++ b/plugins/shared/src/main/java/best/lolicon/felis/link/FelisApiClient.java @@ -173,6 +173,33 @@ public final class FelisApiClient { return barred instanceof Boolean && (Boolean) barred; } + /** + * opLoginApprove records an in-game administrator's vouch for a pending op.console + * staff login — the second factor of the spec §B op-login door, supplied from + * Velocity's {@code /felis web op approve }. It POSTs the approver's verified + * online-mode UUID to {@code POST /api/v1/internal/op-login/{id}/approve}; felis-api + * resolves that UUID to a linked account and refuses unless it is {@code role=admin} + * (403 {@code not_admin}), so this is defence in depth over Velocity's own in-game + * guard rather than the sole check. A {@code requestId} naming no live pending + * request is 404 {@code op_login_not_found}. Both arrive as branchable + * {@link LinkException}s; a 200 that does not affirm {@code approved:true} is a + * contract breach, not a refusal. + * + *

{@code requestId} is interpolated into the request path, so the caller must + * pass a validated opaque handle (the 32-hex id minted by op-login start) — never + * unsanitised chat input. The Velocity command validates the charset first. + */ + public void opLoginApprove(String requestId, UUID approverUuid) throws LinkException { + Objects.requireNonNull(requestId, "requestId"); + Objects.requireNonNull(approverUuid, "approverUuid"); + String body = "{\"approver_uuid\":\"" + approverUuid + "\"}"; + Map res = postObject("/api/v1/internal/op-login/" + requestId + "/approve", body, 200); + Object approved = res.get("approved"); + if (!(approved instanceof Boolean) || !((Boolean) approved)) { + throw new LinkException(200, "bad_response", "approve returned 200 without approved=true"); + } + } + // ---- transport ---- private Map getObject(String path, int expect) throws LinkException { diff --git a/plugins/velocity/src/main/java/best/lolicon/felis/velocity/FelisVelocityPlugin.java b/plugins/velocity/src/main/java/best/lolicon/felis/velocity/FelisVelocityPlugin.java index 7fb0fc8..5a6c04d 100644 --- a/plugins/velocity/src/main/java/best/lolicon/felis/velocity/FelisVelocityPlugin.java +++ b/plugins/velocity/src/main/java/best/lolicon/felis/velocity/FelisVelocityPlugin.java @@ -8,6 +8,7 @@ import best.lolicon.felis.link.ServerView; import com.google.inject.Inject; import com.mojang.brigadier.Command; +import com.mojang.brigadier.arguments.StringArgumentType; import com.mojang.brigadier.tree.LiteralCommandNode; import com.velocitypowered.api.command.BrigadierCommand; import com.velocitypowered.api.command.CommandManager; @@ -19,6 +20,7 @@ import com.velocitypowered.api.plugin.Plugin; import com.velocitypowered.api.plugin.annotation.DataDirectory; import com.velocitypowered.api.proxy.Player; import com.velocitypowered.api.proxy.ProxyServer; +import com.velocitypowered.api.proxy.ServerConnection; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.format.NamedTextColor; import org.slf4j.Logger; @@ -27,6 +29,9 @@ import java.nio.file.Path; import java.time.Duration; import java.util.Collection; import java.util.List; +import java.util.Optional; +import java.util.UUID; +import java.util.regex.Pattern; /** * FelisVelocityPlugin is the proxy-side of Felis (spec §10 account-link + §11 @@ -72,6 +77,7 @@ public final class FelisVelocityPlugin { private LinkClient linkClient; private FelisApiClient apiClient; private ServerRegistry registry; + private WaitingRouter router; private boolean onlineMode; private boolean routingActive; @@ -110,7 +116,7 @@ public final class FelisVelocityPlugin { this.apiClient = new FelisApiClient(config.linkConfig()); this.registry = new ServerRegistry(proxy, logger, config.rootDomain()); - WaitingRouter router = new WaitingRouter(proxy, logger, apiClient, registry, this, config.lobbyServer()); + this.router = new WaitingRouter(proxy, logger, apiClient, registry, this, config.lobbyServer()); MotdResponder motd = new MotdResponder(registry); proxy.getEventManager().register(this, router); proxy.getEventManager().register(this, motd); @@ -197,7 +203,42 @@ public final class FelisVelocityPlugin { }); } - // ---- /felis (operator status) ---- + // ---- /felis command suite (spec §B in-game authz; P4) ---- + // + // /felis is the proxy-wide operator surface: the ONE place a command runs with a + // service token behind it AND a Mojang-verified UUID in front of it, so it is where + // the in-game half of the passwordless-auth flows lives. The tree: + // + // /felis proxy + routing status + // /felis help this list + // /felis server the felis servers this proxy knows + // /felis go wake a server and move me in when it's ready + // /felis claim take ownership of the server I'm on + // /felis web where the web consoles live + // /felis web op approve vouch for a pending op.console staff login (§B) + // + // Two guards run before any subcommand that acts or reveals operational state: + // + // - the login-limbo gate — a player still sitting in the "login" system server + // (naming.SystemLoginServer) has not passed the front door, so every acting + // subcommand is refused there; only `help` is always available. The console + // source is the trusted operator terminal and is never "in limbo". + // - player-only actions (go / claim / web op approve) reject the console: they + // derive identity from the caller's verified UUID, which only a Player carries, + // so an op-login approver is provably online as themselves (spec §14). The + // API independently re-checks that the UUID is a linked admin — this gate is + // defence in depth over that, not a substitute for it. + + /** Opaque-handle charset an op-login code must match before it enters a request + * path: the id minted by op-login start is 32 hex, but a conservative url-safe set + * is accepted so a future id format still passes while path-dangerous input + * (slash, dot, whitespace) is refused client-side rather than sent. */ + private static final Pattern OP_LOGIN_CODE = Pattern.compile("^[A-Za-z0-9_-]{1,128}$"); + + /** The always-on login limbo (LOOHP/Limbo) — the reserved system name + * {@code naming.SystemLoginServer}, which users can never claim, so gating on the + * server name is stable. */ + private static final String LOGIN_LIMBO = "login"; private void registerFelisCommand() { CommandManager commands = proxy.getCommandManager(); @@ -206,31 +247,120 @@ public final class FelisVelocityPlugin { sendSummary(ctx.getSource()); return Command.SINGLE_SUCCESS; }) - .then(BrigadierCommand.literalArgumentBuilder("list") + .then(BrigadierCommand.literalArgumentBuilder("help") .executes(ctx -> { - sendList(ctx.getSource()); + sendHelp(ctx.getSource()); return Command.SINGLE_SUCCESS; })) + .then(BrigadierCommand.literalArgumentBuilder("server") + .executes(ctx -> { + sendServerList(ctx.getSource()); + return Command.SINGLE_SUCCESS; + })) + .then(BrigadierCommand.literalArgumentBuilder("go") + .then(BrigadierCommand.requiredArgumentBuilder("server", StringArgumentType.word()) + .executes(ctx -> { + doGo(ctx.getSource(), StringArgumentType.getString(ctx, "server")); + return Command.SINGLE_SUCCESS; + }))) + .then(BrigadierCommand.literalArgumentBuilder("claim") + .executes(ctx -> { + doClaim(ctx.getSource()); + return Command.SINGLE_SUCCESS; + })) + .then(BrigadierCommand.literalArgumentBuilder("web") + .executes(ctx -> { + sendWebInfo(ctx.getSource()); + return Command.SINGLE_SUCCESS; + }) + .then(BrigadierCommand.literalArgumentBuilder("op") + .executes(ctx -> { + sendWebOpInfo(ctx.getSource()); + return Command.SINGLE_SUCCESS; + }) + .then(BrigadierCommand.literalArgumentBuilder("approve") + .then(BrigadierCommand.requiredArgumentBuilder("code", StringArgumentType.word()) + .executes(ctx -> { + doOpApprove(ctx.getSource(), StringArgumentType.getString(ctx, "code")); + return Command.SINGLE_SUCCESS; + }))))) .build(); CommandMeta meta = commands.metaBuilder("felis").plugin(this).build(); commands.register(meta, new BrigadierCommand(node)); } + // ---- guards ---- + + // requirePlayer refuses the console for identity-bound actions (go / claim / + // approve): they act on the caller's Mojang-verified UUID, which only a Player has. + private Player requirePlayer(CommandSource source) { + if (source instanceof Player) { + return (Player) source; + } + source.sendMessage(Component.text("That command can only be run in-game by a player.", NamedTextColor.RED)); + return null; + } + + // ensureOutOfLimbo refuses an acting subcommand while the player is still in the + // login limbo (or not yet on any backend): they have not passed the front door. + // Fails closed on an unknown position. + private boolean ensureOutOfLimbo(Player player) { + Optional current = player.getCurrentServer(); + if (current.isEmpty()) { + player.sendMessage(Component.text( + "Hold on — finish connecting before using /felis.", NamedTextColor.YELLOW)); + return false; + } + if (LOGIN_LIMBO.equalsIgnoreCase(current.get().getServerInfo().getName())) { + player.sendMessage(Component.text( + "Finish signing in first — /felis isn't available from the login area.", + NamedTextColor.YELLOW)); + return false; + } + return true; + } + + // gateInfo applies only the limbo gate (the console is always allowed) for the + // read-only, operational-info subcommands. Returns true when the caller may proceed. + private boolean gateInfo(CommandSource source) { + return !(source instanceof Player) || ensureOutOfLimbo((Player) source); + } + + // ---- handlers ---- + private void sendSummary(CommandSource source) { + if (!gateInfo(source)) { + return; + } source.sendMessage(Component.text("Felis proxy", NamedTextColor.AQUA)); source.sendMessage(field("online-mode", String.valueOf(onlineMode))); if (!routingActive) { source.sendMessage(Component.text( " routing: disabled" + (onlineMode ? " (no root-domain set)" : " (offline mode)"), NamedTextColor.YELLOW)); + source.sendMessage(Component.text(" /felis help for commands", NamedTextColor.GRAY)); return; } source.sendMessage(field("root-domain", config.rootDomain())); source.sendMessage(field("lobby", config.lobbyServer() == null ? "" : config.lobbyServer())); source.sendMessage(field("servers", String.valueOf(registry.all().size()))); + source.sendMessage(Component.text(" /felis help for commands", NamedTextColor.GRAY)); } - private void sendList(CommandSource source) { + private void sendHelp(CommandSource source) { + source.sendMessage(Component.text("Felis commands", NamedTextColor.AQUA)); + helpLine(source, "/felis", "proxy and routing status"); + helpLine(source, "/felis server", "the felis servers this proxy knows"); + helpLine(source, "/felis go ", "start a server and move you in when it's ready"); + helpLine(source, "/felis claim", "take ownership of the server you're on"); + helpLine(source, "/felis web", "where the web consoles live"); + helpLine(source, "/felis web op approve ", "approve a pending operator sign-in"); + } + + private void sendServerList(CommandSource source) { + if (!gateInfo(source)) { + return; + } if (!routingActive) { source.sendMessage(Component.text("Felis routing is disabled.", NamedTextColor.YELLOW)); return; @@ -249,6 +379,177 @@ public final class FelisVelocityPlugin { } } + private void doGo(CommandSource source, String serverArg) { + Player player = requirePlayer(source); + if (player == null || !ensureOutOfLimbo(player)) { + return; + } + if (!routingActive) { + player.sendMessage(routingDisabled()); + return; + } + String target = serverArg.trim(); + ServerView match = null; + for (ServerView v : registry.all()) { + if (v.name().equalsIgnoreCase(target)) { + match = v; + break; + } + } + if (match == null) { + player.sendMessage(Component.text( + "No felis server named « " + target + " ». Try /felis server.", NamedTextColor.YELLOW)); + return; + } + Optional current = player.getCurrentServer(); + if (current.isPresent() && current.get().getServerInfo().getName().equalsIgnoreCase(match.name())) { + player.sendMessage(Component.text("You're already on « " + match.name() + " ».", NamedTextColor.GRAY)); + return; + } + // Wake + park + transfer through the shared waiting queue; it reports its own + // policy-gate (403) and transient refusals to the player. + router.enqueueFromCommand(player, match.name()); + } + + private void doClaim(CommandSource source) { + Player player = requirePlayer(source); + if (player == null || !ensureOutOfLimbo(player)) { + return; + } + if (!routingActive) { + player.sendMessage(routingDisabled()); + return; + } + Optional current = player.getCurrentServer(); + if (current.isEmpty()) { + player.sendMessage(Component.text("Join a server before claiming it.", NamedTextColor.YELLOW)); + return; + } + String name = current.get().getServerInfo().getName(); + if (!registry.isManaged(name)) { + player.sendMessage(Component.text( + "« " + name + " » isn't a claimable felis server.", NamedTextColor.YELLOW)); + return; + } + UUID uuid = player.getUniqueId(); + player.sendMessage(Component.text("Claiming « " + name + " »…", NamedTextColor.GRAY)); + async(() -> { + try { + apiClient.claim(name, uuid); + player.sendMessage(Component.text("You now own « " + name + " ».", NamedTextColor.GREEN)); + } catch (LinkException e) { + player.sendMessage(Component.text(claimError(e, name), NamedTextColor.RED)); + } + }); + } + + private void sendWebInfo(CommandSource source) { + if (!gateInfo(source)) { + return; + } + String root = config.rootDomain(); + if (root == null) { + source.sendMessage(Component.text( + "The web console isn't configured on this proxy.", NamedTextColor.YELLOW)); + return; + } + source.sendMessage(Component.text("Felis web consoles", NamedTextColor.AQUA)); + source.sendMessage(field("players", "https://console." + root)); + source.sendMessage(field("operators", "https://op.console." + root)); + source.sendMessage(Component.text( + " operators: /felis web op approve vouches for a pending sign-in", + NamedTextColor.GRAY)); + } + + private void sendWebOpInfo(CommandSource source) { + if (!gateInfo(source)) { + return; + } + source.sendMessage(Component.text("Operator sign-in", NamedTextColor.AQUA)); + source.sendMessage(Component.text( + "An operator signing in at op.console shows an approval code. Run", NamedTextColor.GRAY)); + source.sendMessage(Component.text(" /felis web op approve ", NamedTextColor.WHITE)); + source.sendMessage(Component.text( + "to vouch for it — you must be an online, linked administrator.", NamedTextColor.GRAY)); + } + + private void doOpApprove(CommandSource source, String codeArg) { + Player player = requirePlayer(source); + if (player == null || !ensureOutOfLimbo(player)) { + return; + } + if (!routingActive) { + player.sendMessage(routingDisabled()); + return; + } + String code = codeArg.trim(); + if (!OP_LOGIN_CODE.matcher(code).matches()) { + player.sendMessage(Component.text( + "That doesn't look like a valid approval code.", NamedTextColor.RED)); + return; + } + UUID approver = player.getUniqueId(); + String who = player.getUsername(); + player.sendMessage(Component.text("Approving operator sign-in…", NamedTextColor.GRAY)); + async(() -> { + try { + apiClient.opLoginApprove(code, approver); + player.sendMessage(Component.text( + "Approved — the operator can finish signing in now.", NamedTextColor.GREEN)); + logger.info("Felis: op-login {} approved in-game by {} ({})", code, who, approver); + } catch (LinkException e) { + player.sendMessage(Component.text(opApproveError(e), NamedTextColor.RED)); + } + }); + } + + // ---- helpers ---- + + private Component routingDisabled() { + return Component.text( + "Felis routing is disabled on this proxy" + (onlineMode ? " (no root-domain set)." : " (offline mode)."), + NamedTextColor.YELLOW); + } + + // claimError maps the felis-api claim refusals (spec §9.3) to player-safe text. + private static String claimError(LinkException e, String server) { + switch (e.statusCode()) { + case 412: + return "Link your account on the web console before claiming a server."; + case 403: + return "You've reached your server limit — you can't claim another."; + case 409: + return "« " + server + " » is already owned."; + case 404: + return "« " + server + " » is no longer available."; + case 0: + return "Felis is temporarily unavailable — please try again."; + default: + return "Couldn't claim « " + server + " » right now. Please try again."; + } + } + + // opApproveError maps the internal approve refusals to player-safe text. A 403 is + // the API's own admin re-check (defence in depth over the in-game gate); a 404 + // means no live pending request carries that code. + private static String opApproveError(LinkException e) { + switch (e.statusCode()) { + case 403: + return "Only a linked administrator may approve an operator sign-in."; + case 404: + return "No pending operator sign-in with that code (it may have expired)."; + case 0: + return "Felis is temporarily unavailable — please try again."; + default: + return "Couldn't approve that sign-in right now. Please try again."; + } + } + + private static void helpLine(CommandSource source, String cmd, String desc) { + source.sendMessage(Component.text(" " + cmd + " ", NamedTextColor.WHITE) + .append(Component.text("— " + desc, NamedTextColor.GRAY))); + } + private static Component field(String key, String value) { return Component.text(" " + key + ": ", NamedTextColor.GRAY) .append(Component.text(value == null ? "" : value, NamedTextColor.WHITE)); diff --git a/plugins/velocity/src/main/java/best/lolicon/felis/velocity/WaitingRouter.java b/plugins/velocity/src/main/java/best/lolicon/felis/velocity/WaitingRouter.java index d6ef447..2833ecc 100644 --- a/plugins/velocity/src/main/java/best/lolicon/felis/velocity/WaitingRouter.java +++ b/plugins/velocity/src/main/java/best/lolicon/felis/velocity/WaitingRouter.java @@ -94,6 +94,19 @@ public final class WaitingRouter { wakeAndWait(player, serverName, true); } + /** + * enqueueFromCommand parks a player who drove {@code /felis go } from chat + * onto the server they named, then wakes it and lets {@link #tick()} transfer them + * when ready — the same shared waiting queue as host-based routing and the menu + * path, differing only in that it is NOT flagged {@code fromMenu}: a command-driven + * go has no felis-paper GUI tile to notify, so no {@code TransferReady} frame is + * emitted on readiness. The wake stays autostartPolicy-gated on the verified UUID + * exactly as the other origins, so this adds a new entry point, not a new authority. + */ + void enqueueFromCommand(Player player, String serverName) { + wakeAndWait(player, serverName, false); + } + @Subscribe public void onChooseInitialServer(PlayerChooseInitialServerEvent event) { Player player = event.getPlayer();