feat(auth): migrate console login to passwordless

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

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

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

No files matched your search

+41
View File
@@ -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)
+1 -11
View File
@@ -8,7 +8,6 @@ import (
"net/http" "net/http"
"os" "os"
"regexp" "regexp"
goruntime "runtime"
"strings" "strings"
"time" "time"
@@ -169,14 +168,6 @@ func cmdAPI(args []string, stdout, stderr io.Writer) int {
// agree on what local auth knows. // agree on what local auth knows.
repo := api.NewPGRepo(drv.DB()) 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{ a := &api.API{
Repo: repo, Repo: repo,
Cluster: api.NewK8sCluster(cl, cfg.K8s.Namespace), Cluster: api.NewK8sCluster(cl, cfg.K8s.Namespace),
@@ -203,7 +194,6 @@ func cmdAPI(args []string, stdout, stderr io.Writer) int {
}, },
RootDomain: cfg.Server.RootDomain, RootDomain: cfg.Server.RootDomain,
WakeCooldown: 30 * time.Second, WakeCooldown: 30 * time.Second,
MaxConcurrentLogins: loginBcryptCap,
// Bound concurrent console/build-log SSE streams per principal. Generous enough // Bound concurrent console/build-log SSE streams per principal. Generous enough
// for legitimate multi-tab / multi-server watching, while capping how many // for legitimate multi-tab / multi-server watching, while capping how many
// upstream follow connections a single caller can tie up if their streams stall. // 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") 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()) internalSrv := newAPIServer(*internalAddr, a.InternalHandler())
externalSrv := newAPIServer(cfg.Server.Listen, externalHandler) externalSrv := newAPIServer(cfg.Server.Listen, externalHandler)
+101 -146
View File
@@ -3,6 +3,8 @@ package main
import ( import (
"context" "context"
"crypto/rand" "crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"errors" "errors"
@@ -11,13 +13,13 @@ import (
"io" "io"
"os" "os"
"strings" "strings"
"time"
"felis.lolicon.best/internal/api" "felis.lolicon.best/internal/api"
"felis.lolicon.best/internal/config" "felis.lolicon.best/internal/config"
"felis.lolicon.best/internal/store" "felis.lolicon.best/internal/store"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"golang.org/x/crypto/bcrypt"
) )
// `felis breakGlass` is the local break-glass emergency console (spec §B). Its // `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. // bare Enter) keeps the unverified root override from happening by reflex.
const breakGlassOverrideToken = "OVERRIDE" const breakGlassOverrideToken = "OVERRIDE"
// bootstrapPasswordAlphabet excludes visually ambiguous glyphs (0/O, 1/I/l) so a // ownerStore is the minimal repo surface the break-glass / setup console needs.
// 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.
// *api.PGRepo satisfies it; the unit tests drive a fake, so the core logic // *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. // database or a terminal.
type ownerStore interface { 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. // It is the bootstrap-vs-recovery switch.
AdminExists(ctx context.Context) (bool, error) 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) 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 // InsertOperator mints a NEW Operator staff account. Unlike UpsertOwner it is
// insert-only: a username already taken is a conflict (api.ErrConflict), never a // 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 // 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 // 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). // separate operator DB role (migration 0003: staff = role=admin).
InsertOperator(ctx context.Context, id, username, email, passwordHash string, mustChange bool) error 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 SetSetting(ctx context.Context, key string, value []byte) error
// Audit records the break-glass accountability row. // Audit records the break-glass accountability row.
Audit(ctx context.Context, e api.AuditEntry) error 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, "\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) fmt.Fprintf(stdout, "Recorded as %q (mode: %s, os user: %s).\n", res.accountable, res.mode, res.osUser)
if res.displayPassword != "" { if res.setupTokenURL != "" {
// A one-time password was generated (recovery / root override). It is shown, fmt.Fprintf(stdout, "One-time setup URL (opens a lockdown session to verify email / enroll passkey):\n\n %s\n\n", res.setupTokenURL)
// 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.auditWarning != "" { if res.auditWarning != "" {
fmt.Fprintf(stdout, "WARNING: the accountability audit row was NOT written: %s\n", 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 != "" { 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[:]) return "usr-" + hex.EncodeToString(b[:])
} }
// generateBootstrapPassword returns a fresh one-time password from the unambiguous // authenticateAdmin resolves a typed admin username for recovery-mode attribution.
// alphabet. It rejection-samples to avoid modulo bias, so every position is uniform // Password verification is gone (passwordless design); Phase 3 replaces this with
// over the alphabet. 20 chars over a 57-symbol alphabet is ~116 bits — far more than // email-OTP recovery. For now it confirms the named admin exists.
// the must-change credential needs, and it is rotated on first login regardless. func authenticateAdmin(ctx context.Context, s ownerStore, username string) (matched string, ok bool, err error) {
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) {
username = strings.TrimSpace(username) username = strings.TrimSpace(username)
if username == "" || password == "" { if username == "" {
return "", false, nil return "", false, nil
} }
u, err := s.UserByUsername(ctx, username) u, err := s.UserByUsername(ctx, username)
@@ -286,70 +244,47 @@ func authenticateAdmin(ctx context.Context, s ownerStore, username, password str
if err != nil { if err != nil {
return "", false, err return "", false, err
} }
// Only an admin row carrying a bcrypt hash is an authenticatable staff identity; if u.Role != "admin" {
// 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 {
return "", false, nil return "", false, nil
} }
return u.Username, true, nil return u.Username, true, nil
} }
// provisionOwner mints or resets the single Owner account direct-to-Postgres with // provisionOwner mints or resets the single Owner account direct-to-Postgres,
// the given (already-validated-by-the-caller) password. The account is created with // passwordless. The account is role=admin with no password — the Owner completes
// must_change_password=true, which is load-bearing: it is what arms the API's // passwordless login setup via the web setup-token flow after `felis setup`.
// lockdown middleware so the Owner can do nothing but change the password on first func provisionOwner(ctx context.Context, s ownerStore, username, email string) error {
// login. Only the bcrypt hash reaches the database; the plaintext never does.
func provisionOwner(ctx context.Context, s ownerStore, username, email, password string) error {
username = strings.TrimSpace(username) username = strings.TrimSpace(username)
if username == "" { if username == "" {
return errors.New("owner username is required") return errors.New("owner username is required")
} }
if err := validateOwnerPassword(password); err != nil {
return err
}
id := newOwnerID() id := newOwnerID()
if id == "" { if id == "" {
return errors.New("generate owner id: entropy source failed") return errors.New("generate owner id: entropy source failed")
} }
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err := s.UpsertOwner(ctx, id, username, strings.TrimSpace(email)); err != nil {
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 {
return fmt.Errorf("write owner: %w", err) return fmt.Errorf("write owner: %w", err)
} }
return nil return nil
} }
// provisionOperator mints a NEW Operator staff account direct-to-Postgres. Like the // 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 // Owner it is role=admin and passwordless — Felis has no separate operator DB role,
// above-admin 'owner' role was added in migration 0011 and is exclusive to the first // so an Operator is simply an additional staff admin (migration 0003). UNLIKE
// account — every subsequent staff is a plain admin). UNLIKE provisionOwner, which // provisionOwner, which upserts the single Owner and resets it on a username
// username conflict, this is insert-only: a username already taken returns // conflict, this is insert-only: a username already taken returns api.ErrConflict
// api.ErrConflict rather than overwriting a live account, so adding an Operator can // rather than overwriting a live account, so adding an Operator can never silently
// never silently clobber the Owner's or another Operator's credential. Only the // clobber the Owner's or another Operator's account.
// bcrypt hash reaches the database; the plaintext never does. func provisionOperator(ctx context.Context, s ownerStore, username, email string) error {
func provisionOperator(ctx context.Context, s ownerStore, username, email, password string) error {
username = strings.TrimSpace(username) username = strings.TrimSpace(username)
if username == "" { if username == "" {
return errors.New("operator username is required") return errors.New("operator username is required")
} }
if err := validateOwnerPassword(password); err != nil {
return err
}
id := newOwnerID() id := newOwnerID()
if id == "" { if id == "" {
return errors.New("generate operator id: entropy source failed") return errors.New("generate operator id: entropy source failed")
} }
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err := s.InsertOperator(ctx, id, username, strings.TrimSpace(email)); err != nil {
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 errors.Is(err, api.ErrConflict) { if errors.Is(err, api.ErrConflict) {
// Wrap %w so errors.Is(err, api.ErrConflict) still holds — the TUI can render // 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. // 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 osUser string // $SUDO_USER (or "root"); recorded in the payload
ownerUsername string ownerUsername string
ownerEmail string ownerEmail string
ownerPassword string // typed (bootstrap); "" => generate a one-time password
attemptedAdmin string // recovery / override: the admin username the operator typed attemptedAdmin string // recovery / override: the admin username the operator typed
} }
// breakGlassOutcome is what performBreakGlass reports back to the TUI. // breakGlassOutcome is what performBreakGlass reports back to the TUI.
type breakGlassOutcome struct { type breakGlassOutcome struct {
displayPassword string // non-empty only when a one-time password was generated 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 auditErr error // non-nil if the accountability row could not be written
} }
// performBreakGlass executes a resolved break-glass operation: provision (or reset) // performBreakGlass executes a resolved break-glass operation: provision (or reset)
// the Owner, enable local-password login, then record a best-effort accountability // 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 // audit row. The Owner is passwordless — the setup-token flow handles first-login
// / root override) is replaced with a generated one-time password returned for // setup. The audit write is best-effort: a logging failure is reported via auditErr
// one-time display. The audit write is best-effort: a logging failure is reported // but does NOT fail the recovery — break-glass must still work when the audit sink
// via auditErr but does NOT fail the recovery — break-glass must still work when the // is unhappy.
// audit sink is unhappy.
func performBreakGlass(ctx context.Context, s ownerStore, op breakGlassOp) (breakGlassOutcome, error) { func performBreakGlass(ctx context.Context, s ownerStore, op breakGlassOp) (breakGlassOutcome, error) {
password := op.ownerPassword if err := provisionOwner(ctx, s, op.ownerUsername, op.ownerEmail); err != nil {
generated := false
if password == "" {
p, err := generateBootstrapPassword()
if err != nil {
return breakGlassOutcome{}, err return breakGlassOutcome{}, err
} }
password, generated = p, true // 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
if err := provisionOwner(ctx, s, op.ownerUsername, op.ownerEmail, password); err != nil { // never blocks the recovery.
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.
out := breakGlassOutcome{auditErr: auditBreakGlass(ctx, s, op)} out := breakGlassOutcome{auditErr: auditBreakGlass(ctx, s, op)}
if generated {
out.displayPassword = password
}
if err := enableLocalAuth(ctx, s); err != nil { if err := enableLocalAuth(ctx, s); err != nil {
return breakGlassOutcome{}, err return breakGlassOutcome{}, err
} }
return out, nil 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 // auditBreakGlass writes the break-glass accountability row. The actor is the
// resolved human identity (a verified admin in recovery, the OS user otherwise); // 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 // 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 // performAddOperator mints a NEW Operator account and records a best-effort
// accountability row. It mirrors performBreakGlass — a typed password is used as-is, // accountability row. It mirrors performBreakGlass — passwordless — with two
// 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
// deliberate differences. (1) It provisions insert-only (provisionOperator), so it // 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 // 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, // 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 // 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. // a successful provision; a conflict mints nothing, so there is nothing to attribute.
func performAddOperator(ctx context.Context, s ownerStore, op breakGlassOp) (breakGlassOutcome, error) { func performAddOperator(ctx context.Context, s ownerStore, op breakGlassOp) (breakGlassOutcome, error) {
password := op.ownerPassword if err := provisionOperator(ctx, s, op.ownerUsername, op.ownerEmail); err != nil {
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 {
return breakGlassOutcome{}, err return breakGlassOutcome{}, err
} }
out := breakGlassOutcome{auditErr: auditAddOperator(ctx, s, op)} out := breakGlassOutcome{auditErr: auditAddOperator(ctx, s, op)}
if generated {
out.displayPassword = password
}
return out, nil return out, nil
} }
@@ -520,7 +475,7 @@ type breakGlassResult struct {
accountable string accountable string
osUser string osUser string
username string username string
displayPassword string // empty when the operator typed their own bootstrap password setupTokenURL string // non-empty when setup minted a one-time first-login URL
auditWarning string auditWarning string
rootDomain string rootDomain string
adminHostname string adminHostname string
+217 -216
View File
@@ -2,38 +2,65 @@ package main
import ( import (
"context" "context"
"crypto/sha256"
"encoding/hex"
"encoding/json" "encoding/json"
"errors" "errors"
"strings" "strings"
"testing" "testing"
"time"
"felis.lolicon.best/internal/api" "felis.lolicon.best/internal/api"
"golang.org/x/crypto/bcrypt"
) )
// fakeOwnerStore records what break-glass provisioning writes and answers the // fakeOwnerStore records what break-glass / setup provisioning writes and answers
// identity lookups, so the core logic (authentication, provisioning, accountability // the identity lookups, so the core logic (admin resolution, provisioning,
// audit) is exercised without a database or a terminal. // 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 { type fakeOwnerStore struct {
upserts []upsertCall upserts []upsertCall
inserts []upsertCall inserts []upsertCall
settings map[string][]byte settings map[string][]byte
audits []api.AuditEntry audits []api.AuditEntry
tokens []setupTokenCall
redeems []redeemCall
users map[string]*api.StaffUser // keyed by username users map[string]*api.StaffUser // keyed by username
admins bool // AdminExists answer admins bool // AdminExists answer
// 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 upsertErr error
insertErr error insertErr error
setErr error setErr error
auditErr error auditErr error
userErr error // non-not-found error from UserByUsername userErr error // non-not-found error from UserByUsername
adminErr error 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 { type upsertCall struct {
id, username, email, passwordHash string id, username, email string
mustChange bool }
// 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) { 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 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 { if f.upsertErr != nil {
return f.upsertErr 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 return nil
} }
// InsertOperator records an insert-only Operator provision. A username already in // InsertOperator records an insert-only Operator provision. A username already in
// the users map is a conflict (api.ErrConflict), mirroring the PGRepo ON CONFLICT // the users map is a conflict (api.ErrConflict), mirroring the PGRepo insert-only
// DO NOTHING + zero-RowsAffected contract; a fresh one is recorded and reflected // contract; a fresh one is recorded and reflected into users so a later lookup — or
// into users so a later lookup — or a second insert of the same name — sees it. // a second insert of the same name — sees it. The row is passwordless (role=admin).
func (f *fakeOwnerStore) InsertOperator(_ context.Context, id, username, email, passwordHash string, mustChange bool) error { func (f *fakeOwnerStore) InsertOperator(_ context.Context, id, username, email string) error {
if f.insertErr != nil { if f.insertErr != nil {
return f.insertErr return f.insertErr
} }
if _, taken := f.users[username]; taken { if _, taken := f.users[username]; taken {
return api.ErrConflict 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 { if f.users == nil {
f.users = map[string]*api.StaffUser{} f.users = map[string]*api.StaffUser{}
} }
f.users[username] = &api.StaffUser{ f.users[username] = &api.StaffUser{ID: id, Username: username, Email: email, Role: "admin"}
ID: id, Username: username, Email: email, return nil
Role: "admin", PasswordHash: passwordHash, MustChangePassword: mustChange,
} }
// 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 return nil
} }
@@ -102,49 +151,18 @@ func (f *fakeOwnerStore) Audit(_ context.Context, e api.AuditEntry) error {
return nil return nil
} }
// mkAdmin builds an authenticatable admin row (role=admin, real bcrypt hash) for the // mkAdmin builds a resolvable staff row (role=admin). The design is passwordless,
// fake. MinCost keeps the hash fast — these tests are about wiring, not bcrypt. // so a staff account is identity + role — there is no credential to attach.
func mkAdmin(t *testing.T, username, password string) *api.StaffUser { func mkAdmin(username string) *api.StaffUser {
t.Helper() return &api.StaffUser{ID: "usr-admin", Username: username, Role: "admin"}
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))
}
})
}
} }
func TestProvisionOwner(t *testing.T) { func TestProvisionOwner(t *testing.T) {
ctx := context.Background() 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{} f := &fakeOwnerStore{}
const pw = "valid-test-pw" if err := provisionOwner(ctx, f, "owner", "[email protected]"); err != nil {
if err := provisionOwner(ctx, f, "owner", "[email protected]", pw); err != nil {
t.Fatalf("provisionOwner: %v", err) t.Fatalf("provisionOwner: %v", err)
} }
if len(f.upserts) != 1 { if len(f.upserts) != 1 {
@@ -157,26 +175,14 @@ func TestProvisionOwner(t *testing.T) {
if got.email != "[email protected]" { if got.email != "[email protected]" {
t.Errorf("email = %q, want [email protected]", got.email) t.Errorf("email = %q, want [email protected]", 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-") { if !strings.HasPrefix(got.id, "usr-") {
t.Errorf("id = %q, want usr- prefix", got.id) 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) { t.Run("trims surrounding whitespace", func(t *testing.T) {
f := &fakeOwnerStore{} f := &fakeOwnerStore{}
if err := provisionOwner(ctx, f, " owner ", " [email protected] ", "valid-test-pw"); err != nil { if err := provisionOwner(ctx, f, " owner ", " [email protected] "); err != nil {
t.Fatalf("provisionOwner: %v", err) t.Fatalf("provisionOwner: %v", err)
} }
if f.upserts[0].username != "owner" || f.upserts[0].email != "[email protected]" { if f.upserts[0].username != "owner" || f.upserts[0].email != "[email protected]" {
@@ -186,7 +192,7 @@ func TestProvisionOwner(t *testing.T) {
t.Run("rejects an empty username before any write", func(t *testing.T) { t.Run("rejects an empty username before any write", func(t *testing.T) {
f := &fakeOwnerStore{} 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") t.Fatal("want error for empty username")
} }
if len(f.upserts) != 0 { 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) { t.Run("propagates a store error", func(t *testing.T) {
f := &fakeOwnerStore{upsertErr: errors.New("boom")} 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") 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) { func TestAuthenticateAdmin(t *testing.T) {
ctx := context.Background() ctx := context.Background()
t.Run("verifies a matching admin credential", func(t *testing.T) { // Password verification is gone (passwordless design): authenticateAdmin now only
f := &fakeOwnerStore{users: map[string]*api.StaffUser{"root": mkAdmin(t, "root", "correct horse")}} // resolves the named admin so recovery can attribute the audit to a real identity.
matched, ok, err := authenticateAdmin(ctx, f, "root", "correct horse") // 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 { if err != nil {
t.Fatalf("authenticateAdmin: %v", err) t.Fatalf("authenticateAdmin: %v", err)
} }
if !ok { 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" { if matched != "root" {
t.Errorf("matched = %q, want root", matched) 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) { t.Run("a non-admin role can never attribute a break-glass", func(t *testing.T) {
player := mkAdmin(t, "alice", "correct horse") player := mkAdmin("alice")
player.Role = "user" // a player row, even with a hash, is not staff player.Role = "user" // a player row is not staff
f := &fakeOwnerStore{users: map[string]*api.StaffUser{"alice": player}} 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 { if err != nil {
t.Fatalf("unexpected error: %v", err) 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) { t.Run("an unknown user is a non-match, not an error", func(t *testing.T) {
f := &fakeOwnerStore{} f := &fakeOwnerStore{}
_, ok, err := authenticateAdmin(ctx, f, "nobody", "pw") _, ok, err := authenticateAdmin(ctx, f, "nobody")
if err != nil { if err != nil {
t.Fatalf("unexpected error: %v", err) 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")} 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) 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) { t.Run("a datastore fault is surfaced", func(t *testing.T) {
f := &fakeOwnerStore{userErr: errors.New("db down")} 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") 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) { func TestPerformBreakGlass(t *testing.T) {
ctx := context.Background() 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{} f := &fakeOwnerStore{}
op := breakGlassOp{ op := breakGlassOp{
mode: "bootstrap", mode: "bootstrap",
@@ -368,21 +314,16 @@ func TestPerformBreakGlass(t *testing.T) {
osUser: "deploybot", osUser: "deploybot",
ownerUsername: "owner", ownerUsername: "owner",
ownerEmail: "[email protected]", ownerEmail: "[email protected]",
ownerPassword: "valid-test-pw",
} }
out, err := performBreakGlass(ctx, f, op) out, err := performBreakGlass(ctx, f, op)
if err != nil { if err != nil {
t.Fatalf("performBreakGlass: %v", err) 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 { if out.auditErr != nil {
t.Errorf("auditErr = %v, want nil", out.auditErr) 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 { if len(f.upserts) != 1 || f.upserts[0].username != "owner" {
t.Error("owner was not provisioned with the typed password") t.Errorf("owner was not provisioned: %+v", f.upserts)
} }
if _, ok := f.settings[api.LocalAuthEnabledKey]; !ok { if _, ok := f.settings[api.LocalAuthEnabledKey]; !ok {
t.Error("local auth was not enabled — login would 403") 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{} f := &fakeOwnerStore{}
op := breakGlassOp{ op := breakGlassOp{
mode: "recovery", mode: "recovery",
@@ -415,12 +356,14 @@ func TestPerformBreakGlass(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("performBreakGlass: %v", err) t.Fatalf("performBreakGlass: %v", err)
} }
if out.displayPassword == "" { if out.auditErr != nil {
t.Fatal("displayPassword empty, want a generated one-time password") t.Errorf("auditErr = %v, want nil", out.auditErr)
} }
// The shown password must be the one actually stored (as a hash). if len(f.upserts) != 1 {
if bcrypt.CompareHashAndPassword([]byte(f.upserts[0].passwordHash), []byte(out.displayPassword)) != nil { t.Fatalf("want 1 upsert, got %d", len(f.upserts))
t.Error("displayed password does not match the stored hash") }
if _, ok := f.settings[api.LocalAuthEnabledKey]; !ok {
t.Error("local auth was not enabled")
} }
e, payload := auditOf(t, f) e, payload := auditOf(t, f)
if e.Actor != "root" || e.Action != "break_glass.recovery" { if e.Actor != "root" || e.Action != "break_glass.recovery" {
@@ -443,13 +386,9 @@ func TestPerformBreakGlass(t *testing.T) {
ownerUsername: "owner", ownerUsername: "owner",
attemptedAdmin: "typo-admin", attemptedAdmin: "typo-admin",
} }
out, err := performBreakGlass(ctx, f, op) if _, err := performBreakGlass(ctx, f, op); err != nil {
if err != nil {
t.Fatalf("performBreakGlass: %v", err) t.Fatalf("performBreakGlass: %v", err)
} }
if out.displayPassword == "" {
t.Error("displayPassword empty, want a generated one-time password")
}
e, payload := auditOf(t, f) e, payload := auditOf(t, f)
if e.Actor != "alice" || e.Action != "break_glass.root_override" { if e.Actor != "alice" || e.Action != "break_glass.root_override" {
t.Errorf("audit envelope = %+v, want actor=alice action=break_glass.root_override", e) 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) { t.Run("does not enable local auth or audit if the owner write fails", func(t *testing.T) {
f := &fakeOwnerStore{upsertErr: errors.New("boom")} 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 { if _, err := performBreakGlass(ctx, f, op); err == nil {
t.Fatal("want error when the owner write fails") 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) { 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 // The Owner is written by provisionOwner; if the audit were written only after
// after enableLocalAuth, a failed toggle write would leave that reset with no // enableLocalAuth, a failed toggle write would leave that write with no "who did
// "who did it" row. Order guarantees the accountability row lands first. // it" row. Order guarantees the accountability row lands first.
f := &fakeOwnerStore{setErr: errors.New("settings write down")} f := &fakeOwnerStore{setErr: errors.New("settings write down")}
op := breakGlassOp{mode: "recovery", accountable: "root", osUser: "alice", ownerUsername: "owner", attemptedAdmin: "root"} op := breakGlassOp{mode: "recovery", accountable: "root", osUser: "alice", ownerUsername: "owner", attemptedAdmin: "root"}
if _, err := performBreakGlass(ctx, f, op); err == nil { if _, err := performBreakGlass(ctx, f, op); err == nil {
@@ -535,10 +474,9 @@ func TestAccountableOSUser(t *testing.T) {
func TestProvisionOperator(t *testing.T) { func TestProvisionOperator(t *testing.T) {
ctx := context.Background() 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{} f := &fakeOwnerStore{}
const pw = "valid-test-pw" if err := provisionOperator(ctx, f, "ops-jordan", "[email protected]"); err != nil {
if err := provisionOperator(ctx, f, "ops-jordan", "[email protected]", pw); err != nil {
t.Fatalf("provisionOperator: %v", err) t.Fatalf("provisionOperator: %v", err)
} }
// Insert-only: it records an insert and never touches the Owner upsert path. // 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 != "[email protected]" { if got.email != "[email protected]" {
t.Errorf("email = %q, want [email protected]", got.email) t.Errorf("email = %q, want [email protected]", 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-") { if !strings.HasPrefix(got.id, "usr-") {
t.Errorf("id = %q, want usr- prefix", got.id) 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) { 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 // The Owner already holds this username. Operator-add must refuse rather than
// overwrite it the way UpsertOwner would. // overwrite it the way UpsertOwner would.
f := &fakeOwnerStore{users: map[string]*api.StaffUser{ 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 { if err == nil {
t.Fatal("want error when the username is already taken") 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)) t.Errorf("want no insert on conflict, got %d", len(f.inserts))
} }
// The pre-existing account must be untouched. // The pre-existing account must be untouched.
if f.users["owner"].PasswordHash != "x" { if f.users["owner"].ID != "usr-owner" {
t.Error("conflicting insert clobbered the existing account's hash") t.Error("conflicting insert clobbered the existing account")
} }
}) })
t.Run("trims surrounding whitespace", func(t *testing.T) { t.Run("trims surrounding whitespace", func(t *testing.T) {
f := &fakeOwnerStore{} f := &fakeOwnerStore{}
if err := provisionOperator(ctx, f, " ops ", " [email protected] ", "valid-test-pw"); err != nil { if err := provisionOperator(ctx, f, " ops ", " [email protected] "); err != nil {
t.Fatalf("provisionOperator: %v", err) t.Fatalf("provisionOperator: %v", err)
} }
if f.inserts[0].username != "ops" || f.inserts[0].email != "[email protected]" { if f.inserts[0].username != "ops" || f.inserts[0].email != "[email protected]" {
@@ -606,7 +533,7 @@ func TestProvisionOperator(t *testing.T) {
t.Run("rejects an empty username before any write", func(t *testing.T) { t.Run("rejects an empty username before any write", func(t *testing.T) {
f := &fakeOwnerStore{} 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") t.Fatal("want error for empty username")
} }
if len(f.inserts) != 0 { 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) { t.Run("propagates a non-conflict store error without mislabeling it", func(t *testing.T) {
f := &fakeOwnerStore{insertErr: errors.New("boom")} f := &fakeOwnerStore{insertErr: errors.New("boom")}
err := provisionOperator(ctx, f, "ops", "", "valid-test-pw") err := provisionOperator(ctx, f, "ops", "")
if err == nil { if err == nil {
t.Fatal("want error when the store fails") t.Fatal("want error when the store fails")
} }
@@ -640,7 +557,7 @@ func TestProvisionOperator(t *testing.T) {
func TestPerformAddOperator(t *testing.T) { func TestPerformAddOperator(t *testing.T) {
ctx := context.Background() 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{} f := &fakeOwnerStore{}
op := breakGlassOp{ op := breakGlassOp{
mode: "recovery", mode: "recovery",
@@ -648,19 +565,17 @@ func TestPerformAddOperator(t *testing.T) {
osUser: "alice", osUser: "alice",
ownerUsername: "ops-jordan", ownerUsername: "ops-jordan",
ownerEmail: "[email protected]", ownerEmail: "[email protected]",
ownerPassword: "valid-test-pw",
attemptedAdmin: "root", attemptedAdmin: "root",
} }
out, err := performAddOperator(ctx, f, op) out, err := performAddOperator(ctx, f, op)
if err != nil { if err != nil {
t.Fatalf("performAddOperator: %v", err) t.Fatalf("performAddOperator: %v", err)
} }
// The operator's password was typed, so it must NOT be surfaced for display. if out.auditErr != nil {
if out.displayPassword != "" { t.Errorf("auditErr = %v, want nil", out.auditErr)
t.Errorf("displayPassword = %q, want empty for a typed password", out.displayPassword)
} }
if len(f.inserts) != 1 || bcrypt.CompareHashAndPassword([]byte(f.inserts[0].passwordHash), []byte("valid-test-pw")) != nil { if len(f.inserts) != 1 || f.inserts[0].username != "ops-jordan" {
t.Error("operator was not provisioned with the typed password") t.Errorf("operator was not provisioned: %+v", f.inserts)
} }
// Adding an Operator must NOT flip the global local-auth gate (Owner-only). // Adding an Operator must NOT flip the global local-auth gate (Owner-only).
if _, ok := f.settings[api.LocalAuthEnabledKey]; ok { 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{} f := &fakeOwnerStore{}
op := breakGlassOp{mode: "root_override", accountable: "alice", osUser: "alice", ownerUsername: "ops", attemptedAdmin: "typo-admin"} op := breakGlassOp{mode: "root_override", accountable: "alice", osUser: "alice", ownerUsername: "ops", attemptedAdmin: "typo-admin"}
out, err := performAddOperator(ctx, f, op) if _, err := performAddOperator(ctx, f, op); err != nil {
if err != nil {
t.Fatalf("performAddOperator: %v", err) t.Fatalf("performAddOperator: %v", err)
} }
if out.displayPassword == "" { if len(f.inserts) != 1 {
t.Fatal("displayPassword empty, want a generated one-time password to hand off") t.Fatalf("want 1 insert, got %d", len(f.inserts))
}
// 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")
} }
_, payload := auditOf(t, f) _, payload := auditOf(t, f)
if payload["verified"] != false { 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) { t.Run("a conflict mints nothing and writes no audit row", func(t *testing.T) {
f := &fakeOwnerStore{users: map[string]*api.StaffUser{ 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 { if _, err := performAddOperator(ctx, f, op); err == nil {
t.Fatal("want error when the operator username is already taken") 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)
}
})
}
+21 -6
View File
@@ -24,6 +24,15 @@ const hostBootstrapKubeconfigPath = "/etc/rancher/k3s/k3s.yaml"
var errHostBootstrapCancelled = errors.New("host bootstrap cancelled") 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 // cmdSetup is the normal first-run operator console. It is intentionally separate
// from breakGlass: setup creates the initial Owner and optional web edge; breakGlass // from breakGlass: setup creates the initial Owner and optional web edge; breakGlass
// is reserved for emergency local recovery/reset. // 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 := flag.NewFlagSet("setup", flag.ContinueOnError)
fs.SetOutput(stderr) fs.SetOutput(stderr)
cfgPath := fs.String("config", defaultSetupConfigPath, "path to felis.toml") 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 err := fs.Parse(args); err != nil {
if errors.Is(err, flag.ErrHelp) { if errors.Is(err, flag.ErrHelp) {
return 0 return 0
} }
return 2 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 configFlagSet := false
fs.Visit(func(f *flag.Flag) { fs.Visit(func(f *flag.Flag) {
if f.Name == "config" { if f.Name == "config" {
@@ -112,18 +129,16 @@ func cmdSetup(args []string, stdout, stderr io.Writer) int {
} }
if res.provisioned { 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) fmt.Fprintf(stdout, "Recorded as %q (mode: %s, os user: %s).\n", res.accountable, res.mode, res.osUser)
if res.displayPassword != "" { if res.setupTokenURL != "" {
fmt.Fprintf(stdout, "One-time password (you MUST change it on first login):\n\n %s\n\n", res.displayPassword) fmt.Fprintf(stdout, "Open this URL to complete passwordless login setup (verify email / enroll passkey):\n\n %s\n\n", res.setupTokenURL)
} else {
fmt.Fprintln(stdout, "Log in with the password you just entered (you MUST change it on first login).")
} }
if res.auditWarning != "" { if res.auditWarning != "" {
fmt.Fprintf(stdout, "WARNING: the accountability audit row was NOT written: %s\n", res.auditWarning) fmt.Fprintf(stdout, "WARNING: the accountability audit row was NOT written: %s\n", res.auditWarning)
} }
if panelURL != "" { 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.") fmt.Fprintln(stdout, "The local HTTPS certificate is self-signed; your browser may ask for confirmation on first visit.")
} }
} }
+1 -1
View File
@@ -19,7 +19,7 @@ func TestWizardViewsFitTerminal(t *testing.T) {
msg tea.Msg msg tea.Msg
}{ }{
{"owner", preflightDoneMsg{}}, {"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"}}, {"summary", connectResultMsg{method: connectLocal, panelHostname: "panel.example.com"}},
} }
+202
View File
@@ -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()
}
+4 -4
View File
@@ -76,9 +76,9 @@ func TestProvisionCmdSelectsPathByOperation(t *testing.T) {
if _, ok := f.settings[api.LocalAuthEnabledKey]; ok { if _, ok := f.settings[api.LocalAuthEnabledKey]; ok {
t.Error("the operator path flipped local auth — only the Owner thread may") 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. // The provision is fully done: the audit row landed (no recoverable audit error).
if msg.outcome.displayPassword == "" { if msg.outcome.auditErr != nil {
t.Error("want a generated one-time password to hand to the new operator") t.Errorf("operator provision recorded an audit error: %v", msg.outcome.auditErr)
} }
}) })
@@ -171,7 +171,7 @@ func TestOwnerResultCmdCarriesIsOperator(t *testing.T) {
ctx := context.Background() ctx := context.Background()
op := newOperatorModel(ctx, &fakeOwnerStore{}, "root") 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 { if res := op.ownerResultCmd()().(ownerResultMsg); !res.isOperator {
t.Error("operator result.isOperator = false, want true") t.Error("operator result.isOperator = false, want true")
} }
+13 -50
View File
@@ -66,15 +66,12 @@ type ownerModel struct {
// huh-bound form values // huh-bound form values
authUser string authUser string
authPass string
overrideTok string overrideTok string
ownerUser string ownerUser string
ownerEmail string ownerEmail string
ownerPass string
ownerConfirm string
username string username string
displayPassword string setupTokenURL string
auditWarning string auditWarning string
} }
@@ -191,7 +188,7 @@ func (m *ownerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, m.failCmd(msg.err) return m, m.failCmd(msg.err)
} }
m.step = owDone m.step = owDone
m.displayPassword = msg.outcome.displayPassword m.setupTokenURL = msg.outcome.setupTokenURL
if msg.outcome.auditErr != nil { if msg.outcome.auditErr != nil {
m.auditWarning = msg.outcome.auditErr.Error() m.auditWarning = msg.outcome.auditErr.Error()
} }
@@ -255,10 +252,10 @@ func (m *ownerModel) onFormComplete() (tea.Model, tea.Cmd) {
case owAuth: case owAuth:
m.attempt = strings.TrimSpace(m.authUser) m.attempt = strings.TrimSpace(m.authUser)
m.step = owWorking m.step = owWorking
m.working = "Verifying admin credential…" m.working = "Verifying admin…"
user, pass := m.authUser, m.authPass user := m.authUser
return m, tea.Batch(m.sp.Tick, func() tea.Msg { 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} return owAuthMsg{matched: matched, ok: ok, err: err}
}) })
case owOverride: case owOverride:
@@ -277,17 +274,12 @@ func (m *ownerModel) onFormComplete() (tea.Model, tea.Cmd) {
} }
func (m *ownerModel) provisionCmd() tea.Cmd { func (m *ownerModel) provisionCmd() tea.Cmd {
password := ""
if m.mode == "bootstrap" {
password = m.ownerPass
}
op := breakGlassOp{ op := breakGlassOp{
mode: m.mode, mode: m.mode,
accountable: m.accountable, accountable: m.accountable,
osUser: m.osUser, osUser: m.osUser,
ownerUsername: m.username, ownerUsername: m.username,
ownerEmail: m.ownerEmail, ownerEmail: m.ownerEmail,
ownerPassword: password,
attemptedAdmin: m.attempt, attemptedAdmin: m.attempt,
} }
// performAddOperator and performBreakGlass share a signature; the operation // performAddOperator and performBreakGlass share a signature; the operation
@@ -312,7 +304,7 @@ func (m *ownerModel) ownerResultCmd() tea.Cmd {
return func() tea.Msg { return func() tea.Msg {
return ownerResultMsg{ return ownerResultMsg{
username: m.username, username: m.username,
displayPassword: m.displayPassword, setupTokenURL: m.setupTokenURL,
mode: m.mode, mode: m.mode,
accountable: m.accountable, accountable: m.accountable,
auditWarning: m.auditWarning, auditWarning: m.auditWarning,
@@ -332,11 +324,6 @@ func (m *ownerModel) buildAuthForm() *huh.Form {
Title("Admin username"). Title("Admin username").
Value(&m.authUser). Value(&m.authUser).
Validate(requiredField("admin username")), 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) desc := fmt.Sprintf("Create the first Owner — recorded as OS user %q.", m.osUser)
switch m.mode { switch m.mode {
case "recovery": 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": 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 { 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 { switch m.mode {
case "recovery": 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": 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 { if m.provisionErr != nil {
@@ -396,26 +381,6 @@ func (m *ownerModel) buildProvisionForm() *huh.Form {
Placeholder("[email protected]"). Placeholder("[email protected]").
Value(&m.ownerEmail), 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...))) return m.sized(newFelisForm(huh.NewGroup(fields...)))
} }
@@ -454,11 +419,9 @@ func (m *ownerModel) doneView() string {
var box strings.Builder var box strings.Builder
box.WriteString(tuiLabel.Render("username ") + m.username + "\n") box.WriteString(tuiLabel.Render("username ") + m.username + "\n")
if m.displayPassword != "" { if m.setupTokenURL != "" {
box.WriteString(tuiLabel.Render("password ") + tuiPassword.Render(m.displayPassword) + "\n\n") box.WriteString("\n" + tuiLabel.Render("setup URL ") + "\n" + tuiPassword.Render(m.setupTokenURL) + "\n\n")
box.WriteString(tuiWarn.Render("Record this password — it is shown only once.")) box.WriteString(tuiWarn.Render("Open this URL to complete passwordless login setup. It is shown only once."))
} else {
box.WriteString(tuiHint.Render("Log in with the password you entered."))
} }
if m.auditWarning != "" { if m.auditWarning != "" {
box.WriteString("\n\n" + tuiWarn.Render("Audit warning: "+m.auditWarning)) box.WriteString("\n\n" + tuiWarn.Render("Audit warning: "+m.auditWarning))
+7 -4
View File
@@ -53,7 +53,7 @@ type preflightDoneMsg struct{}
type ownerResultMsg struct { type ownerResultMsg struct {
username string username string
displayPassword string setupTokenURL string
mode string mode string
accountable string accountable string
auditWarning string auditWarning string
@@ -207,6 +207,9 @@ func (m *rootModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m.showStatus() return m.showStatus()
} }
m.stage = stageOwner 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)) return m.adopt(newOwnerModel(m.ctx, m.store, m.osUser, false))
case menuChoiceMsg: case menuChoiceMsg:
@@ -228,7 +231,7 @@ func (m *rootModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.result.provisioned = true m.result.provisioned = true
m.result.isOperator = msg.isOperator m.result.isOperator = msg.isOperator
m.result.username = msg.username m.result.username = msg.username
m.result.displayPassword = msg.displayPassword m.result.setupTokenURL = msg.setupTokenURL
m.result.mode = msg.mode m.result.mode = msg.mode
m.result.accountable = msg.accountable m.result.accountable = msg.accountable
m.result.auditWarning = msg.auditWarning m.result.auditWarning = msg.auditWarning
@@ -363,7 +366,7 @@ func (m *rootModel) reviewBody(stage int) string {
if m.result.username != "" { if m.result.username != "" {
b.WriteString(tuiLabel.Render("username ") + m.result.username + "\n") 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: case stageConnect:
b.WriteString(tuiOK.Render("✓ Connection") + "\n") b.WriteString(tuiOK.Render("✓ Connection") + "\n")
b.WriteString(tuiLabel.Render("method ") + connectMethodLabel(m.result.connectMethod) + "\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{ return m.adopt(&summaryModel{
panelURL: m.result.panelURL, panelURL: m.result.panelURL,
ownerUsername: m.result.username, ownerUsername: m.result.username,
ownerPassword: m.result.displayPassword, setupTokenURL: m.result.setupTokenURL,
accessLabel: connectMethodLabel(m.result.connectMethod), accessLabel: connectMethodLabel(m.result.connectMethod),
storageLabel: m.result.storageDetail, storageLabel: m.result.storageDetail,
routedHosts: routed, routedHosts: routed,
+14 -12
View File
@@ -52,24 +52,26 @@ func TestRootSetupHappyPath(t *testing.T) {
t.Fatalf("initial screen = %T, want *preflightModel", m.screen) 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{}) m = drive(t, m, preflightDoneMsg{})
if m.stage != stageOwner { if m.stage != stageOwner {
t.Fatalf("after preflight, stage = %v, want stageOwner", m.stage) t.Fatalf("after preflight, stage = %v, want stageOwner", m.stage)
} }
if _, ok := m.screen.(*ownerModel); !ok { if _, ok := m.screen.(*mcBindModel); !ok {
t.Fatalf("after preflight, screen = %T, want *ownerModel", m.screen) t.Fatalf("after preflight, screen = %T, want *mcBindModel", m.screen)
} }
// Owner provisioned → Connection chooser. // 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 { if m.stage != stageConnect {
t.Fatalf("after owner, stage = %v, want stageConnect", m.stage) t.Fatalf("after owner, stage = %v, want stageConnect", m.stage)
} }
if _, ok := m.screen.(*connectChooserModel); !ok { if _, ok := m.screen.(*connectChooserModel); !ok {
t.Fatalf("after owner, screen = %T, want *connectChooserModel", m.screen) 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) 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 { if want := "https://panel.felis.example.com"; sum.panelURL != want {
t.Fatalf("summary panelURL = %q, want %q", sum.panelURL, want) t.Fatalf("summary panelURL = %q, want %q", sum.panelURL, want)
} }
if sum.ownerPassword != "hunter2" { if want := "https://op.console.example.com/setup?token=t0ken"; sum.setupTokenURL != want {
t.Fatalf("summary ownerPassword = %q, want %q", sum.ownerPassword, "hunter2") t.Fatalf("summary setupTokenURL = %q, want %q", sum.setupTokenURL, want)
} }
if sum.alreadySetUp { if sum.alreadySetUp {
t.Fatalf("first-run summary should not be marked 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) { func TestRootReconfigureConnectSkipsStorage(t *testing.T) {
m := newTestRoot(false, consoleModeSetup, "") m := newTestRoot(false, consoleModeSetup, "")
m = drive(t, m, preflightDoneMsg{}) 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, connectResultMsg{method: connectLocal, panelHostname: "panel.felis.example.com"})
m = drive(t, m, storageResultMsg{method: storageS3, detail: "s3://bucket"}) m = drive(t, m, storageResultMsg{method: storageS3, detail: "s3://bucket"})
if _, ok := m.screen.(*summaryModel); !ok { 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) 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) rm := next.(*rootModel)
if _, ok := rm.screen.(*connectChooserModel); ok { if _, ok := rm.screen.(*connectChooserModel); ok {
t.Fatalf("break-glass must not enter the connection chooser") 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 ←/→). // 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 { if m.reviewing != -1 {
t.Fatalf("fresh chooser should start live, reviewing = %d", m.reviewing) 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 := newTestRoot(false, consoleModeSetup, "")
m = drive(t, m, tea.WindowSizeMsg{Width: 90, Height: 30}) m = drive(t, m, tea.WindowSizeMsg{Width: 90, Height: 30})
m = drive(t, m, preflightDoneMsg{}) m = drive(t, m, preflightDoneMsg{})
if _, ok := m.screen.(*ownerModel); !ok { if _, ok := m.screen.(*mcBindModel); !ok {
t.Fatalf("expected owner screen after preflight, got %T", m.screen) t.Fatalf("expected MC-bind screen after preflight, got %T", m.screen)
} }
if v := m.View(); !strings.Contains(v, "✓ Bootstrap") { if v := m.View(); !strings.Contains(v, "✓ Bootstrap") {
t.Fatalf("wizard rail should carry Bootstrap as a completed step, got:\n%s", v) t.Fatalf("wizard rail should carry Bootstrap as a completed step, got:\n%s", v)
+4 -4
View File
@@ -14,7 +14,7 @@ import (
type summaryModel struct { type summaryModel struct {
panelURL string panelURL string
ownerUsername string ownerUsername string
ownerPassword string // one-time; shown once setupTokenURL string // one-time first-login URL; shown once
accessLabel string accessLabel string
storageLabel string // build-context storage backend recap; empty to omit storageLabel string // build-context storage backend recap; empty to omit
routedHosts []string routedHosts []string
@@ -55,9 +55,9 @@ func (m *summaryModel) View() string {
if m.ownerUsername != "" { if m.ownerUsername != "" {
card.WriteString(tuiLabel.Render("owner ") + m.ownerUsername + "\n") card.WriteString(tuiLabel.Render("owner ") + m.ownerUsername + "\n")
} }
if m.ownerPassword != "" { if m.setupTokenURL != "" {
card.WriteString(tuiLabel.Render("password ") + tuiPassword.Render(m.ownerPassword) + "\n") card.WriteString(tuiLabel.Render("setup URL ") + tuiPassword.Render(m.setupTokenURL) + "\n")
card.WriteString(" " + tuiWarn.Render("shown only once — record it now") + "\n") card.WriteString(" " + tuiWarn.Render("one-time link — open it to finish login setup") + "\n")
} }
if m.accessLabel != "" { if m.accessLabel != "" {
card.WriteString(tuiLabel.Render("access ") + m.accessLabel + "\n") card.WriteString(tuiLabel.Render("access ") + m.accessLabel + "\n")
+58
View File
@@ -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=<git describe>"
//
// 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
}
+603 -109
View File
@@ -879,6 +879,94 @@ paths:
'401': '401':
$ref: '#/components/responses/Unauthorized' $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 <id>. 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 --- # ----------------------------------------------------- external: servers ---
/api/v1/servers/{name}/wake: /api/v1/servers/{name}/wake:
post: post:
@@ -1431,18 +1519,22 @@ paths:
$ref: '#/components/responses/NotFound' $ref: '#/components/responses/NotFound'
# -------------------------------------------------- external: local auth --- # -------------------------------------------------- external: local auth ---
/api/v1/auth/login: /api/v1/auth/options:
post: post:
tags: [auth] tags: [auth]
operationId: login operationId: authOptions
summary: Log in with a local username + password (op.console). summary: Identifier-first login discovery — which methods can this email use (spec §B, #71).
description: >- description: >-
Verifies a username+password against the users row and, on success, mints Public, pre-session discovery for the SPA's identifier-first form: given a typed
a host-only session cookie (spec §B). Mounted Public — there is no prior email, report which console login methods the account can use (passkey and/or
principal — but local auth must be enabled (local_auth_enabled), so a email-OTP) so the UI prompts for the right authenticator. This is the deliberate
deployment fronted entirely by Zero Trust never accepts a local password. counter-slice to the anti-enumeration login doors — the ONE sanctioned place
Every failure returns the same vague invalid_credentials after a uniform account existence is disclosed, so an unknown address returns an empty methods
bcrypt compare, so usernames cannot be enumerated by response or timing. 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-face: [external]
x-felis-tier: public x-felis-tier: public
security: [] security: []
@@ -1452,37 +1544,521 @@ paths:
application/json: application/json:
schema: schema:
type: object type: object
required: [username, password] required: [email]
properties: properties:
username: { type: string } email: { type: string, format: email }
password: { type: string, format: password }
responses: responses:
'200': '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: content:
application/json: application/json:
schema: schema:
type: object type: object
required: [user_id, role, must_change_password] required: [methods]
properties: properties:
user_id: { type: string } methods:
role: type: array
type: string items: { type: string, enum: [passkey, email_otp] }
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.
'400': '400':
$ref: '#/components/responses/BadRequest' description: A valid email is required (bad_request).
'401':
description: Invalid username or password (vague by design).
content: content:
application/json: application/json:
schema: { $ref: '#/components/schemas/Error' } schema: { $ref: '#/components/schemas/Error' }
'403': '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: content:
application/json: application/json:
schema: { $ref: '#/components/schemas/Error' } schema: { $ref: '#/components/schemas/Error' }
@@ -1510,57 +2086,6 @@ paths:
properties: properties:
ok: { type: boolean, const: true } 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: /api/v1/auth/bind:
post: post:
tags: [auth] tags: [auth]
@@ -2061,37 +2586,6 @@ paths:
'404': '404':
$ref: '#/components/responses/NotFound' $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: /api/v1/users/{id}/quotas:
get: get:
tags: [users] tags: [users]
+74 -59
View File
@@ -104,17 +104,6 @@ type API struct {
// a positive value. Enforced via withinRunningCap on the wake path. // a positive value. Enforced via withinRunningCap on the wake path.
MaxRunningServers int 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 // MaxStreamsPerPrincipal caps how many concurrent Server-Sent Event streams
// (console + build-log relays, spec §8) a single principal may hold open at once. // (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 // 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 otpCooldownOnce sync.Once
otpCooldown *cooldownLimiter otpCooldown *cooldownLimiter
loginCapOnce sync.Once
loginCap *concurrencyLimiter
streamCapOnce sync.Once streamCapOnce sync.Once
streamCap *streamLimiter streamCap *streamLimiter
} }
@@ -169,16 +155,6 @@ func (a *API) otpLimiter() *cooldownLimiter {
return a.otpCooldown 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 // streamGate lazily builds the per-principal SSE stream cap bound to
// MaxStreamsPerPrincipal. A zero cap yields a disabled limiter that admits every // MaxStreamsPerPrincipal. A zero cap yields a disabled limiter that admits every
// stream, so a deployment (or test) that leaves it unset pays nothing. // 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. // on one route is harmless but redundant — an owner passes both.
Owner bool Owner bool
// AllowDuringPasswordChange opts a route OUT of the must_change_password // SetupAllowed marks a route as reachable during the setup-lockdown: a session
// lockdown (spec §B). The lockdown is default-deny: every authenticated route is // whose EmailVerified is false is restricted to these routes only.
// fenced off for a staff principal that still owes a first-login password change SetupAllowed bool
// 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
h http.HandlerFunc h http.HandlerFunc
} }
@@ -283,6 +255,12 @@ func (a *API) internalAPIRoutes() []apiRoute {
// Mojang player (same name, different UUID) always passes. // Mojang player (same name, different UUID) always passes.
{Method: "POST", Pattern: "/api/v1/internal/player/reclaim", h: a.handleReclaimUsername}, {Method: "POST", Pattern: "/api/v1/internal/player/reclaim", h: a.handleReclaimUsername},
{Method: "GET", Pattern: "/api/v1/internal/player/blacklist/{mc_uuid}", h: a.handleCheckBlacklist}, {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{ return []apiRoute{
{Method: "GET", Pattern: "/healthz", Public: true, h: a.handleHealthz}, {Method: "GET", Pattern: "/healthz", Public: true, h: a.handleHealthz},
// Local-password auth (spec §B), the op.console login surface. login/logout // logout is Public: it reads the cookie directly so it works even after
// are Public (pre-session: a caller has no principal yet, and logout reads the // expiry. The rest of the auth surface (identifier-first options discovery,
// cookie directly so it works even after expiry). change-password requires a // setup redeem/status, passkey login, email OTP login, op-login) is Public and
// live session and stays reachable while must_change_password is set // pre-session: a caller has no principal yet.
// (AllowDuringPasswordChange) so a forced first-login change can complete.
{Method: "POST", Pattern: "/api/v1/auth/login", Public: true, h: a.handleLogin},
{Method: "POST", Pattern: "/api/v1/auth/logout", Public: true, h: a.handleLogout}, {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-console bootstrap (console-tier access model): the account-less
// player's door into console.<root_domain>. Public — like login there is no prior // player's door into console.<root_domain>. Public — like login there is no prior
// principal — and session-minting, but the artifact it consumes is a one-time // 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 // every authenticated principal may read its OWN identity. is_admin is the
// server-computed Principal.IsAdmin() (Role + admin Access path), so the client // server-computed Principal.IsAdmin() (Role + admin Access path), so the client
// never re-derives the graded-ZT rule; it remains UX truth, not enforcement. // 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 // /me is reachable during setup-lockdown so the panel can read its own
// identity (including must_change_password) to render the change-password card. // identity (including email_verified) to drive the setup flow.
{Method: "GET", Pattern: "/api/v1/me", AllowDuringPasswordChange: true, h: a.handleMe}, {Method: "GET", Pattern: "/api/v1/me", SetupAllowed: true, h: a.handleMe},
{Method: "GET", Pattern: "/api/v1/me/servers", h: a.handleMyServers}, {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 // 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 // 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 // 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 // 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 // the account. App-tier, not admin — linking your own account is an ordinary
// authenticated operation. // authenticated operation.
{Method: "POST", Pattern: "/api/v1/account/link/start", h: a.handleLinkStart}, {Method: "POST", Pattern: "/api/v1/account/link/start", SetupAllowed: true, h: a.handleLinkStart},
{Method: "POST", Pattern: "/api/v1/account/link/verify", h: a.handleLinkVerify}, {Method: "POST", Pattern: "/api/v1/account/link/verify", SetupAllowed: true, h: a.handleLinkVerify},
// Email verification (spec §B2 onboarding), web side: /start mints+delivers a // 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 // 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_verified. App-tier like the link routes — proving control of your own
// email is an ordinary authenticated operation, scoped to the principal. // 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/start", SetupAllowed: true, h: a.handleEmailOTPStart},
{Method: "POST", Pattern: "/api/v1/account/email/verify", h: a.handleEmailOTPVerify}, {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 // Passkey enrollment (spec §14 WebAuthn / Phase 6 bind), web side: /register/begin
// mints a credential-creation challenge for the caller, /register/finish verifies // mints a credential-creation challenge for the caller, /register/finish verifies
// the authenticator's attestation and binds the passkey, and the credentials // 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 // 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 // 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 // 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 // is the ENROLLMENT side; the passkey LOGIN/assertion door is the Public,
// 0007 and handlers_passkey.go). // pre-session /api/v1/auth/passkey/login/{begin,finish} pair above.
{Method: "POST", Pattern: "/api/v1/account/passkey/register/begin", h: a.handlePasskeyRegisterBegin}, {Method: "POST", Pattern: "/api/v1/account/passkey/register/begin", SetupAllowed: true, h: a.handlePasskeyRegisterBegin},
{Method: "POST", Pattern: "/api/v1/account/passkey/register/finish", h: a.handlePasskeyRegisterFinish}, {Method: "POST", Pattern: "/api/v1/account/passkey/register/finish", SetupAllowed: true, h: a.handlePasskeyRegisterFinish},
{Method: "GET", Pattern: "/api/v1/account/passkey/credentials", h: a.handlePasskeyList}, {Method: "GET", Pattern: "/api/v1/account/passkey/credentials", SetupAllowed: true, h: a.handlePasskeyList},
{Method: "DELETE", Pattern: "/api/v1/account/passkey/credentials/{id}", h: a.handlePasskeyDelete}, {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 // 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 // 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 // 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: "PATCH", Pattern: "/api/v1/users/{id}", Owner: true, h: a.handlePatchUser},
{Method: "DELETE", Pattern: "/api/v1/users/{id}", Owner: true, h: a.handleDeleteUser}, {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}/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: "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: "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}, {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 { if rt.Admin {
h = a.adminOnly(rt.h) h = a.adminOnly(rt.h)
} }
// Default-deny first-login lockdown (spec §B): wrap every authenticated route // Default-deny setup-lockdown: wrap every authenticated route unless it
// unless it explicitly opts out. The wrapper is nil-principal safe, so it is // explicitly opts out. The wrapper is nil-principal safe, so it is inert on
// inert on the internal face (service-token callers carry no Principal). // the internal face (service-token callers carry no Principal).
if !rt.AllowDuringPasswordChange { if !rt.SetupAllowed {
h = a.lockdownDuringPasswordChange(h) h = a.requireEmailVerified(h)
} }
auth.HandleFunc(pattern, 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) return a.baseChain(mux)
} }
@@ -494,6 +490,25 @@ func (a *API) baseChain(h http.Handler) http.Handler {
return withRequestID(withRecover(h)) 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 ---- // ---- request context plumbing ----
type ctxKey int type ctxKey int
+216 -29
View File
@@ -61,6 +61,12 @@ type fakeRepo struct {
// player email OTPs (spec §B2). Keyed by row id; the verify path scans for the // 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. // newest live (user, purpose) just as the PG query does.
otps map[string]*fakeEmailOTP 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 // username-collision reclaim (spec §B3). blacklist mirrors username_blacklist
// (mc_uuid -> barred), holds mirrors player_data_holds keyed by the held // (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) // (squatter) mc_uuid — both keyed by UUID, matching the PG UNIQUE(mc_uuid)
@@ -141,6 +147,29 @@ type fakeLinkCode struct {
expiresAt time.Time 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 { func newFakeRepo() *fakeRepo {
return &fakeRepo{ return &fakeRepo{
bySub: map[string]*ServerRecord{}, byName: map[string]*ServerRecord{}, bySub: map[string]*ServerRecord{}, byName: map[string]*ServerRecord{},
@@ -156,6 +185,8 @@ func newFakeRepo() *fakeRepo {
sessions: map[string]*fakeSession{}, sessions: map[string]*fakeSession{},
settings: map[string][]byte{}, settings: map[string][]byte{},
otps: map[string]*fakeEmailOTP{}, otps: map[string]*fakeEmailOTP{},
opLogins: map[string]*fakeOpLogin{},
setupTokens: map[string]fakeSetupToken{},
blacklist: map[string]bool{}, blacklist: map[string]bool{},
holds: map[string]fakeDataHold{}, holds: map[string]fakeDataHold{},
passkeyCreds: map[string]PasskeyCredential{}, passkeyCreds: map[string]PasskeyCredential{},
@@ -375,14 +406,19 @@ func (f *fakeRepo) DeleteAllPasskeyCredentialsForUser(_ context.Context, userID
} }
// fakePasskeyVerifier is the hermetic PasskeyVerifier: it performs no real attestation // fakePasskeyVerifier is the hermetic PasskeyVerifier: it performs no real attestation
// crypto, so it exercises the enrollment STATE MACHINE (challenge persistence, consume, // or assertion crypto, so it exercises the enrollment AND login STATE MACHINES (challenge
// conflict, audit) without go-webauthn. BeginRegistration returns a fixed options blob // persistence, consume, conflict, audit, session mint) without go-webauthn.
// and an opaque session marker; FinishRegistration returns the credential the test // BeginRegistration/BeginLogin return a fixed options blob and an opaque session marker;
// preloaded, or a forced error when failErr is set (to drive the 400 path). // 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 { type fakePasskeyVerifier struct {
options json.RawMessage options json.RawMessage
credential VerifiedCredential credential VerifiedCredential
assertion VerifiedAssertion
failErr error failErr error
beginLoginErr error
// lastUser/lastSession capture what the handler passed, so a test can assert the // lastUser/lastSession capture what the handler passed, so a test can assert the
// stashed SessionData round-trips and the existing credentials reach the verifier. // stashed SessionData round-trips and the existing credentials reach the verifier.
lastUser PasskeyUser lastUser PasskeyUser
@@ -406,6 +442,28 @@ func (v *fakePasskeyVerifier) FinishRegistration(user PasskeyUser, sessionData [
} }
return v.credential, nil 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) { func (f *fakeRepo) UserInAllowlist(_ context.Context, n, u string) (bool, error) {
return f.allowlist[n][u], nil 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, // 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 // 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) { func (f *fakeRepo) IsProtectedAdminLink(_ context.Context, mcUUID string) (bool, error) {
userID, ok := f.links[mcUUID] userID, ok := f.links[mcUUID]
if !ok || f.linkAuthSource[mcUUID] != authSourceThirdParty { if !ok || f.linkAuthSource[mcUUID] != authSourceThirdParty {
@@ -569,28 +627,17 @@ func (f *fakeRepo) UserByID(_ context.Context, id string) (*StaffUser, error) {
} }
return nil, ErrNotFound 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 // 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 { if existing, ok := f.staff[username]; ok {
id = existing.ID id = existing.ID
} }
f.staff[username] = &StaffUser{ f.staff[username] = &StaffUser{
ID: id, Username: username, Email: email, Role: "admin", ID: id, Username: username, Email: email, Role: "admin",
PasswordHash: passwordHash, MustChangePassword: mustChange,
} }
return nil 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 { func (f *fakeRepo) CreateSession(_ context.Context, tokenHash, userID string, expiresAt time.Time) error {
f.sessions[tokenHash] = &fakeSession{userID: userID, expiresAt: expiresAt} f.sessions[tokenHash] = &fakeSession{userID: userID, expiresAt: expiresAt}
return nil return nil
@@ -604,7 +651,6 @@ func (f *fakeRepo) SessionUser(_ context.Context, tokenHash string, now time.Tim
if u.ID == s.userID { if u.ID == s.userID {
return &SessionedUser{ return &SessionedUser{
ID: u.ID, Email: u.Email, Role: u.Role, ID: u.ID, Email: u.Email, Role: u.Role,
MustChangePassword: u.MustChangePassword,
}, nil }, nil
} }
} }
@@ -717,7 +763,7 @@ func (f *fakeRepo) CreateUser(_ context.Context, input CreateUserInput, _ string
id := "test-" + input.Username id := "test-" + input.Username
u := UserView{ u := UserView{
ID: id, Username: input.Username, Email: input.Email, ID: id, Username: input.Username, Email: input.Email,
Role: input.Role, MustChangePassword: input.MustChange, Role: input.Role,
CreatedAt: time.Now(), UpdatedAt: time.Now(), CreatedAt: time.Now(), UpdatedAt: time.Now(),
} }
d := UserDetail{UserView: u} d := UserDetail{UserView: u}
@@ -775,15 +821,6 @@ func (f *fakeRepo) SetUserDisabled(_ context.Context, userID string, disabled bo
return ErrNotFound 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 ---- // ---- quota admin fakes ----
func (f *fakeRepo) GetQuotas(_ context.Context, userID string) (*QuotaView, error) { 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 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 // 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 // error, mirroring the Restorer kick-off contract. The real restore Job is
// integration-only, so the handler is tested against this fake (spec §466). // 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 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 { func decodeErr(t *testing.T, w *httptest.ResponseRecorder) string {
t.Helper() t.Helper()
var raw map[string]map[string]string var raw map[string]map[string]string
+14 -7
View File
@@ -21,16 +21,23 @@ type Principal struct {
Role string Role string
// ViaAdminAccess is true only when the request arrived through an admin-graded // ViaAdminAccess is true only when the request arrived through an admin-graded
// path: the admin.* Zero-Trust hostname (Cloudflare Access, the remote face) OR // path: the admin.* Zero-Trust hostname (Cloudflare Access, the remote face) OR
// a local-password session presented on the op.console host (SessionAuth, the // a local session presented on the op.console host (SessionAuth, the
// break-glass-enabled face). Admin-tier operations require it in addition to // passwordless face). Admin-tier operations require it in addition to
// Role=="admin" (spec §14: ZT is graded by operation). A role=admin session // Role=="admin" (spec §14: ZT is graded by operation). A role=admin session
// arriving on the player console (console.*) never sets it. // arriving on the player console (console.*) never sets it.
ViaAdminAccess bool ViaAdminAccess bool
// MustChangePassword is set only on the local-password (SessionAuth) path when // EmailVerified mirrors users.email_verified. The lockdown middleware gates
// the staff account still owes a first-login change. The JWT path leaves it // setup-incomplete accounts (EmailVerified=false, e.g. a freshly bootstrapped
// false. The lockdown middleware fences such a principal to the change-password // Owner who has not yet proven control of their mailbox) to the setup-wizard
// and logout surface until it is cleared. // routes only, so an intercepted setup URL cannot yield full admin access
MustChangePassword bool // 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. // IsAdmin reports whether the principal may perform admin-tier operations.
+9 -13
View File
@@ -49,6 +49,15 @@ var (
// never mints a session for an admin identity. It is distinct from ErrConflict so // 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). // the handler answers 403 (wrong door) rather than 409 (already linked).
ErrPlayerBindForbidden = errors.New("bind code belongs to a staff account") 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, // 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") errUnauthorized = newError(http.StatusUnauthorized, "unauthorized", "authentication required")
errForbidden = newError(http.StatusForbidden, "forbidden", "not permitted") errForbidden = newError(http.StatusForbidden, "forbidden", "not permitted")
errBadRequest = newError(http.StatusBadRequest, "bad_request", "invalid request") 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. // writeJSON writes v as an indented JSON body with the given status.
+6 -232
View File
@@ -1,127 +1,12 @@
package api package api
import ( import "net/http"
"net/http"
"golang.org/x/crypto/bcrypt" // 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
// Local-password auth handlers (spec §B). Owner/Operator log in to op.console with // logout handler — the login doors live in handlers_auth_email.go (email OTP),
// username+password when Zero Trust is not in front of the API (the demo's primary // handlers_onboard.go (bind code), and the deferred passkey-login slice.
// 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,
})
}
// handleLogout revokes the presented session and clears the cookie (spec §B). It // 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 // 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) clearSessionCookie(w)
writeJSON(w, http.StatusOK, map[string]any{"ok": true}) writeJSON(w, http.StatusOK, map[string]any{"ok": true})
} }
// changePasswordRequest is the change-password form.
type changePasswordRequest struct {
CurrentPassword string `json:"current_password"`
NewPassword string `json:"new_password"`
}
// handleChangePassword re-verifies the caller's current password, stores a new
// bcrypt hash, clears must_change_password, and revokes the account's OTHER
// sessions while keeping the current one (spec §B). It is reachable while
// must_change_password is set (AllowDuringPasswordChange) so a forced first-login
// change can complete. The session itself authenticates the caller; re-asking the
// current password additionally blocks a hijacked session from silently rotating
// the credential.
func (a *API) handleChangePassword(w http.ResponseWriter, r *http.Request) {
p := principalFromContext(r.Context())
// Defense-in-depth: this route is already CSRF-safe (a session is required, the
// cookie is SameSite=Lax, and the current password is re-verified below), but the
// same content-type guard keeps every local-auth JSON write uniform.
if err := requireJSONContentType(r); err != nil {
writeError(w, r, err)
return
}
var body changePasswordRequest
if err := decodeJSON(w, r, &body); err != nil {
writeError(w, r, err)
return
}
if err := validateNewPassword(body.NewPassword); err != nil {
writeError(w, r, err)
return
}
u, err := a.Repo.UserByID(r.Context(), p.UserID)
switch {
case errIsNotFound(err):
// The session resolved a moment ago but the user is gone: treat as unauthenticated.
writeError(w, r, errUnauthorized)
return
case err != nil:
writeError(w, r, err)
return
}
if u.PasswordHash == "" {
// A link-only account has no password to change — it never reaches this path
// in practice, but fail closed rather than set a first password here.
writeError(w, r, errForbidden)
return
}
if bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(body.CurrentPassword)) != nil {
writeError(w, r, newError(http.StatusUnauthorized, "invalid_credentials", "current password is incorrect"))
return
}
// The new password must actually differ from the current one.
if bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(body.NewPassword)) == nil {
writeError(w, r, newError(http.StatusBadRequest, "password_unchanged",
"new password must differ from the current one"))
return
}
newHash, err := bcrypt.GenerateFromPassword([]byte(body.NewPassword), bcryptCost)
if err != nil {
writeError(w, r, err)
return
}
if err := a.Repo.SetPassword(r.Context(), u.ID, string(newHash)); err != nil {
writeError(w, r, err)
return
}
// Log out the account's other devices, keeping the current session. The current
// session is identified by the cookie hash; with no cookie (no live session to
// keep) every session of the user is revoked, which is the safe direction.
keep := ""
if c, cerr := r.Cookie(sessionCookieName); cerr == nil {
keep = hashCookie(c.Value)
}
if err := a.Repo.RevokeUserSessionsExcept(r.Context(), u.ID, keep); err != nil {
writeError(w, r, err)
return
}
// Revoking sessions is not enough: a passkey needs no password, so one planted
// through a transiently-hijacked session would outlive the reset as a standing login
// foothold. A password change is a possible-compromise signal, so unbind every passkey
// as part of the same remediation. The user re-enrolls afterward if they want one; the
// email-OTP factor stays available in the meantime, so this never locks anyone out.
if err := a.Repo.DeleteAllPasskeyCredentialsForUser(r.Context(), u.ID); err != nil {
writeError(w, r, err)
return
}
a.audit(r, u.Username, "auth.password_change", "")
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
// validateNewPassword enforces the minimal password policy: 8–72 bytes. The upper
// bound is bcrypt's hard limit (it errors past 72 bytes), surfaced here as a clean
// 400 rather than an opaque 500 from GenerateFromPassword.
func validateNewPassword(pw string) error {
if len(pw) < 8 {
return newError(http.StatusBadRequest, "weak_password", "password must be at least 8 characters")
}
if len(pw) > 72 {
return newError(http.StatusBadRequest, "weak_password", "password must be at most 72 bytes")
}
return nil
}
+266
View File
@@ -0,0 +1,266 @@
package api
import (
"errors"
"net/http"
"strings"
)
// Pre-session Email-OTP LOGIN (spec §B, console.<root_domain> returning-player door).
// This is the passwordless counterpart of handleLogin and the returning-player
// counterpart of handleBindRedeem: an account that already proved control of an
// email (email_verified, migration 0010) logs back in with a one-time code mailed
// to that address — no password, no in-game Bind Code. The two halves are Public,
// pre-session routes: the caller has no principal yet, so identity is resolved from
// the typed email via UserByEmail, exactly as handleBindRedeem resolves it from the
// code.
//
// Distinct from the authenticated /account/email/* onboarding pair in three ways,
// all load-bearing:
//
// - Purpose. Codes are minted under otpPurposeLogin ("login_email"), never
// otpPurposeOnboard, so a login code and an onboarding code for the same user
// never clobber or satisfy each other (the email_otps purpose column is exactly
// this separator).
// - No principal. The throttle cannot key off a user id (there is none yet); it
// keys off the typed recipient address, the same anti-bomb dimension the onboard
// start uses. Per-source (client-IP) aggregate limiting is deliberately NOT done
// here: cooldownLimiter is a one-per-window primitive, so keying it on client IP
// would false-positive on shared egress (CGNAT / office NAT), and behind
// Cloudflare RemoteAddr is the proxy anyway. The only real harm — bombing one
// mailbox — is already bounded per recipient; volumetric per-source limiting
// belongs at the edge.
// - Refuse staff. Like handleBindRedeem this public door provably never mints a
// session for an admin identity: op.console stays behind Zero Trust (and its own
// in-game approval gate). The refusal happens only AFTER a valid code is
// redeemed (see handleLoginEmailVerify), so a caller without the code cannot use
// it to enumerate which addresses are staff.
//
// Enumeration is an accepted product decision (a dedicated /auth/options oracle is a
// sibling slice), so this pair does not go out of its way to equalise timing between
// existing and unknown addresses — it only keeps the *verify* response uniform so a
// code-less caller learns nothing a wrong guess would not already reveal.
// otpPurposeLogin scopes a code to the pre-session email LOGIN flow, keeping it from
// ever colliding with or satisfying an onboarding-email code (otpPurposeOnboard) for
// the same user. VerifyEmailOTP is queried per (user, purpose), so the two flows are
// fully independent even for one account with both a live onboarding and a live
// login code.
const otpPurposeLogin = "login_email"
// loginEmailStartRequest is the start-login-by-email body: the address whose mailbox
// the returning player will read the code from.
type loginEmailStartRequest struct {
Email string `json:"email"`
}
// handleLoginEmailStart mints and mails a login code for a returning account (Public,
// pre-session). It gates on local sessions being enabled — like handleLogin and
// handleBindRedeem, minting a code toward a felis_session while SessionAuth would
// reject that cookie is pointless — reserves the per-recipient cooldown, resolves the
// address to an account, and (only if one exists) mints a code under otpPurposeLogin.
// An address with no verified account yields the SAME 202 as a successful send with
// no code minted: the response never distinguishes the two, and the reservation is
// kept on that path too so repeated probing of one address is throttled identically
// to repeated sends.
func (a *API) handleLoginEmailStart(w http.ResponseWriter, r *http.Request) {
if !localAuthEnabled(r.Context(), a.Repo) {
writeError(w, r, newError(http.StatusForbidden, "local_auth_disabled",
"session login is disabled"))
return
}
if err := requireJSONContentType(r); err != nil {
writeError(w, r, err)
return
}
var req loginEmailStartRequest
if err := decodeJSON(w, r, &req); err != nil {
writeError(w, r, err)
return
}
email := strings.TrimSpace(req.Email)
if !looksLikeEmail(email) {
writeError(w, r, newError(http.StatusBadRequest, "bad_request", "a valid email is required"))
return
}
// Atomically reserve the per-recipient cooldown BEFORE any work, so a burst of
// truly concurrent starts yields exactly one winner and each admitted send is one
// real, non-idempotent email. The key is namespaced apart from the onboard door's
// "email:" key on purpose: this door is unauthenticated, so it must not perturb
// the authenticated onboarding throttle. Both caps are 1/window, so a mailbox sees
// at most one login code plus one onboard code per window — far below any bomb.
emailKey := "login:email:" + strings.ToLower(email)
lim := a.otpLimiter()
emailAt, ok := lim.reserve(emailKey, otpResendCooldown)
if !ok {
writeError(w, r, newError(http.StatusTooManyRequests, "otp_resend_cooldown",
"a code was sent recently; wait a moment before requesting another"))
return
}
committed := false
defer func() {
if !committed {
lim.release(emailKey, emailAt)
}
}()
// Compute the expiry once so the neutral (no-account) branch and the real-send
// branch return byte-identical bodies.
expiresAt := a.now().Add(otpTTL)
u, err := a.Repo.UserByEmail(r.Context(), email)
switch {
case errors.Is(err, ErrNotFound):
// No verified account for this address. Return the same 202 as a real send
// (no code minted) and KEEP the reservation, so probing an unknown address is
// throttled exactly like resending to a known one — the throttle reveals
// nothing, and the accepted /auth/options oracle is where existence is learnt.
committed = true
writeJSON(w, http.StatusAccepted, map[string]any{"sent": true, "expires_at": expiresAt.UTC()})
return
case err != nil:
// A real read error is NOT a neutral outcome: leave committed false so the
// deferred rollback frees the window (a transient DB blip must not burn it).
writeError(w, r, err)
return
}
code, err := newEmailOTP()
if err != nil {
writeError(w, r, err)
return
}
id, err := newOTPID()
if err != nil {
writeError(w, r, err)
return
}
// Mint and deliver against the account's STORED address, not the typed string:
// UserByEmail matched case-insensitively, and the code must reach the mailbox of
// record. The login redeem (ConsumeLoginEmailOTP) never reads or writes this
// address, so the stored casing is authoritative and the row's email snapshot is
// purely for the audit trail.
if err := a.Repo.CreateEmailOTP(r.Context(), id, u.ID, u.Email, otpCodeHash(code), otpPurposeLogin, expiresAt); err != nil {
writeError(w, r, err)
return
}
if err := a.deliverOTP(r.Context(), u.Email, code); err != nil {
writeError(w, r, err)
return
}
committed = true
a.audit(r, u.Username, "auth.login_email.otp_sent", "")
writeJSON(w, http.StatusAccepted, map[string]any{"sent": true, "expires_at": expiresAt.UTC()})
}
// loginEmailVerifyRequest is the verify body: the address and the code read from it.
// Both are needed because there is no principal — the address selects the account,
// the code proves control this session.
type loginEmailVerifyRequest struct {
Email string `json:"email"`
Code string `json:"code"`
}
// handleLoginEmailVerify redeems a login code into a session (Public, pre-session).
// It resolves the address to an account, verifies the code under otpPurposeLogin, and
// on success mints the same host-only felis_session as handleLogin. A missing account,
// a wrong code, AND an attempt-exhausted (locked) code all return the IDENTICAL 400
// invalid_code, so a code-less caller cannot tell an unknown address from a bad guess
// or farm a lockout into an is-this-a-real-account oracle. Staff are refused — but only
// after a valid code is redeemed, so the refusal is reachable solely by the account
// owner and never leaks which addresses are staff.
func (a *API) handleLoginEmailVerify(w http.ResponseWriter, r *http.Request) {
if !localAuthEnabled(r.Context(), a.Repo) {
writeError(w, r, newError(http.StatusForbidden, "local_auth_disabled",
"session login is disabled"))
return
}
if err := requireJSONContentType(r); err != nil {
writeError(w, r, err)
return
}
var req loginEmailVerifyRequest
if err := decodeJSON(w, r, &req); err != nil {
writeError(w, r, err)
return
}
email := strings.TrimSpace(req.Email)
code := strings.TrimSpace(req.Code)
if !looksLikeEmail(email) {
writeError(w, r, newError(http.StatusBadRequest, "bad_request", "a valid email is required"))
return
}
if code == "" {
writeError(w, r, newError(http.StatusBadRequest, "bad_request", "code is required"))
return
}
u, err := a.Repo.UserByEmail(r.Context(), email)
switch {
case errors.Is(err, ErrNotFound):
// Uniform with a wrong code: a caller probing whether an address has an account
// gets the same invalid_code either way. (The /auth/options oracle is the
// sanctioned place to learn existence; this door does not double as one.)
writeError(w, r, newError(http.StatusBadRequest, "invalid_code", "email code is invalid or expired"))
return
case err != nil:
writeError(w, r, err)
return
}
// Consume the code BEFORE the staff check. Ordering is the whole leak-safety
// argument: a caller without a valid code always lands in the invalid_code branch
// below — identical for staff and non-staff — so only the account owner, holding a
// live code, can ever reach the staff refusal.
//
// ConsumeLoginEmailOTP, not VerifyEmailOTP: this door only re-proves control of an
// already-verified address for the session, so it must NOT rewrite users.email or
// run the onboarding taken-check. UserByEmail already guaranteed the account is
// verified; touching the row here would let a stale OTP-snapshot address overwrite
// the live one and could 500 a correct code on a spurious collision.
switch err := a.Repo.ConsumeLoginEmailOTP(r.Context(), u.ID, otpPurposeLogin, otpCodeHash(code), a.now()); {
case errors.Is(err, ErrOTPInvalid), errors.Is(err, ErrOTPLocked):
// Both a wrong/expired code and an attempt-exhausted one return the SAME 400
// invalid_code, byte-identical to the unknown-account branch above. Surfacing
// otp_locked as a distinct 429 (as the authenticated onboarding door does) would
// turn this public door into the existence oracle its no-account branch is
// careful not to be: a code-less prober could mail a code to a victim address,
// exhaust the attempt budget, and read otp_locked as "this address has an
// account." Enumeration is a product decision reserved for /auth/options, not a
// side channel of the login verify.
writeError(w, r, newError(http.StatusBadRequest, "invalid_code", "email code is invalid or expired"))
return
case err != nil:
// ConsumeLoginEmailOTP performs no users write, so ErrEmailTaken is structurally
// impossible here; anything left is a genuine fault and surfaces as a 500.
writeError(w, r, err)
return
}
// Code redeemed. Refuse staff here — never before the verify — so op.console keeps
// its Zero-Trust + in-game-approval gates and this public door provably yields only
// a role=user player session (mirrors handleBindRedeem's refuse-staff contract).
if u.Role == "admin" {
writeError(w, r, newError(http.StatusForbidden, "staff_account",
"that account is staff; sign in at the operator console"))
return
}
token, err := newSessionToken()
if err != nil {
writeError(w, r, err)
return
}
expires := a.now().Add(sessionTTL)
if err := a.Repo.CreateSession(r.Context(), hashCookie(token), u.ID, expires); err != nil {
writeError(w, r, err)
return
}
setSessionCookie(w, token, expires)
a.audit(r, u.Username, "auth.login_email", "")
writeJSON(w, http.StatusOK, map[string]any{
"user_id": u.ID,
"role": u.Role,
})
}
+612
View File
@@ -0,0 +1,612 @@
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
)
// Pre-session Email-OTP LOGIN tests (spec §B, console.<root_domain> returning-player
// door). The load-bearing properties, in the order the flow meets them:
//
// - Neutrality on start: an unknown address gets a byte-identical 202 to a real
// send AND the same cooldown reservation, so neither the response nor the
// throttle is an existence oracle.
// - Purpose separation: login codes (otpPurposeLogin) and onboarding codes
// (otpPurposeOnboard) never satisfy each other, even for one account holding
// both live at once.
// - Verify uniformity: unknown address and wrong code collapse to the same
// invalid_code envelope, so a code-less caller learns nothing.
// - Staff refusal AFTER redeem: only the mailbox owner, holding a live code, can
// ever see the staff_account refusal — and the code is spent reaching it.
// seedLoginEmailAPI wires the public email-login door: local sessions enabled and a
// single verified player "player" (id u1) whose proven address is stored in MIXED
// case, so the case-insensitivity contracts (resolve on typed lowercase, mint against
// stored casing) are exercised by default. Both routes are Public — no External
// wiring needed.
func seedLoginEmailAPI(t *testing.T) (*API, *fakeRepo, *captureMailer) {
t.Helper()
repo := newFakeRepo()
repo.settings[LocalAuthEnabledKey] = []byte("true")
repo.staff["player"] = &StaffUser{
ID: "u1", Username: "player", Email: "[email protected]",
Role: "user", EmailVerified: true,
}
mailer := &captureMailer{}
api := newTestAPI(repo, newFakeCluster())
api.Mailer = mailer
return api, repo, mailer
}
// errEnvelope decodes the standard error body into its stable (code, message) pair —
// request_id varies per request, so uniformity assertions compare these two fields,
// never raw bytes.
func errEnvelope(t *testing.T, w *httptest.ResponseRecorder) (code, msg string) {
t.Helper()
var raw map[string]map[string]string
if err := json.Unmarshal(w.Body.Bytes(), &raw); err != nil {
t.Fatalf("error body not JSON: %v (%s)", err, w.Body.String())
}
return raw["error"]["code"], raw["error"]["message"]
}
// TestLoginEmailVertical walks the whole returning-player slice: a typed lowercase
// address resolves the mixed-case stored account, the code is mailed to the account's
// STORED casing (the address of record), and redeeming it mints the same host-only
// felis_session as the password door — single-use, audited on both halves by the
// account's username. The redeem never rewrites users.email (login re-proves an
// already-verified address via ConsumeLoginEmailOTP), so the stored casing is
// untouched by definition.
func TestLoginEmailVertical(t *testing.T) {
api, repo, mailer := seedLoginEmailAPI(t)
eh := api.ExternalHandler()
// 1) start: 202 says "sent" and when it expires — never the code itself.
w := do(eh, "POST", "/api/v1/auth/email/start", `{"email":"[email protected]"}`, jsonHeader)
if w.Code != http.StatusAccepted {
t.Fatalf("start: code = %d, want 202 (%s)", w.Code, w.Body.String())
}
b := acctBody(t, w)
if b["sent"] != true {
t.Errorf("start body sent = %v, want true", b["sent"])
}
if _, leaked := b["code"]; leaked {
t.Error("start response must NEVER carry the code")
}
if s, _ := b["expires_at"].(string); s == "" {
t.Error("start must report expires_at")
}
// The mail goes to the account's STORED address, not the typed casing — the code
// is delivered to the mailbox of record regardless of how the player typed it.
if mailer.calls != 1 || mailer.email != "[email protected]" {
t.Fatalf("mailer: calls=%d email=%q, want 1 send to the STORED casing [email protected]",
mailer.calls, mailer.email)
}
code := mailer.code
if len(code) != otpCodeDigits {
t.Fatalf("delivered code %q: len = %d, want %d", code, len(code), otpCodeDigits)
}
// Exactly one row, scoped to the LOGIN purpose, holding a hash — not the digits.
if len(repo.otps) != 1 {
t.Fatalf("persisted codes = %d, want 1", len(repo.otps))
}
for _, o := range repo.otps {
if o.purpose != otpPurposeLogin {
t.Errorf("otp purpose = %q, want %q", o.purpose, otpPurposeLogin)
}
if o.codeHash == code {
t.Error("store holds the plaintext code, not its hash")
}
}
// 2) verify — typed in yet another casing, proving the verify-side resolver is
// case-insensitive too — mints the session.
w = do(eh, "POST", "/api/v1/auth/email/verify",
`{"email":"[email protected]","code":"`+code+`"}`, jsonHeader)
if w.Code != http.StatusOK {
t.Fatalf("verify: code = %d, want 200 (%s)", w.Code, w.Body.String())
}
vb := acctBody(t, w)
if vb["user_id"] != "u1" || vb["role"] != "user" {
t.Fatalf("verify body = %v, want user_id:u1 role:user", vb)
}
// The HttpOnly cookie is the whole point — same contract as handleLogin.
cookies := w.Result().Cookies()
if len(cookies) != 1 || cookies[0].Name != sessionCookieName || cookies[0].Value == "" {
t.Fatalf("want one non-empty %s cookie, got %v", sessionCookieName, cookies)
}
s, ok := repo.sessions[hashCookie(cookies[0].Value)]
if !ok {
t.Fatal("no session row for the issued cookie (must be stored hashed)")
}
if s.userID != "u1" {
t.Errorf("session userID = %q, want u1", s.userID)
}
if want := time.Unix(1_700_000_000, 0).Add(sessionTTL); !s.expiresAt.Equal(want) {
t.Errorf("session expiresAt = %v, want now+sessionTTL = %v", s.expiresAt, want)
}
// Both halves audit by the account's username (there is no principal yet).
if n := len(repo.audits); n != 2 {
t.Fatalf("want 2 audits (otp_sent, login), got %d: %+v", n, repo.audits)
}
if repo.audits[0].Action != "auth.login_email.otp_sent" || repo.audits[0].Actor != "player" {
t.Errorf("first audit = %+v, want auth.login_email.otp_sent by player", repo.audits[0])
}
if repo.audits[1].Action != "auth.login_email" || repo.audits[1].Actor != "player" {
t.Errorf("second audit = %+v, want auth.login_email by player", repo.audits[1])
}
// 3) single-use: the consumed code buys nothing a second time.
if w := do(eh, "POST", "/api/v1/auth/email/verify",
`{"email":"[email protected]","code":"`+code+`"}`, jsonHeader); w.Code != http.StatusBadRequest || decodeErr(t, w) != "invalid_code" {
t.Fatalf("replay of consumed code: code = %d body %s, want 400 invalid_code", w.Code, w.Body.String())
}
}
// TestLoginEmailStartNeutralOnUnknownAddress pins the start-side anti-enumeration
// contract: an address with no verified account yields a 202 BYTE-IDENTICAL to a
// real send (frozen clock ⇒ same expires_at), mints and mails nothing, audits
// nothing — and still burns the cooldown window, so probing is throttled exactly
// like sending.
func TestLoginEmailStartNeutralOnUnknownAddress(t *testing.T) {
// A real send for comparison.
apiK, _, _ := seedLoginEmailAPI(t)
wK := do(apiK.ExternalHandler(), "POST", "/api/v1/auth/email/start",
`{"email":"[email protected]"}`, jsonHeader)
if wK.Code != http.StatusAccepted {
t.Fatalf("known-address start: code = %d (%s)", wK.Code, wK.Body.String())
}
// The unknown address: same 202, same bytes, nothing behind it.
repoU := newFakeRepo()
repoU.settings[LocalAuthEnabledKey] = []byte("true")
mailerU := &captureMailer{}
apiU := newTestAPI(repoU, newFakeCluster())
apiU.Mailer = mailerU
ehU := apiU.ExternalHandler()
wU := do(ehU, "POST", "/api/v1/auth/email/start", `{"email":"[email protected]"}`, jsonHeader)
if wU.Code != http.StatusAccepted {
t.Fatalf("unknown-address start: code = %d, want 202 (%s)", wU.Code, wU.Body.String())
}
if wU.Body.String() != wK.Body.String() {
t.Errorf("neutral 202 differs from a real send's:\n unknown: %s\n known: %s",
wU.Body.String(), wK.Body.String())
}
if len(repoU.otps) != 0 || mailerU.calls != 0 || len(repoU.audits) != 0 {
t.Errorf("neutral path must mint/mail/audit nothing, got otps=%d mails=%d audits=%d",
len(repoU.otps), mailerU.calls, len(repoU.audits))
}
// The reservation is KEPT on the neutral path: re-probing the same unknown
// address inside the window is throttled identically to a resend.
if w := do(ehU, "POST", "/api/v1/auth/email/start", `{"email":"[email protected]"}`, jsonHeader); w.Code != http.StatusTooManyRequests || decodeErr(t, w) != "otp_resend_cooldown" {
t.Fatalf("re-probe of unknown address: code = %d body %s, want 429 otp_resend_cooldown",
w.Code, w.Body.String())
}
// An UNVERIFIED account is indistinguishable from no account: UserByEmail only
// resolves proven addresses, so the door never mails one nobody controls.
repoV := newFakeRepo()
repoV.settings[LocalAuthEnabledKey] = []byte("true")
repoV.staff["u"] = &StaffUser{ID: "u9", Username: "u", Email: "[email protected]", Role: "user"} // EmailVerified false
mailerV := &captureMailer{}
apiV := newTestAPI(repoV, newFakeCluster())
apiV.Mailer = mailerV
if w := do(apiV.ExternalHandler(), "POST", "/api/v1/auth/email/start",
`{"email":"[email protected]"}`, jsonHeader); w.Code != http.StatusAccepted {
t.Fatalf("unverified-address start: code = %d, want neutral 202 (%s)", w.Code, w.Body.String())
}
if len(repoV.otps) != 0 || mailerV.calls != 0 {
t.Errorf("unverified address must behave as absent, got otps=%d mails=%d",
len(repoV.otps), mailerV.calls)
}
}
// TestLoginEmailGates covers the shared front doors of both halves: the fail-closed
// local-auth toggle, the cross-site-forgery Content-Type guard (these are Public,
// credential-minting routes — same rationale as handleLogin), and the input gates
// that must reject before any mint or lookup.
func TestLoginEmailGates(t *testing.T) {
t.Run("local auth disabled -> 403 on both halves", func(t *testing.T) {
api := newTestAPI(newFakeRepo(), newFakeCluster()) // no LocalAuthEnabledKey: fails closed
eh := api.ExternalHandler()
if w := do(eh, "POST", "/api/v1/auth/email/start", `{"email":"[email protected]"}`, jsonHeader); w.Code != http.StatusForbidden || decodeErr(t, w) != "local_auth_disabled" {
t.Errorf("start: code = %d body %s, want 403 local_auth_disabled", w.Code, w.Body.String())
}
if w := do(eh, "POST", "/api/v1/auth/email/verify", `{"email":"[email protected]","code":"123456"}`, jsonHeader); w.Code != http.StatusForbidden || decodeErr(t, w) != "local_auth_disabled" {
t.Errorf("verify: code = %d body %s, want 403 local_auth_disabled", w.Code, w.Body.String())
}
})
t.Run("non-JSON content type -> 415 on both halves", func(t *testing.T) {
api, _, _ := seedLoginEmailAPI(t)
eh := api.ExternalHandler()
for _, ct := range []string{"", "text/plain", "application/x-www-form-urlencoded"} {
if w := do(eh, "POST", "/api/v1/auth/email/start", `{"email":"[email protected]"}`, ctHeader(ct)); w.Code != http.StatusUnsupportedMediaType {
t.Errorf("start with Content-Type %q: code = %d, want 415", ct, w.Code)
}
if w := do(eh, "POST", "/api/v1/auth/email/verify", `{"email":"[email protected]","code":"123456"}`, ctHeader(ct)); w.Code != http.StatusUnsupportedMediaType {
t.Errorf("verify with Content-Type %q: code = %d, want 415", ct, w.Code)
}
}
})
t.Run("start bad email -> 400, nothing minted or mailed", func(t *testing.T) {
bad := map[string]string{
"missing email": `{}`,
"empty email": `{"email":""}`,
"no at-sign": `{"email":"notanemail"}`,
"two at-signs": `{"email":"a@[email protected]"}`,
"unknown field": `{"email":"[email protected]","x":1}`,
}
for name, body := range bad {
api, repo, mailer := seedLoginEmailAPI(t)
w := do(api.ExternalHandler(), "POST", "/api/v1/auth/email/start", body, jsonHeader)
if w.Code != http.StatusBadRequest {
t.Errorf("%s: code = %d, want 400 (%s)", name, w.Code, w.Body.String())
}
if len(repo.otps) != 0 || mailer.calls != 0 {
t.Errorf("%s: a rejected start must not mint or mail (otps=%d mails=%d)",
name, len(repo.otps), mailer.calls)
}
}
})
t.Run("verify bad inputs -> 400 bad_request", func(t *testing.T) {
bad := map[string]string{
"bad email": `{"email":"notanemail","code":"123456"}`,
"empty code": `{"email":"[email protected]","code":""}`,
"missing code": `{"email":"[email protected]"}`,
"unknown field": `{"email":"[email protected]","code":"123456","x":1}`,
}
for name, body := range bad {
api, _, _ := seedLoginEmailAPI(t)
w := do(api.ExternalHandler(), "POST", "/api/v1/auth/email/verify", body, jsonHeader)
if w.Code != http.StatusBadRequest {
t.Errorf("%s: code = %d, want 400 (%s)", name, w.Code, w.Body.String())
}
}
})
}
// TestLoginEmailStartRateLimited closes the unauthenticated email-bomb vector on the
// public door: one send per recipient per window, keyed case-insensitively, and
// namespaced apart from the authenticated onboarding throttle so neither door can
// starve the other.
func TestLoginEmailStartRateLimited(t *testing.T) {
start := func(eh http.Handler, email string) *httptest.ResponseRecorder {
return do(eh, "POST", "/api/v1/auth/email/start", `{"email":"`+email+`"}`, jsonHeader)
}
t.Run("same recipient is throttled, then recovers after the cooldown", func(t *testing.T) {
api, repo, mailer := seedLoginEmailAPI(t)
clock := time.Unix(1_700_000_000, 0)
api.Now = func() time.Time { return clock }
eh := api.ExternalHandler()
if w := start(eh, "[email protected]"); w.Code != http.StatusAccepted {
t.Fatalf("first send: code = %d, want 202 (%s)", w.Code, w.Body.String())
}
if w := start(eh, "[email protected]"); w.Code != http.StatusTooManyRequests || decodeErr(t, w) != "otp_resend_cooldown" {
t.Fatalf("immediate resend: code = %d body %s, want 429 otp_resend_cooldown", w.Code, w.Body.String())
}
if mailer.calls != 1 || len(repo.otps) != 1 {
t.Errorf("throttled resend must not mint or mail: mails=%d otps=%d, want 1/1",
mailer.calls, len(repo.otps))
}
clock = clock.Add(otpResendCooldown + time.Second)
if w := start(eh, "[email protected]"); w.Code != http.StatusAccepted {
t.Fatalf("post-cooldown send: code = %d, want 202 (%s)", w.Code, w.Body.String())
}
})
t.Run("throttle key is case-insensitive", func(t *testing.T) {
api, _, _ := seedLoginEmailAPI(t)
eh := api.ExternalHandler()
if w := start(eh, "[email protected]"); w.Code != http.StatusAccepted {
t.Fatalf("first send: code = %d, want 202 (%s)", w.Code, w.Body.String())
}
// A recased retype is the same mailbox: it must hit the same window.
if w := start(eh, "[email protected]"); w.Code != http.StatusTooManyRequests {
t.Fatalf("recased resend: code = %d, want 429 (key must be lowercased)", w.Code)
}
})
t.Run("login and onboard cooldowns are namespaced apart", func(t *testing.T) {
// The unauthenticated login door must not perturb the authenticated
// onboarding throttle for the same mailbox — distinct keys, so both doors
// admit one send each at the same instant.
api, _, mailer := seedLoginEmailAPI(t)
api.External = staticExternal{p: &Principal{UserID: "u1", Email: "[email protected]", Role: "user"}}
eh := api.ExternalHandler()
if w := start(eh, "[email protected]"); w.Code != http.StatusAccepted {
t.Fatalf("login start: code = %d, want 202 (%s)", w.Code, w.Body.String())
}
if w := do(eh, "POST", "/api/v1/account/email/start", `{"email":"[email protected]"}`, nil); w.Code != http.StatusAccepted {
t.Fatalf("onboard start same mailbox, same instant: code = %d, want 202 — the doors must not share a throttle key (%s)",
w.Code, w.Body.String())
}
if mailer.calls != 2 {
t.Errorf("mailer calls = %d, want 2 (one per door)", mailer.calls)
}
})
}
// TestLoginEmailVerifyRejections is the redeem-side failure matrix. The anchor case
// is uniformity: an unknown address and a wrong code for a known address answer with
// the same (code, message) envelope, so the verify half never doubles as an
// existence oracle. Expired/locked rows are planted directly — the frozen clock
// makes that the only deterministic route to those branches.
func TestLoginEmailVerifyRejections(t *testing.T) {
verify := func(eh http.Handler, email, code string) *httptest.ResponseRecorder {
return do(eh, "POST", "/api/v1/auth/email/verify",
`{"email":"`+email+`","code":"`+code+`"}`, jsonHeader)
}
// liveLogin plants an unconsumed LOGIN-purpose code for u1.
liveLogin := func(repo *fakeRepo, id, codeHash string, expiresAt time.Time, attempts int) {
repo.otps[id] = &fakeEmailOTP{
id: id, userID: "u1", email: "[email protected]", codeHash: codeHash,
purpose: otpPurposeLogin, attempts: attempts,
expiresAt: expiresAt, createdAt: expiresAt,
}
}
t.Run("unknown address is indistinguishable from a wrong code", func(t *testing.T) {
// Known account, live code, wrong digits.
apiW, repoW, _ := seedLoginEmailAPI(t)
liveLogin(repoW, "lg", otpCodeHash("123456"), time.Unix(1_700_000_600, 0), 0)
wWrong := verify(apiW.ExternalHandler(), "[email protected]", "654321")
// No account at all.
apiU, _, _ := seedLoginEmailAPI(t)
wGhost := verify(apiU.ExternalHandler(), "[email protected]", "654321")
if wWrong.Code != http.StatusBadRequest || wGhost.Code != http.StatusBadRequest {
t.Fatalf("codes = %d/%d, want 400/400", wWrong.Code, wGhost.Code)
}
wc, wm := errEnvelope(t, wWrong)
gc, gm := errEnvelope(t, wGhost)
if wc != "invalid_code" || wc != gc || wm != gm {
t.Errorf("envelopes differ: known=(%s,%q) unknown=(%s,%q) — must be identical", wc, wm, gc, gm)
}
})
t.Run("known address, no live code -> 400 invalid_code", func(t *testing.T) {
api, repo, _ := seedLoginEmailAPI(t)
w := verify(api.ExternalHandler(), "[email protected]", "123456")
if w.Code != http.StatusBadRequest || decodeErr(t, w) != "invalid_code" {
t.Fatalf("code = %d body %s", w.Code, w.Body.String())
}
if len(repo.sessions) != 0 {
t.Error("no session may be minted on a failed verify")
}
})
t.Run("wrong code charges an attempt, does not consume", func(t *testing.T) {
api, repo, _ := seedLoginEmailAPI(t)
liveLogin(repo, "wr", otpCodeHash("123456"), time.Unix(1_700_000_600, 0), 0)
w := verify(api.ExternalHandler(), "[email protected]", "654321")
if w.Code != http.StatusBadRequest || decodeErr(t, w) != "invalid_code" {
t.Fatalf("code = %d body %s", w.Code, w.Body.String())
}
if repo.otps["wr"].attempts != 1 || repo.otps["wr"].consumed {
t.Errorf("attempts=%d consumed=%v, want 1/false", repo.otps["wr"].attempts, repo.otps["wr"].consumed)
}
})
t.Run("expired code -> 400 invalid_code", func(t *testing.T) {
api, repo, _ := seedLoginEmailAPI(t)
// One second before the frozen clock (time.Unix(1_700_000_000, 0)).
liveLogin(repo, "ex", otpCodeHash("123456"), time.Unix(1_699_999_999, 0), 0)
w := verify(api.ExternalHandler(), "[email protected]", "123456")
if w.Code != http.StatusBadRequest || decodeErr(t, w) != "invalid_code" {
t.Fatalf("code = %d body %s", w.Code, w.Body.String())
}
})
t.Run("exhausted code is invisible: same invalid_code as a wrong code, no locked oracle", func(t *testing.T) {
// A locked row (attempts == cap) must NOT surface as a distinct 429 otp_locked
// on this public, pre-session door: that status would be an existence oracle —
// a code-less prober could mail a victim address a code, burn its attempt budget,
// and read otp_locked as "this address has an account." It collapses to the SAME
// 400 invalid_code a wrong code returns, byte-for-byte. (The authenticated
// onboarding door keeps otp_locked as actionable feedback; this one cannot.)
api, repo, _ := seedLoginEmailAPI(t)
liveLogin(repo, "lk", otpCodeHash("123456"), time.Unix(1_700_000_600, 0), otpMaxAttempts)
wLocked := verify(api.ExternalHandler(), "[email protected]", "123456") // correct digits, but exhausted
// A plain wrong code on a fresh known account, for the byte-for-byte comparison.
apiW, repoW, _ := seedLoginEmailAPI(t)
liveLogin(repoW, "wr", otpCodeHash("123456"), time.Unix(1_700_000_600, 0), 0)
wWrong := verify(apiW.ExternalHandler(), "[email protected]", "654321")
if wLocked.Code != http.StatusBadRequest {
t.Fatalf("exhausted code: code = %d body %s, want 400 invalid_code (NOT 429 otp_locked)",
wLocked.Code, wLocked.Body.String())
}
lc, lm := errEnvelope(t, wLocked)
wc, wm := errEnvelope(t, wWrong)
if lc != "invalid_code" || lc != wc || lm != wm {
t.Errorf("locked envelope must be identical to a wrong code's: locked=(%s,%q) wrong=(%s,%q)",
lc, lm, wc, wm)
}
if len(repo.sessions) != 0 {
t.Error("no session may be minted from a locked code")
}
})
}
// TestLoginEmailPurposeSeparation proves the email_otps purpose column does its one
// job in both directions: a live ONBOARDING code cannot open the login door, a live
// LOGIN code cannot satisfy onboarding verify. Two complementary properties fall out
// and both are pinned: the OTHER flow's row is never consumed or charged (the
// per-purpose query never sees it), while the door's OWN live code IS charged one
// attempt — cross-door digits are just a wrong guess, never a free brute-force try.
func TestLoginEmailPurposeSeparation(t *testing.T) {
api, repo, _ := seedLoginEmailAPI(t)
api.External = staticExternal{p: &Principal{UserID: "u1", Email: "[email protected]", Role: "user"}}
eh := api.ExternalHandler()
// Both purposes live at once for u1, distinct digits.
repo.otps["ob"] = &fakeEmailOTP{
id: "ob", userID: "u1", email: "[email protected]", codeHash: otpCodeHash("111111"),
purpose: otpPurposeOnboard, expiresAt: time.Unix(1_700_000_600, 0), createdAt: time.Unix(1_700_000_600, 0),
}
repo.otps["lg"] = &fakeEmailOTP{
id: "lg", userID: "u1", email: "[email protected]", codeHash: otpCodeHash("222222"),
purpose: otpPurposeLogin, expiresAt: time.Unix(1_700_000_600, 0), createdAt: time.Unix(1_700_000_600, 0),
}
// Onboarding code at the LOGIN door: refused. The onboard row is untouched (the
// login-purpose query never saw it); the LIVE LOGIN code is charged one attempt —
// to the login door these are simply wrong digits.
w := do(eh, "POST", "/api/v1/auth/email/verify",
`{"email":"[email protected]","code":"111111"}`, jsonHeader)
if w.Code != http.StatusBadRequest || decodeErr(t, w) != "invalid_code" {
t.Fatalf("onboard code at login door: code = %d body %s, want 400 invalid_code", w.Code, w.Body.String())
}
if o := repo.otps["ob"]; o.consumed || o.attempts != 0 {
t.Errorf("onboard row must be untouched by a login verify: consumed=%v attempts=%d", o.consumed, o.attempts)
}
if o := repo.otps["lg"]; o.consumed || o.attempts != 1 {
t.Errorf("cross-door digits must cost the login code one attempt (never a free guess): consumed=%v attempts=%d",
o.consumed, o.attempts)
}
if len(repo.sessions) != 0 {
t.Fatal("a cross-purpose code must never mint a session")
}
// Login code at the ONBOARDING door: refused symmetrically — the login row is not
// consumed and not charged further (the onboard-purpose query never saw it; its
// one attempt above stands), while the onboard code eats the wrong guess...
w = do(eh, "POST", "/api/v1/account/email/verify", `{"code":"222222"}`, nil)
if w.Code != http.StatusBadRequest || decodeErr(t, w) != "invalid_code" {
t.Fatalf("login code at onboard door: code = %d body %s, want 400 invalid_code", w.Code, w.Body.String())
}
if o := repo.otps["lg"]; o.consumed || o.attempts != 1 {
t.Errorf("login row must not be consumed or re-charged by an onboard verify: consumed=%v attempts=%d",
o.consumed, o.attempts)
}
if o := repo.otps["ob"]; o.consumed || o.attempts != 1 {
t.Errorf("cross-door digits must cost the onboard code one attempt: consumed=%v attempts=%d",
o.consumed, o.attempts)
}
// ...and the login code is still redeemable at its own door afterwards.
w = do(eh, "POST", "/api/v1/auth/email/verify",
`{"email":"[email protected]","code":"222222"}`, jsonHeader)
if w.Code != http.StatusOK {
t.Fatalf("login code at its own door after the cross attempts: code = %d, want 200 (%s)",
w.Code, w.Body.String())
}
}
// TestLoginEmailVerifyRefusesStaff pins the staff refusal AND its ordering. The
// public door provably never mints a session for role=admin (op.console keeps its
// Zero-Trust gate) — but the refusal must be reachable only by the mailbox owner:
// a wrong code for a staff address answers the same invalid_code as for anyone, and
// the 403 costs the valid code (verify-then-refuse), so it cannot be farmed as an
// is-this-address-staff oracle.
func TestLoginEmailVerifyRefusesStaff(t *testing.T) {
repo := newFakeRepo()
repo.settings[LocalAuthEnabledKey] = []byte("true")
repo.staff["owner"] = &StaffUser{
ID: "a1", Username: "owner", Email: "[email protected]",
Role: "admin", EmailVerified: true,
}
mailer := &captureMailer{}
api := newTestAPI(repo, newFakeCluster())
api.Mailer = mailer
eh := api.ExternalHandler()
// Start happily mails a staff address — the refusal lives at verify, after the
// code proves mailbox control, so start stays neutral.
if w := do(eh, "POST", "/api/v1/auth/email/start", `{"email":"[email protected]"}`, jsonHeader); w.Code != http.StatusAccepted {
t.Fatalf("start for staff address: code = %d, want 202 (%s)", w.Code, w.Body.String())
}
good := mailer.code
if good == "000000" {
t.Skip("astronomically unlucky code collision; rerun")
}
// Without the code, staffness is invisible: plain invalid_code.
if w := do(eh, "POST", "/api/v1/auth/email/verify",
`{"email":"[email protected]","code":"000000"}`, jsonHeader); w.Code != http.StatusBadRequest || decodeErr(t, w) != "invalid_code" {
t.Fatalf("wrong code for staff: code = %d body %s, want 400 invalid_code", w.Code, w.Body.String())
}
// With the code: 403 staff_account — and no cookie, no session row, no success audit.
w := do(eh, "POST", "/api/v1/auth/email/verify",
`{"email":"[email protected]","code":"`+good+`"}`, jsonHeader)
if w.Code != http.StatusForbidden || decodeErr(t, w) != "staff_account" {
t.Fatalf("valid code for staff: code = %d body %s, want 403 staff_account", w.Code, w.Body.String())
}
if len(w.Result().Cookies()) != 0 {
t.Error("no session cookie may be set for a refused staff login")
}
if len(repo.sessions) != 0 {
t.Error("no session row may be minted for a refused staff login")
}
for _, a := range repo.audits {
if a.Action == "auth.login_email" {
t.Error("a refused staff login must not be audited as a successful login")
}
}
// Ordering pin: the refusal consumed the code (verify ran BEFORE the staff
// check), so replaying it now collapses to invalid_code.
if w := do(eh, "POST", "/api/v1/auth/email/verify",
`{"email":"[email protected]","code":"`+good+`"}`, jsonHeader); w.Code != http.StatusBadRequest || decodeErr(t, w) != "invalid_code" {
t.Fatalf("replay after staff refusal: code = %d body %s, want 400 invalid_code (code must be spent)",
w.Code, w.Body.String())
}
}
// TestLoginEmailFaceSeparation enforces that both halves are web-only: the internal
// (service-token) face must 404 them, never serve them.
func TestLoginEmailFaceSeparation(t *testing.T) {
api, _, _ := seedLoginEmailAPI(t)
ih := api.InternalHandler()
if w := do(ih, "POST", "/api/v1/auth/email/start", `{"email":"[email protected]"}`, jsonHeader); w.Code != http.StatusNotFound {
t.Errorf("start on internal face: code = %d, want 404", w.Code)
}
if w := do(ih, "POST", "/api/v1/auth/email/verify", `{"email":"[email protected]","code":"123456"}`, jsonHeader); w.Code != http.StatusNotFound {
t.Errorf("verify on internal face: code = %d, want 404", w.Code)
}
}
// TestLoginEmailStartFailedDeliveryReleasesCooldown covers the reserve→rollback
// path on the login door: a send that reserves the window but fails to deliver must
// release it, so the immediate retry is admitted instead of 429'd — a transient SMTP
// blip must not lock a returning player out for the whole window. The failure is
// also not audited as a send.
func TestLoginEmailStartFailedDeliveryReleasesCooldown(t *testing.T) {
repo := newFakeRepo()
repo.settings[LocalAuthEnabledKey] = []byte("true")
repo.staff["player"] = &StaffUser{
ID: "u1", Username: "player", Email: "[email protected]",
Role: "user", EmailVerified: true,
}
mailer := &flakyMailer{}
api := newTestAPI(repo, newFakeCluster()) // frozen clock: both attempts share one window
api.Mailer = mailer
eh := api.ExternalHandler()
if w := do(eh, "POST", "/api/v1/auth/email/start", `{"email":"[email protected]"}`, jsonHeader); w.Code < 500 {
t.Fatalf("first send (mailer fails): code = %d, want 5xx (%s)", w.Code, w.Body.String())
}
for _, a := range repo.audits {
if a.Action == "auth.login_email.otp_sent" {
t.Error("a failed delivery must not be audited as otp_sent")
}
}
if w := do(eh, "POST", "/api/v1/auth/email/start", `{"email":"[email protected]"}`, jsonHeader); w.Code != http.StatusAccepted {
t.Fatalf("retry after failed delivery: code = %d, want 202 (the failed send must release the cooldown) (%s)",
w.Code, w.Body.String())
}
if mailer.calls != 2 {
t.Errorf("mailer calls = %d, want 2 (one failed, one delivered)", mailer.calls)
}
}
+95
View File
@@ -0,0 +1,95 @@
package api
import (
"errors"
"net/http"
"strings"
)
// Pre-session identifier-first discovery (spec §B, #71). Given a typed email, this
// Public door reports which console login methods the account can use, so the SPA's
// identifier-first form can prompt for the right authenticator (a passkey assertion,
// or "we'll email you a code") instead of guessing.
//
// It is the deliberate counter-slice to the anti-enumeration login doors
// (handlers_auth_email.go, handlers_passkey.go): those refuse to disclose whether an
// address has an account precisely because THIS endpoint is the one sanctioned place
// existence is revealed. An empty methods array means "no (verified) account". That
// makes it a mass-enumeration surface by design — an accepted product decision, the
// same one the email door's header records. It is bounded only at the edge: the
// handler sends no mail and mutates nothing, so a per-recipient cooldown would merely
// block a legitimate retry, and per-source (client-IP) limiting is the edge's job
// (behind Cloudflare RemoteAddr is the proxy, and CGNAT would false-positive) — see
// the handlers_auth_email.go header for the same reasoning.
//
// It never reveals STAFFNESS. Methods are computed by the SAME rule for every resolved
// account — no role branch, no operator hint — so a staff email and a player email in
// the same credential state return byte-identical bodies. The console doors' own
// post-redemption staff refusal is not previewed here: a staff caller is told
// email_otp is available and is turned away only later, at op.console's Zero-Trust
// gate. Staffness is thus invisible by construction, with no side channel to regress.
type authOptionsRequest struct {
Email string `json:"email"`
}
// handleAuthOptions resolves the typed email and returns the console login methods it
// can use. Public, pre-session, gated on local_auth_enabled like its sibling doors.
func (a *API) handleAuthOptions(w http.ResponseWriter, r *http.Request) {
if !localAuthEnabled(r.Context(), a.Repo) {
writeError(w, r, newError(http.StatusForbidden, "local_auth_disabled",
"session login is disabled"))
return
}
if err := requireJSONContentType(r); err != nil {
writeError(w, r, err)
return
}
var req authOptionsRequest
if err := decodeJSON(w, r, &req); err != nil {
writeError(w, r, err)
return
}
email := strings.TrimSpace(req.Email)
if !looksLikeEmail(email) {
writeError(w, r, newError(http.StatusBadRequest, "bad_request", "a valid email is required"))
return
}
// methods is initialised non-nil so the no-account branch marshals as [] (not null).
methods := []string{}
u, err := a.Repo.UserByEmail(r.Context(), email)
switch {
case errors.Is(err, ErrNotFound):
// The sanctioned existence oracle: an unknown (or not-yet-verified) address is
// not disguised — it honestly reports no methods.
writeJSON(w, http.StatusOK, map[string]any{"methods": methods})
return
case err != nil:
writeError(w, r, err)
return
}
// Compute methods identically for EVERY resolved account. There is deliberately no
// branch on u.Role: a staff address must be indistinguishable from a player address
// in the same credential state, so the response carries nothing account-identifying.
//
// Advertise passkey only when a verifier is actually wired: both login halves 503
// passkey_unavailable when a.Passkey is nil regardless of enrolled credentials, so
// options must not offer a method the finish door would immediately reject.
if a.Passkey != nil {
creds, err := a.Repo.PasskeyCredentialsForUser(r.Context(), u.ID)
if err != nil {
writeError(w, r, err)
return
}
if len(creds) > 0 {
methods = append(methods, "passkey")
}
}
// Email-OTP login works for any resolved verified account (UserByEmail resolves only
// email_verified rows), so it is always on offer.
methods = append(methods, "email_otp")
writeJSON(w, http.StatusOK, map[string]any{"methods": methods})
}
+190
View File
@@ -0,0 +1,190 @@
package api
import (
"net/http"
"net/http/httptest"
"reflect"
"strings"
"testing"
)
// Pre-session identifier-first discovery tests (spec §B, #71). Load-bearing properties:
//
// - Methods reflect real state: email_otp for any resolved verified account, plus
// passkey when a verifier is wired AND the account has >=1 enrolled credential.
// - Existence IS disclosed: an unknown address returns an empty methods array. This
// endpoint is the deliberate, sanctioned counter-slice to the anti-enumeration
// login doors, so it does not disguise non-existence.
// - Staffness is NOT disclosed: a staff email and a player email in the same
// credential state return BYTE-IDENTICAL bodies — the highest-value guard, because a
// role branch here would out which addresses are operators.
// - passkey is gated on a wired verifier: options never advertises a method the finish
// door would immediately 503.
// seedAuthOptionsAPI wires the discovery door: local sessions enabled, a verified player
// (u1) and a verified staff account (a1), and a passkey verifier wired by default.
// Callers seed passkey credentials per-test to set the credential state.
func seedAuthOptionsAPI(t *testing.T) (*API, *fakeRepo) {
t.Helper()
repo := newFakeRepo()
repo.settings[LocalAuthEnabledKey] = []byte("true")
repo.staff["player"] = &StaffUser{ID: "u1", Username: "player", Email: "[email protected]", Role: "user", EmailVerified: true}
repo.staff["boss"] = &StaffUser{ID: "a1", Username: "boss", Email: "[email protected]", Role: "admin", EmailVerified: true}
api := newTestAPI(repo, newFakeCluster())
api.Passkey = &fakePasskeyVerifier{}
return api, repo
}
const authOptionsPath = "/api/v1/auth/options"
// optionsMethods pulls the methods array out of a 200 body as []string.
func optionsMethods(t *testing.T, w *httptest.ResponseRecorder) []string {
t.Helper()
raw, ok := acctBody(t, w)["methods"].([]any)
if !ok {
t.Fatalf("body has no methods array: %s", w.Body.String())
}
out := make([]string, len(raw))
for i, m := range raw {
out[i], _ = m.(string)
}
return out
}
func TestAuthOptionsMethodsByState(t *testing.T) {
t.Run("account with no passkey -> email_otp only", func(t *testing.T) {
api, _ := seedAuthOptionsAPI(t)
w := do(api.ExternalHandler(), "POST", authOptionsPath, `{"email":"[email protected]"}`, jsonHeader)
if w.Code != http.StatusOK {
t.Fatalf("code = %d, want 200 (%s)", w.Code, w.Body.String())
}
if got := optionsMethods(t, w); !reflect.DeepEqual(got, []string{"email_otp"}) {
t.Errorf("methods = %v, want [email_otp]", got)
}
})
t.Run("account with a passkey (verifier wired) -> passkey + email_otp", func(t *testing.T) {
api, repo := seedAuthOptionsAPI(t)
repo.passkeyCreds["row1"] = PasskeyCredential{ID: "row1", UserID: "u1", CredentialID: "cred-1", PublicKey: "k", CreatedAt: frozenNow}
w := do(api.ExternalHandler(), "POST", authOptionsPath, `{"email":"[email protected]"}`, jsonHeader)
if w.Code != http.StatusOK {
t.Fatalf("code = %d, want 200 (%s)", w.Code, w.Body.String())
}
// Deterministic order (passkey before email_otp) so clients and this assertion
// can compare without sorting.
if got := optionsMethods(t, w); !reflect.DeepEqual(got, []string{"passkey", "email_otp"}) {
t.Errorf("methods = %v, want [passkey email_otp]", got)
}
})
}
// TestAuthOptionsUnknownEmail pins the sanctioned-oracle contract: an address with no
// verified account is not disguised — it returns an explicit empty array (not null), so
// the client can trust "no methods" as "no account".
func TestAuthOptionsUnknownEmail(t *testing.T) {
api, _ := seedAuthOptionsAPI(t)
w := do(api.ExternalHandler(), "POST", authOptionsPath, `{"email":"[email protected]"}`, jsonHeader)
if w.Code != http.StatusOK {
t.Fatalf("code = %d, want 200 (%s)", w.Code, w.Body.String())
}
if got := optionsMethods(t, w); len(got) != 0 {
t.Errorf("methods = %v, want []", got)
}
if body := w.Body.String(); !strings.Contains(body, `"methods":[]`) {
t.Errorf("unknown-email body = %s, want an explicit \"methods\":[] (not null)", body)
}
}
// TestAuthOptionsDoesNotRevealStaffness is the security anchor. For each credential
// state, a staff address and a player address in the SAME state must return
// byte-identical bodies. A role branch in the handler — even one that only reordered or
// relabelled — would out which addresses are operators; this is the guard that such a
// branch can never be introduced without a red test.
func TestAuthOptionsDoesNotRevealStaffness(t *testing.T) {
states := []struct {
name string
withPasskey bool
}{
{"neither has a passkey", false},
{"both have a passkey", true},
}
for _, st := range states {
t.Run(st.name, func(t *testing.T) {
api, repo := seedAuthOptionsAPI(t)
if st.withPasskey {
repo.passkeyCreds["p"] = PasskeyCredential{ID: "p", UserID: "u1", CredentialID: "c-u1", PublicKey: "k", CreatedAt: frozenNow}
repo.passkeyCreds["a"] = PasskeyCredential{ID: "a", UserID: "a1", CredentialID: "c-a1", PublicKey: "k", CreatedAt: frozenNow}
}
eh := api.ExternalHandler()
wPlayer := do(eh, "POST", authOptionsPath, `{"email":"[email protected]"}`, jsonHeader)
wStaff := do(eh, "POST", authOptionsPath, `{"email":"[email protected]"}`, jsonHeader)
if wPlayer.Code != http.StatusOK || wStaff.Code != http.StatusOK {
t.Fatalf("codes = %d/%d, want 200/200", wPlayer.Code, wStaff.Code)
}
if wPlayer.Body.String() != wStaff.Body.String() {
t.Errorf("staff/player bodies differ — options reveals staffness:\n player: %s\n staff: %s",
wPlayer.Body.String(), wStaff.Body.String())
}
})
}
}
// TestAuthOptionsPasskeyRequiresWiredVerifier: the account HAS an enrolled passkey, but
// no verifier is wired (a.Passkey == nil). Both login halves 503 passkey_unavailable in
// that state, so options must NOT advertise passkey — it would be a dead offer.
func TestAuthOptionsPasskeyRequiresWiredVerifier(t *testing.T) {
api, repo := seedAuthOptionsAPI(t)
repo.passkeyCreds["row1"] = PasskeyCredential{ID: "row1", UserID: "u1", CredentialID: "cred-1", PublicKey: "k", CreatedAt: frozenNow}
api.Passkey = nil
w := do(api.ExternalHandler(), "POST", authOptionsPath, `{"email":"[email protected]"}`, jsonHeader)
if w.Code != http.StatusOK {
t.Fatalf("code = %d, want 200 (%s)", w.Code, w.Body.String())
}
if got := optionsMethods(t, w); !reflect.DeepEqual(got, []string{"email_otp"}) {
t.Errorf("methods = %v, want [email_otp] (passkey must not be offered without a wired verifier)", got)
}
}
func TestAuthOptionsGates(t *testing.T) {
t.Run("local auth disabled -> 403", func(t *testing.T) {
api := newTestAPI(newFakeRepo(), newFakeCluster()) // no LocalAuthEnabledKey: fails closed
api.Passkey = &fakePasskeyVerifier{}
w := do(api.ExternalHandler(), "POST", authOptionsPath, `{"email":"[email protected]"}`, jsonHeader)
if w.Code != http.StatusForbidden || decodeErr(t, w) != "local_auth_disabled" {
t.Errorf("code = %d body %s, want 403 local_auth_disabled", w.Code, w.Body.String())
}
})
t.Run("non-JSON content type -> 415", func(t *testing.T) {
api, _ := seedAuthOptionsAPI(t)
eh := api.ExternalHandler()
for _, ct := range []string{"", "text/plain", "application/x-www-form-urlencoded"} {
if w := do(eh, "POST", authOptionsPath, `{"email":"[email protected]"}`, ctHeader(ct)); w.Code != http.StatusUnsupportedMediaType {
t.Errorf("Content-Type %q: code = %d, want 415", ct, w.Code)
}
}
})
t.Run("bad or unknown-field body -> 400", func(t *testing.T) {
bad := map[string]string{
"missing email": `{}`,
"empty email": `{"email":""}`,
"no at-sign": `{"email":"notanemail"}`,
"unknown field": `{"email":"[email protected]","x":1}`,
}
api, _ := seedAuthOptionsAPI(t)
eh := api.ExternalHandler()
for name, body := range bad {
if w := do(eh, "POST", authOptionsPath, body, jsonHeader); w.Code != http.StatusBadRequest {
t.Errorf("%s: code = %d, want 400 (%s)", name, w.Code, w.Body.String())
}
}
})
}
// TestAuthOptionsFaceSeparation: the route is external-only (registered in
// externalAPIRoutes), so the internal face must 404 it.
func TestAuthOptionsFaceSeparation(t *testing.T) {
api, _ := seedAuthOptionsAPI(t)
ih := api.InternalHandler()
if w := do(ih, "POST", authOptionsPath, `{"email":"[email protected]"}`, jsonHeader); w.Code != http.StatusNotFound {
t.Errorf("options on internal face: code = %d, want 404 (it is external-only)", w.Code)
}
}
-321
View File
@@ -1,321 +0,0 @@
package api
import (
"encoding/json"
"net/http"
"testing"
"time"
"golang.org/x/crypto/bcrypt"
)
// Local-password auth handler tests (spec §B). These exercise the three-route
// surface — login, logout, change-password — against the in-memory fakeRepo, which
// mirrors the PG fail-closed contract. The load-bearing cases are the anti-
// enumeration uniformity (an unknown user and a wrong password are indistinguishable)
// and the requireJSONContentType guard that closes the cross-site login-forgery
// vector: a forged HTML-form POST cannot set application/json, so it is rejected
// before any credential check.
// seedAuthAPI returns an API whose repo has local auth enabled and a single admin
// "owner" (id u1) whose password is the given plaintext. Login is Public, so these
// tests need no External wiring.
func seedAuthAPI(t *testing.T, password string, mustChange bool) (*API, *fakeRepo) {
t.Helper()
repo := newFakeRepo()
repo.settings[LocalAuthEnabledKey] = []byte("true")
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost)
if err != nil {
t.Fatalf("hash seed password: %v", err)
}
repo.staff["owner"] = &StaffUser{
ID: "u1", Username: "owner", Email: "owner@" + testRoot,
Role: "admin", PasswordHash: string(hash), MustChangePassword: mustChange,
}
return newTestAPI(repo, newFakeCluster()), repo
}
// seedAuthedAPI extends seedAuthAPI with an injected session principal so the
// authenticated change-password route resolves a caller. change-password opts out of
// the first-login lockdown (AllowDuringPasswordChange), so a must-change principal
// still reaches the handler.
func seedAuthedAPI(t *testing.T, password string, mustChange bool) (*API, *fakeRepo) {
t.Helper()
api, repo := seedAuthAPI(t, password, mustChange)
api.External = staticExternal{p: &Principal{
UserID: "u1", Email: "owner@" + testRoot, Role: "admin", MustChangePassword: mustChange,
}}
return api, repo
}
// ctHeader builds a headers map carrying the given Content-Type, or nil for the
// absent-header case (do() then sets no Content-Type at all).
func ctHeader(ct string) map[string]string {
if ct == "" {
return nil
}
return map[string]string{"Content-Type": ct}
}
var jsonHeader = map[string]string{"Content-Type": "application/json"}
func TestHandleLoginSuccess(t *testing.T) {
api, _ := seedAuthAPI(t, "correct-horse-battery", true)
w := do(api.ExternalHandler(), "POST", "/api/v1/auth/login",
`{"username":"owner","password":"correct-horse-battery"}`, jsonHeader)
if w.Code != http.StatusOK {
t.Fatalf("code = %d, want 200 (%s)", w.Code, w.Body.String())
}
// The HttpOnly session cookie is the login's whole point — the panel never reads
// it, the browser just carries it back.
cookies := w.Result().Cookies()
if len(cookies) != 1 || cookies[0].Name != sessionCookieName || cookies[0].Value == "" {
t.Fatalf("want one non-empty %s cookie, got %v", sessionCookieName, cookies)
}
if !cookies[0].HttpOnly {
t.Fatalf("session cookie must be HttpOnly")
}
var got map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
t.Fatalf("body not JSON: %v (%s)", err, w.Body.String())
}
if got["user_id"] != "u1" || got["role"] != "admin" || got["must_change_password"] != true {
t.Fatalf("got %v, want user_id=u1 role=admin must_change_password=true", got)
}
}
// TestHandleLoginContentTypeGuard pins the confirmed login-CSRF fix: a body whose
// Content-Type is anything an HTML form (or a default cross-site fetch) can emit is
// rejected 415 BEFORE the credential check, so a forged off-origin login never even
// reaches bcrypt. Local auth is enabled and the credentials are valid here, proving
// the rejection is the content-type, not a bad password.
func TestHandleLoginContentTypeGuard(t *testing.T) {
api, _ := seedAuthAPI(t, "correct-horse-battery", false)
h := api.ExternalHandler()
body := `{"username":"owner","password":"correct-horse-battery"}`
for _, ct := range []string{
"application/x-www-form-urlencoded",
"multipart/form-data; boundary=x",
"text/plain;charset=UTF-8",
"", // header absent entirely
} {
w := do(h, "POST", "/api/v1/auth/login", body, ctHeader(ct))
if w.Code != http.StatusUnsupportedMediaType {
t.Fatalf("Content-Type %q: code = %d, want 415", ct, w.Code)
}
if code := decodeErr(t, w); code != "unsupported_media_type" {
t.Fatalf("Content-Type %q: error code = %q, want unsupported_media_type", ct, code)
}
if len(w.Result().Cookies()) != 0 {
t.Fatalf("Content-Type %q: no session cookie may be set on a rejected login", ct)
}
}
// A JSON content-type with a charset parameter is still JSON and must pass.
if w := do(h, "POST", "/api/v1/auth/login", body,
map[string]string{"Content-Type": "application/json; charset=utf-8"}); w.Code != http.StatusOK {
t.Fatalf("application/json; charset=utf-8: code = %d, want 200 (%s)", w.Code, w.Body.String())
}
}
// TestHandleLoginConcurrencyCap pins the audit-hardening bound on the public login
// route: bcrypt is CPU-costly and runs on every request (the anti-enumeration dummy
// included), so at most MaxConcurrentLogins compares may be in flight at once and the
// excess is shed with a 429 rather than piling more onto every core. Holding the sole
// slot makes the next login — with otherwise-valid credentials — return 429 auth_busy
// with no cookie BEFORE any credential check; releasing it lets the identical request
// succeed, proving the 429 was the cap, not the password. A concurrency cap, not a
// per-account lockout: the same account gets in the moment the burst clears.
func TestHandleLoginConcurrencyCap(t *testing.T) {
api, _ := seedAuthAPI(t, "correct-horse-battery", false)
api.MaxConcurrentLogins = 1
h := api.ExternalHandler()
body := `{"username":"owner","password":"correct-horse-battery"}`
// Occupy the one compare slot so the handler finds the cap full. loginLimiter is
// lazily built from MaxConcurrentLogins (set just above), so this and the handler
// share the same one-token limiter.
release, ok := api.loginLimiter().acquire()
if !ok {
t.Fatal("could not acquire the sole login slot in test setup")
}
w := do(h, "POST", "/api/v1/auth/login", body, jsonHeader)
if w.Code != http.StatusTooManyRequests {
t.Fatalf("with the slot held: code = %d, want 429 (%s)", w.Code, w.Body.String())
}
if code := decodeErr(t, w); code != "auth_busy" {
t.Fatalf("error code = %q, want auth_busy", code)
}
if len(w.Result().Cookies()) != 0 {
t.Fatal("no session cookie may be set on a shed login")
}
// Release the slot: the identical request now runs the compare and succeeds.
release()
if w := do(h, "POST", "/api/v1/auth/login", body, jsonHeader); w.Code != http.StatusOK {
t.Fatalf("after releasing the slot: code = %d, want 200 (%s)", w.Code, w.Body.String())
}
}
// TestHandleLoginInvalidCredentials proves the anti-enumeration uniformity: a wrong
// password and an unknown username return the SAME 401 invalid_credentials with no
// cookie, so a caller cannot learn which usernames carry a password.
func TestHandleLoginInvalidCredentials(t *testing.T) {
api, _ := seedAuthAPI(t, "correct-horse-battery", false)
h := api.ExternalHandler()
for _, tc := range []struct{ name, body string }{
{"wrong password", `{"username":"owner","password":"wrong"}`},
{"unknown user", `{"username":"ghost","password":"whatever"}`},
} {
t.Run(tc.name, func(t *testing.T) {
w := do(h, "POST", "/api/v1/auth/login", tc.body, jsonHeader)
if w.Code != http.StatusUnauthorized {
t.Fatalf("code = %d, want 401 (%s)", w.Code, w.Body.String())
}
if code := decodeErr(t, w); code != "invalid_credentials" {
t.Fatalf("error code = %q, want invalid_credentials", code)
}
if len(w.Result().Cookies()) != 0 {
t.Fatalf("no session cookie may be set on a failed login")
}
})
}
}
// TestHandleLoginLocalAuthDisabled proves a deployment with no local_auth_enabled
// setting refuses every local login (403), so a Zero-Trust-only console never
// accepts a password.
func TestHandleLoginLocalAuthDisabled(t *testing.T) {
repo := newFakeRepo() // local_auth_enabled never set → fail closed
api := newTestAPI(repo, newFakeCluster())
w := do(api.ExternalHandler(), "POST", "/api/v1/auth/login",
`{"username":"owner","password":"x"}`, jsonHeader)
if w.Code != http.StatusForbidden {
t.Fatalf("code = %d, want 403 (%s)", w.Code, w.Body.String())
}
if code := decodeErr(t, w); code != "local_auth_disabled" {
t.Fatalf("error code = %q, want local_auth_disabled", code)
}
}
func TestHandleLoginMissingFields(t *testing.T) {
api, _ := seedAuthAPI(t, "correct-horse-battery", false)
w := do(api.ExternalHandler(), "POST", "/api/v1/auth/login",
`{"username":"","password":""}`, jsonHeader)
if w.Code != http.StatusBadRequest {
t.Fatalf("code = %d, want 400 (%s)", w.Code, w.Body.String())
}
}
// TestHandleLogout is idempotent: it clears the cookie and returns 200 even with no
// live session, and revokes the presented one when there is.
func TestHandleLogout(t *testing.T) {
api, repo := seedAuthAPI(t, "correct-horse-battery", false)
h := api.ExternalHandler()
// No cookie: still 200, still clears.
if w := do(h, "POST", "/api/v1/auth/logout", "", nil); w.Code != http.StatusOK {
t.Fatalf("logout without session: code = %d, want 200", w.Code)
}
// With a live session cookie: the matching session is revoked.
token, err := newSessionToken()
if err != nil {
t.Fatalf("token: %v", err)
}
repo.sessions[hashCookie(token)] = &fakeSession{userID: "u1", expiresAt: api.now().Add(time.Hour)}
w := do(h, "POST", "/api/v1/auth/logout", "",
map[string]string{"Cookie": sessionCookieName + "=" + token})
if w.Code != http.StatusOK {
t.Fatalf("logout with session: code = %d, want 200", w.Code)
}
if !repo.sessions[hashCookie(token)].revoked {
t.Fatalf("presented session should be revoked")
}
}
func TestHandleChangePasswordSuccess(t *testing.T) {
api, repo := seedAuthedAPI(t, "old-password", true)
// A second live session for u1: the change must revoke it. This request carries
// no felis_session cookie, so keep="" and every session of u1 is revoked — the
// safe direction the handler documents.
repo.sessions["other-device"] = &fakeSession{userID: "u1", expiresAt: api.now().Add(time.Hour)}
// A bound passkey for u1: the change must unbind it too. A passkey planted through a
// hijacked session needs no password, so it would otherwise survive the reset as a
// standing login foothold.
repo.passkeyCreds["pk1"] = PasskeyCredential{ID: "pk1", UserID: "u1", CredentialID: "cred-1", PublicKey: "k"}
w := do(api.ExternalHandler(), "POST", "/api/v1/auth/change-password",
`{"current_password":"old-password","new_password":"brand-new-password"}`, jsonHeader)
if w.Code != http.StatusOK {
t.Fatalf("code = %d, want 200 (%s)", w.Code, w.Body.String())
}
u := repo.staff["owner"]
if u.MustChangePassword {
t.Fatalf("must_change_password should be cleared after a change")
}
if bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte("brand-new-password")) != nil {
t.Fatalf("the new password does not verify against the stored hash")
}
if !repo.sessions["other-device"].revoked {
t.Fatalf("other sessions should be revoked on a password change")
}
if len(repo.passkeyCreds) != 0 {
t.Fatalf("password change left %d passkeys, want 0 — a planted passkey must not survive remediation", len(repo.passkeyCreds))
}
}
// TestHandleChangePasswordContentTypeGuard pins the defense-in-depth guard on the
// authenticated change-password route.
func TestHandleChangePasswordContentTypeGuard(t *testing.T) {
api, _ := seedAuthedAPI(t, "old-password", false)
w := do(api.ExternalHandler(), "POST", "/api/v1/auth/change-password",
`{"current_password":"old-password","new_password":"brand-new-password"}`,
map[string]string{"Content-Type": "text/plain"})
if w.Code != http.StatusUnsupportedMediaType {
t.Fatalf("code = %d, want 415 (%s)", w.Code, w.Body.String())
}
}
func TestHandleChangePasswordRejections(t *testing.T) {
t.Run("weak new password", func(t *testing.T) {
api, _ := seedAuthedAPI(t, "old-password", false)
w := do(api.ExternalHandler(), "POST", "/api/v1/auth/change-password",
`{"current_password":"old-password","new_password":"short"}`, jsonHeader)
if w.Code != http.StatusBadRequest {
t.Fatalf("code = %d, want 400", w.Code)
}
if code := decodeErr(t, w); code != "weak_password" {
t.Fatalf("error code = %q, want weak_password", code)
}
})
t.Run("unchanged password", func(t *testing.T) {
api, _ := seedAuthedAPI(t, "old-password", false)
w := do(api.ExternalHandler(), "POST", "/api/v1/auth/change-password",
`{"current_password":"old-password","new_password":"old-password"}`, jsonHeader)
if w.Code != http.StatusBadRequest {
t.Fatalf("code = %d, want 400", w.Code)
}
if code := decodeErr(t, w); code != "password_unchanged" {
t.Fatalf("error code = %q, want password_unchanged", code)
}
})
t.Run("wrong current password", func(t *testing.T) {
api, _ := seedAuthedAPI(t, "old-password", false)
w := do(api.ExternalHandler(), "POST", "/api/v1/auth/change-password",
`{"current_password":"wrong","new_password":"brand-new-password"}`, jsonHeader)
if w.Code != http.StatusUnauthorized {
t.Fatalf("code = %d, want 401", w.Code)
}
if code := decodeErr(t, w); code != "invalid_credentials" {
t.Fatalf("error code = %q, want invalid_credentials", code)
}
})
}
+2 -2
View File
@@ -73,8 +73,8 @@ func TestBindRedeemBootstrapsPlayer(t *testing.T) {
} }
// A fresh role=user player row was created and bound; the code was consumed. // 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 { if u := repo.staff[bindTestUUID]; u == nil || u.Role != "user" || u.ID != userID {
t.Fatalf("created row = %+v, want role=user, NULL hash, id=%s", u, userID) t.Fatalf("created row = %+v, want role=user, id=%s", u, userID)
} }
if repo.links[bindTestUUID] != userID { if repo.links[bindTestUUID] != userID {
t.Fatalf("account_links[%s] = %q, want %q", bindTestUUID, repo.links[bindTestUUID], userID) t.Fatalf("account_links[%s] = %q, want %q", bindTestUUID, repo.links[bindTestUUID], userID)
+408
View File
@@ -0,0 +1,408 @@
package api
import (
"encoding/json"
"errors"
"net/http"
"strings"
)
// op.console STAFF login (spec §B op-login): the two-factor door for the most
// sensitive tier. Unlike the console.<root_domain> player doors (email OTP / bind
// code), a staff web session is never minted from a single factor. The flow is a
// three-call state machine over op_login_requests (migration 0012), all Public
// pre-session routes (the caller has no principal yet), plus two internal-face routes
// velocity drives on behalf of online admins:
//
// POST /api/v1/auth/op-login/start (public) — mint a request + mail an OTP
// GET /api/v1/auth/op-login/status/{id} (public) — poll until an admin approves
// POST /api/v1/auth/op-login/finish (public) — redeem code+approval → session
// GET /api/v1/internal/op-login/pending (internal) — the online-admin push list
// POST /api/v1/internal/op-login/{id}/approve (internal) — an in-game admin vouches
//
// The two factors:
//
// - Possession of the staff mailbox — an email-OTP under purpose op_login, minted by
// start and redeemed by finish, reusing the email_otps lifecycle (the purpose
// column keeps it from ever colliding with a console login_email or onboard code).
// - An in-game vouch — an already-trusted admin who is ONLINE approves the pending
// request via velocity's /felis command (internal approve). Only a linked
// role=admin account may approve; velocity additionally gates the command on
// in-game op, so the API check is defence in depth over its own user table.
//
// finish mints the session only when BOTH have landed. Neither factor alone — a mailed
// code without an approval, or an approval without the code — yields a session.
//
// Anti-enumeration. op.console sits behind Cloudflare Zero-Trust at the edge, but the
// external API is hostname-agnostic at the route level, so these Public routes are
// reachable from console.<root_domain> too and must not become a staff oracle:
//
// - start resolves the typed email; a non-staff or unknown address gets the SAME 202
// with a plausible (non-persisted, random) request_id and mails nothing, so a
// caller cannot tell a staff address from any other.
// - status returns approved:false for an unknown/expired/denied/consumed id exactly
// as for a live-but-unapproved one; only a genuinely approved live request reads
// approved:true, and driving an id to that state REQUIRES an in-game admin vouch a
// fabricated id can never obtain.
// - finish collapses unknown id, not-yet-approved, wrong code, locked, and lost-race
// into one uniform failure, and (like the console door) never reveals staffness.
// otpPurposeOpLogin scopes an email code to the op.console staff door, keeping it from
// ever colliding with or satisfying a console login_email or onboarding code for the
// same account. VerifyEmailOTP/ConsumeLoginEmailOTP are queried per (user, purpose),
// so the op-login factor is fully independent of the player-console doors.
const otpPurposeOpLogin = "op_login"
// opLoginStartRequest is the start body: the staff address the code is mailed to.
type opLoginStartRequest struct {
Email string `json:"email"`
}
// handleOpLoginStart begins a staff op.console login (Public, pre-session): it mints an
// op_login_requests row for the resolved staff account and mails an email-OTP under
// otpPurposeOpLogin, returning the request handle the browser polls. A non-staff or
// unknown address yields the SAME 202 with a random, non-persisted handle and no mail,
// so this never doubles as a staff-enumeration oracle (op.console's own Zero-Trust is
// the edge gate; this app-layer neutrality covers the hostname-agnostic route).
func (a *API) handleOpLoginStart(w http.ResponseWriter, r *http.Request) {
if !localAuthEnabled(r.Context(), a.Repo) {
writeError(w, r, newError(http.StatusForbidden, "local_auth_disabled",
"session login is disabled"))
return
}
if err := requireJSONContentType(r); err != nil {
writeError(w, r, err)
return
}
var req opLoginStartRequest
if err := decodeJSON(w, r, &req); err != nil {
writeError(w, r, err)
return
}
email := strings.TrimSpace(req.Email)
if !looksLikeEmail(email) {
writeError(w, r, newError(http.StatusBadRequest, "bad_request", "a valid email is required"))
return
}
// Per-recipient cooldown reserved BEFORE any work, identical to the console email
// door: one winner per window, and the neutral (non-staff) branch keeps the
// reservation too so probing an address is throttled exactly like a real send. The
// key is namespaced apart from the console door's "login:email:" so the two
// unauthenticated doors never perturb each other's throttle.
emailKey := "oplogin:email:" + strings.ToLower(email)
lim := a.otpLimiter()
emailAt, ok := lim.reserve(emailKey, otpResendCooldown)
if !ok {
writeError(w, r, newError(http.StatusTooManyRequests, "otp_resend_cooldown",
"a code was sent recently; wait a moment before requesting another"))
return
}
committed := false
defer func() {
if !committed {
lim.release(emailKey, emailAt)
}
}()
// Compute expiry once so the neutral and real branches return identical-shaped
// bodies and (real branch) the request row and its OTP are coterminous.
expiresAt := a.now().Add(otpTTL)
// neutral returns the indistinguishable no-op success: a plausible but non-persisted
// handle that status(id) reads approved:false forever (no row, never approvable). It
// mints nothing and mails nothing, and KEEPS the reservation so probing is throttled
// exactly like a real send.
neutral := func() {
fakeID, err := newOTPID()
if err != nil {
writeError(w, r, err) // committed stays false → deferred rollback frees the window
return
}
committed = true
writeJSON(w, http.StatusAccepted, map[string]any{
"request_id": fakeID, "expires_at": expiresAt.UTC(),
})
}
u, err := a.Repo.UserByEmail(r.Context(), email)
switch {
case errors.Is(err, ErrNotFound):
neutral()
return
case err != nil:
// Real read fault: leave committed false so the deferred rollback frees the
// window (a transient DB blip must not burn it).
writeError(w, r, err)
return
}
// op.console is the STAFF door: a non-admin who typed their address here (they belong
// on console.<root_domain>) gets the neutral response, never a request or a code.
if u.Role != "admin" {
neutral()
return
}
id, err := newOTPID()
if err != nil {
writeError(w, r, err)
return
}
if err := a.Repo.CreateOpLoginRequest(r.Context(), id, u.ID, u.Email, expiresAt); err != nil {
writeError(w, r, err)
return
}
code, err := newEmailOTP()
if err != nil {
writeError(w, r, err)
return
}
otpID, err := newOTPID()
if err != nil {
writeError(w, r, err)
return
}
// Mint+mail against the STORED staff address (UserByEmail matched case-insensitively);
// the request row snapshots the same address for its audit trail.
if err := a.Repo.CreateEmailOTP(r.Context(), otpID, u.ID, u.Email, otpCodeHash(code), otpPurposeOpLogin, expiresAt); err != nil {
writeError(w, r, err)
return
}
if err := a.deliverOTP(r.Context(), u.Email, code); err != nil {
writeError(w, r, err)
return
}
committed = true
a.audit(r, u.Username, "auth.op_login.otp_sent", "")
writeJSON(w, http.StatusAccepted, map[string]any{
"request_id": id, "expires_at": expiresAt.UTC(),
})
}
// handleOpLoginStatus reports whether a staff login request has been approved in-game
// (Public, pre-session). It is a pure read the browser polls after start: it returns
// approved:true only for a genuinely approved, live, unconsumed request, and
// approved:false for everything else — including an unknown, expired, denied, or
// already-consumed id — so a fabricated handle polls as approved:false forever and the
// endpoint is not a staff-enumeration oracle (only an in-game admin vouch, impossible
// against a fake id, flips it true).
func (a *API) handleOpLoginStatus(w http.ResponseWriter, r *http.Request) {
if !localAuthEnabled(r.Context(), a.Repo) {
writeError(w, r, newError(http.StatusForbidden, "local_auth_disabled",
"session login is disabled"))
return
}
id := r.PathValue("id")
approved := false
switch req, err := a.Repo.OpLoginRequestByID(r.Context(), id); {
case errors.Is(err, ErrNotFound):
// Unknown handle: neutral approved:false (never 404), uniform with a real request
// still awaiting approval.
case err != nil:
writeError(w, r, err)
return
default:
approved = req.Status == "approved" && !req.Consumed && req.ExpiresAt.After(a.now())
}
writeJSON(w, http.StatusOK, map[string]any{"approved": approved})
}
// opLoginFinishRequest is the finish body: the request handle from start and the code
// read from the staff mailbox. The handle selects the account (there is no principal);
// the code proves possession of the mailbox this session.
type opLoginFinishRequest struct {
RequestID string `json:"request_id"`
Code string `json:"code"`
}
// handleOpLoginFinish redeems an approved request plus its mailed code into a staff
// session (Public, pre-session). It mints the session only when BOTH factors have
// landed: the request is approved-and-live AND the code verifies. Every failure mode —
// unknown handle, not-yet-approved, wrong or locked code, lost race — collapses into
// ONE uniform 400, so a code-less caller learns nothing (not staffness, not approval
// state). The approval is read BEFORE the code is consumed so a valid code submitted
// early (before an admin approves) is preserved for a retry rather than burned.
func (a *API) handleOpLoginFinish(w http.ResponseWriter, r *http.Request) {
if !localAuthEnabled(r.Context(), a.Repo) {
writeError(w, r, newError(http.StatusForbidden, "local_auth_disabled",
"session login is disabled"))
return
}
if err := requireJSONContentType(r); err != nil {
writeError(w, r, err)
return
}
var req opLoginFinishRequest
if err := decodeJSON(w, r, &req); err != nil {
writeError(w, r, err)
return
}
requestID := strings.TrimSpace(req.RequestID)
code := strings.TrimSpace(req.Code)
if requestID == "" || code == "" {
writeError(w, r, newError(http.StatusBadRequest, "bad_request", "request_id and code are required"))
return
}
// The single uniform failure every "cannot complete" branch returns, so unknown
// handle / not-approved / wrong code / locked / lost-race are indistinguishable.
invalid := newError(http.StatusBadRequest, "op_login_invalid",
"this operator login could not be completed; restart the sign-in")
now := a.now()
loginReq, err := a.Repo.OpLoginRequestByID(r.Context(), requestID)
switch {
case errors.Is(err, ErrNotFound):
writeError(w, r, invalid)
return
case err != nil:
writeError(w, r, err)
return
}
// Read the approval state BEFORE touching the code: an early finish (user typed the
// code before an admin approved) must not consume the code. Not-approved collapses
// into the same uniform failure as a bad code, so the ordering leaks nothing.
if loginReq.Status != "approved" || loginReq.Consumed || !loginReq.ExpiresAt.After(now) {
writeError(w, r, invalid)
return
}
// Consume the mailed code (op_login purpose). A wrong/expired/locked code charges an
// attempt without minting anything and returns the uniform failure — the code, not
// the request, is the problem, and the request stays approved for a retry.
switch err := a.Repo.ConsumeLoginEmailOTP(r.Context(), loginReq.UserID, otpPurposeOpLogin, otpCodeHash(code), now); {
case errors.Is(err, ErrOTPInvalid), errors.Is(err, ErrOTPLocked):
writeError(w, r, invalid)
return
case err != nil:
writeError(w, r, err)
return
}
// Both factors proven. Atomically spend the request (approved→consumed, single-use):
// this serialises against a concurrent finish and records which request completed.
switch err := a.Repo.ConsumeOpLoginRequest(r.Context(), requestID, now); {
case errors.Is(err, ErrNotFound):
// Lost a race (another finish consumed it) or it expired between the checks —
// uniform failure. The code was already spent by the winner.
writeError(w, r, invalid)
return
case err != nil:
writeError(w, r, err)
return
}
// Load the staff account for the session + response. Re-assert admin as defence in
// depth: only admins ever get a request minted, but the session must never be issued
// to a non-admin identity even if the row were somehow otherwise.
u, err := a.Repo.UserByID(r.Context(), loginReq.UserID)
if err != nil {
writeError(w, r, err)
return
}
if u.Role != "admin" {
writeError(w, r, newError(http.StatusForbidden, "staff_account", "that account is not an operator"))
return
}
token, err := newSessionToken()
if err != nil {
writeError(w, r, err)
return
}
expires := now.Add(sessionTTL)
if err := a.Repo.CreateSession(r.Context(), hashCookie(token), u.ID, expires); err != nil {
writeError(w, r, err)
return
}
setSessionCookie(w, token, expires)
a.audit(r, u.Username, "auth.op_login", "")
writeJSON(w, http.StatusOK, map[string]any{"user_id": u.ID, "role": u.Role})
}
// handleOpLoginPending lists live pending staff login requests, oldest first (internal
// face). Velocity polls it and pushes the waiting requests to online admins, who
// approve one with /felis web op approve <id>. Internal-only: velocity holds a service
// token and no pending request is secret to the operator crew.
func (a *API) handleOpLoginPending(w http.ResponseWriter, r *http.Request) {
reqs, err := a.Repo.ListPendingOpLogins(r.Context(), a.now())
if err != nil {
writeError(w, r, err)
return
}
out := make([]map[string]any, 0, len(reqs))
for _, req := range reqs {
out = append(out, map[string]any{
"request_id": req.ID,
"username": req.Username,
"email": req.Email,
"created_at": req.CreatedAt.UTC(),
})
}
writeJSON(w, http.StatusOK, map[string]any{"pending": out})
}
// opLoginApproveRequest is the internal approve body: the online-mode UUID of the
// in-game admin running /felis web op approve. The API resolves it to a linked account
// and refuses unless that account is role=admin — defence in depth over velocity's own
// in-game op gate, checked against the API's authoritative user table.
type opLoginApproveRequest struct {
ApproverUUID string `json:"approver_uuid"`
}
// handleOpLoginApprove records an in-game admin's vouch for a pending staff login
// (internal face), supplying the second factor. It resolves the approver UUID to a
// linked role=admin account (else 403), then flips the request approved. A missing or
// no-longer-pending request is 404. Self-approval is allowed: a staff member online as
// their own admin identity supplies a genuine second factor (in-game session control)
// distinct from the mailbox factor.
func (a *API) handleOpLoginApprove(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
var req opLoginApproveRequest
if err := decodeJSON(w, r, &req); err != nil {
writeError(w, r, err)
return
}
approverUUID := strings.TrimSpace(req.ApproverUUID)
if approverUUID == "" {
writeError(w, r, newError(http.StatusBadRequest, "bad_request", "approver_uuid is required"))
return
}
// Resolve the in-game approver to a linked account and require admin. An unlinked
// UUID or a non-admin player may never vouch for an op.console login. All three
// refusals share one response so a caller cannot tell "not linked" from "linked but
// not staff".
notAdmin := newError(http.StatusForbidden, "not_admin", "only a linked administrator may approve an operator login")
approverID, err := a.Repo.UserByMCUUID(r.Context(), approverUUID)
switch {
case errors.Is(err, ErrNotFound):
writeError(w, r, notAdmin)
return
case err != nil:
writeError(w, r, err)
return
}
approver, err := a.Repo.UserByID(r.Context(), approverID)
switch {
case errors.Is(err, ErrNotFound):
writeError(w, r, notAdmin)
return
case err != nil:
writeError(w, r, err)
return
}
if approver.Role != "admin" {
writeError(w, r, notAdmin)
return
}
switch err := a.Repo.ApproveOpLogin(r.Context(), id, approverID, a.now()); {
case errors.Is(err, ErrNotFound):
writeError(w, r, newError(http.StatusNotFound, "op_login_not_found", "no pending operator login with that id"))
return
case err != nil:
writeError(w, r, err)
return
}
payload, _ := json.Marshal(map[string]string{"request_id": id, "approver_user_id": approverID})
_ = a.Repo.Audit(r.Context(), AuditEntry{
Actor: approver.Username, Source: "internal", Action: "auth.op_login.approved",
RequestID: requestIDFromContext(r.Context()), Payload: payload,
})
writeJSON(w, http.StatusOK, map[string]any{"approved": true})
}
+509
View File
@@ -0,0 +1,509 @@
package api
import (
"net/http"
"net/http/httptest"
"testing"
"time"
)
// op.console STAFF login tests (spec §B op-login). The two-factor door's load-bearing
// properties, in the order the flow meets them:
//
// - Two factors, both required. finish mints a session only when the mailed op_login
// code verifies AND an in-game admin has approved the request; neither alone works.
// - Neutral start. A non-staff or unknown address gets a 202 with a plausible but
// non-persisted request_id and nothing mailed, so start is not a staff oracle.
// - Neutral status. An unknown/expired/consumed handle reads approved:false exactly
// like a real request awaiting approval, so a fabricated handle is not an oracle.
// - Uniform finish failure. Unknown handle / not-approved / wrong code / lost race
// all collapse to one op_login_invalid envelope; an early-but-correct code is
// preserved (approval is read before the code is consumed), and a wrong code costs
// an attempt without burning the approval.
// - Admin-only approval. Only a linked role=admin UUID may vouch; the check is the
// API's own user table, defence in depth over velocity's in-game op gate.
const opUUID = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" // the seeded admin's linked in-game UUID
// seedOpLoginAPI wires the op.console door: local sessions enabled and a single staff
// admin "op" (id a1) whose proven address is stored in MIXED case (so the mint-against-
// stored-casing contract is exercised by default) and whose in-game UUID opUUID is
// linked, so the admin can act as an in-game approver.
func seedOpLoginAPI(t *testing.T) (*API, *fakeRepo, *captureMailer) {
t.Helper()
repo := newFakeRepo()
repo.settings[LocalAuthEnabledKey] = []byte("true")
repo.staff["op"] = &StaffUser{
ID: "a1", Username: "op", Email: "[email protected]",
Role: "admin", EmailVerified: true,
}
repo.links[opUUID] = "a1"
mailer := &captureMailer{}
api := newTestAPI(repo, newFakeCluster())
api.Mailer = mailer
return api, repo, mailer
}
// startOp / statusOp / finishOp drive the three public browser calls; approveOp drives
// the internal in-game vouch. They return the recorder so each test asserts its own
// codes and bodies.
func startOp(eh http.Handler, email string) *httptest.ResponseRecorder {
return do(eh, "POST", "/api/v1/auth/op-login/start", `{"email":"`+email+`"}`, jsonHeader)
}
func statusOp(eh http.Handler, id string) *httptest.ResponseRecorder {
return do(eh, "GET", "/api/v1/auth/op-login/status/"+id, "", nil)
}
func finishOp(eh http.Handler, id, code string) *httptest.ResponseRecorder {
return do(eh, "POST", "/api/v1/auth/op-login/finish",
`{"request_id":"`+id+`","code":"`+code+`"}`, jsonHeader)
}
func approveOp(ih http.Handler, id, approverUUID string) *httptest.ResponseRecorder {
return do(ih, "POST", "/api/v1/internal/op-login/"+id+"/approve",
`{"approver_uuid":"`+approverUUID+`"}`, nil)
}
// TestOpLoginVertical walks the whole two-factor slice end to end: start mails a code
// (purpose op_login) to the staff address of record and mints a pending request; the
// browser polls status until an in-game admin approves; finish redeems code+approval
// into the same host-only session the other doors mint. All three legs audit by the
// account's username, and the request is single-use.
func TestOpLoginVertical(t *testing.T) {
api, repo, mailer := seedOpLoginAPI(t)
eh := api.ExternalHandler()
ih := api.InternalHandler()
// 1) start: 202 with a request handle + expiry, never the code itself.
w := startOp(eh, "[email protected]")
if w.Code != http.StatusAccepted {
t.Fatalf("start: code = %d, want 202 (%s)", w.Code, w.Body.String())
}
b := acctBody(t, w)
reqID, _ := b["request_id"].(string)
if reqID == "" {
t.Fatal("start must return a request_id")
}
if _, leaked := b["code"]; leaked {
t.Error("start response must NEVER carry the code")
}
if s, _ := b["expires_at"].(string); s == "" {
t.Error("start must report expires_at")
}
// The code goes to the STORED casing (address of record), under the op_login purpose.
if mailer.calls != 1 || mailer.email != "[email protected]" {
t.Fatalf("mailer: calls=%d email=%q, want 1 send to the STORED casing [email protected]",
mailer.calls, mailer.email)
}
code := mailer.code
if len(repo.otps) != 1 {
t.Fatalf("persisted codes = %d, want 1", len(repo.otps))
}
for _, o := range repo.otps {
if o.purpose != otpPurposeOpLogin {
t.Errorf("otp purpose = %q, want %q", o.purpose, otpPurposeOpLogin)
}
}
// Exactly one pending request row, owned by the staff account.
if len(repo.opLogins) != 1 {
t.Fatalf("op_login_requests rows = %d, want 1", len(repo.opLogins))
}
if got := repo.opLogins[reqID]; got == nil || got.userID != "a1" || got.status != "pending" {
t.Fatalf("request row = %+v, want {userID:a1, status:pending}", got)
}
// 2) status before approval: not approved yet.
if sb := acctBody(t, statusOp(eh, reqID)); sb["approved"] != false {
t.Fatalf("status before approval: approved = %v, want false", sb["approved"])
}
// 3) finish before approval is REFUSED and must NOT burn the code (approval is read
// before the code is consumed).
if w := finishOp(eh, reqID, code); w.Code != http.StatusBadRequest || decodeErr(t, w) != "op_login_invalid" {
t.Fatalf("finish before approval: code = %d body %s, want 400 op_login_invalid", w.Code, w.Body.String())
}
if len(repo.sessions) != 0 {
t.Fatal("no session may be minted before approval")
}
// 4) an in-game admin approves via the internal face.
if w := approveOp(ih, reqID, opUUID); w.Code != http.StatusOK {
t.Fatalf("approve: code = %d, want 200 (%s)", w.Code, w.Body.String())
}
if ab := acctBody(t, statusOp(eh, reqID)); ab["approved"] != true {
t.Fatalf("status after approval: approved = %v, want true", ab["approved"])
}
// 5) finish with the preserved code: session minted, role=admin.
w = finishOp(eh, reqID, code)
if w.Code != http.StatusOK {
t.Fatalf("finish: code = %d, want 200 (%s)", w.Code, w.Body.String())
}
vb := acctBody(t, w)
if vb["user_id"] != "a1" || vb["role"] != "admin" {
t.Fatalf("finish body = %v, want user_id:a1 role:admin", vb)
}
cookies := w.Result().Cookies()
if len(cookies) != 1 || cookies[0].Name != sessionCookieName || cookies[0].Value == "" {
t.Fatalf("want one non-empty %s cookie, got %v", sessionCookieName, cookies)
}
if s, ok := repo.sessions[hashCookie(cookies[0].Value)]; !ok || s.userID != "a1" {
t.Fatalf("session row for the cookie = %+v (ok=%v), want userID a1", s, ok)
}
// Three audits by "op": otp_sent (start), approved (in-game vouch), op_login (finish).
if n := len(repo.audits); n != 3 {
t.Fatalf("want 3 audits, got %d: %+v", n, repo.audits)
}
wantActions := []string{"auth.op_login.otp_sent", "auth.op_login.approved", "auth.op_login"}
for i, want := range wantActions {
if repo.audits[i].Action != want || repo.audits[i].Actor != "op" {
t.Errorf("audit[%d] = %+v, want action %q by op", i, repo.audits[i], want)
}
}
// 6) single-use: the consumed request finishes no second time, and status flips back
// to approved:false (consumed).
if w := finishOp(eh, reqID, code); w.Code != http.StatusBadRequest || decodeErr(t, w) != "op_login_invalid" {
t.Fatalf("replay finish: code = %d body %s, want 400 op_login_invalid", w.Code, w.Body.String())
}
if sb := acctBody(t, statusOp(eh, reqID)); sb["approved"] != false {
t.Errorf("status after consume: approved = %v, want false", sb["approved"])
}
}
// TestOpLoginStartNeutral pins the start-side anti-enumeration contract: op.console is
// the STAFF door, so a non-admin account AND an unknown address both get a 202 carrying
// a request_id + expires_at, mint/mail nothing, and still burn the per-recipient
// cooldown — so neither the response nor the throttle tells a caller who is staff.
func TestOpLoginStartNeutral(t *testing.T) {
check := func(t *testing.T, seed func(*fakeRepo), email string) {
t.Helper()
repo := newFakeRepo()
repo.settings[LocalAuthEnabledKey] = []byte("true")
if seed != nil {
seed(repo)
}
mailer := &captureMailer{}
api := newTestAPI(repo, newFakeCluster())
api.Mailer = mailer
eh := api.ExternalHandler()
w := startOp(eh, email)
if w.Code != http.StatusAccepted {
t.Fatalf("neutral start: code = %d, want 202 (%s)", w.Code, w.Body.String())
}
b := acctBody(t, w)
if id, _ := b["request_id"].(string); id == "" {
t.Error("neutral start must still return a plausible request_id")
}
if s, _ := b["expires_at"].(string); s == "" {
t.Error("neutral start must still return expires_at")
}
if len(repo.opLogins) != 0 || len(repo.otps) != 0 || mailer.calls != 0 || len(repo.audits) != 0 {
t.Errorf("neutral start must mint/mail/audit nothing: reqs=%d otps=%d mails=%d audits=%d",
len(repo.opLogins), len(repo.otps), mailer.calls, len(repo.audits))
}
// The reservation is KEPT: re-probing the same address is throttled like a resend.
if w := startOp(eh, email); w.Code != http.StatusTooManyRequests || decodeErr(t, w) != "otp_resend_cooldown" {
t.Fatalf("re-probe: code = %d body %s, want 429 otp_resend_cooldown", w.Code, w.Body.String())
}
}
t.Run("unknown address", func(t *testing.T) {
check(t, nil, "[email protected]")
})
t.Run("non-staff (role=user) address is ignored by the staff door", func(t *testing.T) {
check(t, func(repo *fakeRepo) {
repo.staff["p"] = &StaffUser{ID: "u9", Username: "p", Email: "[email protected]", Role: "user", EmailVerified: true}
}, "[email protected]")
})
}
// TestOpLoginStatusNeutral proves status is never an enumeration oracle: it returns
// approved:true ONLY for a genuinely approved, live, unconsumed request, and
// approved:false (never 404) for an unknown, expired, denied, or consumed handle — all
// indistinguishable from a real request still awaiting approval.
func TestOpLoginStatusNeutral(t *testing.T) {
api, repo, _ := seedOpLoginAPI(t)
eh := api.ExternalHandler()
future := time.Unix(1_700_000_600, 0)
past := time.Unix(1_699_999_999, 0)
repo.opLogins["pending"] = &fakeOpLogin{id: "pending", userID: "a1", email: "[email protected]", status: "pending", expiresAt: future, createdAt: future}
repo.opLogins["expired"] = &fakeOpLogin{id: "expired", userID: "a1", email: "[email protected]", status: "approved", expiresAt: past, createdAt: past}
repo.opLogins["consumed"] = &fakeOpLogin{id: "consumed", userID: "a1", email: "[email protected]", status: "approved", consumed: true, expiresAt: future, createdAt: future}
repo.opLogins["denied"] = &fakeOpLogin{id: "denied", userID: "a1", email: "[email protected]", status: "denied", expiresAt: future, createdAt: future}
repo.opLogins["live"] = &fakeOpLogin{id: "live", userID: "a1", email: "[email protected]", status: "approved", expiresAt: future, createdAt: future}
for _, id := range []string{"unknown-handle", "pending", "expired", "consumed", "denied"} {
w := statusOp(eh, id)
if w.Code != http.StatusOK {
t.Fatalf("status %q: code = %d, want 200", id, w.Code)
}
if acctBody(t, w)["approved"] != false {
t.Errorf("status %q: approved = true, want false (must not be an oracle)", id)
}
}
// Only the genuinely-approved live request reads true.
if acctBody(t, statusOp(eh, "live"))["approved"] != true {
t.Error("status of an approved live request must read approved:true")
}
}
// TestOpLoginFinishUniform is the redeem-side failure matrix. The anchor is uniformity:
// an unknown handle and a wrong code for an approved request answer with the SAME
// (code, message) envelope, so finish never doubles as an oracle. Two lifecycle
// invariants are pinned alongside: a correct code submitted BEFORE approval is
// preserved (approval read before consume), and a wrong code costs an attempt without
// burning the approval.
func TestOpLoginFinishUniform(t *testing.T) {
t.Run("unknown handle and wrong code are indistinguishable", func(t *testing.T) {
api, _, mailer := seedOpLoginAPI(t)
eh, ih := api.ExternalHandler(), api.InternalHandler()
reqID := acctBody(t, startOp(eh, "[email protected]"))["request_id"].(string)
code := mailer.code
if w := approveOp(ih, reqID, opUUID); w.Code != http.StatusOK {
t.Fatalf("approve: %d (%s)", w.Code, w.Body.String())
}
// Wrong code for a real, approved request.
wWrong := finishOp(eh, reqID, code+"x")
// Unknown handle.
wGhost := finishOp(eh, "deadbeefdeadbeefdeadbeefdeadbeef", code)
if wWrong.Code != http.StatusBadRequest || wGhost.Code != http.StatusBadRequest {
t.Fatalf("codes = %d/%d, want 400/400", wWrong.Code, wGhost.Code)
}
wc, wm := errEnvelope(t, wWrong)
gc, gm := errEnvelope(t, wGhost)
if wc != "op_login_invalid" || wc != gc || wm != gm {
t.Errorf("envelopes differ: wrong=(%s,%q) unknown=(%s,%q) — must be identical", wc, wm, gc, gm)
}
})
t.Run("correct code before approval is preserved, not burned", func(t *testing.T) {
api, repo, mailer := seedOpLoginAPI(t)
eh, ih := api.ExternalHandler(), api.InternalHandler()
reqID := acctBody(t, startOp(eh, "[email protected]"))["request_id"].(string)
code := mailer.code
// Finish before approval: refused, and the code is NOT consumed.
if w := finishOp(eh, reqID, code); w.Code != http.StatusBadRequest || decodeErr(t, w) != "op_login_invalid" {
t.Fatalf("early finish: code = %d body %s, want 400 op_login_invalid", w.Code, w.Body.String())
}
for _, o := range repo.otps {
if o.consumed || o.attempts != 0 {
t.Errorf("early finish must not touch the code: consumed=%v attempts=%d", o.consumed, o.attempts)
}
}
// Approve, then the same code completes.
if w := approveOp(ih, reqID, opUUID); w.Code != http.StatusOK {
t.Fatalf("approve: %d (%s)", w.Code, w.Body.String())
}
if w := finishOp(eh, reqID, code); w.Code != http.StatusOK {
t.Fatalf("finish with preserved code: code = %d, want 200 (%s)", w.Code, w.Body.String())
}
})
t.Run("wrong code charges an attempt without burning the approval", func(t *testing.T) {
api, repo, mailer := seedOpLoginAPI(t)
eh, ih := api.ExternalHandler(), api.InternalHandler()
reqID := acctBody(t, startOp(eh, "[email protected]"))["request_id"].(string)
code := mailer.code
if w := approveOp(ih, reqID, opUUID); w.Code != http.StatusOK {
t.Fatalf("approve: %d (%s)", w.Code, w.Body.String())
}
// Wrong code: refused, one attempt charged, request still approved+unconsumed.
if w := finishOp(eh, reqID, code+"x"); w.Code != http.StatusBadRequest || decodeErr(t, w) != "op_login_invalid" {
t.Fatalf("wrong code: code = %d body %s, want 400 op_login_invalid", w.Code, w.Body.String())
}
for _, o := range repo.otps {
if o.consumed || o.attempts != 1 {
t.Errorf("wrong code must charge one attempt, not consume: consumed=%v attempts=%d", o.consumed, o.attempts)
}
}
if r := repo.opLogins[reqID]; r.status != "approved" || r.consumed {
t.Errorf("a wrong code must not burn the approval: status=%q consumed=%v", r.status, r.consumed)
}
// The right code still completes.
if w := finishOp(eh, reqID, code); w.Code != http.StatusOK {
t.Fatalf("retry with right code: code = %d, want 200 (%s)", w.Code, w.Body.String())
}
})
}
// TestOpLoginApproveGate pins the in-game approval gate: only a linked role=admin UUID
// may vouch (all refusals share one 403 not_admin), a missing/no-longer-pending request
// is 404, and a bare request without an approver UUID is 400.
func TestOpLoginApproveGate(t *testing.T) {
plantPending := func(repo *fakeRepo) string {
repo.opLogins["r1"] = &fakeOpLogin{
id: "r1", userID: "a1", email: "[email protected]", status: "pending",
expiresAt: time.Unix(1_700_000_600, 0), createdAt: time.Unix(1_700_000_000, 0),
}
return "r1"
}
t.Run("unlinked approver UUID -> 403, request stays pending", func(t *testing.T) {
api, repo, _ := seedOpLoginAPI(t)
id := plantPending(repo)
if w := approveOp(api.InternalHandler(), id, "ffffffff-ffff-ffff-ffff-ffffffffffff"); w.Code != http.StatusForbidden || decodeErr(t, w) != "not_admin" {
t.Fatalf("unlinked approver: code = %d body %s, want 403 not_admin", w.Code, w.Body.String())
}
if repo.opLogins[id].status != "pending" {
t.Error("a refused approval must leave the request pending")
}
})
t.Run("linked non-admin approver -> 403", func(t *testing.T) {
api, repo, _ := seedOpLoginAPI(t)
id := plantPending(repo)
repo.staff["p"] = &StaffUser{ID: "u9", Username: "p", Email: "[email protected]", Role: "user", EmailVerified: true}
repo.links["bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"] = "u9"
if w := approveOp(api.InternalHandler(), id, "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"); w.Code != http.StatusForbidden || decodeErr(t, w) != "not_admin" {
t.Fatalf("non-admin approver: code = %d body %s, want 403 not_admin", w.Code, w.Body.String())
}
})
t.Run("missing approver_uuid -> 400", func(t *testing.T) {
api, repo, _ := seedOpLoginAPI(t)
id := plantPending(repo)
if w := do(api.InternalHandler(), "POST", "/api/v1/internal/op-login/"+id+"/approve", `{}`, nil); w.Code != http.StatusBadRequest || decodeErr(t, w) != "bad_request" {
t.Fatalf("missing approver_uuid: code = %d body %s, want 400 bad_request", w.Code, w.Body.String())
}
})
t.Run("unknown request id -> 404", func(t *testing.T) {
api, _, _ := seedOpLoginAPI(t)
if w := approveOp(api.InternalHandler(), "nosuchrequest", opUUID); w.Code != http.StatusNotFound || decodeErr(t, w) != "op_login_not_found" {
t.Fatalf("unknown request: code = %d body %s, want 404 op_login_not_found", w.Code, w.Body.String())
}
})
t.Run("re-approving an approved request -> 404 (first approval stands)", func(t *testing.T) {
api, repo, _ := seedOpLoginAPI(t)
id := plantPending(repo)
if w := approveOp(api.InternalHandler(), id, opUUID); w.Code != http.StatusOK {
t.Fatalf("first approve: code = %d, want 200 (%s)", w.Code, w.Body.String())
}
if w := approveOp(api.InternalHandler(), id, opUUID); w.Code != http.StatusNotFound {
t.Fatalf("second approve: code = %d, want 404 (no longer pending)", w.Code)
}
if repo.opLogins[id].status != "approved" {
t.Error("the request must remain approved after a redundant re-approval")
}
})
}
// TestOpLoginPendingList covers the internal push list: live pending requests are
// returned oldest first, carrying the joined username, and an approved or expired
// request is absent.
func TestOpLoginPendingList(t *testing.T) {
api, repo, _ := seedOpLoginAPI(t)
ih := api.InternalHandler()
future := time.Unix(1_700_000_600, 0)
// Two pending (distinct createdAt so ordering is deterministic), one approved, one
// expired.
repo.opLogins["r2"] = &fakeOpLogin{id: "r2", userID: "a1", email: "[email protected]", status: "pending", expiresAt: future, createdAt: time.Unix(1_700_000_200, 0)}
repo.opLogins["r1"] = &fakeOpLogin{id: "r1", userID: "a1", email: "[email protected]", status: "pending", expiresAt: future, createdAt: time.Unix(1_700_000_100, 0)}
repo.opLogins["ap"] = &fakeOpLogin{id: "ap", userID: "a1", email: "[email protected]", status: "approved", expiresAt: future, createdAt: time.Unix(1_700_000_150, 0)}
repo.opLogins["ex"] = &fakeOpLogin{id: "ex", userID: "a1", email: "[email protected]", status: "pending", expiresAt: time.Unix(1_699_999_999, 0), createdAt: time.Unix(1_700_000_050, 0)}
w := do(ih, "GET", "/api/v1/internal/op-login/pending", "", nil)
if w.Code != http.StatusOK {
t.Fatalf("pending: code = %d, want 200 (%s)", w.Code, w.Body.String())
}
body := acctBody(t, w)
pending, _ := body["pending"].([]any)
if len(pending) != 2 {
t.Fatalf("pending count = %d, want 2 (only live pending rows) — %v", len(pending), body["pending"])
}
// Oldest first: r1 (created earlier) before r2.
first, _ := pending[0].(map[string]any)
second, _ := pending[1].(map[string]any)
if first["request_id"] != "r1" || second["request_id"] != "r2" {
t.Errorf("order = [%v, %v], want [r1, r2] (oldest first)", first["request_id"], second["request_id"])
}
if first["username"] != "op" || first["email"] != "[email protected]" {
t.Errorf("row projection = %v, want username op / email [email protected]", first)
}
}
// TestOpLoginGates covers the shared front doors: the fail-closed local-auth toggle on
// all three public legs, the CSRF Content-Type guard on the credential-minting POSTs,
// and the input gates that must reject before any lookup or mint.
func TestOpLoginGates(t *testing.T) {
t.Run("local auth disabled -> 403 on start/status/finish", func(t *testing.T) {
api := newTestAPI(newFakeRepo(), newFakeCluster()) // no LocalAuthEnabledKey: fails closed
eh := api.ExternalHandler()
if w := startOp(eh, "[email protected]"); w.Code != http.StatusForbidden || decodeErr(t, w) != "local_auth_disabled" {
t.Errorf("start: code = %d body %s, want 403 local_auth_disabled", w.Code, w.Body.String())
}
if w := statusOp(eh, "anything"); w.Code != http.StatusForbidden || decodeErr(t, w) != "local_auth_disabled" {
t.Errorf("status: code = %d body %s, want 403 local_auth_disabled", w.Code, w.Body.String())
}
if w := finishOp(eh, "anything", "123456"); w.Code != http.StatusForbidden || decodeErr(t, w) != "local_auth_disabled" {
t.Errorf("finish: code = %d body %s, want 403 local_auth_disabled", w.Code, w.Body.String())
}
})
t.Run("non-JSON content type -> 415 on the minting POSTs", func(t *testing.T) {
api, _, _ := seedOpLoginAPI(t)
eh := api.ExternalHandler()
for _, ct := range []string{"", "text/plain", "application/x-www-form-urlencoded"} {
if w := do(eh, "POST", "/api/v1/auth/op-login/start", `{"email":"[email protected]"}`, ctHeader(ct)); w.Code != http.StatusUnsupportedMediaType {
t.Errorf("start Content-Type %q: code = %d, want 415", ct, w.Code)
}
if w := do(eh, "POST", "/api/v1/auth/op-login/finish", `{"request_id":"x","code":"1"}`, ctHeader(ct)); w.Code != http.StatusUnsupportedMediaType {
t.Errorf("finish Content-Type %q: code = %d, want 415", ct, w.Code)
}
}
})
t.Run("start bad email -> 400, nothing minted", func(t *testing.T) {
for _, body := range []string{`{}`, `{"email":""}`, `{"email":"notanemail"}`, `{"email":"[email protected]","x":1}`} {
api, repo, mailer := seedOpLoginAPI(t)
w := do(api.ExternalHandler(), "POST", "/api/v1/auth/op-login/start", body, jsonHeader)
if w.Code != http.StatusBadRequest {
t.Errorf("start %q: code = %d, want 400 (%s)", body, w.Code, w.Body.String())
}
if len(repo.opLogins) != 0 || len(repo.otps) != 0 || mailer.calls != 0 {
t.Errorf("start %q: a rejected start must mint nothing", body)
}
}
})
t.Run("finish missing request_id or code -> 400 bad_request", func(t *testing.T) {
api, _, _ := seedOpLoginAPI(t)
eh := api.ExternalHandler()
for _, body := range []string{`{"code":"123456"}`, `{"request_id":"x"}`, `{"request_id":"","code":""}`} {
if w := do(eh, "POST", "/api/v1/auth/op-login/finish", body, jsonHeader); w.Code != http.StatusBadRequest || decodeErr(t, w) != "bad_request" {
t.Errorf("finish %q: code = %d body %s, want 400 bad_request", body, w.Code, w.Body.String())
}
}
})
}
// TestOpLoginFaceSeparation enforces the two-face split: the three public browser legs
// must 404 on the internal (service-token) face, and the two internal in-game legs must
// 404 on the external (Access-JWT) face.
func TestOpLoginFaceSeparation(t *testing.T) {
api, _, _ := seedOpLoginAPI(t)
eh, ih := api.ExternalHandler(), api.InternalHandler()
// Public legs must not appear on the internal face.
if w := do(ih, "POST", "/api/v1/auth/op-login/start", `{"email":"[email protected]"}`, jsonHeader); w.Code != http.StatusNotFound {
t.Errorf("start on internal face: code = %d, want 404", w.Code)
}
if w := do(ih, "GET", "/api/v1/auth/op-login/status/x", "", nil); w.Code != http.StatusNotFound {
t.Errorf("status on internal face: code = %d, want 404", w.Code)
}
if w := do(ih, "POST", "/api/v1/auth/op-login/finish", `{"request_id":"x","code":"1"}`, jsonHeader); w.Code != http.StatusNotFound {
t.Errorf("finish on internal face: code = %d, want 404", w.Code)
}
// Internal legs must not appear on the external face.
if w := do(eh, "GET", "/api/v1/internal/op-login/pending", "", nil); w.Code != http.StatusNotFound {
t.Errorf("pending on external face: code = %d, want 404", w.Code)
}
if w := do(eh, "POST", "/api/v1/internal/op-login/x/approve", `{"approver_uuid":"`+opUUID+`"}`, nil); w.Code != http.StatusNotFound {
t.Errorf("approve on external face: code = %d, want 404", w.Code)
}
}
+288 -35
View File
@@ -8,46 +8,47 @@ import (
"errors" "errors"
"io" "io"
"net/http" "net/http"
"strings"
"time" "time"
) )
// Passkey enrollment (spec §14 WebAuthn / Phase 6 bind). An already-authenticated // Passkey (spec §14 WebAuthn). Two slices live in this file: ENROLLMENT — an already-
// principal binds a passkey to their account — the WebAuthn credential-creation // authenticated principal binds a passkey to their account (the WebAuthn credential-
// ceremony — and manages the credentials they have bound. Email-OTP (handlers_email_otp.go) // creation ceremony) and manages the credentials they have bound — and the public LOGIN
// stays the fallback factor, so a player with no passkey is never locked out. // (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 // Every ceremony rides on a challenge bound to a user_id whose finish verifies against the
// known principal — the challenge is bound to the caller's user_id and the finish // server-stashed SessionData, never a client-echoed challenge. The login door's
// verifies against the server-stashed SessionData, never a client-echoed challenge. The // cryptographic half is built and Oracle-verified in the adapter (internal/passkey
// login/assertion path (proving a passkey to mint a session from an UNauthenticated // BeginLogin/FinishLogin, against a virtual authenticator); its persist-ready output shape
// state) has its cryptographic half built and Oracle-verified in the adapter // is VerifiedAssertion below. The login door's design checkpoint (task #36) resolved two
// (internal/passkey BeginLogin/FinishLogin, against a virtual authenticator) and its // questions that still frame it:
// 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:
// //
// - RP boundary (RESOLVED): felis-api is the app-login relying party (panel.*); the // - 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 // 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 // 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 // no WebAuthn requirement — so this door is a login convenience, not a spec-required
// nor a backend step-up consumer for one (the role-switcher step-up UX is frontend). // backend step-up consumer (the role-switcher step-up UX is frontend).
// - Identifier (BLOCKING): a from-zero login needs a unique, human-typable handle to // - Identifier (RESOLVED by #69/#70): a from-zero login needs a unique, human-typable
// resolve the account before its passkeys can be offered. users.email is nullable // handle to resolve the account before its passkeys can be offered. users.email was
// and NOT unique (0001_init.sql), and a player's users.username IS their Minecraft // nullable and NOT unique (0001_init.sql), and a player's users.username IS their
// uuid (pgrepo.go RedeemPlayerBindCode mints a uuid-derived unique username) — // Minecraft uuid (pgrepo.go RedeemPlayerBindCode mints a uuid-derived unique username)
// opaque, never typed into a form. The username-first assertion the non-resident // — opaque, never typed into a form. The verified-email uniqueness invariant
// credentials + user-keyed challenge store support therefore has nothing to key on. // (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 // This is an EMAIL-first assertion, not a usernameless one. The system's returning-player
// is the root of trust — handlers_onboard.go re-mints a session through the bind-code // root of trust is still re-link (control of the in-game identity — handlers_onboard.go
// flow even after passkey/OTP are bound); passkey and email-OTP are factors on an // re-mints a session through the bind-code flow even after passkey/OTP are bound); the
// ALREADY-authenticated principal here, not from-zero login methods. The real enabler // email and passkey login doors are convenience layered on top, never the root. The real
// for a from-zero passkey login is discoverable ("usernameless") credentials, which // enabler for a TRULY from-zero passkey login (no identifier typed at all) is discoverable
// sidestep the identifier gap but reshape enrollment (residentKey) and need a // ("usernameless") credentials, which sidestep even the email handle but reshape enrollment
// non-user-keyed challenge store — a future migration and its own checkpoint (that door // (residentKey) and need a non-user-keyed challenge store — a future migration and its own
// partly bypasses the in-game-identity root of trust). The adapter crypto is verified // checkpoint, task #40 (that door partly bypasses the in-game-identity root of trust). The
// now so that slice inherits correct crypto; this file adds no unauthenticated login // adapter crypto is verified now so that slice inherits correct crypto.
// route until then.
// //
// The cryptographic half is a seam (PasskeyVerifier) so this package never imports // The cryptographic half is a seam (PasskeyVerifier) so this package never imports
// go-webauthn: ceremony state crosses the boundary as opaque bytes, the attestation // 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; // 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). // the handler maps it to 400 (the ceremony state exists; the attestation is bad).
FinishRegistration(user PasskeyUser, sessionData []byte, attestation io.Reader) (VerifiedCredential, error) 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: // 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 // 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 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 // The login handler below (handlePasskeyLoginFinish) obtains this from FinishLogin but
// output the production adapter (internal/passkey) already produces and its Oracle test // currently checks only that the assertion verified — the SignCount/UserVerified consumer
// already asserts on, so wiring the handlers later needs no reshaping here. // 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 { type VerifiedAssertion struct {
CredentialID string // base64url(raw credential id) — which bound credential signed CredentialID string // base64url(raw credential id) — which bound credential signed
SignCount uint32 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 // 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")) writeError(w, r, newError(http.StatusBadRequest, "bad_request", "attestation is required"))
return 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()) sessionData, err := a.Repo.ConsumePasskeyChallengeByUser(r.Context(), p.UserID, passkeyPurposeRegister, a.now())
if err != nil { if err != nil {
if errors.Is(err, ErrPasskeyChallengeInvalid) { if errors.Is(err, ErrPasskeyChallengeInvalid) {
@@ -250,7 +277,7 @@ func (a *API) handlePasskeyRegisterFinish(w http.ResponseWriter, r *http.Request
PublicKey: vc.PublicKey, PublicKey: vc.PublicKey,
SignCount: vc.SignCount, SignCount: vc.SignCount,
AAGUID: vc.AAGUID, AAGUID: vc.AAGUID,
Name: req.Name, Name: name,
CreatedAt: a.now(), CreatedAt: a.now(),
UserVerified: vc.UserVerified, UserVerified: vc.UserVerified,
BackupEligible: vc.BackupEligible, 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) a.audit(r, auditActor(p), "account.passkey.removed", id)
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
} }
// ---- passkey login (assertion) ----
// passkeyPurposeLogin scopes a challenge to the login (assertion) flow, keeping it
// from ever colliding with an enrollment challenge (passkeyPurposeRegister) for the
// same user. The challenge store is queried per (user, purpose), so the two flows
// are fully independent even for one account with both a live enrollment and a live
// login challenge.
const passkeyPurposeLogin = "passkey_login"
// passkeyLoginBeginRequest is the begin body: the email that resolves the account
// before its passkeys can be offered. There is no principal yet (this is a
// pre-session route), so the email is the identifier — the same role the typed
// email plays in the email-OTP and op-login doors.
type passkeyLoginBeginRequest struct {
Email string `json:"email"`
}
// handlePasskeyLoginBegin starts a passkey assertion ceremony for a returning user
// (Public, pre-session). It resolves the typed email to an account, loads the
// passkeys that account has bound, and asks the verifier for the assertion options
// + opaque SessionData the browser needs for navigator.credentials.get(). The
// SessionData is stashed under a short TTL, keyed to the user so the finish step
// can consume it. Requires local sessions to be enabled (like the other pre-session
// doors). A user with no bound passkey, an unknown email, and a real account with
// passkeys are distinguished by status code (400 vs 200) — this is an accepted
// enumeration trade-off (the /auth/options oracle is the sanctioned place to learn
// existence), but the per-recipient cooldown below makes probing impractical.
func (a *API) handlePasskeyLoginBegin(w http.ResponseWriter, r *http.Request) {
if !localAuthEnabled(r.Context(), a.Repo) {
writeError(w, r, newError(http.StatusForbidden, "local_auth_disabled",
"session login is disabled"))
return
}
if a.Passkey == nil {
writeError(w, r, errPasskeyUnavailable)
return
}
if err := requireJSONContentType(r); err != nil {
writeError(w, r, err)
return
}
var req passkeyLoginBeginRequest
if err := decodeJSON(w, r, &req); err != nil {
writeError(w, r, err)
return
}
email := strings.TrimSpace(req.Email)
if !looksLikeEmail(email) {
writeError(w, r, newError(http.StatusBadRequest, "bad_request", "a valid email is required"))
return
}
// Per-recipient cooldown reserved BEFORE any work, identical to the email-OTP and
// op-login doors: one winner per window, so a burst of probes is throttled. The
// key is namespaced apart from the other pre-session doors so they never perturb
// each other's throttle.
emailKey := "passkey:login:" + strings.ToLower(email)
lim := a.otpLimiter()
emailAt, ok := lim.reserve(emailKey, otpResendCooldown)
if !ok {
writeError(w, r, newError(http.StatusTooManyRequests, "otp_resend_cooldown",
"a passkey login was started recently; wait a moment before requesting another"))
return
}
committed := false
defer func() {
if !committed {
lim.release(emailKey, emailAt)
}
}()
u, err := a.Repo.UserByEmail(r.Context(), email)
if err != nil {
if errors.Is(err, ErrNotFound) {
committed = true // keep the reservation so probing is throttled
writeError(w, r, newError(http.StatusBadRequest, "no_passkey",
"no passkey enrolled for this account; use email or operator login"))
return
}
writeError(w, r, err)
return
}
creds, err := a.Repo.PasskeyCredentialsForUser(r.Context(), u.ID)
if err != nil {
writeError(w, r, err)
return
}
if len(creds) == 0 {
committed = true
writeError(w, r, newError(http.StatusBadRequest, "no_passkey",
"no passkey enrolled for this account; use email or operator login"))
return
}
user := PasskeyUser{
ID: u.ID,
Name: email,
DisplayName: u.Username,
Credentials: creds,
}
options, sessionData, err := a.Passkey.BeginLogin(user)
if err != nil {
writeError(w, r, newError(http.StatusBadRequest, "passkey_login_failed",
"could not start passkey login"))
return
}
id, err := newPasskeyID()
if err != nil {
writeError(w, r, err)
return
}
expiresAt := a.now().Add(passkeyChallengeTTL)
if err := a.Repo.CreatePasskeyChallenge(r.Context(), id, u.ID, passkeyPurposeLogin, sessionData, expiresAt); err != nil {
writeError(w, r, err)
return
}
committed = true
writeJSON(w, http.StatusOK, options)
}
// passkeyLoginFinishRequest is the finish body: the email (to resolve the account,
// as in the begin step) and the raw navigator.credentials.get() assertion response.
// Attestation is captured as RawMessage so the handler hands the exact bytes the
// browser produced to the verifier without re-encoding.
type passkeyLoginFinishRequest struct {
Email string `json:"email"`
Assertion json.RawMessage `json:"assertion"`
}
// handlePasskeyLoginFinish verifies a passkey assertion and mints a session (Public,
// pre-session). It resolves the email to the account, atomically consumes the
// stashed login challenge (a missing or expired one → 400), verifies the assertion
// against the SessionData, and mints a felis_session. Both players and staff may
// log in this way — the passkey is a two-factor authenticator (possession +
// biometric/PIN), strong enough to stand alone without the in-game approval the
// op-login flow requires. The session cookie is host-only, so a session minted on
// console.<root_domain> cannot reach op.console, and ViaAdminAccess is host-checked
// so admin operations are gated regardless.
func (a *API) handlePasskeyLoginFinish(w http.ResponseWriter, r *http.Request) {
if !localAuthEnabled(r.Context(), a.Repo) {
writeError(w, r, newError(http.StatusForbidden, "local_auth_disabled",
"session login is disabled"))
return
}
if a.Passkey == nil {
writeError(w, r, errPasskeyUnavailable)
return
}
if err := requireJSONContentType(r); err != nil {
writeError(w, r, err)
return
}
var req passkeyLoginFinishRequest
if err := decodeJSON(w, r, &req); err != nil {
writeError(w, r, err)
return
}
email := strings.TrimSpace(req.Email)
if !looksLikeEmail(email) {
writeError(w, r, newError(http.StatusBadRequest, "bad_request", "a valid email is required"))
return
}
if len(req.Assertion) == 0 {
writeError(w, r, newError(http.StatusBadRequest, "bad_request", "assertion is required"))
return
}
u, err := a.Repo.UserByEmail(r.Context(), email)
if err != nil {
if errors.Is(err, ErrNotFound) {
writeError(w, r, newError(http.StatusBadRequest, "passkey_login_invalid",
"passkey login could not be completed; begin again"))
return
}
writeError(w, r, err)
return
}
sessionData, err := a.Repo.ConsumePasskeyChallengeByUser(r.Context(), u.ID, passkeyPurposeLogin, a.now())
if err != nil {
if errors.Is(err, ErrPasskeyChallengeInvalid) {
writeError(w, r, newError(http.StatusBadRequest, "passkey_login_invalid",
"passkey login could not be completed; begin again"))
return
}
writeError(w, r, err)
return
}
creds, err := a.Repo.PasskeyCredentialsForUser(r.Context(), u.ID)
if err != nil {
writeError(w, r, err)
return
}
user := PasskeyUser{
ID: u.ID,
Name: email,
DisplayName: u.Username,
Credentials: creds,
}
_, err = a.Passkey.FinishLogin(user, sessionData, bytes.NewReader(req.Assertion))
if err != nil {
writeError(w, r, newError(http.StatusBadRequest, "passkey_login_invalid",
"passkey login could not be completed; begin again"))
return
}
token, err := newSessionToken()
if err != nil {
writeError(w, r, err)
return
}
expires := a.now().Add(sessionTTL)
if err := a.Repo.CreateSession(r.Context(), hashCookie(token), u.ID, expires); err != nil {
writeError(w, r, err)
return
}
setSessionCookie(w, token, expires)
a.audit(r, u.Username, "auth.passkey_login", "")
writeJSON(w, http.StatusOK, map[string]any{
"user_id": u.ID,
"role": u.Role,
})
}
+460
View File
@@ -0,0 +1,460 @@
package api
import (
"bytes"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
"time"
)
// Pre-session Passkey (assertion) LOGIN tests (spec §B, console.<root_domain>
// returning-player door — the public sibling of the email-OTP login door). These drive
// the two Public routes against the fakeRepo challenge state machine and a fake
// PasskeyVerifier, so what they PROVE is the handler + login state machine (challenge
// stash → consume → session mint), not the pgrepo SQL nor the cryptographic assertion
// verification (both mirrored, not run here). The load-bearing properties, in flow order:
//
// - Session-data round-trip: the finish body carries only email+assertion, so the only
// path for the stashed blob into FinishLogin is store-stash → consume — the challenge
// is never client-echoed.
// - Anti-enumeration on finish: unknown email, no live challenge, expired challenge and
// a bad assertion all collapse to ONE passkey_login_invalid envelope, so the finish
// half is never an existence/state oracle.
// - Cooldown seals the accepted begin-side trade-off: has-passkey (200) vs no_passkey
// (400) is a status oracle, but one begin per recipient per window throttles probing.
// - Staff admitted: unlike the email door's staff_account refusal, a passkey stands
// alone (possession + user-verification), so role=admin mints a session here.
// seedLoginPasskeyAPI wires the public passkey-login door: local sessions enabled, a
// single verified player "player" (id u1) whose proven address is stored in MIXED case
// (so the resolve-on-typed-lowercase contract is exercised by default), one passkey
// credential bound to that account, and a verifier primed with fixed options + a verified
// assertion. Both routes are Public — no External principal wired, proving they are truly
// pre-session.
func seedLoginPasskeyAPI(t *testing.T) (*API, *fakeRepo, *fakePasskeyVerifier) {
t.Helper()
repo := newFakeRepo()
repo.settings[LocalAuthEnabledKey] = []byte("true")
repo.staff["player"] = &StaffUser{
ID: "u1", Username: "player", Email: "[email protected]",
Role: "user", EmailVerified: true,
}
repo.passkeyCreds["row1"] = PasskeyCredential{
ID: "row1", UserID: "u1", CredentialID: "cred-1", PublicKey: "k", CreatedAt: frozenNow,
}
v := &fakePasskeyVerifier{
options: json.RawMessage(`{"publicKey":{"challenge":"YXNzZXJ0"}}`),
assertion: VerifiedAssertion{CredentialID: "cred-1", UserVerified: true},
}
api := newTestAPI(repo, newFakeCluster())
api.Passkey = v
return api, repo, v
}
// plantLoginChallenge seeds a stashed LOGIN-purpose challenge for u1 directly, so the
// finish-side branches (expired, verification failure) are reachable under the frozen
// clock without running begin first. Mirrors plantPasskeyChallenge, but scoped to
// passkeyPurposeLogin so it is only ever consumed by the login door.
func plantLoginChallenge(repo *fakeRepo, id string, expiresAt time.Time) {
repo.passkeyChallenges[id] = &fakePasskeyChallenge{
id: id, userID: "u1", purpose: passkeyPurposeLogin,
sessionData: []byte("login-session:u1"), expiresAt: expiresAt, createdAt: expiresAt,
}
}
// TestPasskeyLoginVertical walks the whole returning-player slice across the external
// face: begin resolves the mixed-case account from a lowercase-typed email, hands its
// bound credential to the verifier, returns the assertion options verbatim and stashes
// one login challenge; finish verifies the assertion against the SERVER-STASHED session
// data and mints the same host-only felis_session as the email door. The decisive
// assertion is the session-data round-trip: the finish body carries only email+assertion,
// so the only path for the stashed blob into FinishLogin is store-stash → consume,
// proving the challenge is never client-echoed.
func TestPasskeyLoginVertical(t *testing.T) {
api, repo, v := seedLoginPasskeyAPI(t)
eh := api.ExternalHandler()
// 1) begin: options verbatim, exactly one login-purpose challenge stashed for u1, and
// the account's bound credential handed to the verifier (so the authenticator can be
// asked to assert with a known key).
w := do(eh, "POST", "/api/v1/auth/passkey/login/begin", `{"email":"[email protected]"}`, jsonHeader)
if w.Code != http.StatusOK {
t.Fatalf("begin: code = %d, want 200 (%s)", w.Code, w.Body.String())
}
if b := acctBody(t, w); b["publicKey"] == nil {
t.Errorf("begin must return the assertion options verbatim, got %s", w.Body.String())
}
if v.lastUser.ID != "u1" {
t.Errorf("begin passed user id %q, want u1", v.lastUser.ID)
}
if len(v.lastUser.Credentials) != 1 || v.lastUser.Credentials[0].CredentialID != "cred-1" {
t.Errorf("begin must hand the account's bound credential to the verifier, got %+v", v.lastUser.Credentials)
}
if len(repo.passkeyChallenges) != 1 {
t.Fatalf("begin must stash exactly one challenge, got %d", len(repo.passkeyChallenges))
}
for _, c := range repo.passkeyChallenges {
if c.userID != "u1" || c.purpose != passkeyPurposeLogin {
t.Errorf("stashed challenge = %+v, want user u1 purpose %q", c, passkeyPurposeLogin)
}
}
// 2) finish — typed in yet another casing, proving the finish-side resolver is
// case-insensitive too — verifies the assertion and mints the session. The body
// carries NO challenge, only email+assertion.
w = do(eh, "POST", "/api/v1/auth/passkey/login/finish",
`{"email":"[email protected]","assertion":{"id":"cred-1","type":"public-key"}}`, jsonHeader)
if w.Code != http.StatusOK {
t.Fatalf("finish: code = %d, want 200 (%s)", w.Code, w.Body.String())
}
// THE security assertion: the blob the verifier saw at finish is exactly what begin
// stashed — it travelled store-stash → consume, never the client.
if !bytes.Equal(v.lastSession, []byte("login-session:u1")) {
t.Fatalf("finish session data = %q, want the server-stashed %q (challenge must not be client-echoed)",
v.lastSession, "login-session:u1")
}
vb := acctBody(t, w)
if vb["user_id"] != "u1" || vb["role"] != "user" {
t.Fatalf("finish body = %v, want user_id:u1 role:user", vb)
}
// The host-only HttpOnly cookie is the whole point — same contract as the email door.
cookies := w.Result().Cookies()
if len(cookies) != 1 || cookies[0].Name != sessionCookieName || cookies[0].Value == "" {
t.Fatalf("want one non-empty %s cookie, got %v", sessionCookieName, cookies)
}
s, ok := repo.sessions[hashCookie(cookies[0].Value)]
if !ok {
t.Fatal("no session row for the issued cookie (must be stored hashed)")
}
if s.userID != "u1" {
t.Errorf("session userID = %q, want u1", s.userID)
}
if want := frozenNow.Add(sessionTTL); !s.expiresAt.Equal(want) {
t.Errorf("session expiresAt = %v, want now+sessionTTL = %v", s.expiresAt, want)
}
// Audited once, by the account's username (there is no principal yet); begin is silent.
if n := len(repo.audits); n != 1 {
t.Fatalf("want exactly 1 audit (passkey_login), got %d: %+v", n, repo.audits)
}
if repo.audits[0].Action != "auth.passkey_login" || repo.audits[0].Actor != "player" {
t.Errorf("audit = %+v, want auth.passkey_login by player", repo.audits[0])
}
// 3) single-use: the consumed challenge buys nothing a second time.
if w := do(eh, "POST", "/api/v1/auth/passkey/login/finish",
`{"email":"[email protected]","assertion":{"id":"cred-1","type":"public-key"}}`, jsonHeader); w.Code != http.StatusBadRequest || decodeErr(t, w) != "passkey_login_invalid" {
t.Fatalf("replay of consumed challenge: code = %d body %s, want 400 passkey_login_invalid", w.Code, w.Body.String())
}
}
// TestPasskeyLoginBeginNoPasskey pins the begin-side anti-enumeration floor: an unknown
// email and a KNOWN verified account that has enrolled no passkey answer the SAME
// no_passkey envelope, so the two are indistinguishable. (The remaining has-passkey-vs-not
// status split is the documented, accepted trade-off; the cooldown below makes probing
// it impractical.) Neither path stashes a challenge, and both KEEP the reservation.
func TestPasskeyLoginBeginNoPasskey(t *testing.T) {
begin := func(eh http.Handler, email string) *httptest.ResponseRecorder {
return do(eh, "POST", "/api/v1/auth/passkey/login/begin", `{"email":"`+email+`"}`, jsonHeader)
}
t.Run("unknown email and a passkey-less account answer the same no_passkey", func(t *testing.T) {
// Unknown email: no account at all.
apiU, repoU, _ := seedLoginPasskeyAPI(t)
wGhost := begin(apiU.ExternalHandler(), "[email protected]")
// Known verified account that has enrolled NO passkey.
apiN, repoN, _ := seedLoginPasskeyAPI(t)
delete(repoN.passkeyCreds, "row1")
wNone := begin(apiN.ExternalHandler(), "[email protected]")
if wGhost.Code != http.StatusBadRequest || wNone.Code != http.StatusBadRequest {
t.Fatalf("codes = %d/%d, want 400/400", wGhost.Code, wNone.Code)
}
gc, gm := errEnvelope(t, wGhost)
nc, nm := errEnvelope(t, wNone)
if gc != "no_passkey" || gc != nc || gm != nm {
t.Errorf("envelopes differ: unknown=(%s,%q) no-cred=(%s,%q) — must be identical no_passkey", gc, gm, nc, nm)
}
// Neither may stash a challenge or reach BeginLogin.
if len(repoU.passkeyChallenges) != 0 || len(repoN.passkeyChallenges) != 0 {
t.Errorf("no_passkey paths must stash nothing, got unknown=%d no-cred=%d",
len(repoU.passkeyChallenges), len(repoN.passkeyChallenges))
}
})
t.Run("the no_passkey path KEEPS the reservation so probing is throttled", func(t *testing.T) {
api, _, _ := seedLoginPasskeyAPI(t)
eh := api.ExternalHandler()
if w := begin(eh, "[email protected]"); w.Code != http.StatusBadRequest || decodeErr(t, w) != "no_passkey" {
t.Fatalf("first probe: code = %d body %s, want 400 no_passkey", w.Code, w.Body.String())
}
// Re-probing the same unknown address inside the window is throttled identically to
// a real begin — the response is not the only channel; the throttle is sealed too.
if w := begin(eh, "[email protected]"); w.Code != http.StatusTooManyRequests || decodeErr(t, w) != "otp_resend_cooldown" {
t.Fatalf("re-probe: code = %d body %s, want 429 otp_resend_cooldown", w.Code, w.Body.String())
}
})
}
// TestPasskeyLoginBeginVerifierError pins the reserve→rollback path: a BeginLogin failure
// is a server-side fault, not a probe signal, so it answers passkey_login_failed AND
// RELEASES the reservation — the immediate retry is admitted, not 429'd. Distinguishing
// 400-not-429 on the retry is what proves the release: a kept reservation would 429 before
// ever reaching BeginLogin.
func TestPasskeyLoginBeginVerifierError(t *testing.T) {
api, repo, v := seedLoginPasskeyAPI(t)
v.beginLoginErr = errors.New("no assertable credential")
eh := api.ExternalHandler()
if w := do(eh, "POST", "/api/v1/auth/passkey/login/begin", `{"email":"[email protected]"}`, jsonHeader); w.Code != http.StatusBadRequest || decodeErr(t, w) != "passkey_login_failed" {
t.Fatalf("verifier error: code = %d body %s, want 400 passkey_login_failed", w.Code, w.Body.String())
}
if len(repo.passkeyChallenges) != 0 {
t.Errorf("a failed begin must stash no challenge, got %d", len(repo.passkeyChallenges))
}
if w := do(eh, "POST", "/api/v1/auth/passkey/login/begin", `{"email":"[email protected]"}`, jsonHeader); w.Code != http.StatusBadRequest || decodeErr(t, w) != "passkey_login_failed" {
t.Fatalf("retry after verifier error: code = %d body %s, want 400 passkey_login_failed (reservation must be released, not 429)", w.Code, w.Body.String())
}
}
// TestPasskeyLoginGates covers the shared front doors of both halves: the fail-closed
// local-auth toggle, graceful degradation when no verifier is wired, the CSRF Content-Type
// guard (these are Public, credential-minting routes), and the input gates that must
// reject before any lookup or stash.
func TestPasskeyLoginGates(t *testing.T) {
const beginPath = "/api/v1/auth/passkey/login/begin"
const finishPath = "/api/v1/auth/passkey/login/finish"
const goodBegin = `{"email":"[email protected]"}`
const goodFinish = `{"email":"[email protected]","assertion":{"id":"cred-1"}}`
t.Run("local auth disabled -> 403 on both halves", func(t *testing.T) {
api := newTestAPI(newFakeRepo(), newFakeCluster()) // no LocalAuthEnabledKey: fails closed
api.Passkey = &fakePasskeyVerifier{}
eh := api.ExternalHandler()
if w := do(eh, "POST", beginPath, goodBegin, jsonHeader); w.Code != http.StatusForbidden || decodeErr(t, w) != "local_auth_disabled" {
t.Errorf("begin: code = %d body %s, want 403 local_auth_disabled", w.Code, w.Body.String())
}
if w := do(eh, "POST", finishPath, goodFinish, jsonHeader); w.Code != http.StatusForbidden || decodeErr(t, w) != "local_auth_disabled" {
t.Errorf("finish: code = %d body %s, want 403 local_auth_disabled", w.Code, w.Body.String())
}
})
t.Run("no verifier wired -> 503 passkey_unavailable on both halves", func(t *testing.T) {
api, _, _ := seedLoginPasskeyAPI(t)
api.Passkey = nil // unwire it: the degraded path must be a clean 503, not a panic
eh := api.ExternalHandler()
if w := do(eh, "POST", beginPath, goodBegin, jsonHeader); w.Code != http.StatusServiceUnavailable || decodeErr(t, w) != "passkey_unavailable" {
t.Errorf("begin: code = %d body %s, want 503 passkey_unavailable", w.Code, w.Body.String())
}
if w := do(eh, "POST", finishPath, goodFinish, jsonHeader); w.Code != http.StatusServiceUnavailable || decodeErr(t, w) != "passkey_unavailable" {
t.Errorf("finish: code = %d body %s, want 503 passkey_unavailable", w.Code, w.Body.String())
}
})
t.Run("non-JSON content type -> 415 on both halves", func(t *testing.T) {
api, _, _ := seedLoginPasskeyAPI(t)
eh := api.ExternalHandler()
for _, ct := range []string{"", "text/plain", "application/x-www-form-urlencoded"} {
if w := do(eh, "POST", beginPath, goodBegin, ctHeader(ct)); w.Code != http.StatusUnsupportedMediaType {
t.Errorf("begin with Content-Type %q: code = %d, want 415", ct, w.Code)
}
if w := do(eh, "POST", finishPath, goodFinish, ctHeader(ct)); w.Code != http.StatusUnsupportedMediaType {
t.Errorf("finish with Content-Type %q: code = %d, want 415", ct, w.Code)
}
}
})
t.Run("begin bad email -> 400, nothing stashed", func(t *testing.T) {
bad := map[string]string{
"missing email": `{}`,
"empty email": `{"email":""}`,
"no at-sign": `{"email":"notanemail"}`,
"two at-signs": `{"email":"a@[email protected]"}`,
"unknown field": `{"email":"[email protected]","x":1}`,
}
for name, body := range bad {
api, repo, _ := seedLoginPasskeyAPI(t)
w := do(api.ExternalHandler(), "POST", beginPath, body, jsonHeader)
if w.Code != http.StatusBadRequest {
t.Errorf("%s: code = %d, want 400 (%s)", name, w.Code, w.Body.String())
}
if len(repo.passkeyChallenges) != 0 {
t.Errorf("%s: a rejected begin must stash nothing (%d)", name, len(repo.passkeyChallenges))
}
}
})
t.Run("finish bad inputs -> 400", func(t *testing.T) {
// An assertion key of {} is non-empty (2 bytes), so it clears the len==0 gate and
// fails later at passkey_login_invalid — the "missing assertion" gate is only the
// absent key. These are the cases the input gate itself must catch.
cases := []struct{ name, body, wantCode string }{
{"bad email", `{"email":"notanemail","assertion":{"id":"x"}}`, "bad_request"},
{"missing assertion", `{"email":"[email protected]"}`, "bad_request"},
{"unknown field", `{"email":"[email protected]","assertion":{"id":"x"},"z":1}`, ""},
}
for _, c := range cases {
api, _, _ := seedLoginPasskeyAPI(t)
w := do(api.ExternalHandler(), "POST", finishPath, c.body, jsonHeader)
if w.Code != http.StatusBadRequest {
t.Errorf("%s: code = %d, want 400 (%s)", c.name, w.Code, w.Body.String())
}
if c.wantCode != "" && decodeErr(t, w) != c.wantCode {
t.Errorf("%s: error code = %q, want %q", c.name, decodeErr(t, w), c.wantCode)
}
}
})
}
// TestPasskeyLoginFinishRejections is the redeem-side failure matrix and the anchor for
// anti-enumeration: an unknown email, a known account with no live challenge, an expired
// challenge and an assertion that fails verification must ALL answer the byte-identical
// passkey_login_invalid envelope (code AND message) and mint no session — so the finish
// half never doubles as an existence or ceremony-state oracle. Expired state is planted
// directly: the frozen clock makes that the only deterministic route to that branch.
func TestPasskeyLoginFinishRejections(t *testing.T) {
const finishPath = "/api/v1/auth/passkey/login/finish"
finish := func(eh http.Handler, email string) *httptest.ResponseRecorder {
return do(eh, "POST", finishPath,
`{"email":"`+email+`","assertion":{"id":"cred-1","type":"public-key"}}`, jsonHeader)
}
cases := []struct {
name string
email string
setup func(repo *fakeRepo, v *fakePasskeyVerifier)
}{
{"unknown email", "[email protected]", func(repo *fakeRepo, v *fakePasskeyVerifier) {}},
{"known account, no live challenge", "[email protected]", func(repo *fakeRepo, v *fakePasskeyVerifier) {}},
{"expired challenge", "[email protected]", func(repo *fakeRepo, v *fakePasskeyVerifier) {
plantLoginChallenge(repo, "ex", frozenNow.Add(-time.Second))
}},
{"assertion fails verification", "[email protected]", func(repo *fakeRepo, v *fakePasskeyVerifier) {
plantLoginChallenge(repo, "live", frozenNow.Add(passkeyChallengeTTL))
v.failErr = errors.New("bad assertion")
}},
}
var envelopes [][2]string
for _, c := range cases {
api, repo, v := seedLoginPasskeyAPI(t)
c.setup(repo, v)
w := finish(api.ExternalHandler(), c.email)
if w.Code != http.StatusBadRequest {
t.Fatalf("%s: code = %d, want 400 (%s)", c.name, w.Code, w.Body.String())
}
code, msg := errEnvelope(t, w)
if code != "passkey_login_invalid" {
t.Errorf("%s: error code = %q, want passkey_login_invalid", c.name, code)
}
if len(repo.sessions) != 0 {
t.Errorf("%s: a rejected finish must mint no session (got %d)", c.name, len(repo.sessions))
}
if len(w.Result().Cookies()) != 0 {
t.Errorf("%s: a rejected finish must set no cookie", c.name)
}
envelopes = append(envelopes, [2]string{code, msg})
}
// The anchor: every envelope is identical (code AND message), so no branch is
// distinguishable from another.
for i := 1; i < len(envelopes); i++ {
if envelopes[i] != envelopes[0] {
t.Errorf("envelope for %q %v differs from %q %v — all rejections must be identical",
cases[i].name, envelopes[i], cases[0].name, envelopes[0])
}
}
}
// TestPasskeyLoginBeginRateLimited closes the unauthenticated probing/DoS vector on the
// public door: one begin per recipient per window, keyed case-insensitively (a recased
// retype is the same mailbox), recovering after the window elapses. This is what makes the
// accepted has-passkey-vs-not status oracle impractical to farm.
func TestPasskeyLoginBeginRateLimited(t *testing.T) {
begin := func(eh http.Handler, email string) *httptest.ResponseRecorder {
return do(eh, "POST", "/api/v1/auth/passkey/login/begin", `{"email":"`+email+`"}`, jsonHeader)
}
t.Run("same recipient is throttled, then recovers after the cooldown", func(t *testing.T) {
api, _, _ := seedLoginPasskeyAPI(t)
clock := frozenNow
api.Now = func() time.Time { return clock }
eh := api.ExternalHandler()
if w := begin(eh, "[email protected]"); w.Code != http.StatusOK {
t.Fatalf("first begin: code = %d, want 200 (%s)", w.Code, w.Body.String())
}
if w := begin(eh, "[email protected]"); w.Code != http.StatusTooManyRequests || decodeErr(t, w) != "otp_resend_cooldown" {
t.Fatalf("immediate re-begin: code = %d body %s, want 429 otp_resend_cooldown", w.Code, w.Body.String())
}
clock = clock.Add(otpResendCooldown + time.Second)
if w := begin(eh, "[email protected]"); w.Code != http.StatusOK {
t.Fatalf("post-cooldown begin: code = %d, want 200 (%s)", w.Code, w.Body.String())
}
})
t.Run("throttle key is case-insensitive", func(t *testing.T) {
api, _, _ := seedLoginPasskeyAPI(t)
eh := api.ExternalHandler()
if w := begin(eh, "[email protected]"); w.Code != http.StatusOK {
t.Fatalf("first begin: code = %d, want 200 (%s)", w.Code, w.Body.String())
}
if w := begin(eh, "[email protected]"); w.Code != http.StatusTooManyRequests {
t.Fatalf("recased re-begin: code = %d, want 429 (key must be lowercased)", w.Code)
}
})
}
// TestPasskeyLoginAllowsStaff pins the deliberate contrast with the email door: that door
// refuses role != user with 403 staff_account (op.console keeps its Zero-Trust in-game
// gate), but the passkey door ADMITS staff — a passkey is a strong two-factor authenticator
// (possession + user-verification), enough to stand alone. This guards against a future
// "make the doors consistent" change silently locking admins out of passkey login.
func TestPasskeyLoginAllowsStaff(t *testing.T) {
repo := newFakeRepo()
repo.settings[LocalAuthEnabledKey] = []byte("true")
repo.staff["boss"] = &StaffUser{
ID: "a1", Username: "boss", Email: "[email protected]",
Role: "admin", EmailVerified: true,
}
repo.passkeyCreds["row1"] = PasskeyCredential{
ID: "row1", UserID: "a1", CredentialID: "cred-a1", PublicKey: "k", CreatedAt: frozenNow,
}
v := &fakePasskeyVerifier{
options: json.RawMessage(`{"publicKey":{"challenge":"YXNzZXJ0"}}`),
assertion: VerifiedAssertion{CredentialID: "cred-a1", UserVerified: true},
}
api := newTestAPI(repo, newFakeCluster())
api.Passkey = v
eh := api.ExternalHandler()
if w := do(eh, "POST", "/api/v1/auth/passkey/login/begin", `{"email":"[email protected]"}`, jsonHeader); w.Code != http.StatusOK {
t.Fatalf("begin for staff: code = %d, want 200 (%s)", w.Code, w.Body.String())
}
w := do(eh, "POST", "/api/v1/auth/passkey/login/finish",
`{"email":"[email protected]","assertion":{"id":"cred-a1","type":"public-key"}}`, jsonHeader)
if w.Code != http.StatusOK {
t.Fatalf("finish for staff: code = %d, want 200 (passkey admits staff) (%s)", w.Code, w.Body.String())
}
if b := acctBody(t, w); b["user_id"] != "a1" || b["role"] != "admin" {
t.Fatalf("finish body = %v, want user_id:a1 role:admin", b)
}
if len(repo.sessions) != 1 {
t.Errorf("a staff passkey login must mint a session, got %d", len(repo.sessions))
}
}
// TestPasskeyLoginFaceSeparation enforces that both halves are web-only: the internal
// (service-token) face must 404 them, never serve them.
func TestPasskeyLoginFaceSeparation(t *testing.T) {
api, _, _ := seedLoginPasskeyAPI(t)
ih := api.InternalHandler()
if w := do(ih, "POST", "/api/v1/auth/passkey/login/begin", `{"email":"[email protected]"}`, jsonHeader); w.Code != http.StatusNotFound {
t.Errorf("begin on internal face: code = %d, want 404", w.Code)
}
if w := do(ih, "POST", "/api/v1/auth/passkey/login/finish", `{"email":"[email protected]","assertion":{"id":"x"}}`, jsonHeader); w.Code != http.StatusNotFound {
t.Errorf("finish on internal face: code = %d, want 404", w.Code)
}
}
+8 -9
View File
@@ -100,7 +100,7 @@ func TestReclaimProtectsAdminOnYggdrasil(t *testing.T) {
const adminUUID = "0a11dead-0000-0000-0000-00000000ad11" const adminUUID = "0a11dead-0000-0000-0000-00000000ad11"
repo := newFakeRepo() repo := newFakeRepo()
// An Operator who linked in-game through the third-party Yggdrasil (auth_source). // 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.links[adminUUID] = "op-1"
repo.linkAuthSource[adminUUID] = authSourceThirdParty repo.linkAuthSource[adminUUID] = authSourceThirdParty
@@ -151,26 +151,25 @@ func TestReclaimProtectsAdminOnYggdrasil(t *testing.T) {
// - a Mojang-authenticated admin is still reclaimed (pins auth_source='thirdparty') — // - 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 // 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); // 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 // - an SSO Operator authenticated through the third-party Yggdrasil is protected even
// ABSENCE of a password_hash test) — signing in via Cloudflare Access (§14) leaves // with no local login secret at all — protection turns on role + auth_source, so an
// role='admin' with a NULL hash, and that holder must be protected all the same. // admin who signs in via Cloudflare Access (§14) is covered just the same.
func TestReclaimAdminProtectionScope(t *testing.T) { func TestReclaimAdminProtectionScope(t *testing.T) {
const squatter = "0a11dead-0000-0000-0000-00000000ad11" const squatter = "0a11dead-0000-0000-0000-00000000ad11"
cases := []struct { cases := []struct {
name string name string
role string role string
auth string auth string
passHash string
protected bool // true: reclaim refused (409); false: reclaim succeeds (200, barred) protected bool // true: reclaim refused (409); false: reclaim succeeds (200, barred)
}{ }{
{"thirdparty non-admin is reclaimed", "user", authSourceThirdParty, "", false}, {"thirdparty non-admin is reclaimed", "user", authSourceThirdParty, false},
{"mojang admin is reclaimed", "admin", authSourceMojang, "$2a$10$VnJ5kZqZ9bQmsCp1uoQ3qO", false}, {"mojang admin is reclaimed", "admin", authSourceMojang, false},
{"sso admin without local password is protected", "admin", authSourceThirdParty, "", true}, {"sso admin without local password is protected", "admin", authSourceThirdParty, true},
} }
for _, tc := range cases { for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
repo := newFakeRepo() 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.links[squatter] = "h-1"
repo.linkAuthSource[squatter] = tc.auth repo.linkAuthSource[squatter] = tc.auth
api := newTestAPI(repo, newFakeCluster()) api := newTestAPI(repo, newFakeCluster())
+135
View File
@@ -0,0 +1,135 @@
package api
import (
"crypto/sha256"
"encoding/hex"
"errors"
"net/http"
"strings"
)
// Setup-token redemption (spec §B setup bootstrap). The `felis setup` MC-bind
// flow mints a one-time token and prints a URL like:
//
// https://op.console.<root>/setup?token=<raw>
//
// The Owner opens that URL in a browser; the SPA reads the token from the query
// string and POSTs it here. This handler consumes the token (single-use, hashed
// at rest like session cookies), mints a felis_session, and returns the caller's
// setup state so the frontend can guide email verification + passkey enrollment
// before unlocking the admin console.
//
// The minted session is a "lockdown" session in product terms: the Owner has not
// yet proven control of an email or enrolled a passkey, so the frontend restricts
// it to the setup wizard. Backend enforcement of the lockdown is a separate
// middleware concern (checking email_verified on the principal); this handler's
// job is the one-time token→session swap and reporting what setup remains.
// setupRedeemRequest is the redeem body: the raw one-time token from the setup URL.
type setupRedeemRequest struct {
Token string `json:"token"`
}
// handleSetupRedeem consumes a one-time setup token and mints a lockdown session
// (Public, pre-session). The token is hashed (sha-256) before lookup — only the
// hash is persisted, mirroring session-cookie storage. On success the caller
// receives a felis_session cookie and a JSON body describing the remaining setup
// steps (email set? verified? passkey enrolled?) so the SPA can drive the wizard.
func (a *API) handleSetupRedeem(w http.ResponseWriter, r *http.Request) {
if !localAuthEnabled(r.Context(), a.Repo) {
writeError(w, r, newError(http.StatusForbidden, "local_auth_disabled",
"session login is disabled"))
return
}
if err := requireJSONContentType(r); err != nil {
writeError(w, r, err)
return
}
var req setupRedeemRequest
if err := decodeJSON(w, r, &req); err != nil {
writeError(w, r, err)
return
}
token := strings.TrimSpace(req.Token)
if token == "" {
writeError(w, r, newError(http.StatusBadRequest, "bad_request", "token is required"))
return
}
// Hash the raw token — only the hash is stored (mirroring session cookies and
// setup token creation in performSetupMCBind).
sum := sha256.Sum256([]byte(token))
tokenHash := hex.EncodeToString(sum[:])
now := a.now()
userID, err := a.Repo.ConsumeSetupToken(r.Context(), tokenHash, now)
if err != nil {
// Unknown, already-consumed, or expired — uniform 400 so the token cannot
// be used as an oracle.
writeError(w, r, newError(http.StatusBadRequest, "setup_token_invalid",
"this setup link is invalid or has already been used"))
return
}
u, err := a.Repo.UserByID(r.Context(), userID)
if err != nil {
writeError(w, r, err)
return
}
// Mint the session — a regular felis_session; the lockdown is a product-level
// restriction the frontend enforces until email is verified / a passkey is bound.
sessionToken, err := newSessionToken()
if err != nil {
writeError(w, r, err)
return
}
expires := now.Add(sessionTTL)
if err := a.Repo.CreateSession(r.Context(), hashCookie(sessionToken), u.ID, expires); err != nil {
writeError(w, r, err)
return
}
setSessionCookie(w, sessionToken, expires)
// Report the setup state so the SPA knows which wizard steps remain.
creds, _ := a.Repo.PasskeyCredentialsForUser(r.Context(), u.ID)
hasPasskey := len(creds) > 0
a.audit(r, u.Username, "auth.setup_redeem", "")
writeJSON(w, http.StatusOK, map[string]any{
"user_id": u.ID,
"username": u.Username,
"role": u.Role,
"email": u.Email,
"email_verified": u.EmailVerified,
"has_passkey": hasPasskey,
"setup_required": !u.EmailVerified || !hasPasskey,
})
}
// handleSetupStatus reports the caller's setup progress (app-tier). The SPA polls
// it after each wizard step (email verify, passkey enroll) to decide whether the
// lockdown can lift. It reads only the principal's own state.
func (a *API) handleSetupStatus(w http.ResponseWriter, r *http.Request) {
p := principalFromContext(r.Context())
u, err := a.Repo.UserByID(r.Context(), p.UserID)
if err != nil {
if errors.Is(err, ErrNotFound) {
writeError(w, r, newError(http.StatusNotFound, "not_found", "user not found"))
return
}
writeError(w, r, err)
return
}
creds, _ := a.Repo.PasskeyCredentialsForUser(r.Context(), u.ID)
hasPasskey := len(creds) > 0
writeJSON(w, http.StatusOK, map[string]any{
"user_id": u.ID,
"username": u.Username,
"role": u.Role,
"email": u.Email,
"email_verified": u.EmailVerified,
"has_passkey": hasPasskey,
"setup_required": !u.EmailVerified || !hasPasskey,
})
}
-4
View File
@@ -186,10 +186,6 @@ func (a *API) handleMe(w http.ResponseWriter, r *http.Request) {
"is_admin": p.IsAdmin(), "is_admin": p.IsAdmin(),
"is_owner": p.IsOwner(), "is_owner": p.IsOwner(),
"email_verified": emailVerified, "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,
}) })
} }
-100
View File
@@ -2,15 +2,10 @@ package api
import ( import (
"context" "context"
"crypto/rand"
"errors" "errors"
"log"
"math/big"
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
"golang.org/x/crypto/bcrypt"
) )
// ResetMailer delivers a freshly-generated admin-reset password to the user's // ResetMailer delivers a freshly-generated admin-reset password to the user's
@@ -77,8 +72,6 @@ type createUserRequest struct {
Username string `json:"username"` Username string `json:"username"`
Email string `json:"email,omitempty"` Email string `json:"email,omitempty"`
Role string `json:"role"` Role string `json:"role"`
Password string `json:"password"`
MustChange bool `json:"must_change_password"`
} }
// handleCreateUser is the admin-tier create-user endpoint (POST /users). // 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 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{ u, err := a.Repo.CreateUser(r.Context(), CreateUserInput{
Username: body.Username, Username: body.Username,
Email: body.Email, Email: body.Email,
Role: body.Role, Role: body.Role,
PasswordHash: string(hash),
MustChange: body.MustChange,
}, p.Email) }, p.Email)
if err != nil { if err != nil {
if errors.Is(err, ErrConflict) { 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}) 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 ---- // ---- quota admin ----
// handleGetQuotas is the admin-tier quotas read (GET /users/{id}/quotas). // handleGetQuotas is the admin-tier quotas read (GET /users/{id}/quotas).
-17
View File
@@ -122,23 +122,6 @@ func (a *API) ownerOnly(next http.HandlerFunc) http.HandlerFunc {
} }
} }
// lockdownDuringPasswordChange fences a staff principal that still owes a
// first-login password change to the change-password surface (spec §B). It is the
// default-deny half of the lockdown: buildFace wraps every authenticated route
// with it except the AllowDuringPasswordChange opt-outs, so a half-onboarded
// account can do nothing but change its password, log out, or read /me. It is
// nil-principal safe (the internal face sets no Principal), so it passes such
// requests straight through and only ever acts on the external face.
func (a *API) lockdownDuringPasswordChange(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if p := principalFromContext(r.Context()); p != nil && p.MustChangePassword {
writeError(w, r, errPasswordChangeRequired)
return
}
next(w, r)
}
}
// newRequestID returns a short random hex id. crypto/rand never fails on the // 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 // 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. // so a request still gets a (non-unique) id rather than crashing.
+314 -117
View File
@@ -189,6 +189,73 @@ func (p *PGRepo) RedeemPlayerBindCode(ctx context.Context, newUserID, code strin
return userID, mcUUID, authSource, nil 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; // 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). // 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) ---- // ---- local-password auth (spec §B) ----
// UserByUsername loads a staff login projection by username, or ErrNotFound. A // UserByUsername loads a staff login projection by username, or ErrNotFound.
// player row (NULL password_hash) is returned with an empty PasswordHash, never // The account is passwordless — staff authenticate via email-OTP / passkey, so
// hidden — the caller rejects it by the hash compare, so login cannot be used to // no password column is read.
// enumerate which usernames carry a password.
func (p *PGRepo) UserByUsername(ctx context.Context, username string) (*StaffUser, error) { func (p *PGRepo) UserByUsername(ctx context.Context, username string) (*StaffUser, error) {
const q = `SELECT id, username, COALESCE(email, ''), role::text, const q = `SELECT id, username, COALESCE(email, ''), role::text, email_verified
COALESCE(password_hash, ''), must_change_password, email_verified
FROM users WHERE username = $1` FROM users WHERE username = $1`
var u StaffUser var u StaffUser
switch err := p.db.QueryRowContext(ctx, q, username).Scan( 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): case errors.Is(err, sql.ErrNoRows):
return nil, ErrNotFound return nil, ErrNotFound
case err != nil: case err != nil:
@@ -676,32 +741,31 @@ func (p *PGRepo) UserByUsername(ctx context.Context, username string) (*StaffUse
return &u, nil return &u, nil
} }
// AdminExists reports whether any authenticatable staff account already exists — // AdminExists reports whether any admin account already exists. It is the
// an admin row WITH a bcrypt password hash. It is the break-glass console's // break-glass console's bootstrap-vs-recovery switch: false means the typed
// bootstrap-vs-recovery switch: false means the typed credential mints the first // credential mints the first Owner (no prior identity to verify against), true
// Owner (no prior identity to verify against), true means the operator must // means the operator must identify against an existing admin for accountability.
// identify against an existing admin for accountability. It is not on the Repo // It is not on the Repo interface because only the break-glass CLI consults it.
// interface because only the break-glass CLI consults it.
func (p *PGRepo) AdminExists(ctx context.Context) (bool, error) { func (p *PGRepo) AdminExists(ctx context.Context) (bool, error) {
const q = `SELECT EXISTS ( const q = `SELECT 1 FROM users WHERE role = 'admin' LIMIT 1`
SELECT 1 FROM users WHERE role = 'admin' AND password_hash IS NOT NULL)` var one int
var exists bool switch err := p.db.QueryRowContext(ctx, q).Scan(&one); {
if err := p.db.QueryRowContext(ctx, q).Scan(&exists); err != nil { case errors.Is(err, sql.ErrNoRows):
return false, nil
case err != nil:
return false, err return false, err
} }
return exists, nil return true, nil
} }
// UserByID loads the same staff projection by id, or ErrNotFound. The // UserByID loads the same staff projection by id, or ErrNotFound. The
// change-password flow re-verifies the caller's current password with it: the // account is passwordless — no password column is read.
// session yields a user id, not a username.
func (p *PGRepo) UserByID(ctx context.Context, id string) (*StaffUser, error) { func (p *PGRepo) UserByID(ctx context.Context, id string) (*StaffUser, error) {
const q = `SELECT id, username, COALESCE(email, ''), role::text, const q = `SELECT id, username, COALESCE(email, ''), role::text, email_verified
COALESCE(password_hash, ''), must_change_password, email_verified
FROM users WHERE id = $1` FROM users WHERE id = $1`
var u StaffUser var u StaffUser
switch err := p.db.QueryRowContext(ctx, q, id).Scan( 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): case errors.Is(err, sql.ErrNoRows):
return nil, ErrNotFound return nil, ErrNotFound
case err != nil: case err != nil:
@@ -711,67 +775,31 @@ func (p *PGRepo) UserByID(ctx context.Context, id string) (*StaffUser, error) {
} }
// UpsertOwner creates or resets the Owner account direct-to-Postgres (the // UpsertOwner creates or resets the Owner account direct-to-Postgres (the
// break-glass first-run / reset-password path). role is forced to 'owner' — // break-glass first-run / recovery path). role is forced to 'admin' — the
// the platform-level identity one level above admin. On a username conflict the // platform-level identity. On a username conflict the email is overwritten
// email, hash and must_change_password flag are overwritten while the existing // while the existing id is preserved, so live sessions referencing it survive
// id is preserved, so live sessions referencing it survive a password reset. // a reset. The account is passwordless by design. The empty email is stored
// The empty email is stored as NULL (users.email is nullable). // as NULL (users.email is nullable).
func (p *PGRepo) UpsertOwner(ctx context.Context, id, username, email, passwordHash string, mustChange bool) error { func (p *PGRepo) UpsertOwner(ctx context.Context, id, username, email string) error {
_, err := p.db.ExecContext(ctx, _, err := p.db.ExecContext(ctx,
`INSERT INTO users (id, username, email, role, password_hash, must_change_password) `INSERT INTO users (id, username, email, role) VALUES ($1, $2, NULLIF($3, ''), 'admin')
VALUES ($1, $2, NULLIF($3, ''), 'owner', $4, $5) ON CONFLICT (username) DO UPDATE SET email = EXCLUDED.email`,
ON CONFLICT (username) DO UPDATE SET id, username, email)
email = NULLIF($3, ''), role = 'owner',
password_hash = $4, must_change_password = $5`,
id, username, email, passwordHash, mustChange)
return err return err
} }
// InsertOperator mints a NEW Operator (additional staff admin) account // InsertOperator mints a NEW Operator (additional staff admin) account
// direct-to-Postgres. role is forced to 'admin' — Felis has a separate 'owner' // direct-to-Postgres. role is forced to 'admin'. UNLIKE UpsertOwner this is
// role (migration 0011) for the single platform owner; Operators are below // insert-only: a username conflict is left untouched and surfaces as a driver
// that. UNLIKE UpsertOwner this is insert-only: a username conflict is // error, so adding an Operator can never silently reset the Owner's or another
// left untouched (ON CONFLICT DO NOTHING) and reported as ErrConflict via a zero // Operator's row. The account is passwordless by design. The empty email is
// RowsAffected, so adding an Operator can never silently reset the Owner's or // stored as NULL.
// another Operator's credential. The empty email is stored as NULL. func (p *PGRepo) InsertOperator(ctx context.Context, id, username, email string) error {
func (p *PGRepo) InsertOperator(ctx context.Context, id, username, email, passwordHash string, mustChange bool) error { _, err := p.db.ExecContext(ctx,
res, err := p.db.ExecContext(ctx, `INSERT INTO users (id, username, email, role) VALUES ($1, $2, NULLIF($3, ''), 'admin')`,
`INSERT INTO users (id, username, email, role, password_hash, must_change_password) id, username, email)
VALUES ($1, $2, NULLIF($3, ''), 'admin', $4, $5)
ON CONFLICT (username) DO NOTHING`,
id, username, email, passwordHash, mustChange)
if err != nil {
return err 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
}
// CreateSession records a minted session by the sha-256 of its cookie value // CreateSession records a minted session by the sha-256 of its cookie value
// (spec §B). Only the hash is stored, mirroring tokens. // (spec §B). Only the hash is stored, mirroring tokens.
@@ -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 // SessionUser resolves a live (unrevoked, unexpired at now) session hash to its
// user, or ErrNotFound. // user, or ErrNotFound.
func (p *PGRepo) SessionUser(ctx context.Context, tokenHash string, now time.Time) (*SessionedUser, error) { 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 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` WHERE s.token_hash = $1 AND s.revoked_at IS NULL AND s.expires_at > $2`
var u SessionedUser var u SessionedUser
switch err := p.db.QueryRowContext(ctx, q, tokenHash, now).Scan( 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): case errors.Is(err, sql.ErrNoRows):
return nil, ErrNotFound return nil, ErrNotFound
case err != nil: 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, 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.created_at, u.updated_at,
COALESCE((SELECT count(*) FROM servers s WHERE s.owner_id = u.id AND s.deleted_at IS NULL), 0) COALESCE((SELECT count(*) FROM servers s WHERE s.owner_id = u.id AND s.deleted_at IS NULL), 0)
FROM users u` + where FROM users u` + where
@@ -1075,7 +1103,7 @@ func (p *PGRepo) ListUsers(ctx context.Context, opts ListUsersOpts) ([]UserView,
for rows.Next() { for rows.Next() {
var v UserView var v UserView
if err := rows.Scan(&v.ID, &v.Username, &v.Email, &v.Role, 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 { &v.CreatedAt, &v.UpdatedAt, &v.ServerCount); err != nil {
return nil, 0, err 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. // UserDetail loads one user with its linked MC accounts, or ErrNotFound.
func (p *PGRepo) UserDetail(ctx context.Context, userID string) (*UserDetail, error) { func (p *PGRepo) UserDetail(ctx context.Context, userID string) (*UserDetail, error) {
const q = `SELECT u.id, u.username, COALESCE(u.email, ''), u.role::text, 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, 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) 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` FROM users u WHERE u.id = $1`
var d UserDetail var d UserDetail
switch err := p.db.QueryRowContext(ctx, q, userID).Scan( switch err := p.db.QueryRowContext(ctx, q, userID).Scan(
&d.ID, &d.Username, &d.Email, &d.Role, &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); { &d.CreatedAt, &d.UpdatedAt, &d.DeletedAt, &d.ServerCount); {
case errors.Is(err, sql.ErrNoRows): case errors.Is(err, sql.ErrNoRows):
return nil, ErrNotFound return nil, ErrNotFound
@@ -1120,19 +1148,18 @@ func (p *PGRepo) UserDetail(ctx context.Context, userID string) (*UserDetail, er
return &d, linkRows.Err() return &d, linkRows.Err()
} }
// CreateUser mints a new user row with an initial password hash. A username // CreateUser mints a new user row. A username conflict → ErrConflict.
// conflict → ErrConflict.
func (p *PGRepo) CreateUser(ctx context.Context, input CreateUserInput, _ string) (*UserView, error) { 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) const q = `INSERT INTO users (id, username, email, role)
VALUES (gen_random_uuid()::text, $1, NULLIF($2, ''), $3::user_role, $4, $5) VALUES (gen_random_uuid()::text, $1, NULLIF($2, ''), $3::user_role)
ON CONFLICT (username) DO NOTHING ON CONFLICT (username) DO NOTHING
RETURNING id, username, COALESCE(email, ''), role::text, disabled, email_verified, 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 var v UserView
switch err := p.db.QueryRowContext(ctx, q, 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.ID, &v.Username, &v.Email, &v.Role,
&v.Disabled, &v.EmailVerified, &v.MustChangePassword, &v.Disabled, &v.EmailVerified,
&v.CreatedAt, &v.UpdatedAt, &v.ServerCount); { &v.CreatedAt, &v.UpdatedAt, &v.ServerCount); {
case errors.Is(err, sql.ErrNoRows): case errors.Is(err, sql.ErrNoRows):
return nil, ErrConflict 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 += fmt.Sprintf(` WHERE id = $%d AND deleted_at IS NULL`, argn)
q += ` RETURNING id, username, COALESCE(email, ''), role::text, disabled, 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)` (SELECT count(*) FROM servers WHERE owner_id = users.id AND deleted_at IS NULL)`
var v UserView var v UserView
switch err := p.db.QueryRowContext(ctx, q, args...).Scan( switch err := p.db.QueryRowContext(ctx, q, args...).Scan(
&v.ID, &v.Username, &v.Email, &v.Role, &v.ID, &v.Username, &v.Email, &v.Role,
&v.Disabled, &v.EmailVerified, &v.MustChangePassword, &v.Disabled, &v.EmailVerified,
&v.CreatedAt, &v.UpdatedAt, &v.ServerCount); { &v.CreatedAt, &v.UpdatedAt, &v.ServerCount); {
case errors.Is(err, sql.ErrNoRows): case errors.Is(err, sql.ErrNoRows):
return nil, ErrNotFound 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. // shared by UpdateUser (no-op return) and several other paths.
func (p *PGRepo) userView(ctx context.Context, userID string) (*UserView, error) { func (p *PGRepo) userView(ctx context.Context, userID string) (*UserView, error) {
const q = `SELECT id, username, COALESCE(email, ''), role::text, disabled, 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) (SELECT count(*) FROM servers WHERE owner_id = users.id AND deleted_at IS NULL)
FROM users WHERE id = $1 AND deleted_at IS NULL` FROM users WHERE id = $1 AND deleted_at IS NULL`
var v UserView var v UserView
switch err := p.db.QueryRowContext(ctx, q, userID).Scan( switch err := p.db.QueryRowContext(ctx, q, userID).Scan(
&v.ID, &v.Username, &v.Email, &v.Role, &v.ID, &v.Username, &v.Email, &v.Role,
&v.Disabled, &v.EmailVerified, &v.MustChangePassword, &v.Disabled, &v.EmailVerified,
&v.CreatedAt, &v.UpdatedAt, &v.ServerCount); { &v.CreatedAt, &v.UpdatedAt, &v.ServerCount); {
case errors.Is(err, sql.ErrNoRows): case errors.Is(err, sql.ErrNoRows):
return nil, ErrNotFound return nil, ErrNotFound
@@ -1294,29 +1321,6 @@ func (p *PGRepo) SetUserDisabled(ctx context.Context, userID string, disabled bo
return nil 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 ---- // ---- quota admin ----
// GetQuotas returns the quotas row for a user, or a zero-value view when no // 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 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 ", ". // joinStr joins a slice of strings with ", ".
func joinStr(vals []string) string { func joinStr(vals []string) string {
if len(vals) == 0 { if len(vals) == 0 {
+99 -33
View File
@@ -71,21 +71,19 @@ type BackupRecord struct {
SizeBytes int64 SizeBytes int64
} }
// StaffUser is the login-side projection of a users row that carries a password // StaffUser is the login-side projection of a users row (spec §B passwordless
// (spec §B local-auth). Owner/Operator are role=admin rows WITH a bcrypt hash, // auth). Owner/Operator are role=admin rows, minted by `felis setup` (MC link)
// minted by `felis breakGlass`; players are role=user rows whose PasswordHash is // and recovered by `felis breakGlass` (email OTP); players are role=user rows.
// empty. It is loaded by username at login to verify the password and learn // There is no password column — staff authenticate via email-OTP / passkey +
// whether a first-login change is still pending. // in-game approve, never a password.
type StaffUser struct { type StaffUser struct {
ID string ID string
Username string Username string
Email string Email string
Role string Role string
PasswordHash string
MustChangePassword bool
// EmailVerified mirrors users.email_verified (spec §B2): the address was proven // EmailVerified mirrors users.email_verified (spec §B2): the address was proven
// via an email OTP, not merely asserted. Players carry it through onboarding; // 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 EmailVerified bool
} }
@@ -117,15 +115,30 @@ type PasskeyCredential struct {
} }
// SessionedUser is the projection resolved from a live session cookie: the // SessionedUser is the projection resolved from a live session cookie: the
// identity SessionAuth needs to build a Principal. It omits the password hash — // identity SessionAuth needs to build a Principal. EmailVerified mirrors
// the session has already authenticated the caller — but carries the pending // users.email_verified so the lockdown middleware can gate setup-incomplete
// first-login change flag so the lockdown middleware can fence a half-onboarded // accounts without a second DB read.
// staff account to the change-password surface.
type SessionedUser struct { type SessionedUser struct {
ID string ID string
Email string Email string
Role string Role string
MustChangePassword bool 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 // 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 // code (so a typo does not burn it). On a match the code is consumed and the
// user row is flipped to email=<the proven address>, email_verified=true; the // user row is flipped to email=<the proven address>, email_verified=true; the
// proven email is returned. now is the API clock so expiry is testable. // 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) 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) ---- // ---- 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 // session yields a user id, not a username, so this is the id-keyed counterpart
// of UserByUsername. // of UserByUsername.
UserByID(ctx context.Context, id string) (*StaffUser, error) 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 // UpsertOwner creates or resets the single Owner account direct-to-Postgres
// (the `felis breakGlass` first-run / reset-password path). role is forced to // (the `felis setup` / `felis breakGlass` recovery path). role is forced to
// 'admin' and must_change_password to mustChange; on a username conflict the // 'admin'; on a username conflict the existing row's email is overwritten so
// existing row's email, hash and flag are overwritten so a reset is idempotent. // a reset is idempotent. The account is passwordless by design.
UpsertOwner(ctx context.Context, id, username, email, passwordHash string, mustChange bool) error UpsertOwner(ctx context.Context, id, username, email string) error
// SetPassword stores a new bcrypt hash for a user and clears
// must_change_password (the panel change-password flow). ErrNotFound when no
// row matches, so a stale session cannot silently no-op a password change.
SetPassword(ctx context.Context, userID, passwordHash string) error
// CreateSession records a minted session: the sha-256 of the opaque cookie // 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, // 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. // 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. // absent or already-revoked session is not an error.
RevokeSession(ctx context.Context, tokenHash string) error RevokeSession(ctx context.Context, tokenHash string) error
// RevokeUserSessionsExcept revokes every live session of a user except the one // RevokeUserSessionsExcept revokes every live session of a user except the one
// whose hash is keepTokenHash. The change-password flow calls it so a successful // whose hash is keepTokenHash. Used to log out other devices on a security event.
// password change logs out the account's other devices but not the current one.
RevokeUserSessionsExcept(ctx context.Context, userID, keepTokenHash string) error 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) ---- // ---- runtime platform settings (spec §B platform_settings) ----
// GetSetting reads a runtime setting's raw jsonb value, or ErrNotFound when the // 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. // UserDetail loads one user with its linked MC accounts, or ErrNotFound.
// A deleted user is returned (the row lives for audit) but flagged. // A deleted user is returned (the row lives for audit) but flagged.
UserDetail(ctx context.Context, userID string) (*UserDetail, error) UserDetail(ctx context.Context, userID string) (*UserDetail, error)
// CreateUser mints a new user row (role forced to either 'admin' or 'user') // 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 // createdBy is the actor email for audit. A username conflict → ErrConflict.
// username conflict → ErrConflict.
CreateUser(ctx context.Context, input CreateUserInput, createdBy string) (*UserView, error) CreateUser(ctx context.Context, input CreateUserInput, createdBy string) (*UserView, error)
// UpdateUser applies the non-nil fields of patch to the user identified by // UpdateUser applies the non-nil fields of patch to the user identified by
// userID and returns the updated view. A username conflict → ErrConflict; // 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 → // disabled account is immediately locked out. A non-existent user →
// ErrNotFound; a deleted user → ErrNotFound. // ErrNotFound; a deleted user → ErrNotFound.
SetUserDisabled(ctx context.Context, userID string, disabled bool) error 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) ---- // ---- quota admin (spec §6 quotas, admin-only) ----
@@ -469,7 +538,6 @@ type UserView struct {
Disabled bool `json:"disabled"` Disabled bool `json:"disabled"`
EmailVerified bool `json:"email_verified"` EmailVerified bool `json:"email_verified"`
ServerCount int `json:"server_count"` ServerCount int `json:"server_count"`
MustChangePassword bool `json:"must_change_password"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
} }
@@ -493,8 +561,6 @@ type CreateUserInput struct {
Username string `json:"username"` Username string `json:"username"`
Email string `json:"email,omitempty"` Email string `json:"email,omitempty"`
Role string `json:"role"` Role string `json:"role"`
PasswordHash string `json:"-"`
MustChange bool `json:"must_change_password"`
} }
// UpdateUserInput is the admin patch-user form. Every field is a pointer so // UpdateUserInput is the admin patch-user form. Every field is a pointer so
+2 -1
View File
@@ -160,7 +160,8 @@ func (s SessionAuth) Authenticate(r *http.Request) (*Principal, error) {
Email: u.Email, Email: u.Email,
Role: u.Role, Role: u.Role,
ViaAdminAccess: u.Role == "admin" && hostIsAdminConsole(r, s.RootDomain, s.AdminHostname), ViaAdminAccess: u.Role == "admin" && hostIsAdminConsole(r, s.RootDomain, s.AdminHostname),
MustChangePassword: u.MustChangePassword, EmailVerified: u.EmailVerified,
ViaSession: true,
}, nil }, nil
} }
+85 -3
View File
@@ -10,6 +10,7 @@ import (
"io/fs" "io/fs"
"net/http" "net/http"
"path" "path"
"regexp"
"strings" "strings"
) )
@@ -19,10 +20,79 @@ var static embed.FS
type runtimeConfig struct { type runtimeConfig struct {
APIBase string `json:"apiBase"` APIBase string `json:"apiBase"`
RootDomain string `json:"rootDomain"` 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. // buildInfo is the resolved build stamp the panel renders in its version badge.
func Handler(api http.Handler, rootDomain string) http.Handler { // 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 "-<commits>-g<sha>" 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 "<tag>-N-gSHA" → Release=<tag>,
// 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.<root_domain> and op.console.<root_domain> 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") files, err := fs.Sub(static, "static")
if err != nil { if err != nil {
panic(err) panic(err)
@@ -30,6 +100,9 @@ func Handler(api http.Handler, rootDomain string) http.Handler {
return &handler{ return &handler{
api: api, api: api,
rootDomain: rootDomain, rootDomain: rootDomain,
panelHostname: panelHost,
adminHostname: adminHost,
build: parseBuildVersion(version),
files: files, files: files,
fileServer: http.FileServer(http.FS(files)), fileServer: http.FileServer(http.FS(files)),
} }
@@ -38,6 +111,9 @@ func Handler(api http.Handler, rootDomain string) http.Handler {
type handler struct { type handler struct {
api http.Handler api http.Handler
rootDomain string rootDomain string
panelHostname string
adminHostname string
build buildInfo
files fs.FS files fs.FS
fileServer http.Handler fileServer http.Handler
} }
@@ -55,7 +131,13 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
case r.URL.Path == "/config.json": case r.URL.Path == "/config.json":
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store") 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): case h.hasStaticFile(r.URL.Path):
h.fileServer.ServeHTTP(w, r) h.fileServer.ServeHTTP(w, r)
default: default:
+10 -1
View File
@@ -15,7 +15,7 @@ func TestHandlerServesPanelAndConfig(t *testing.T) {
} }
w.WriteHeader(http.StatusTeapot) 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() w := httptest.NewRecorder()
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/", nil)) 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" { if cfg.APIBase != "/api/v1" || cfg.RootDomain != "example.test" {
t.Fatalf("config = %+v", cfg) 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() w = httptest.NewRecorder()
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/v1/me", nil)) h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/v1/me", nil))
+1 -1
View File
@@ -40,7 +40,7 @@ func newPanelHandler(t *testing.T) http.Handler {
api := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { api := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusTeapot) w.WriteHeader(http.StatusTeapot)
}) })
return Handler(api, "example.test") return Handler(api, "example.test", "", "", "")
} }
func TestGuardServesInterstitialForWeChatNavigation(t *testing.T) { func TestGuardServesInterstitialForWeChatNavigation(t *testing.T) {
@@ -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);
@@ -173,6 +173,33 @@ public final class FelisApiClient {
return barred instanceof Boolean && (Boolean) barred; 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 <code>}. 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.
*
* <p>{@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 ---- // ---- transport ----
private Map<?, ?> getObject(String path, int expect) throws LinkException { private Map<?, ?> getObject(String path, int expect) throws LinkException {
@@ -8,6 +8,7 @@ import best.lolicon.felis.link.ServerView;
import com.google.inject.Inject; import com.google.inject.Inject;
import com.mojang.brigadier.Command; import com.mojang.brigadier.Command;
import com.mojang.brigadier.arguments.StringArgumentType;
import com.mojang.brigadier.tree.LiteralCommandNode; import com.mojang.brigadier.tree.LiteralCommandNode;
import com.velocitypowered.api.command.BrigadierCommand; import com.velocitypowered.api.command.BrigadierCommand;
import com.velocitypowered.api.command.CommandManager; 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.plugin.annotation.DataDirectory;
import com.velocitypowered.api.proxy.Player; import com.velocitypowered.api.proxy.Player;
import com.velocitypowered.api.proxy.ProxyServer; import com.velocitypowered.api.proxy.ProxyServer;
import com.velocitypowered.api.proxy.ServerConnection;
import net.kyori.adventure.text.Component; import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor; import net.kyori.adventure.text.format.NamedTextColor;
import org.slf4j.Logger; import org.slf4j.Logger;
@@ -27,6 +29,9 @@ import java.nio.file.Path;
import java.time.Duration; import java.time.Duration;
import java.util.Collection; import java.util.Collection;
import java.util.List; 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 * FelisVelocityPlugin is the proxy-side of Felis (spec §10 account-link + §11
@@ -72,6 +77,7 @@ public final class FelisVelocityPlugin {
private LinkClient linkClient; private LinkClient linkClient;
private FelisApiClient apiClient; private FelisApiClient apiClient;
private ServerRegistry registry; private ServerRegistry registry;
private WaitingRouter router;
private boolean onlineMode; private boolean onlineMode;
private boolean routingActive; private boolean routingActive;
@@ -110,7 +116,7 @@ public final class FelisVelocityPlugin {
this.apiClient = new FelisApiClient(config.linkConfig()); this.apiClient = new FelisApiClient(config.linkConfig());
this.registry = new ServerRegistry(proxy, logger, config.rootDomain()); 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); MotdResponder motd = new MotdResponder(registry);
proxy.getEventManager().register(this, router); proxy.getEventManager().register(this, router);
proxy.getEventManager().register(this, motd); 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 <server> 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 <code> 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() { private void registerFelisCommand() {
CommandManager commands = proxy.getCommandManager(); CommandManager commands = proxy.getCommandManager();
@@ -206,31 +247,120 @@ public final class FelisVelocityPlugin {
sendSummary(ctx.getSource()); sendSummary(ctx.getSource());
return Command.SINGLE_SUCCESS; return Command.SINGLE_SUCCESS;
}) })
.then(BrigadierCommand.literalArgumentBuilder("list") .then(BrigadierCommand.literalArgumentBuilder("help")
.executes(ctx -> { .executes(ctx -> {
sendList(ctx.getSource()); sendHelp(ctx.getSource());
return Command.SINGLE_SUCCESS; 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(); .build();
CommandMeta meta = commands.metaBuilder("felis").plugin(this).build(); CommandMeta meta = commands.metaBuilder("felis").plugin(this).build();
commands.register(meta, new BrigadierCommand(node)); 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<ServerConnection> 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) { private void sendSummary(CommandSource source) {
if (!gateInfo(source)) {
return;
}
source.sendMessage(Component.text("Felis proxy", NamedTextColor.AQUA)); source.sendMessage(Component.text("Felis proxy", NamedTextColor.AQUA));
source.sendMessage(field("online-mode", String.valueOf(onlineMode))); source.sendMessage(field("online-mode", String.valueOf(onlineMode)));
if (!routingActive) { if (!routingActive) {
source.sendMessage(Component.text( source.sendMessage(Component.text(
" routing: disabled" + (onlineMode ? " (no root-domain set)" : " (offline mode)"), " routing: disabled" + (onlineMode ? " (no root-domain set)" : " (offline mode)"),
NamedTextColor.YELLOW)); NamedTextColor.YELLOW));
source.sendMessage(Component.text(" /felis help for commands", NamedTextColor.GRAY));
return; return;
} }
source.sendMessage(field("root-domain", config.rootDomain())); source.sendMessage(field("root-domain", config.rootDomain()));
source.sendMessage(field("lobby", config.lobbyServer() == null ? "<none>" : config.lobbyServer())); source.sendMessage(field("lobby", config.lobbyServer() == null ? "<none>" : config.lobbyServer()));
source.sendMessage(field("servers", String.valueOf(registry.all().size()))); 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 <server>", "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 <code>", "approve a pending operator sign-in");
}
private void sendServerList(CommandSource source) {
if (!gateInfo(source)) {
return;
}
if (!routingActive) { if (!routingActive) {
source.sendMessage(Component.text("Felis routing is disabled.", NamedTextColor.YELLOW)); source.sendMessage(Component.text("Felis routing is disabled.", NamedTextColor.YELLOW));
return; 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<ServerConnection> 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<ServerConnection> 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 <code> 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 <code>", 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) { private static Component field(String key, String value) {
return Component.text(" " + key + ": ", NamedTextColor.GRAY) return Component.text(" " + key + ": ", NamedTextColor.GRAY)
.append(Component.text(value == null ? "<unset>" : value, NamedTextColor.WHITE)); .append(Component.text(value == null ? "<unset>" : value, NamedTextColor.WHITE));
@@ -94,6 +94,19 @@ public final class WaitingRouter {
wakeAndWait(player, serverName, true); wakeAndWait(player, serverName, true);
} }
/**
* enqueueFromCommand parks a player who drove {@code /felis go <server>} 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 @Subscribe
public void onChooseInitialServer(PlayerChooseInitialServerEvent event) { public void onChooseInitialServer(PlayerChooseInitialServerEvent event) {
Player player = event.getPlayer(); Player player = event.getPlayer();