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:
44 files changed
+5456
-1553
No files matched your search
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"}},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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
@@ -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))
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
+217
-30
@@ -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,12 +185,14 @@ 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{},
|
||||||
passkeyChallenges: map[string]*fakePasskeyChallenge{},
|
passkeyChallenges: map[string]*fakePasskeyChallenge{},
|
||||||
fakeQuotas: map[string]*QuotaView{},
|
fakeQuotas: map[string]*QuotaView{},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeRepo) ServerBySubdomain(_ context.Context, s string) (*ServerRecord, error) {
|
func (f *fakeRepo) ServerBySubdomain(_ context.Context, s string) (*ServerRecord, error) {
|
||||||
@@ -375,14 +406,19 @@ func (f *fakeRepo) DeleteAllPasskeyCredentialsForUser(_ context.Context, userID
|
|||||||
}
|
}
|
||||||
|
|
||||||
// fakePasskeyVerifier is the hermetic PasskeyVerifier: it performs no real attestation
|
// 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
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Pre-session Email-OTP LOGIN (spec §B, console.<root_domain> returning-player door).
|
||||||
|
// This is the passwordless counterpart of handleLogin and the returning-player
|
||||||
|
// counterpart of handleBindRedeem: an account that already proved control of an
|
||||||
|
// email (email_verified, migration 0010) logs back in with a one-time code mailed
|
||||||
|
// to that address — no password, no in-game Bind Code. The two halves are Public,
|
||||||
|
// pre-session routes: the caller has no principal yet, so identity is resolved from
|
||||||
|
// the typed email via UserByEmail, exactly as handleBindRedeem resolves it from the
|
||||||
|
// code.
|
||||||
|
//
|
||||||
|
// Distinct from the authenticated /account/email/* onboarding pair in three ways,
|
||||||
|
// all load-bearing:
|
||||||
|
//
|
||||||
|
// - Purpose. Codes are minted under otpPurposeLogin ("login_email"), never
|
||||||
|
// otpPurposeOnboard, so a login code and an onboarding code for the same user
|
||||||
|
// never clobber or satisfy each other (the email_otps purpose column is exactly
|
||||||
|
// this separator).
|
||||||
|
// - No principal. The throttle cannot key off a user id (there is none yet); it
|
||||||
|
// keys off the typed recipient address, the same anti-bomb dimension the onboard
|
||||||
|
// start uses. Per-source (client-IP) aggregate limiting is deliberately NOT done
|
||||||
|
// here: cooldownLimiter is a one-per-window primitive, so keying it on client IP
|
||||||
|
// would false-positive on shared egress (CGNAT / office NAT), and behind
|
||||||
|
// Cloudflare RemoteAddr is the proxy anyway. The only real harm — bombing one
|
||||||
|
// mailbox — is already bounded per recipient; volumetric per-source limiting
|
||||||
|
// belongs at the edge.
|
||||||
|
// - Refuse staff. Like handleBindRedeem this public door provably never mints a
|
||||||
|
// session for an admin identity: op.console stays behind Zero Trust (and its own
|
||||||
|
// in-game approval gate). The refusal happens only AFTER a valid code is
|
||||||
|
// redeemed (see handleLoginEmailVerify), so a caller without the code cannot use
|
||||||
|
// it to enumerate which addresses are staff.
|
||||||
|
//
|
||||||
|
// Enumeration is an accepted product decision (a dedicated /auth/options oracle is a
|
||||||
|
// sibling slice), so this pair does not go out of its way to equalise timing between
|
||||||
|
// existing and unknown addresses — it only keeps the *verify* response uniform so a
|
||||||
|
// code-less caller learns nothing a wrong guess would not already reveal.
|
||||||
|
|
||||||
|
// otpPurposeLogin scopes a code to the pre-session email LOGIN flow, keeping it from
|
||||||
|
// ever colliding with or satisfying an onboarding-email code (otpPurposeOnboard) for
|
||||||
|
// the same user. VerifyEmailOTP is queried per (user, purpose), so the two flows are
|
||||||
|
// fully independent even for one account with both a live onboarding and a live
|
||||||
|
// login code.
|
||||||
|
const otpPurposeLogin = "login_email"
|
||||||
|
|
||||||
|
// loginEmailStartRequest is the start-login-by-email body: the address whose mailbox
|
||||||
|
// the returning player will read the code from.
|
||||||
|
type loginEmailStartRequest struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleLoginEmailStart mints and mails a login code for a returning account (Public,
|
||||||
|
// pre-session). It gates on local sessions being enabled — like handleLogin and
|
||||||
|
// handleBindRedeem, minting a code toward a felis_session while SessionAuth would
|
||||||
|
// reject that cookie is pointless — reserves the per-recipient cooldown, resolves the
|
||||||
|
// address to an account, and (only if one exists) mints a code under otpPurposeLogin.
|
||||||
|
// An address with no verified account yields the SAME 202 as a successful send with
|
||||||
|
// no code minted: the response never distinguishes the two, and the reservation is
|
||||||
|
// kept on that path too so repeated probing of one address is throttled identically
|
||||||
|
// to repeated sends.
|
||||||
|
func (a *API) handleLoginEmailStart(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !localAuthEnabled(r.Context(), a.Repo) {
|
||||||
|
writeError(w, r, newError(http.StatusForbidden, "local_auth_disabled",
|
||||||
|
"session login is disabled"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := requireJSONContentType(r); err != nil {
|
||||||
|
writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req loginEmailStartRequest
|
||||||
|
if err := decodeJSON(w, r, &req); err != nil {
|
||||||
|
writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
email := strings.TrimSpace(req.Email)
|
||||||
|
if !looksLikeEmail(email) {
|
||||||
|
writeError(w, r, newError(http.StatusBadRequest, "bad_request", "a valid email is required"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Atomically reserve the per-recipient cooldown BEFORE any work, so a burst of
|
||||||
|
// truly concurrent starts yields exactly one winner and each admitted send is one
|
||||||
|
// real, non-idempotent email. The key is namespaced apart from the onboard door's
|
||||||
|
// "email:" key on purpose: this door is unauthenticated, so it must not perturb
|
||||||
|
// the authenticated onboarding throttle. Both caps are 1/window, so a mailbox sees
|
||||||
|
// at most one login code plus one onboard code per window — far below any bomb.
|
||||||
|
emailKey := "login:email:" + strings.ToLower(email)
|
||||||
|
lim := a.otpLimiter()
|
||||||
|
emailAt, ok := lim.reserve(emailKey, otpResendCooldown)
|
||||||
|
if !ok {
|
||||||
|
writeError(w, r, newError(http.StatusTooManyRequests, "otp_resend_cooldown",
|
||||||
|
"a code was sent recently; wait a moment before requesting another"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
committed := false
|
||||||
|
defer func() {
|
||||||
|
if !committed {
|
||||||
|
lim.release(emailKey, emailAt)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Compute the expiry once so the neutral (no-account) branch and the real-send
|
||||||
|
// branch return byte-identical bodies.
|
||||||
|
expiresAt := a.now().Add(otpTTL)
|
||||||
|
|
||||||
|
u, err := a.Repo.UserByEmail(r.Context(), email)
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, ErrNotFound):
|
||||||
|
// No verified account for this address. Return the same 202 as a real send
|
||||||
|
// (no code minted) and KEEP the reservation, so probing an unknown address is
|
||||||
|
// throttled exactly like resending to a known one — the throttle reveals
|
||||||
|
// nothing, and the accepted /auth/options oracle is where existence is learnt.
|
||||||
|
committed = true
|
||||||
|
writeJSON(w, http.StatusAccepted, map[string]any{"sent": true, "expires_at": expiresAt.UTC()})
|
||||||
|
return
|
||||||
|
case err != nil:
|
||||||
|
// A real read error is NOT a neutral outcome: leave committed false so the
|
||||||
|
// deferred rollback frees the window (a transient DB blip must not burn it).
|
||||||
|
writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
code, err := newEmailOTP()
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, err := newOTPID()
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Mint and deliver against the account's STORED address, not the typed string:
|
||||||
|
// UserByEmail matched case-insensitively, and the code must reach the mailbox of
|
||||||
|
// record. The login redeem (ConsumeLoginEmailOTP) never reads or writes this
|
||||||
|
// address, so the stored casing is authoritative and the row's email snapshot is
|
||||||
|
// purely for the audit trail.
|
||||||
|
if err := a.Repo.CreateEmailOTP(r.Context(), id, u.ID, u.Email, otpCodeHash(code), otpPurposeLogin, expiresAt); err != nil {
|
||||||
|
writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := a.deliverOTP(r.Context(), u.Email, code); err != nil {
|
||||||
|
writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
committed = true
|
||||||
|
a.audit(r, u.Username, "auth.login_email.otp_sent", "")
|
||||||
|
writeJSON(w, http.StatusAccepted, map[string]any{"sent": true, "expires_at": expiresAt.UTC()})
|
||||||
|
}
|
||||||
|
|
||||||
|
// loginEmailVerifyRequest is the verify body: the address and the code read from it.
|
||||||
|
// Both are needed because there is no principal — the address selects the account,
|
||||||
|
// the code proves control this session.
|
||||||
|
type loginEmailVerifyRequest struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
Code string `json:"code"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleLoginEmailVerify redeems a login code into a session (Public, pre-session).
|
||||||
|
// It resolves the address to an account, verifies the code under otpPurposeLogin, and
|
||||||
|
// on success mints the same host-only felis_session as handleLogin. A missing account,
|
||||||
|
// a wrong code, AND an attempt-exhausted (locked) code all return the IDENTICAL 400
|
||||||
|
// invalid_code, so a code-less caller cannot tell an unknown address from a bad guess
|
||||||
|
// or farm a lockout into an is-this-a-real-account oracle. Staff are refused — but only
|
||||||
|
// after a valid code is redeemed, so the refusal is reachable solely by the account
|
||||||
|
// owner and never leaks which addresses are staff.
|
||||||
|
func (a *API) handleLoginEmailVerify(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !localAuthEnabled(r.Context(), a.Repo) {
|
||||||
|
writeError(w, r, newError(http.StatusForbidden, "local_auth_disabled",
|
||||||
|
"session login is disabled"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := requireJSONContentType(r); err != nil {
|
||||||
|
writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req loginEmailVerifyRequest
|
||||||
|
if err := decodeJSON(w, r, &req); err != nil {
|
||||||
|
writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
email := strings.TrimSpace(req.Email)
|
||||||
|
code := strings.TrimSpace(req.Code)
|
||||||
|
if !looksLikeEmail(email) {
|
||||||
|
writeError(w, r, newError(http.StatusBadRequest, "bad_request", "a valid email is required"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if code == "" {
|
||||||
|
writeError(w, r, newError(http.StatusBadRequest, "bad_request", "code is required"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
u, err := a.Repo.UserByEmail(r.Context(), email)
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, ErrNotFound):
|
||||||
|
// Uniform with a wrong code: a caller probing whether an address has an account
|
||||||
|
// gets the same invalid_code either way. (The /auth/options oracle is the
|
||||||
|
// sanctioned place to learn existence; this door does not double as one.)
|
||||||
|
writeError(w, r, newError(http.StatusBadRequest, "invalid_code", "email code is invalid or expired"))
|
||||||
|
return
|
||||||
|
case err != nil:
|
||||||
|
writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Consume the code BEFORE the staff check. Ordering is the whole leak-safety
|
||||||
|
// argument: a caller without a valid code always lands in the invalid_code branch
|
||||||
|
// below — identical for staff and non-staff — so only the account owner, holding a
|
||||||
|
// live code, can ever reach the staff refusal.
|
||||||
|
//
|
||||||
|
// ConsumeLoginEmailOTP, not VerifyEmailOTP: this door only re-proves control of an
|
||||||
|
// already-verified address for the session, so it must NOT rewrite users.email or
|
||||||
|
// run the onboarding taken-check. UserByEmail already guaranteed the account is
|
||||||
|
// verified; touching the row here would let a stale OTP-snapshot address overwrite
|
||||||
|
// the live one and could 500 a correct code on a spurious collision.
|
||||||
|
switch err := a.Repo.ConsumeLoginEmailOTP(r.Context(), u.ID, otpPurposeLogin, otpCodeHash(code), a.now()); {
|
||||||
|
case errors.Is(err, ErrOTPInvalid), errors.Is(err, ErrOTPLocked):
|
||||||
|
// Both a wrong/expired code and an attempt-exhausted one return the SAME 400
|
||||||
|
// invalid_code, byte-identical to the unknown-account branch above. Surfacing
|
||||||
|
// otp_locked as a distinct 429 (as the authenticated onboarding door does) would
|
||||||
|
// turn this public door into the existence oracle its no-account branch is
|
||||||
|
// careful not to be: a code-less prober could mail a code to a victim address,
|
||||||
|
// exhaust the attempt budget, and read otp_locked as "this address has an
|
||||||
|
// account." Enumeration is a product decision reserved for /auth/options, not a
|
||||||
|
// side channel of the login verify.
|
||||||
|
writeError(w, r, newError(http.StatusBadRequest, "invalid_code", "email code is invalid or expired"))
|
||||||
|
return
|
||||||
|
case err != nil:
|
||||||
|
// ConsumeLoginEmailOTP performs no users write, so ErrEmailTaken is structurally
|
||||||
|
// impossible here; anything left is a genuine fault and surfaces as a 500.
|
||||||
|
writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Code redeemed. Refuse staff here — never before the verify — so op.console keeps
|
||||||
|
// its Zero-Trust + in-game-approval gates and this public door provably yields only
|
||||||
|
// a role=user player session (mirrors handleBindRedeem's refuse-staff contract).
|
||||||
|
if u.Role == "admin" {
|
||||||
|
writeError(w, r, newError(http.StatusForbidden, "staff_account",
|
||||||
|
"that account is staff; sign in at the operator console"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := newSessionToken()
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
expires := a.now().Add(sessionTTL)
|
||||||
|
if err := a.Repo.CreateSession(r.Context(), hashCookie(token), u.ID, expires); err != nil {
|
||||||
|
writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSessionCookie(w, token, expires)
|
||||||
|
a.audit(r, u.Username, "auth.login_email", "")
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"user_id": u.ID,
|
||||||
|
"role": u.Role,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,612 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Pre-session Email-OTP LOGIN tests (spec §B, console.<root_domain> returning-player
|
||||||
|
// door). The load-bearing properties, in the order the flow meets them:
|
||||||
|
//
|
||||||
|
// - Neutrality on start: an unknown address gets a byte-identical 202 to a real
|
||||||
|
// send AND the same cooldown reservation, so neither the response nor the
|
||||||
|
// throttle is an existence oracle.
|
||||||
|
// - Purpose separation: login codes (otpPurposeLogin) and onboarding codes
|
||||||
|
// (otpPurposeOnboard) never satisfy each other, even for one account holding
|
||||||
|
// both live at once.
|
||||||
|
// - Verify uniformity: unknown address and wrong code collapse to the same
|
||||||
|
// invalid_code envelope, so a code-less caller learns nothing.
|
||||||
|
// - Staff refusal AFTER redeem: only the mailbox owner, holding a live code, can
|
||||||
|
// ever see the staff_account refusal — and the code is spent reaching it.
|
||||||
|
|
||||||
|
// seedLoginEmailAPI wires the public email-login door: local sessions enabled and a
|
||||||
|
// single verified player "player" (id u1) whose proven address is stored in MIXED
|
||||||
|
// case, so the case-insensitivity contracts (resolve on typed lowercase, mint against
|
||||||
|
// stored casing) are exercised by default. Both routes are Public — no External
|
||||||
|
// wiring needed.
|
||||||
|
func seedLoginEmailAPI(t *testing.T) (*API, *fakeRepo, *captureMailer) {
|
||||||
|
t.Helper()
|
||||||
|
repo := newFakeRepo()
|
||||||
|
repo.settings[LocalAuthEnabledKey] = []byte("true")
|
||||||
|
repo.staff["player"] = &StaffUser{
|
||||||
|
ID: "u1", Username: "player", Email: "[email protected]",
|
||||||
|
Role: "user", EmailVerified: true,
|
||||||
|
}
|
||||||
|
mailer := &captureMailer{}
|
||||||
|
api := newTestAPI(repo, newFakeCluster())
|
||||||
|
api.Mailer = mailer
|
||||||
|
return api, repo, mailer
|
||||||
|
}
|
||||||
|
|
||||||
|
// errEnvelope decodes the standard error body into its stable (code, message) pair —
|
||||||
|
// request_id varies per request, so uniformity assertions compare these two fields,
|
||||||
|
// never raw bytes.
|
||||||
|
func errEnvelope(t *testing.T, w *httptest.ResponseRecorder) (code, msg string) {
|
||||||
|
t.Helper()
|
||||||
|
var raw map[string]map[string]string
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &raw); err != nil {
|
||||||
|
t.Fatalf("error body not JSON: %v (%s)", err, w.Body.String())
|
||||||
|
}
|
||||||
|
return raw["error"]["code"], raw["error"]["message"]
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLoginEmailVertical walks the whole returning-player slice: a typed lowercase
|
||||||
|
// address resolves the mixed-case stored account, the code is mailed to the account's
|
||||||
|
// STORED casing (the address of record), and redeeming it mints the same host-only
|
||||||
|
// felis_session as the password door — single-use, audited on both halves by the
|
||||||
|
// account's username. The redeem never rewrites users.email (login re-proves an
|
||||||
|
// already-verified address via ConsumeLoginEmailOTP), so the stored casing is
|
||||||
|
// untouched by definition.
|
||||||
|
func TestLoginEmailVertical(t *testing.T) {
|
||||||
|
api, repo, mailer := seedLoginEmailAPI(t)
|
||||||
|
eh := api.ExternalHandler()
|
||||||
|
|
||||||
|
// 1) start: 202 says "sent" and when it expires — never the code itself.
|
||||||
|
w := do(eh, "POST", "/api/v1/auth/email/start", `{"email":"[email protected]"}`, jsonHeader)
|
||||||
|
if w.Code != http.StatusAccepted {
|
||||||
|
t.Fatalf("start: code = %d, want 202 (%s)", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
b := acctBody(t, w)
|
||||||
|
if b["sent"] != true {
|
||||||
|
t.Errorf("start body sent = %v, want true", b["sent"])
|
||||||
|
}
|
||||||
|
if _, leaked := b["code"]; leaked {
|
||||||
|
t.Error("start response must NEVER carry the code")
|
||||||
|
}
|
||||||
|
if s, _ := b["expires_at"].(string); s == "" {
|
||||||
|
t.Error("start must report expires_at")
|
||||||
|
}
|
||||||
|
// The mail goes to the account's STORED address, not the typed casing — the code
|
||||||
|
// is delivered to the mailbox of record regardless of how the player typed it.
|
||||||
|
if mailer.calls != 1 || mailer.email != "[email protected]" {
|
||||||
|
t.Fatalf("mailer: calls=%d email=%q, want 1 send to the STORED casing [email protected]",
|
||||||
|
mailer.calls, mailer.email)
|
||||||
|
}
|
||||||
|
code := mailer.code
|
||||||
|
if len(code) != otpCodeDigits {
|
||||||
|
t.Fatalf("delivered code %q: len = %d, want %d", code, len(code), otpCodeDigits)
|
||||||
|
}
|
||||||
|
// Exactly one row, scoped to the LOGIN purpose, holding a hash — not the digits.
|
||||||
|
if len(repo.otps) != 1 {
|
||||||
|
t.Fatalf("persisted codes = %d, want 1", len(repo.otps))
|
||||||
|
}
|
||||||
|
for _, o := range repo.otps {
|
||||||
|
if o.purpose != otpPurposeLogin {
|
||||||
|
t.Errorf("otp purpose = %q, want %q", o.purpose, otpPurposeLogin)
|
||||||
|
}
|
||||||
|
if o.codeHash == code {
|
||||||
|
t.Error("store holds the plaintext code, not its hash")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) verify — typed in yet another casing, proving the verify-side resolver is
|
||||||
|
// case-insensitive too — mints the session.
|
||||||
|
w = do(eh, "POST", "/api/v1/auth/email/verify",
|
||||||
|
`{"email":"[email protected]","code":"`+code+`"}`, jsonHeader)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("verify: code = %d, want 200 (%s)", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
vb := acctBody(t, w)
|
||||||
|
if vb["user_id"] != "u1" || vb["role"] != "user" {
|
||||||
|
t.Fatalf("verify body = %v, want user_id:u1 role:user", vb)
|
||||||
|
}
|
||||||
|
// The HttpOnly cookie is the whole point — same contract as handleLogin.
|
||||||
|
cookies := w.Result().Cookies()
|
||||||
|
if len(cookies) != 1 || cookies[0].Name != sessionCookieName || cookies[0].Value == "" {
|
||||||
|
t.Fatalf("want one non-empty %s cookie, got %v", sessionCookieName, cookies)
|
||||||
|
}
|
||||||
|
s, ok := repo.sessions[hashCookie(cookies[0].Value)]
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("no session row for the issued cookie (must be stored hashed)")
|
||||||
|
}
|
||||||
|
if s.userID != "u1" {
|
||||||
|
t.Errorf("session userID = %q, want u1", s.userID)
|
||||||
|
}
|
||||||
|
if want := time.Unix(1_700_000_000, 0).Add(sessionTTL); !s.expiresAt.Equal(want) {
|
||||||
|
t.Errorf("session expiresAt = %v, want now+sessionTTL = %v", s.expiresAt, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Both halves audit by the account's username (there is no principal yet).
|
||||||
|
if n := len(repo.audits); n != 2 {
|
||||||
|
t.Fatalf("want 2 audits (otp_sent, login), got %d: %+v", n, repo.audits)
|
||||||
|
}
|
||||||
|
if repo.audits[0].Action != "auth.login_email.otp_sent" || repo.audits[0].Actor != "player" {
|
||||||
|
t.Errorf("first audit = %+v, want auth.login_email.otp_sent by player", repo.audits[0])
|
||||||
|
}
|
||||||
|
if repo.audits[1].Action != "auth.login_email" || repo.audits[1].Actor != "player" {
|
||||||
|
t.Errorf("second audit = %+v, want auth.login_email by player", repo.audits[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) single-use: the consumed code buys nothing a second time.
|
||||||
|
if w := do(eh, "POST", "/api/v1/auth/email/verify",
|
||||||
|
`{"email":"[email protected]","code":"`+code+`"}`, jsonHeader); w.Code != http.StatusBadRequest || decodeErr(t, w) != "invalid_code" {
|
||||||
|
t.Fatalf("replay of consumed code: code = %d body %s, want 400 invalid_code", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLoginEmailStartNeutralOnUnknownAddress pins the start-side anti-enumeration
|
||||||
|
// contract: an address with no verified account yields a 202 BYTE-IDENTICAL to a
|
||||||
|
// real send (frozen clock ⇒ same expires_at), mints and mails nothing, audits
|
||||||
|
// nothing — and still burns the cooldown window, so probing is throttled exactly
|
||||||
|
// like sending.
|
||||||
|
func TestLoginEmailStartNeutralOnUnknownAddress(t *testing.T) {
|
||||||
|
// A real send for comparison.
|
||||||
|
apiK, _, _ := seedLoginEmailAPI(t)
|
||||||
|
wK := do(apiK.ExternalHandler(), "POST", "/api/v1/auth/email/start",
|
||||||
|
`{"email":"[email protected]"}`, jsonHeader)
|
||||||
|
if wK.Code != http.StatusAccepted {
|
||||||
|
t.Fatalf("known-address start: code = %d (%s)", wK.Code, wK.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// The unknown address: same 202, same bytes, nothing behind it.
|
||||||
|
repoU := newFakeRepo()
|
||||||
|
repoU.settings[LocalAuthEnabledKey] = []byte("true")
|
||||||
|
mailerU := &captureMailer{}
|
||||||
|
apiU := newTestAPI(repoU, newFakeCluster())
|
||||||
|
apiU.Mailer = mailerU
|
||||||
|
ehU := apiU.ExternalHandler()
|
||||||
|
|
||||||
|
wU := do(ehU, "POST", "/api/v1/auth/email/start", `{"email":"[email protected]"}`, jsonHeader)
|
||||||
|
if wU.Code != http.StatusAccepted {
|
||||||
|
t.Fatalf("unknown-address start: code = %d, want 202 (%s)", wU.Code, wU.Body.String())
|
||||||
|
}
|
||||||
|
if wU.Body.String() != wK.Body.String() {
|
||||||
|
t.Errorf("neutral 202 differs from a real send's:\n unknown: %s\n known: %s",
|
||||||
|
wU.Body.String(), wK.Body.String())
|
||||||
|
}
|
||||||
|
if len(repoU.otps) != 0 || mailerU.calls != 0 || len(repoU.audits) != 0 {
|
||||||
|
t.Errorf("neutral path must mint/mail/audit nothing, got otps=%d mails=%d audits=%d",
|
||||||
|
len(repoU.otps), mailerU.calls, len(repoU.audits))
|
||||||
|
}
|
||||||
|
// The reservation is KEPT on the neutral path: re-probing the same unknown
|
||||||
|
// address inside the window is throttled identically to a resend.
|
||||||
|
if w := do(ehU, "POST", "/api/v1/auth/email/start", `{"email":"[email protected]"}`, jsonHeader); w.Code != http.StatusTooManyRequests || decodeErr(t, w) != "otp_resend_cooldown" {
|
||||||
|
t.Fatalf("re-probe of unknown address: code = %d body %s, want 429 otp_resend_cooldown",
|
||||||
|
w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// An UNVERIFIED account is indistinguishable from no account: UserByEmail only
|
||||||
|
// resolves proven addresses, so the door never mails one nobody controls.
|
||||||
|
repoV := newFakeRepo()
|
||||||
|
repoV.settings[LocalAuthEnabledKey] = []byte("true")
|
||||||
|
repoV.staff["u"] = &StaffUser{ID: "u9", Username: "u", Email: "[email protected]", Role: "user"} // EmailVerified false
|
||||||
|
mailerV := &captureMailer{}
|
||||||
|
apiV := newTestAPI(repoV, newFakeCluster())
|
||||||
|
apiV.Mailer = mailerV
|
||||||
|
if w := do(apiV.ExternalHandler(), "POST", "/api/v1/auth/email/start",
|
||||||
|
`{"email":"[email protected]"}`, jsonHeader); w.Code != http.StatusAccepted {
|
||||||
|
t.Fatalf("unverified-address start: code = %d, want neutral 202 (%s)", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if len(repoV.otps) != 0 || mailerV.calls != 0 {
|
||||||
|
t.Errorf("unverified address must behave as absent, got otps=%d mails=%d",
|
||||||
|
len(repoV.otps), mailerV.calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLoginEmailGates covers the shared front doors of both halves: the fail-closed
|
||||||
|
// local-auth toggle, the cross-site-forgery Content-Type guard (these are Public,
|
||||||
|
// credential-minting routes — same rationale as handleLogin), and the input gates
|
||||||
|
// that must reject before any mint or lookup.
|
||||||
|
func TestLoginEmailGates(t *testing.T) {
|
||||||
|
t.Run("local auth disabled -> 403 on both halves", func(t *testing.T) {
|
||||||
|
api := newTestAPI(newFakeRepo(), newFakeCluster()) // no LocalAuthEnabledKey: fails closed
|
||||||
|
eh := api.ExternalHandler()
|
||||||
|
if w := do(eh, "POST", "/api/v1/auth/email/start", `{"email":"[email protected]"}`, jsonHeader); w.Code != http.StatusForbidden || decodeErr(t, w) != "local_auth_disabled" {
|
||||||
|
t.Errorf("start: code = %d body %s, want 403 local_auth_disabled", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if w := do(eh, "POST", "/api/v1/auth/email/verify", `{"email":"[email protected]","code":"123456"}`, jsonHeader); w.Code != http.StatusForbidden || decodeErr(t, w) != "local_auth_disabled" {
|
||||||
|
t.Errorf("verify: code = %d body %s, want 403 local_auth_disabled", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("non-JSON content type -> 415 on both halves", func(t *testing.T) {
|
||||||
|
api, _, _ := seedLoginEmailAPI(t)
|
||||||
|
eh := api.ExternalHandler()
|
||||||
|
for _, ct := range []string{"", "text/plain", "application/x-www-form-urlencoded"} {
|
||||||
|
if w := do(eh, "POST", "/api/v1/auth/email/start", `{"email":"[email protected]"}`, ctHeader(ct)); w.Code != http.StatusUnsupportedMediaType {
|
||||||
|
t.Errorf("start with Content-Type %q: code = %d, want 415", ct, w.Code)
|
||||||
|
}
|
||||||
|
if w := do(eh, "POST", "/api/v1/auth/email/verify", `{"email":"[email protected]","code":"123456"}`, ctHeader(ct)); w.Code != http.StatusUnsupportedMediaType {
|
||||||
|
t.Errorf("verify with Content-Type %q: code = %d, want 415", ct, w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("start bad email -> 400, nothing minted or mailed", func(t *testing.T) {
|
||||||
|
bad := map[string]string{
|
||||||
|
"missing email": `{}`,
|
||||||
|
"empty email": `{"email":""}`,
|
||||||
|
"no at-sign": `{"email":"notanemail"}`,
|
||||||
|
"two at-signs": `{"email":"a@[email protected]"}`,
|
||||||
|
"unknown field": `{"email":"[email protected]","x":1}`,
|
||||||
|
}
|
||||||
|
for name, body := range bad {
|
||||||
|
api, repo, mailer := seedLoginEmailAPI(t)
|
||||||
|
w := do(api.ExternalHandler(), "POST", "/api/v1/auth/email/start", body, jsonHeader)
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("%s: code = %d, want 400 (%s)", name, w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if len(repo.otps) != 0 || mailer.calls != 0 {
|
||||||
|
t.Errorf("%s: a rejected start must not mint or mail (otps=%d mails=%d)",
|
||||||
|
name, len(repo.otps), mailer.calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("verify bad inputs -> 400 bad_request", func(t *testing.T) {
|
||||||
|
bad := map[string]string{
|
||||||
|
"bad email": `{"email":"notanemail","code":"123456"}`,
|
||||||
|
"empty code": `{"email":"[email protected]","code":""}`,
|
||||||
|
"missing code": `{"email":"[email protected]"}`,
|
||||||
|
"unknown field": `{"email":"[email protected]","code":"123456","x":1}`,
|
||||||
|
}
|
||||||
|
for name, body := range bad {
|
||||||
|
api, _, _ := seedLoginEmailAPI(t)
|
||||||
|
w := do(api.ExternalHandler(), "POST", "/api/v1/auth/email/verify", body, jsonHeader)
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("%s: code = %d, want 400 (%s)", name, w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLoginEmailStartRateLimited closes the unauthenticated email-bomb vector on the
|
||||||
|
// public door: one send per recipient per window, keyed case-insensitively, and
|
||||||
|
// namespaced apart from the authenticated onboarding throttle so neither door can
|
||||||
|
// starve the other.
|
||||||
|
func TestLoginEmailStartRateLimited(t *testing.T) {
|
||||||
|
start := func(eh http.Handler, email string) *httptest.ResponseRecorder {
|
||||||
|
return do(eh, "POST", "/api/v1/auth/email/start", `{"email":"`+email+`"}`, jsonHeader)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("same recipient is throttled, then recovers after the cooldown", func(t *testing.T) {
|
||||||
|
api, repo, mailer := seedLoginEmailAPI(t)
|
||||||
|
clock := time.Unix(1_700_000_000, 0)
|
||||||
|
api.Now = func() time.Time { return clock }
|
||||||
|
eh := api.ExternalHandler()
|
||||||
|
|
||||||
|
if w := start(eh, "[email protected]"); w.Code != http.StatusAccepted {
|
||||||
|
t.Fatalf("first send: code = %d, want 202 (%s)", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if w := start(eh, "[email protected]"); w.Code != http.StatusTooManyRequests || decodeErr(t, w) != "otp_resend_cooldown" {
|
||||||
|
t.Fatalf("immediate resend: code = %d body %s, want 429 otp_resend_cooldown", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if mailer.calls != 1 || len(repo.otps) != 1 {
|
||||||
|
t.Errorf("throttled resend must not mint or mail: mails=%d otps=%d, want 1/1",
|
||||||
|
mailer.calls, len(repo.otps))
|
||||||
|
}
|
||||||
|
clock = clock.Add(otpResendCooldown + time.Second)
|
||||||
|
if w := start(eh, "[email protected]"); w.Code != http.StatusAccepted {
|
||||||
|
t.Fatalf("post-cooldown send: code = %d, want 202 (%s)", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("throttle key is case-insensitive", func(t *testing.T) {
|
||||||
|
api, _, _ := seedLoginEmailAPI(t)
|
||||||
|
eh := api.ExternalHandler()
|
||||||
|
if w := start(eh, "[email protected]"); w.Code != http.StatusAccepted {
|
||||||
|
t.Fatalf("first send: code = %d, want 202 (%s)", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
// A recased retype is the same mailbox: it must hit the same window.
|
||||||
|
if w := start(eh, "[email protected]"); w.Code != http.StatusTooManyRequests {
|
||||||
|
t.Fatalf("recased resend: code = %d, want 429 (key must be lowercased)", w.Code)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("login and onboard cooldowns are namespaced apart", func(t *testing.T) {
|
||||||
|
// The unauthenticated login door must not perturb the authenticated
|
||||||
|
// onboarding throttle for the same mailbox — distinct keys, so both doors
|
||||||
|
// admit one send each at the same instant.
|
||||||
|
api, _, mailer := seedLoginEmailAPI(t)
|
||||||
|
api.External = staticExternal{p: &Principal{UserID: "u1", Email: "[email protected]", Role: "user"}}
|
||||||
|
eh := api.ExternalHandler()
|
||||||
|
|
||||||
|
if w := start(eh, "[email protected]"); w.Code != http.StatusAccepted {
|
||||||
|
t.Fatalf("login start: code = %d, want 202 (%s)", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if w := do(eh, "POST", "/api/v1/account/email/start", `{"email":"[email protected]"}`, nil); w.Code != http.StatusAccepted {
|
||||||
|
t.Fatalf("onboard start same mailbox, same instant: code = %d, want 202 — the doors must not share a throttle key (%s)",
|
||||||
|
w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if mailer.calls != 2 {
|
||||||
|
t.Errorf("mailer calls = %d, want 2 (one per door)", mailer.calls)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLoginEmailVerifyRejections is the redeem-side failure matrix. The anchor case
|
||||||
|
// is uniformity: an unknown address and a wrong code for a known address answer with
|
||||||
|
// the same (code, message) envelope, so the verify half never doubles as an
|
||||||
|
// existence oracle. Expired/locked rows are planted directly — the frozen clock
|
||||||
|
// makes that the only deterministic route to those branches.
|
||||||
|
func TestLoginEmailVerifyRejections(t *testing.T) {
|
||||||
|
verify := func(eh http.Handler, email, code string) *httptest.ResponseRecorder {
|
||||||
|
return do(eh, "POST", "/api/v1/auth/email/verify",
|
||||||
|
`{"email":"`+email+`","code":"`+code+`"}`, jsonHeader)
|
||||||
|
}
|
||||||
|
// liveLogin plants an unconsumed LOGIN-purpose code for u1.
|
||||||
|
liveLogin := func(repo *fakeRepo, id, codeHash string, expiresAt time.Time, attempts int) {
|
||||||
|
repo.otps[id] = &fakeEmailOTP{
|
||||||
|
id: id, userID: "u1", email: "[email protected]", codeHash: codeHash,
|
||||||
|
purpose: otpPurposeLogin, attempts: attempts,
|
||||||
|
expiresAt: expiresAt, createdAt: expiresAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("unknown address is indistinguishable from a wrong code", func(t *testing.T) {
|
||||||
|
// Known account, live code, wrong digits.
|
||||||
|
apiW, repoW, _ := seedLoginEmailAPI(t)
|
||||||
|
liveLogin(repoW, "lg", otpCodeHash("123456"), time.Unix(1_700_000_600, 0), 0)
|
||||||
|
wWrong := verify(apiW.ExternalHandler(), "[email protected]", "654321")
|
||||||
|
// No account at all.
|
||||||
|
apiU, _, _ := seedLoginEmailAPI(t)
|
||||||
|
wGhost := verify(apiU.ExternalHandler(), "[email protected]", "654321")
|
||||||
|
|
||||||
|
if wWrong.Code != http.StatusBadRequest || wGhost.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("codes = %d/%d, want 400/400", wWrong.Code, wGhost.Code)
|
||||||
|
}
|
||||||
|
wc, wm := errEnvelope(t, wWrong)
|
||||||
|
gc, gm := errEnvelope(t, wGhost)
|
||||||
|
if wc != "invalid_code" || wc != gc || wm != gm {
|
||||||
|
t.Errorf("envelopes differ: known=(%s,%q) unknown=(%s,%q) — must be identical", wc, wm, gc, gm)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("known address, no live code -> 400 invalid_code", func(t *testing.T) {
|
||||||
|
api, repo, _ := seedLoginEmailAPI(t)
|
||||||
|
w := verify(api.ExternalHandler(), "[email protected]", "123456")
|
||||||
|
if w.Code != http.StatusBadRequest || decodeErr(t, w) != "invalid_code" {
|
||||||
|
t.Fatalf("code = %d body %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if len(repo.sessions) != 0 {
|
||||||
|
t.Error("no session may be minted on a failed verify")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("wrong code charges an attempt, does not consume", func(t *testing.T) {
|
||||||
|
api, repo, _ := seedLoginEmailAPI(t)
|
||||||
|
liveLogin(repo, "wr", otpCodeHash("123456"), time.Unix(1_700_000_600, 0), 0)
|
||||||
|
w := verify(api.ExternalHandler(), "[email protected]", "654321")
|
||||||
|
if w.Code != http.StatusBadRequest || decodeErr(t, w) != "invalid_code" {
|
||||||
|
t.Fatalf("code = %d body %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if repo.otps["wr"].attempts != 1 || repo.otps["wr"].consumed {
|
||||||
|
t.Errorf("attempts=%d consumed=%v, want 1/false", repo.otps["wr"].attempts, repo.otps["wr"].consumed)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("expired code -> 400 invalid_code", func(t *testing.T) {
|
||||||
|
api, repo, _ := seedLoginEmailAPI(t)
|
||||||
|
// One second before the frozen clock (time.Unix(1_700_000_000, 0)).
|
||||||
|
liveLogin(repo, "ex", otpCodeHash("123456"), time.Unix(1_699_999_999, 0), 0)
|
||||||
|
w := verify(api.ExternalHandler(), "[email protected]", "123456")
|
||||||
|
if w.Code != http.StatusBadRequest || decodeErr(t, w) != "invalid_code" {
|
||||||
|
t.Fatalf("code = %d body %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("exhausted code is invisible: same invalid_code as a wrong code, no locked oracle", func(t *testing.T) {
|
||||||
|
// A locked row (attempts == cap) must NOT surface as a distinct 429 otp_locked
|
||||||
|
// on this public, pre-session door: that status would be an existence oracle —
|
||||||
|
// a code-less prober could mail a victim address a code, burn its attempt budget,
|
||||||
|
// and read otp_locked as "this address has an account." It collapses to the SAME
|
||||||
|
// 400 invalid_code a wrong code returns, byte-for-byte. (The authenticated
|
||||||
|
// onboarding door keeps otp_locked as actionable feedback; this one cannot.)
|
||||||
|
api, repo, _ := seedLoginEmailAPI(t)
|
||||||
|
liveLogin(repo, "lk", otpCodeHash("123456"), time.Unix(1_700_000_600, 0), otpMaxAttempts)
|
||||||
|
wLocked := verify(api.ExternalHandler(), "[email protected]", "123456") // correct digits, but exhausted
|
||||||
|
|
||||||
|
// A plain wrong code on a fresh known account, for the byte-for-byte comparison.
|
||||||
|
apiW, repoW, _ := seedLoginEmailAPI(t)
|
||||||
|
liveLogin(repoW, "wr", otpCodeHash("123456"), time.Unix(1_700_000_600, 0), 0)
|
||||||
|
wWrong := verify(apiW.ExternalHandler(), "[email protected]", "654321")
|
||||||
|
|
||||||
|
if wLocked.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("exhausted code: code = %d body %s, want 400 invalid_code (NOT 429 otp_locked)",
|
||||||
|
wLocked.Code, wLocked.Body.String())
|
||||||
|
}
|
||||||
|
lc, lm := errEnvelope(t, wLocked)
|
||||||
|
wc, wm := errEnvelope(t, wWrong)
|
||||||
|
if lc != "invalid_code" || lc != wc || lm != wm {
|
||||||
|
t.Errorf("locked envelope must be identical to a wrong code's: locked=(%s,%q) wrong=(%s,%q)",
|
||||||
|
lc, lm, wc, wm)
|
||||||
|
}
|
||||||
|
if len(repo.sessions) != 0 {
|
||||||
|
t.Error("no session may be minted from a locked code")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLoginEmailPurposeSeparation proves the email_otps purpose column does its one
|
||||||
|
// job in both directions: a live ONBOARDING code cannot open the login door, a live
|
||||||
|
// LOGIN code cannot satisfy onboarding verify. Two complementary properties fall out
|
||||||
|
// and both are pinned: the OTHER flow's row is never consumed or charged (the
|
||||||
|
// per-purpose query never sees it), while the door's OWN live code IS charged one
|
||||||
|
// attempt — cross-door digits are just a wrong guess, never a free brute-force try.
|
||||||
|
func TestLoginEmailPurposeSeparation(t *testing.T) {
|
||||||
|
api, repo, _ := seedLoginEmailAPI(t)
|
||||||
|
api.External = staticExternal{p: &Principal{UserID: "u1", Email: "[email protected]", Role: "user"}}
|
||||||
|
eh := api.ExternalHandler()
|
||||||
|
|
||||||
|
// Both purposes live at once for u1, distinct digits.
|
||||||
|
repo.otps["ob"] = &fakeEmailOTP{
|
||||||
|
id: "ob", userID: "u1", email: "[email protected]", codeHash: otpCodeHash("111111"),
|
||||||
|
purpose: otpPurposeOnboard, expiresAt: time.Unix(1_700_000_600, 0), createdAt: time.Unix(1_700_000_600, 0),
|
||||||
|
}
|
||||||
|
repo.otps["lg"] = &fakeEmailOTP{
|
||||||
|
id: "lg", userID: "u1", email: "[email protected]", codeHash: otpCodeHash("222222"),
|
||||||
|
purpose: otpPurposeLogin, expiresAt: time.Unix(1_700_000_600, 0), createdAt: time.Unix(1_700_000_600, 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Onboarding code at the LOGIN door: refused. The onboard row is untouched (the
|
||||||
|
// login-purpose query never saw it); the LIVE LOGIN code is charged one attempt —
|
||||||
|
// to the login door these are simply wrong digits.
|
||||||
|
w := do(eh, "POST", "/api/v1/auth/email/verify",
|
||||||
|
`{"email":"[email protected]","code":"111111"}`, jsonHeader)
|
||||||
|
if w.Code != http.StatusBadRequest || decodeErr(t, w) != "invalid_code" {
|
||||||
|
t.Fatalf("onboard code at login door: code = %d body %s, want 400 invalid_code", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if o := repo.otps["ob"]; o.consumed || o.attempts != 0 {
|
||||||
|
t.Errorf("onboard row must be untouched by a login verify: consumed=%v attempts=%d", o.consumed, o.attempts)
|
||||||
|
}
|
||||||
|
if o := repo.otps["lg"]; o.consumed || o.attempts != 1 {
|
||||||
|
t.Errorf("cross-door digits must cost the login code one attempt (never a free guess): consumed=%v attempts=%d",
|
||||||
|
o.consumed, o.attempts)
|
||||||
|
}
|
||||||
|
if len(repo.sessions) != 0 {
|
||||||
|
t.Fatal("a cross-purpose code must never mint a session")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Login code at the ONBOARDING door: refused symmetrically — the login row is not
|
||||||
|
// consumed and not charged further (the onboard-purpose query never saw it; its
|
||||||
|
// one attempt above stands), while the onboard code eats the wrong guess...
|
||||||
|
w = do(eh, "POST", "/api/v1/account/email/verify", `{"code":"222222"}`, nil)
|
||||||
|
if w.Code != http.StatusBadRequest || decodeErr(t, w) != "invalid_code" {
|
||||||
|
t.Fatalf("login code at onboard door: code = %d body %s, want 400 invalid_code", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if o := repo.otps["lg"]; o.consumed || o.attempts != 1 {
|
||||||
|
t.Errorf("login row must not be consumed or re-charged by an onboard verify: consumed=%v attempts=%d",
|
||||||
|
o.consumed, o.attempts)
|
||||||
|
}
|
||||||
|
if o := repo.otps["ob"]; o.consumed || o.attempts != 1 {
|
||||||
|
t.Errorf("cross-door digits must cost the onboard code one attempt: consumed=%v attempts=%d",
|
||||||
|
o.consumed, o.attempts)
|
||||||
|
}
|
||||||
|
// ...and the login code is still redeemable at its own door afterwards.
|
||||||
|
w = do(eh, "POST", "/api/v1/auth/email/verify",
|
||||||
|
`{"email":"[email protected]","code":"222222"}`, jsonHeader)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("login code at its own door after the cross attempts: code = %d, want 200 (%s)",
|
||||||
|
w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLoginEmailVerifyRefusesStaff pins the staff refusal AND its ordering. The
|
||||||
|
// public door provably never mints a session for role=admin (op.console keeps its
|
||||||
|
// Zero-Trust gate) — but the refusal must be reachable only by the mailbox owner:
|
||||||
|
// a wrong code for a staff address answers the same invalid_code as for anyone, and
|
||||||
|
// the 403 costs the valid code (verify-then-refuse), so it cannot be farmed as an
|
||||||
|
// is-this-address-staff oracle.
|
||||||
|
func TestLoginEmailVerifyRefusesStaff(t *testing.T) {
|
||||||
|
repo := newFakeRepo()
|
||||||
|
repo.settings[LocalAuthEnabledKey] = []byte("true")
|
||||||
|
repo.staff["owner"] = &StaffUser{
|
||||||
|
ID: "a1", Username: "owner", Email: "[email protected]",
|
||||||
|
Role: "admin", EmailVerified: true,
|
||||||
|
}
|
||||||
|
mailer := &captureMailer{}
|
||||||
|
api := newTestAPI(repo, newFakeCluster())
|
||||||
|
api.Mailer = mailer
|
||||||
|
eh := api.ExternalHandler()
|
||||||
|
|
||||||
|
// Start happily mails a staff address — the refusal lives at verify, after the
|
||||||
|
// code proves mailbox control, so start stays neutral.
|
||||||
|
if w := do(eh, "POST", "/api/v1/auth/email/start", `{"email":"[email protected]"}`, jsonHeader); w.Code != http.StatusAccepted {
|
||||||
|
t.Fatalf("start for staff address: code = %d, want 202 (%s)", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
good := mailer.code
|
||||||
|
if good == "000000" {
|
||||||
|
t.Skip("astronomically unlucky code collision; rerun")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Without the code, staffness is invisible: plain invalid_code.
|
||||||
|
if w := do(eh, "POST", "/api/v1/auth/email/verify",
|
||||||
|
`{"email":"[email protected]","code":"000000"}`, jsonHeader); w.Code != http.StatusBadRequest || decodeErr(t, w) != "invalid_code" {
|
||||||
|
t.Fatalf("wrong code for staff: code = %d body %s, want 400 invalid_code", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// With the code: 403 staff_account — and no cookie, no session row, no success audit.
|
||||||
|
w := do(eh, "POST", "/api/v1/auth/email/verify",
|
||||||
|
`{"email":"[email protected]","code":"`+good+`"}`, jsonHeader)
|
||||||
|
if w.Code != http.StatusForbidden || decodeErr(t, w) != "staff_account" {
|
||||||
|
t.Fatalf("valid code for staff: code = %d body %s, want 403 staff_account", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if len(w.Result().Cookies()) != 0 {
|
||||||
|
t.Error("no session cookie may be set for a refused staff login")
|
||||||
|
}
|
||||||
|
if len(repo.sessions) != 0 {
|
||||||
|
t.Error("no session row may be minted for a refused staff login")
|
||||||
|
}
|
||||||
|
for _, a := range repo.audits {
|
||||||
|
if a.Action == "auth.login_email" {
|
||||||
|
t.Error("a refused staff login must not be audited as a successful login")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Ordering pin: the refusal consumed the code (verify ran BEFORE the staff
|
||||||
|
// check), so replaying it now collapses to invalid_code.
|
||||||
|
if w := do(eh, "POST", "/api/v1/auth/email/verify",
|
||||||
|
`{"email":"[email protected]","code":"`+good+`"}`, jsonHeader); w.Code != http.StatusBadRequest || decodeErr(t, w) != "invalid_code" {
|
||||||
|
t.Fatalf("replay after staff refusal: code = %d body %s, want 400 invalid_code (code must be spent)",
|
||||||
|
w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLoginEmailFaceSeparation enforces that both halves are web-only: the internal
|
||||||
|
// (service-token) face must 404 them, never serve them.
|
||||||
|
func TestLoginEmailFaceSeparation(t *testing.T) {
|
||||||
|
api, _, _ := seedLoginEmailAPI(t)
|
||||||
|
ih := api.InternalHandler()
|
||||||
|
if w := do(ih, "POST", "/api/v1/auth/email/start", `{"email":"[email protected]"}`, jsonHeader); w.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("start on internal face: code = %d, want 404", w.Code)
|
||||||
|
}
|
||||||
|
if w := do(ih, "POST", "/api/v1/auth/email/verify", `{"email":"[email protected]","code":"123456"}`, jsonHeader); w.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("verify on internal face: code = %d, want 404", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLoginEmailStartFailedDeliveryReleasesCooldown covers the reserve→rollback
|
||||||
|
// path on the login door: a send that reserves the window but fails to deliver must
|
||||||
|
// release it, so the immediate retry is admitted instead of 429'd — a transient SMTP
|
||||||
|
// blip must not lock a returning player out for the whole window. The failure is
|
||||||
|
// also not audited as a send.
|
||||||
|
func TestLoginEmailStartFailedDeliveryReleasesCooldown(t *testing.T) {
|
||||||
|
repo := newFakeRepo()
|
||||||
|
repo.settings[LocalAuthEnabledKey] = []byte("true")
|
||||||
|
repo.staff["player"] = &StaffUser{
|
||||||
|
ID: "u1", Username: "player", Email: "[email protected]",
|
||||||
|
Role: "user", EmailVerified: true,
|
||||||
|
}
|
||||||
|
mailer := &flakyMailer{}
|
||||||
|
api := newTestAPI(repo, newFakeCluster()) // frozen clock: both attempts share one window
|
||||||
|
api.Mailer = mailer
|
||||||
|
eh := api.ExternalHandler()
|
||||||
|
|
||||||
|
if w := do(eh, "POST", "/api/v1/auth/email/start", `{"email":"[email protected]"}`, jsonHeader); w.Code < 500 {
|
||||||
|
t.Fatalf("first send (mailer fails): code = %d, want 5xx (%s)", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
for _, a := range repo.audits {
|
||||||
|
if a.Action == "auth.login_email.otp_sent" {
|
||||||
|
t.Error("a failed delivery must not be audited as otp_sent")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if w := do(eh, "POST", "/api/v1/auth/email/start", `{"email":"[email protected]"}`, jsonHeader); w.Code != http.StatusAccepted {
|
||||||
|
t.Fatalf("retry after failed delivery: code = %d, want 202 (the failed send must release the cooldown) (%s)",
|
||||||
|
w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if mailer.calls != 2 {
|
||||||
|
t.Errorf("mailer calls = %d, want 2 (one failed, one delivered)", mailer.calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Pre-session identifier-first discovery (spec §B, #71). Given a typed email, this
|
||||||
|
// Public door reports which console login methods the account can use, so the SPA's
|
||||||
|
// identifier-first form can prompt for the right authenticator (a passkey assertion,
|
||||||
|
// or "we'll email you a code") instead of guessing.
|
||||||
|
//
|
||||||
|
// It is the deliberate counter-slice to the anti-enumeration login doors
|
||||||
|
// (handlers_auth_email.go, handlers_passkey.go): those refuse to disclose whether an
|
||||||
|
// address has an account precisely because THIS endpoint is the one sanctioned place
|
||||||
|
// existence is revealed. An empty methods array means "no (verified) account". That
|
||||||
|
// makes it a mass-enumeration surface by design — an accepted product decision, the
|
||||||
|
// same one the email door's header records. It is bounded only at the edge: the
|
||||||
|
// handler sends no mail and mutates nothing, so a per-recipient cooldown would merely
|
||||||
|
// block a legitimate retry, and per-source (client-IP) limiting is the edge's job
|
||||||
|
// (behind Cloudflare RemoteAddr is the proxy, and CGNAT would false-positive) — see
|
||||||
|
// the handlers_auth_email.go header for the same reasoning.
|
||||||
|
//
|
||||||
|
// It never reveals STAFFNESS. Methods are computed by the SAME rule for every resolved
|
||||||
|
// account — no role branch, no operator hint — so a staff email and a player email in
|
||||||
|
// the same credential state return byte-identical bodies. The console doors' own
|
||||||
|
// post-redemption staff refusal is not previewed here: a staff caller is told
|
||||||
|
// email_otp is available and is turned away only later, at op.console's Zero-Trust
|
||||||
|
// gate. Staffness is thus invisible by construction, with no side channel to regress.
|
||||||
|
type authOptionsRequest struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAuthOptions resolves the typed email and returns the console login methods it
|
||||||
|
// can use. Public, pre-session, gated on local_auth_enabled like its sibling doors.
|
||||||
|
func (a *API) handleAuthOptions(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !localAuthEnabled(r.Context(), a.Repo) {
|
||||||
|
writeError(w, r, newError(http.StatusForbidden, "local_auth_disabled",
|
||||||
|
"session login is disabled"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := requireJSONContentType(r); err != nil {
|
||||||
|
writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req authOptionsRequest
|
||||||
|
if err := decodeJSON(w, r, &req); err != nil {
|
||||||
|
writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
email := strings.TrimSpace(req.Email)
|
||||||
|
if !looksLikeEmail(email) {
|
||||||
|
writeError(w, r, newError(http.StatusBadRequest, "bad_request", "a valid email is required"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// methods is initialised non-nil so the no-account branch marshals as [] (not null).
|
||||||
|
methods := []string{}
|
||||||
|
|
||||||
|
u, err := a.Repo.UserByEmail(r.Context(), email)
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, ErrNotFound):
|
||||||
|
// The sanctioned existence oracle: an unknown (or not-yet-verified) address is
|
||||||
|
// not disguised — it honestly reports no methods.
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"methods": methods})
|
||||||
|
return
|
||||||
|
case err != nil:
|
||||||
|
writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute methods identically for EVERY resolved account. There is deliberately no
|
||||||
|
// branch on u.Role: a staff address must be indistinguishable from a player address
|
||||||
|
// in the same credential state, so the response carries nothing account-identifying.
|
||||||
|
//
|
||||||
|
// Advertise passkey only when a verifier is actually wired: both login halves 503
|
||||||
|
// passkey_unavailable when a.Passkey is nil regardless of enrolled credentials, so
|
||||||
|
// options must not offer a method the finish door would immediately reject.
|
||||||
|
if a.Passkey != nil {
|
||||||
|
creds, err := a.Repo.PasskeyCredentialsForUser(r.Context(), u.ID)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(creds) > 0 {
|
||||||
|
methods = append(methods, "passkey")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Email-OTP login works for any resolved verified account (UserByEmail resolves only
|
||||||
|
// email_verified rows), so it is always on offer.
|
||||||
|
methods = append(methods, "email_otp")
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"methods": methods})
|
||||||
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Pre-session identifier-first discovery tests (spec §B, #71). Load-bearing properties:
|
||||||
|
//
|
||||||
|
// - Methods reflect real state: email_otp for any resolved verified account, plus
|
||||||
|
// passkey when a verifier is wired AND the account has >=1 enrolled credential.
|
||||||
|
// - Existence IS disclosed: an unknown address returns an empty methods array. This
|
||||||
|
// endpoint is the deliberate, sanctioned counter-slice to the anti-enumeration
|
||||||
|
// login doors, so it does not disguise non-existence.
|
||||||
|
// - Staffness is NOT disclosed: a staff email and a player email in the same
|
||||||
|
// credential state return BYTE-IDENTICAL bodies — the highest-value guard, because a
|
||||||
|
// role branch here would out which addresses are operators.
|
||||||
|
// - passkey is gated on a wired verifier: options never advertises a method the finish
|
||||||
|
// door would immediately 503.
|
||||||
|
|
||||||
|
// seedAuthOptionsAPI wires the discovery door: local sessions enabled, a verified player
|
||||||
|
// (u1) and a verified staff account (a1), and a passkey verifier wired by default.
|
||||||
|
// Callers seed passkey credentials per-test to set the credential state.
|
||||||
|
func seedAuthOptionsAPI(t *testing.T) (*API, *fakeRepo) {
|
||||||
|
t.Helper()
|
||||||
|
repo := newFakeRepo()
|
||||||
|
repo.settings[LocalAuthEnabledKey] = []byte("true")
|
||||||
|
repo.staff["player"] = &StaffUser{ID: "u1", Username: "player", Email: "[email protected]", Role: "user", EmailVerified: true}
|
||||||
|
repo.staff["boss"] = &StaffUser{ID: "a1", Username: "boss", Email: "[email protected]", Role: "admin", EmailVerified: true}
|
||||||
|
api := newTestAPI(repo, newFakeCluster())
|
||||||
|
api.Passkey = &fakePasskeyVerifier{}
|
||||||
|
return api, repo
|
||||||
|
}
|
||||||
|
|
||||||
|
const authOptionsPath = "/api/v1/auth/options"
|
||||||
|
|
||||||
|
// optionsMethods pulls the methods array out of a 200 body as []string.
|
||||||
|
func optionsMethods(t *testing.T, w *httptest.ResponseRecorder) []string {
|
||||||
|
t.Helper()
|
||||||
|
raw, ok := acctBody(t, w)["methods"].([]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("body has no methods array: %s", w.Body.String())
|
||||||
|
}
|
||||||
|
out := make([]string, len(raw))
|
||||||
|
for i, m := range raw {
|
||||||
|
out[i], _ = m.(string)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthOptionsMethodsByState(t *testing.T) {
|
||||||
|
t.Run("account with no passkey -> email_otp only", func(t *testing.T) {
|
||||||
|
api, _ := seedAuthOptionsAPI(t)
|
||||||
|
w := do(api.ExternalHandler(), "POST", authOptionsPath, `{"email":"[email protected]"}`, jsonHeader)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("code = %d, want 200 (%s)", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if got := optionsMethods(t, w); !reflect.DeepEqual(got, []string{"email_otp"}) {
|
||||||
|
t.Errorf("methods = %v, want [email_otp]", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("account with a passkey (verifier wired) -> passkey + email_otp", func(t *testing.T) {
|
||||||
|
api, repo := seedAuthOptionsAPI(t)
|
||||||
|
repo.passkeyCreds["row1"] = PasskeyCredential{ID: "row1", UserID: "u1", CredentialID: "cred-1", PublicKey: "k", CreatedAt: frozenNow}
|
||||||
|
w := do(api.ExternalHandler(), "POST", authOptionsPath, `{"email":"[email protected]"}`, jsonHeader)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("code = %d, want 200 (%s)", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
// Deterministic order (passkey before email_otp) so clients and this assertion
|
||||||
|
// can compare without sorting.
|
||||||
|
if got := optionsMethods(t, w); !reflect.DeepEqual(got, []string{"passkey", "email_otp"}) {
|
||||||
|
t.Errorf("methods = %v, want [passkey email_otp]", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAuthOptionsUnknownEmail pins the sanctioned-oracle contract: an address with no
|
||||||
|
// verified account is not disguised — it returns an explicit empty array (not null), so
|
||||||
|
// the client can trust "no methods" as "no account".
|
||||||
|
func TestAuthOptionsUnknownEmail(t *testing.T) {
|
||||||
|
api, _ := seedAuthOptionsAPI(t)
|
||||||
|
w := do(api.ExternalHandler(), "POST", authOptionsPath, `{"email":"[email protected]"}`, jsonHeader)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("code = %d, want 200 (%s)", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if got := optionsMethods(t, w); len(got) != 0 {
|
||||||
|
t.Errorf("methods = %v, want []", got)
|
||||||
|
}
|
||||||
|
if body := w.Body.String(); !strings.Contains(body, `"methods":[]`) {
|
||||||
|
t.Errorf("unknown-email body = %s, want an explicit \"methods\":[] (not null)", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAuthOptionsDoesNotRevealStaffness is the security anchor. For each credential
|
||||||
|
// state, a staff address and a player address in the SAME state must return
|
||||||
|
// byte-identical bodies. A role branch in the handler — even one that only reordered or
|
||||||
|
// relabelled — would out which addresses are operators; this is the guard that such a
|
||||||
|
// branch can never be introduced without a red test.
|
||||||
|
func TestAuthOptionsDoesNotRevealStaffness(t *testing.T) {
|
||||||
|
states := []struct {
|
||||||
|
name string
|
||||||
|
withPasskey bool
|
||||||
|
}{
|
||||||
|
{"neither has a passkey", false},
|
||||||
|
{"both have a passkey", true},
|
||||||
|
}
|
||||||
|
for _, st := range states {
|
||||||
|
t.Run(st.name, func(t *testing.T) {
|
||||||
|
api, repo := seedAuthOptionsAPI(t)
|
||||||
|
if st.withPasskey {
|
||||||
|
repo.passkeyCreds["p"] = PasskeyCredential{ID: "p", UserID: "u1", CredentialID: "c-u1", PublicKey: "k", CreatedAt: frozenNow}
|
||||||
|
repo.passkeyCreds["a"] = PasskeyCredential{ID: "a", UserID: "a1", CredentialID: "c-a1", PublicKey: "k", CreatedAt: frozenNow}
|
||||||
|
}
|
||||||
|
eh := api.ExternalHandler()
|
||||||
|
wPlayer := do(eh, "POST", authOptionsPath, `{"email":"[email protected]"}`, jsonHeader)
|
||||||
|
wStaff := do(eh, "POST", authOptionsPath, `{"email":"[email protected]"}`, jsonHeader)
|
||||||
|
if wPlayer.Code != http.StatusOK || wStaff.Code != http.StatusOK {
|
||||||
|
t.Fatalf("codes = %d/%d, want 200/200", wPlayer.Code, wStaff.Code)
|
||||||
|
}
|
||||||
|
if wPlayer.Body.String() != wStaff.Body.String() {
|
||||||
|
t.Errorf("staff/player bodies differ — options reveals staffness:\n player: %s\n staff: %s",
|
||||||
|
wPlayer.Body.String(), wStaff.Body.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAuthOptionsPasskeyRequiresWiredVerifier: the account HAS an enrolled passkey, but
|
||||||
|
// no verifier is wired (a.Passkey == nil). Both login halves 503 passkey_unavailable in
|
||||||
|
// that state, so options must NOT advertise passkey — it would be a dead offer.
|
||||||
|
func TestAuthOptionsPasskeyRequiresWiredVerifier(t *testing.T) {
|
||||||
|
api, repo := seedAuthOptionsAPI(t)
|
||||||
|
repo.passkeyCreds["row1"] = PasskeyCredential{ID: "row1", UserID: "u1", CredentialID: "cred-1", PublicKey: "k", CreatedAt: frozenNow}
|
||||||
|
api.Passkey = nil
|
||||||
|
w := do(api.ExternalHandler(), "POST", authOptionsPath, `{"email":"[email protected]"}`, jsonHeader)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("code = %d, want 200 (%s)", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if got := optionsMethods(t, w); !reflect.DeepEqual(got, []string{"email_otp"}) {
|
||||||
|
t.Errorf("methods = %v, want [email_otp] (passkey must not be offered without a wired verifier)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthOptionsGates(t *testing.T) {
|
||||||
|
t.Run("local auth disabled -> 403", func(t *testing.T) {
|
||||||
|
api := newTestAPI(newFakeRepo(), newFakeCluster()) // no LocalAuthEnabledKey: fails closed
|
||||||
|
api.Passkey = &fakePasskeyVerifier{}
|
||||||
|
w := do(api.ExternalHandler(), "POST", authOptionsPath, `{"email":"[email protected]"}`, jsonHeader)
|
||||||
|
if w.Code != http.StatusForbidden || decodeErr(t, w) != "local_auth_disabled" {
|
||||||
|
t.Errorf("code = %d body %s, want 403 local_auth_disabled", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("non-JSON content type -> 415", func(t *testing.T) {
|
||||||
|
api, _ := seedAuthOptionsAPI(t)
|
||||||
|
eh := api.ExternalHandler()
|
||||||
|
for _, ct := range []string{"", "text/plain", "application/x-www-form-urlencoded"} {
|
||||||
|
if w := do(eh, "POST", authOptionsPath, `{"email":"[email protected]"}`, ctHeader(ct)); w.Code != http.StatusUnsupportedMediaType {
|
||||||
|
t.Errorf("Content-Type %q: code = %d, want 415", ct, w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("bad or unknown-field body -> 400", func(t *testing.T) {
|
||||||
|
bad := map[string]string{
|
||||||
|
"missing email": `{}`,
|
||||||
|
"empty email": `{"email":""}`,
|
||||||
|
"no at-sign": `{"email":"notanemail"}`,
|
||||||
|
"unknown field": `{"email":"[email protected]","x":1}`,
|
||||||
|
}
|
||||||
|
api, _ := seedAuthOptionsAPI(t)
|
||||||
|
eh := api.ExternalHandler()
|
||||||
|
for name, body := range bad {
|
||||||
|
if w := do(eh, "POST", authOptionsPath, body, jsonHeader); w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("%s: code = %d, want 400 (%s)", name, w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAuthOptionsFaceSeparation: the route is external-only (registered in
|
||||||
|
// externalAPIRoutes), so the internal face must 404 it.
|
||||||
|
func TestAuthOptionsFaceSeparation(t *testing.T) {
|
||||||
|
api, _ := seedAuthOptionsAPI(t)
|
||||||
|
ih := api.InternalHandler()
|
||||||
|
if w := do(ih, "POST", authOptionsPath, `{"email":"[email protected]"}`, jsonHeader); w.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("options on internal face: code = %d, want 404 (it is external-only)", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,321 +0,0 @@
|
|||||||
package api
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"golang.org/x/crypto/bcrypt"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Local-password auth handler tests (spec §B). These exercise the three-route
|
|
||||||
// surface — login, logout, change-password — against the in-memory fakeRepo, which
|
|
||||||
// mirrors the PG fail-closed contract. The load-bearing cases are the anti-
|
|
||||||
// enumeration uniformity (an unknown user and a wrong password are indistinguishable)
|
|
||||||
// and the requireJSONContentType guard that closes the cross-site login-forgery
|
|
||||||
// vector: a forged HTML-form POST cannot set application/json, so it is rejected
|
|
||||||
// before any credential check.
|
|
||||||
|
|
||||||
// seedAuthAPI returns an API whose repo has local auth enabled and a single admin
|
|
||||||
// "owner" (id u1) whose password is the given plaintext. Login is Public, so these
|
|
||||||
// tests need no External wiring.
|
|
||||||
func seedAuthAPI(t *testing.T, password string, mustChange bool) (*API, *fakeRepo) {
|
|
||||||
t.Helper()
|
|
||||||
repo := newFakeRepo()
|
|
||||||
repo.settings[LocalAuthEnabledKey] = []byte("true")
|
|
||||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("hash seed password: %v", err)
|
|
||||||
}
|
|
||||||
repo.staff["owner"] = &StaffUser{
|
|
||||||
ID: "u1", Username: "owner", Email: "owner@" + testRoot,
|
|
||||||
Role: "admin", PasswordHash: string(hash), MustChangePassword: mustChange,
|
|
||||||
}
|
|
||||||
return newTestAPI(repo, newFakeCluster()), repo
|
|
||||||
}
|
|
||||||
|
|
||||||
// seedAuthedAPI extends seedAuthAPI with an injected session principal so the
|
|
||||||
// authenticated change-password route resolves a caller. change-password opts out of
|
|
||||||
// the first-login lockdown (AllowDuringPasswordChange), so a must-change principal
|
|
||||||
// still reaches the handler.
|
|
||||||
func seedAuthedAPI(t *testing.T, password string, mustChange bool) (*API, *fakeRepo) {
|
|
||||||
t.Helper()
|
|
||||||
api, repo := seedAuthAPI(t, password, mustChange)
|
|
||||||
api.External = staticExternal{p: &Principal{
|
|
||||||
UserID: "u1", Email: "owner@" + testRoot, Role: "admin", MustChangePassword: mustChange,
|
|
||||||
}}
|
|
||||||
return api, repo
|
|
||||||
}
|
|
||||||
|
|
||||||
// ctHeader builds a headers map carrying the given Content-Type, or nil for the
|
|
||||||
// absent-header case (do() then sets no Content-Type at all).
|
|
||||||
func ctHeader(ct string) map[string]string {
|
|
||||||
if ct == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return map[string]string{"Content-Type": ct}
|
|
||||||
}
|
|
||||||
|
|
||||||
var jsonHeader = map[string]string{"Content-Type": "application/json"}
|
|
||||||
|
|
||||||
func TestHandleLoginSuccess(t *testing.T) {
|
|
||||||
api, _ := seedAuthAPI(t, "correct-horse-battery", true)
|
|
||||||
w := do(api.ExternalHandler(), "POST", "/api/v1/auth/login",
|
|
||||||
`{"username":"owner","password":"correct-horse-battery"}`, jsonHeader)
|
|
||||||
if w.Code != http.StatusOK {
|
|
||||||
t.Fatalf("code = %d, want 200 (%s)", w.Code, w.Body.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
// The HttpOnly session cookie is the login's whole point — the panel never reads
|
|
||||||
// it, the browser just carries it back.
|
|
||||||
cookies := w.Result().Cookies()
|
|
||||||
if len(cookies) != 1 || cookies[0].Name != sessionCookieName || cookies[0].Value == "" {
|
|
||||||
t.Fatalf("want one non-empty %s cookie, got %v", sessionCookieName, cookies)
|
|
||||||
}
|
|
||||||
if !cookies[0].HttpOnly {
|
|
||||||
t.Fatalf("session cookie must be HttpOnly")
|
|
||||||
}
|
|
||||||
|
|
||||||
var got map[string]any
|
|
||||||
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
|
|
||||||
t.Fatalf("body not JSON: %v (%s)", err, w.Body.String())
|
|
||||||
}
|
|
||||||
if got["user_id"] != "u1" || got["role"] != "admin" || got["must_change_password"] != true {
|
|
||||||
t.Fatalf("got %v, want user_id=u1 role=admin must_change_password=true", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHandleLoginContentTypeGuard pins the confirmed login-CSRF fix: a body whose
|
|
||||||
// Content-Type is anything an HTML form (or a default cross-site fetch) can emit is
|
|
||||||
// rejected 415 BEFORE the credential check, so a forged off-origin login never even
|
|
||||||
// reaches bcrypt. Local auth is enabled and the credentials are valid here, proving
|
|
||||||
// the rejection is the content-type, not a bad password.
|
|
||||||
func TestHandleLoginContentTypeGuard(t *testing.T) {
|
|
||||||
api, _ := seedAuthAPI(t, "correct-horse-battery", false)
|
|
||||||
h := api.ExternalHandler()
|
|
||||||
body := `{"username":"owner","password":"correct-horse-battery"}`
|
|
||||||
|
|
||||||
for _, ct := range []string{
|
|
||||||
"application/x-www-form-urlencoded",
|
|
||||||
"multipart/form-data; boundary=x",
|
|
||||||
"text/plain;charset=UTF-8",
|
|
||||||
"", // header absent entirely
|
|
||||||
} {
|
|
||||||
w := do(h, "POST", "/api/v1/auth/login", body, ctHeader(ct))
|
|
||||||
if w.Code != http.StatusUnsupportedMediaType {
|
|
||||||
t.Fatalf("Content-Type %q: code = %d, want 415", ct, w.Code)
|
|
||||||
}
|
|
||||||
if code := decodeErr(t, w); code != "unsupported_media_type" {
|
|
||||||
t.Fatalf("Content-Type %q: error code = %q, want unsupported_media_type", ct, code)
|
|
||||||
}
|
|
||||||
if len(w.Result().Cookies()) != 0 {
|
|
||||||
t.Fatalf("Content-Type %q: no session cookie may be set on a rejected login", ct)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A JSON content-type with a charset parameter is still JSON and must pass.
|
|
||||||
if w := do(h, "POST", "/api/v1/auth/login", body,
|
|
||||||
map[string]string{"Content-Type": "application/json; charset=utf-8"}); w.Code != http.StatusOK {
|
|
||||||
t.Fatalf("application/json; charset=utf-8: code = %d, want 200 (%s)", w.Code, w.Body.String())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHandleLoginConcurrencyCap pins the audit-hardening bound on the public login
|
|
||||||
// route: bcrypt is CPU-costly and runs on every request (the anti-enumeration dummy
|
|
||||||
// included), so at most MaxConcurrentLogins compares may be in flight at once and the
|
|
||||||
// excess is shed with a 429 rather than piling more onto every core. Holding the sole
|
|
||||||
// slot makes the next login — with otherwise-valid credentials — return 429 auth_busy
|
|
||||||
// with no cookie BEFORE any credential check; releasing it lets the identical request
|
|
||||||
// succeed, proving the 429 was the cap, not the password. A concurrency cap, not a
|
|
||||||
// per-account lockout: the same account gets in the moment the burst clears.
|
|
||||||
func TestHandleLoginConcurrencyCap(t *testing.T) {
|
|
||||||
api, _ := seedAuthAPI(t, "correct-horse-battery", false)
|
|
||||||
api.MaxConcurrentLogins = 1
|
|
||||||
h := api.ExternalHandler()
|
|
||||||
body := `{"username":"owner","password":"correct-horse-battery"}`
|
|
||||||
|
|
||||||
// Occupy the one compare slot so the handler finds the cap full. loginLimiter is
|
|
||||||
// lazily built from MaxConcurrentLogins (set just above), so this and the handler
|
|
||||||
// share the same one-token limiter.
|
|
||||||
release, ok := api.loginLimiter().acquire()
|
|
||||||
if !ok {
|
|
||||||
t.Fatal("could not acquire the sole login slot in test setup")
|
|
||||||
}
|
|
||||||
w := do(h, "POST", "/api/v1/auth/login", body, jsonHeader)
|
|
||||||
if w.Code != http.StatusTooManyRequests {
|
|
||||||
t.Fatalf("with the slot held: code = %d, want 429 (%s)", w.Code, w.Body.String())
|
|
||||||
}
|
|
||||||
if code := decodeErr(t, w); code != "auth_busy" {
|
|
||||||
t.Fatalf("error code = %q, want auth_busy", code)
|
|
||||||
}
|
|
||||||
if len(w.Result().Cookies()) != 0 {
|
|
||||||
t.Fatal("no session cookie may be set on a shed login")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Release the slot: the identical request now runs the compare and succeeds.
|
|
||||||
release()
|
|
||||||
if w := do(h, "POST", "/api/v1/auth/login", body, jsonHeader); w.Code != http.StatusOK {
|
|
||||||
t.Fatalf("after releasing the slot: code = %d, want 200 (%s)", w.Code, w.Body.String())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHandleLoginInvalidCredentials proves the anti-enumeration uniformity: a wrong
|
|
||||||
// password and an unknown username return the SAME 401 invalid_credentials with no
|
|
||||||
// cookie, so a caller cannot learn which usernames carry a password.
|
|
||||||
func TestHandleLoginInvalidCredentials(t *testing.T) {
|
|
||||||
api, _ := seedAuthAPI(t, "correct-horse-battery", false)
|
|
||||||
h := api.ExternalHandler()
|
|
||||||
|
|
||||||
for _, tc := range []struct{ name, body string }{
|
|
||||||
{"wrong password", `{"username":"owner","password":"wrong"}`},
|
|
||||||
{"unknown user", `{"username":"ghost","password":"whatever"}`},
|
|
||||||
} {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
w := do(h, "POST", "/api/v1/auth/login", tc.body, jsonHeader)
|
|
||||||
if w.Code != http.StatusUnauthorized {
|
|
||||||
t.Fatalf("code = %d, want 401 (%s)", w.Code, w.Body.String())
|
|
||||||
}
|
|
||||||
if code := decodeErr(t, w); code != "invalid_credentials" {
|
|
||||||
t.Fatalf("error code = %q, want invalid_credentials", code)
|
|
||||||
}
|
|
||||||
if len(w.Result().Cookies()) != 0 {
|
|
||||||
t.Fatalf("no session cookie may be set on a failed login")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHandleLoginLocalAuthDisabled proves a deployment with no local_auth_enabled
|
|
||||||
// setting refuses every local login (403), so a Zero-Trust-only console never
|
|
||||||
// accepts a password.
|
|
||||||
func TestHandleLoginLocalAuthDisabled(t *testing.T) {
|
|
||||||
repo := newFakeRepo() // local_auth_enabled never set → fail closed
|
|
||||||
api := newTestAPI(repo, newFakeCluster())
|
|
||||||
w := do(api.ExternalHandler(), "POST", "/api/v1/auth/login",
|
|
||||||
`{"username":"owner","password":"x"}`, jsonHeader)
|
|
||||||
if w.Code != http.StatusForbidden {
|
|
||||||
t.Fatalf("code = %d, want 403 (%s)", w.Code, w.Body.String())
|
|
||||||
}
|
|
||||||
if code := decodeErr(t, w); code != "local_auth_disabled" {
|
|
||||||
t.Fatalf("error code = %q, want local_auth_disabled", code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHandleLoginMissingFields(t *testing.T) {
|
|
||||||
api, _ := seedAuthAPI(t, "correct-horse-battery", false)
|
|
||||||
w := do(api.ExternalHandler(), "POST", "/api/v1/auth/login",
|
|
||||||
`{"username":"","password":""}`, jsonHeader)
|
|
||||||
if w.Code != http.StatusBadRequest {
|
|
||||||
t.Fatalf("code = %d, want 400 (%s)", w.Code, w.Body.String())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHandleLogout is idempotent: it clears the cookie and returns 200 even with no
|
|
||||||
// live session, and revokes the presented one when there is.
|
|
||||||
func TestHandleLogout(t *testing.T) {
|
|
||||||
api, repo := seedAuthAPI(t, "correct-horse-battery", false)
|
|
||||||
h := api.ExternalHandler()
|
|
||||||
|
|
||||||
// No cookie: still 200, still clears.
|
|
||||||
if w := do(h, "POST", "/api/v1/auth/logout", "", nil); w.Code != http.StatusOK {
|
|
||||||
t.Fatalf("logout without session: code = %d, want 200", w.Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
// With a live session cookie: the matching session is revoked.
|
|
||||||
token, err := newSessionToken()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("token: %v", err)
|
|
||||||
}
|
|
||||||
repo.sessions[hashCookie(token)] = &fakeSession{userID: "u1", expiresAt: api.now().Add(time.Hour)}
|
|
||||||
w := do(h, "POST", "/api/v1/auth/logout", "",
|
|
||||||
map[string]string{"Cookie": sessionCookieName + "=" + token})
|
|
||||||
if w.Code != http.StatusOK {
|
|
||||||
t.Fatalf("logout with session: code = %d, want 200", w.Code)
|
|
||||||
}
|
|
||||||
if !repo.sessions[hashCookie(token)].revoked {
|
|
||||||
t.Fatalf("presented session should be revoked")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHandleChangePasswordSuccess(t *testing.T) {
|
|
||||||
api, repo := seedAuthedAPI(t, "old-password", true)
|
|
||||||
// A second live session for u1: the change must revoke it. This request carries
|
|
||||||
// no felis_session cookie, so keep="" and every session of u1 is revoked — the
|
|
||||||
// safe direction the handler documents.
|
|
||||||
repo.sessions["other-device"] = &fakeSession{userID: "u1", expiresAt: api.now().Add(time.Hour)}
|
|
||||||
// A bound passkey for u1: the change must unbind it too. A passkey planted through a
|
|
||||||
// hijacked session needs no password, so it would otherwise survive the reset as a
|
|
||||||
// standing login foothold.
|
|
||||||
repo.passkeyCreds["pk1"] = PasskeyCredential{ID: "pk1", UserID: "u1", CredentialID: "cred-1", PublicKey: "k"}
|
|
||||||
|
|
||||||
w := do(api.ExternalHandler(), "POST", "/api/v1/auth/change-password",
|
|
||||||
`{"current_password":"old-password","new_password":"brand-new-password"}`, jsonHeader)
|
|
||||||
if w.Code != http.StatusOK {
|
|
||||||
t.Fatalf("code = %d, want 200 (%s)", w.Code, w.Body.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
u := repo.staff["owner"]
|
|
||||||
if u.MustChangePassword {
|
|
||||||
t.Fatalf("must_change_password should be cleared after a change")
|
|
||||||
}
|
|
||||||
if bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte("brand-new-password")) != nil {
|
|
||||||
t.Fatalf("the new password does not verify against the stored hash")
|
|
||||||
}
|
|
||||||
if !repo.sessions["other-device"].revoked {
|
|
||||||
t.Fatalf("other sessions should be revoked on a password change")
|
|
||||||
}
|
|
||||||
if len(repo.passkeyCreds) != 0 {
|
|
||||||
t.Fatalf("password change left %d passkeys, want 0 — a planted passkey must not survive remediation", len(repo.passkeyCreds))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHandleChangePasswordContentTypeGuard pins the defense-in-depth guard on the
|
|
||||||
// authenticated change-password route.
|
|
||||||
func TestHandleChangePasswordContentTypeGuard(t *testing.T) {
|
|
||||||
api, _ := seedAuthedAPI(t, "old-password", false)
|
|
||||||
w := do(api.ExternalHandler(), "POST", "/api/v1/auth/change-password",
|
|
||||||
`{"current_password":"old-password","new_password":"brand-new-password"}`,
|
|
||||||
map[string]string{"Content-Type": "text/plain"})
|
|
||||||
if w.Code != http.StatusUnsupportedMediaType {
|
|
||||||
t.Fatalf("code = %d, want 415 (%s)", w.Code, w.Body.String())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHandleChangePasswordRejections(t *testing.T) {
|
|
||||||
t.Run("weak new password", func(t *testing.T) {
|
|
||||||
api, _ := seedAuthedAPI(t, "old-password", false)
|
|
||||||
w := do(api.ExternalHandler(), "POST", "/api/v1/auth/change-password",
|
|
||||||
`{"current_password":"old-password","new_password":"short"}`, jsonHeader)
|
|
||||||
if w.Code != http.StatusBadRequest {
|
|
||||||
t.Fatalf("code = %d, want 400", w.Code)
|
|
||||||
}
|
|
||||||
if code := decodeErr(t, w); code != "weak_password" {
|
|
||||||
t.Fatalf("error code = %q, want weak_password", code)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("unchanged password", func(t *testing.T) {
|
|
||||||
api, _ := seedAuthedAPI(t, "old-password", false)
|
|
||||||
w := do(api.ExternalHandler(), "POST", "/api/v1/auth/change-password",
|
|
||||||
`{"current_password":"old-password","new_password":"old-password"}`, jsonHeader)
|
|
||||||
if w.Code != http.StatusBadRequest {
|
|
||||||
t.Fatalf("code = %d, want 400", w.Code)
|
|
||||||
}
|
|
||||||
if code := decodeErr(t, w); code != "password_unchanged" {
|
|
||||||
t.Fatalf("error code = %q, want password_unchanged", code)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("wrong current password", func(t *testing.T) {
|
|
||||||
api, _ := seedAuthedAPI(t, "old-password", false)
|
|
||||||
w := do(api.ExternalHandler(), "POST", "/api/v1/auth/change-password",
|
|
||||||
`{"current_password":"wrong","new_password":"brand-new-password"}`, jsonHeader)
|
|
||||||
if w.Code != http.StatusUnauthorized {
|
|
||||||
t.Fatalf("code = %d, want 401", w.Code)
|
|
||||||
}
|
|
||||||
if code := decodeErr(t, w); code != "invalid_credentials" {
|
|
||||||
t.Fatalf("error code = %q, want invalid_credentials", code)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -0,0 +1,408 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// op.console STAFF login (spec §B op-login): the two-factor door for the most
|
||||||
|
// sensitive tier. Unlike the console.<root_domain> player doors (email OTP / bind
|
||||||
|
// code), a staff web session is never minted from a single factor. The flow is a
|
||||||
|
// three-call state machine over op_login_requests (migration 0012), all Public
|
||||||
|
// pre-session routes (the caller has no principal yet), plus two internal-face routes
|
||||||
|
// velocity drives on behalf of online admins:
|
||||||
|
//
|
||||||
|
// POST /api/v1/auth/op-login/start (public) — mint a request + mail an OTP
|
||||||
|
// GET /api/v1/auth/op-login/status/{id} (public) — poll until an admin approves
|
||||||
|
// POST /api/v1/auth/op-login/finish (public) — redeem code+approval → session
|
||||||
|
// GET /api/v1/internal/op-login/pending (internal) — the online-admin push list
|
||||||
|
// POST /api/v1/internal/op-login/{id}/approve (internal) — an in-game admin vouches
|
||||||
|
//
|
||||||
|
// The two factors:
|
||||||
|
//
|
||||||
|
// - Possession of the staff mailbox — an email-OTP under purpose op_login, minted by
|
||||||
|
// start and redeemed by finish, reusing the email_otps lifecycle (the purpose
|
||||||
|
// column keeps it from ever colliding with a console login_email or onboard code).
|
||||||
|
// - An in-game vouch — an already-trusted admin who is ONLINE approves the pending
|
||||||
|
// request via velocity's /felis command (internal approve). Only a linked
|
||||||
|
// role=admin account may approve; velocity additionally gates the command on
|
||||||
|
// in-game op, so the API check is defence in depth over its own user table.
|
||||||
|
//
|
||||||
|
// finish mints the session only when BOTH have landed. Neither factor alone — a mailed
|
||||||
|
// code without an approval, or an approval without the code — yields a session.
|
||||||
|
//
|
||||||
|
// Anti-enumeration. op.console sits behind Cloudflare Zero-Trust at the edge, but the
|
||||||
|
// external API is hostname-agnostic at the route level, so these Public routes are
|
||||||
|
// reachable from console.<root_domain> too and must not become a staff oracle:
|
||||||
|
//
|
||||||
|
// - start resolves the typed email; a non-staff or unknown address gets the SAME 202
|
||||||
|
// with a plausible (non-persisted, random) request_id and mails nothing, so a
|
||||||
|
// caller cannot tell a staff address from any other.
|
||||||
|
// - status returns approved:false for an unknown/expired/denied/consumed id exactly
|
||||||
|
// as for a live-but-unapproved one; only a genuinely approved live request reads
|
||||||
|
// approved:true, and driving an id to that state REQUIRES an in-game admin vouch a
|
||||||
|
// fabricated id can never obtain.
|
||||||
|
// - finish collapses unknown id, not-yet-approved, wrong code, locked, and lost-race
|
||||||
|
// into one uniform failure, and (like the console door) never reveals staffness.
|
||||||
|
|
||||||
|
// otpPurposeOpLogin scopes an email code to the op.console staff door, keeping it from
|
||||||
|
// ever colliding with or satisfying a console login_email or onboarding code for the
|
||||||
|
// same account. VerifyEmailOTP/ConsumeLoginEmailOTP are queried per (user, purpose),
|
||||||
|
// so the op-login factor is fully independent of the player-console doors.
|
||||||
|
const otpPurposeOpLogin = "op_login"
|
||||||
|
|
||||||
|
// opLoginStartRequest is the start body: the staff address the code is mailed to.
|
||||||
|
type opLoginStartRequest struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleOpLoginStart begins a staff op.console login (Public, pre-session): it mints an
|
||||||
|
// op_login_requests row for the resolved staff account and mails an email-OTP under
|
||||||
|
// otpPurposeOpLogin, returning the request handle the browser polls. A non-staff or
|
||||||
|
// unknown address yields the SAME 202 with a random, non-persisted handle and no mail,
|
||||||
|
// so this never doubles as a staff-enumeration oracle (op.console's own Zero-Trust is
|
||||||
|
// the edge gate; this app-layer neutrality covers the hostname-agnostic route).
|
||||||
|
func (a *API) handleOpLoginStart(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !localAuthEnabled(r.Context(), a.Repo) {
|
||||||
|
writeError(w, r, newError(http.StatusForbidden, "local_auth_disabled",
|
||||||
|
"session login is disabled"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := requireJSONContentType(r); err != nil {
|
||||||
|
writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req opLoginStartRequest
|
||||||
|
if err := decodeJSON(w, r, &req); err != nil {
|
||||||
|
writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
email := strings.TrimSpace(req.Email)
|
||||||
|
if !looksLikeEmail(email) {
|
||||||
|
writeError(w, r, newError(http.StatusBadRequest, "bad_request", "a valid email is required"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-recipient cooldown reserved BEFORE any work, identical to the console email
|
||||||
|
// door: one winner per window, and the neutral (non-staff) branch keeps the
|
||||||
|
// reservation too so probing an address is throttled exactly like a real send. The
|
||||||
|
// key is namespaced apart from the console door's "login:email:" so the two
|
||||||
|
// unauthenticated doors never perturb each other's throttle.
|
||||||
|
emailKey := "oplogin:email:" + strings.ToLower(email)
|
||||||
|
lim := a.otpLimiter()
|
||||||
|
emailAt, ok := lim.reserve(emailKey, otpResendCooldown)
|
||||||
|
if !ok {
|
||||||
|
writeError(w, r, newError(http.StatusTooManyRequests, "otp_resend_cooldown",
|
||||||
|
"a code was sent recently; wait a moment before requesting another"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
committed := false
|
||||||
|
defer func() {
|
||||||
|
if !committed {
|
||||||
|
lim.release(emailKey, emailAt)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Compute expiry once so the neutral and real branches return identical-shaped
|
||||||
|
// bodies and (real branch) the request row and its OTP are coterminous.
|
||||||
|
expiresAt := a.now().Add(otpTTL)
|
||||||
|
|
||||||
|
// neutral returns the indistinguishable no-op success: a plausible but non-persisted
|
||||||
|
// handle that status(id) reads approved:false forever (no row, never approvable). It
|
||||||
|
// mints nothing and mails nothing, and KEEPS the reservation so probing is throttled
|
||||||
|
// exactly like a real send.
|
||||||
|
neutral := func() {
|
||||||
|
fakeID, err := newOTPID()
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, r, err) // committed stays false → deferred rollback frees the window
|
||||||
|
return
|
||||||
|
}
|
||||||
|
committed = true
|
||||||
|
writeJSON(w, http.StatusAccepted, map[string]any{
|
||||||
|
"request_id": fakeID, "expires_at": expiresAt.UTC(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
u, err := a.Repo.UserByEmail(r.Context(), email)
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, ErrNotFound):
|
||||||
|
neutral()
|
||||||
|
return
|
||||||
|
case err != nil:
|
||||||
|
// Real read fault: leave committed false so the deferred rollback frees the
|
||||||
|
// window (a transient DB blip must not burn it).
|
||||||
|
writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// op.console is the STAFF door: a non-admin who typed their address here (they belong
|
||||||
|
// on console.<root_domain>) gets the neutral response, never a request or a code.
|
||||||
|
if u.Role != "admin" {
|
||||||
|
neutral()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
id, err := newOTPID()
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := a.Repo.CreateOpLoginRequest(r.Context(), id, u.ID, u.Email, expiresAt); err != nil {
|
||||||
|
writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
code, err := newEmailOTP()
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
otpID, err := newOTPID()
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Mint+mail against the STORED staff address (UserByEmail matched case-insensitively);
|
||||||
|
// the request row snapshots the same address for its audit trail.
|
||||||
|
if err := a.Repo.CreateEmailOTP(r.Context(), otpID, u.ID, u.Email, otpCodeHash(code), otpPurposeOpLogin, expiresAt); err != nil {
|
||||||
|
writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := a.deliverOTP(r.Context(), u.Email, code); err != nil {
|
||||||
|
writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
committed = true
|
||||||
|
a.audit(r, u.Username, "auth.op_login.otp_sent", "")
|
||||||
|
writeJSON(w, http.StatusAccepted, map[string]any{
|
||||||
|
"request_id": id, "expires_at": expiresAt.UTC(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleOpLoginStatus reports whether a staff login request has been approved in-game
|
||||||
|
// (Public, pre-session). It is a pure read the browser polls after start: it returns
|
||||||
|
// approved:true only for a genuinely approved, live, unconsumed request, and
|
||||||
|
// approved:false for everything else — including an unknown, expired, denied, or
|
||||||
|
// already-consumed id — so a fabricated handle polls as approved:false forever and the
|
||||||
|
// endpoint is not a staff-enumeration oracle (only an in-game admin vouch, impossible
|
||||||
|
// against a fake id, flips it true).
|
||||||
|
func (a *API) handleOpLoginStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !localAuthEnabled(r.Context(), a.Repo) {
|
||||||
|
writeError(w, r, newError(http.StatusForbidden, "local_auth_disabled",
|
||||||
|
"session login is disabled"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id := r.PathValue("id")
|
||||||
|
approved := false
|
||||||
|
switch req, err := a.Repo.OpLoginRequestByID(r.Context(), id); {
|
||||||
|
case errors.Is(err, ErrNotFound):
|
||||||
|
// Unknown handle: neutral approved:false (never 404), uniform with a real request
|
||||||
|
// still awaiting approval.
|
||||||
|
case err != nil:
|
||||||
|
writeError(w, r, err)
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
approved = req.Status == "approved" && !req.Consumed && req.ExpiresAt.After(a.now())
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"approved": approved})
|
||||||
|
}
|
||||||
|
|
||||||
|
// opLoginFinishRequest is the finish body: the request handle from start and the code
|
||||||
|
// read from the staff mailbox. The handle selects the account (there is no principal);
|
||||||
|
// the code proves possession of the mailbox this session.
|
||||||
|
type opLoginFinishRequest struct {
|
||||||
|
RequestID string `json:"request_id"`
|
||||||
|
Code string `json:"code"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleOpLoginFinish redeems an approved request plus its mailed code into a staff
|
||||||
|
// session (Public, pre-session). It mints the session only when BOTH factors have
|
||||||
|
// landed: the request is approved-and-live AND the code verifies. Every failure mode —
|
||||||
|
// unknown handle, not-yet-approved, wrong or locked code, lost race — collapses into
|
||||||
|
// ONE uniform 400, so a code-less caller learns nothing (not staffness, not approval
|
||||||
|
// state). The approval is read BEFORE the code is consumed so a valid code submitted
|
||||||
|
// early (before an admin approves) is preserved for a retry rather than burned.
|
||||||
|
func (a *API) handleOpLoginFinish(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !localAuthEnabled(r.Context(), a.Repo) {
|
||||||
|
writeError(w, r, newError(http.StatusForbidden, "local_auth_disabled",
|
||||||
|
"session login is disabled"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := requireJSONContentType(r); err != nil {
|
||||||
|
writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req opLoginFinishRequest
|
||||||
|
if err := decodeJSON(w, r, &req); err != nil {
|
||||||
|
writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
requestID := strings.TrimSpace(req.RequestID)
|
||||||
|
code := strings.TrimSpace(req.Code)
|
||||||
|
if requestID == "" || code == "" {
|
||||||
|
writeError(w, r, newError(http.StatusBadRequest, "bad_request", "request_id and code are required"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// The single uniform failure every "cannot complete" branch returns, so unknown
|
||||||
|
// handle / not-approved / wrong code / locked / lost-race are indistinguishable.
|
||||||
|
invalid := newError(http.StatusBadRequest, "op_login_invalid",
|
||||||
|
"this operator login could not be completed; restart the sign-in")
|
||||||
|
|
||||||
|
now := a.now()
|
||||||
|
loginReq, err := a.Repo.OpLoginRequestByID(r.Context(), requestID)
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, ErrNotFound):
|
||||||
|
writeError(w, r, invalid)
|
||||||
|
return
|
||||||
|
case err != nil:
|
||||||
|
writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Read the approval state BEFORE touching the code: an early finish (user typed the
|
||||||
|
// code before an admin approved) must not consume the code. Not-approved collapses
|
||||||
|
// into the same uniform failure as a bad code, so the ordering leaks nothing.
|
||||||
|
if loginReq.Status != "approved" || loginReq.Consumed || !loginReq.ExpiresAt.After(now) {
|
||||||
|
writeError(w, r, invalid)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Consume the mailed code (op_login purpose). A wrong/expired/locked code charges an
|
||||||
|
// attempt without minting anything and returns the uniform failure — the code, not
|
||||||
|
// the request, is the problem, and the request stays approved for a retry.
|
||||||
|
switch err := a.Repo.ConsumeLoginEmailOTP(r.Context(), loginReq.UserID, otpPurposeOpLogin, otpCodeHash(code), now); {
|
||||||
|
case errors.Is(err, ErrOTPInvalid), errors.Is(err, ErrOTPLocked):
|
||||||
|
writeError(w, r, invalid)
|
||||||
|
return
|
||||||
|
case err != nil:
|
||||||
|
writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Both factors proven. Atomically spend the request (approved→consumed, single-use):
|
||||||
|
// this serialises against a concurrent finish and records which request completed.
|
||||||
|
switch err := a.Repo.ConsumeOpLoginRequest(r.Context(), requestID, now); {
|
||||||
|
case errors.Is(err, ErrNotFound):
|
||||||
|
// Lost a race (another finish consumed it) or it expired between the checks —
|
||||||
|
// uniform failure. The code was already spent by the winner.
|
||||||
|
writeError(w, r, invalid)
|
||||||
|
return
|
||||||
|
case err != nil:
|
||||||
|
writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Load the staff account for the session + response. Re-assert admin as defence in
|
||||||
|
// depth: only admins ever get a request minted, but the session must never be issued
|
||||||
|
// to a non-admin identity even if the row were somehow otherwise.
|
||||||
|
u, err := a.Repo.UserByID(r.Context(), loginReq.UserID)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if u.Role != "admin" {
|
||||||
|
writeError(w, r, newError(http.StatusForbidden, "staff_account", "that account is not an operator"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
token, err := newSessionToken()
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
expires := now.Add(sessionTTL)
|
||||||
|
if err := a.Repo.CreateSession(r.Context(), hashCookie(token), u.ID, expires); err != nil {
|
||||||
|
writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSessionCookie(w, token, expires)
|
||||||
|
a.audit(r, u.Username, "auth.op_login", "")
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"user_id": u.ID, "role": u.Role})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleOpLoginPending lists live pending staff login requests, oldest first (internal
|
||||||
|
// face). Velocity polls it and pushes the waiting requests to online admins, who
|
||||||
|
// approve one with /felis web op approve <id>. Internal-only: velocity holds a service
|
||||||
|
// token and no pending request is secret to the operator crew.
|
||||||
|
func (a *API) handleOpLoginPending(w http.ResponseWriter, r *http.Request) {
|
||||||
|
reqs, err := a.Repo.ListPendingOpLogins(r.Context(), a.now())
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out := make([]map[string]any, 0, len(reqs))
|
||||||
|
for _, req := range reqs {
|
||||||
|
out = append(out, map[string]any{
|
||||||
|
"request_id": req.ID,
|
||||||
|
"username": req.Username,
|
||||||
|
"email": req.Email,
|
||||||
|
"created_at": req.CreatedAt.UTC(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"pending": out})
|
||||||
|
}
|
||||||
|
|
||||||
|
// opLoginApproveRequest is the internal approve body: the online-mode UUID of the
|
||||||
|
// in-game admin running /felis web op approve. The API resolves it to a linked account
|
||||||
|
// and refuses unless that account is role=admin — defence in depth over velocity's own
|
||||||
|
// in-game op gate, checked against the API's authoritative user table.
|
||||||
|
type opLoginApproveRequest struct {
|
||||||
|
ApproverUUID string `json:"approver_uuid"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleOpLoginApprove records an in-game admin's vouch for a pending staff login
|
||||||
|
// (internal face), supplying the second factor. It resolves the approver UUID to a
|
||||||
|
// linked role=admin account (else 403), then flips the request approved. A missing or
|
||||||
|
// no-longer-pending request is 404. Self-approval is allowed: a staff member online as
|
||||||
|
// their own admin identity supplies a genuine second factor (in-game session control)
|
||||||
|
// distinct from the mailbox factor.
|
||||||
|
func (a *API) handleOpLoginApprove(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := r.PathValue("id")
|
||||||
|
var req opLoginApproveRequest
|
||||||
|
if err := decodeJSON(w, r, &req); err != nil {
|
||||||
|
writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
approverUUID := strings.TrimSpace(req.ApproverUUID)
|
||||||
|
if approverUUID == "" {
|
||||||
|
writeError(w, r, newError(http.StatusBadRequest, "bad_request", "approver_uuid is required"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Resolve the in-game approver to a linked account and require admin. An unlinked
|
||||||
|
// UUID or a non-admin player may never vouch for an op.console login. All three
|
||||||
|
// refusals share one response so a caller cannot tell "not linked" from "linked but
|
||||||
|
// not staff".
|
||||||
|
notAdmin := newError(http.StatusForbidden, "not_admin", "only a linked administrator may approve an operator login")
|
||||||
|
approverID, err := a.Repo.UserByMCUUID(r.Context(), approverUUID)
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, ErrNotFound):
|
||||||
|
writeError(w, r, notAdmin)
|
||||||
|
return
|
||||||
|
case err != nil:
|
||||||
|
writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
approver, err := a.Repo.UserByID(r.Context(), approverID)
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, ErrNotFound):
|
||||||
|
writeError(w, r, notAdmin)
|
||||||
|
return
|
||||||
|
case err != nil:
|
||||||
|
writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if approver.Role != "admin" {
|
||||||
|
writeError(w, r, notAdmin)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch err := a.Repo.ApproveOpLogin(r.Context(), id, approverID, a.now()); {
|
||||||
|
case errors.Is(err, ErrNotFound):
|
||||||
|
writeError(w, r, newError(http.StatusNotFound, "op_login_not_found", "no pending operator login with that id"))
|
||||||
|
return
|
||||||
|
case err != nil:
|
||||||
|
writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
payload, _ := json.Marshal(map[string]string{"request_id": id, "approver_user_id": approverID})
|
||||||
|
_ = a.Repo.Audit(r.Context(), AuditEntry{
|
||||||
|
Actor: approver.Username, Source: "internal", Action: "auth.op_login.approved",
|
||||||
|
RequestID: requestIDFromContext(r.Context()), Payload: payload,
|
||||||
|
})
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"approved": true})
|
||||||
|
}
|
||||||
@@ -0,0 +1,509 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// op.console STAFF login tests (spec §B op-login). The two-factor door's load-bearing
|
||||||
|
// properties, in the order the flow meets them:
|
||||||
|
//
|
||||||
|
// - Two factors, both required. finish mints a session only when the mailed op_login
|
||||||
|
// code verifies AND an in-game admin has approved the request; neither alone works.
|
||||||
|
// - Neutral start. A non-staff or unknown address gets a 202 with a plausible but
|
||||||
|
// non-persisted request_id and nothing mailed, so start is not a staff oracle.
|
||||||
|
// - Neutral status. An unknown/expired/consumed handle reads approved:false exactly
|
||||||
|
// like a real request awaiting approval, so a fabricated handle is not an oracle.
|
||||||
|
// - Uniform finish failure. Unknown handle / not-approved / wrong code / lost race
|
||||||
|
// all collapse to one op_login_invalid envelope; an early-but-correct code is
|
||||||
|
// preserved (approval is read before the code is consumed), and a wrong code costs
|
||||||
|
// an attempt without burning the approval.
|
||||||
|
// - Admin-only approval. Only a linked role=admin UUID may vouch; the check is the
|
||||||
|
// API's own user table, defence in depth over velocity's in-game op gate.
|
||||||
|
|
||||||
|
const opUUID = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" // the seeded admin's linked in-game UUID
|
||||||
|
|
||||||
|
// seedOpLoginAPI wires the op.console door: local sessions enabled and a single staff
|
||||||
|
// admin "op" (id a1) whose proven address is stored in MIXED case (so the mint-against-
|
||||||
|
// stored-casing contract is exercised by default) and whose in-game UUID opUUID is
|
||||||
|
// linked, so the admin can act as an in-game approver.
|
||||||
|
func seedOpLoginAPI(t *testing.T) (*API, *fakeRepo, *captureMailer) {
|
||||||
|
t.Helper()
|
||||||
|
repo := newFakeRepo()
|
||||||
|
repo.settings[LocalAuthEnabledKey] = []byte("true")
|
||||||
|
repo.staff["op"] = &StaffUser{
|
||||||
|
ID: "a1", Username: "op", Email: "[email protected]",
|
||||||
|
Role: "admin", EmailVerified: true,
|
||||||
|
}
|
||||||
|
repo.links[opUUID] = "a1"
|
||||||
|
mailer := &captureMailer{}
|
||||||
|
api := newTestAPI(repo, newFakeCluster())
|
||||||
|
api.Mailer = mailer
|
||||||
|
return api, repo, mailer
|
||||||
|
}
|
||||||
|
|
||||||
|
// startOp / statusOp / finishOp drive the three public browser calls; approveOp drives
|
||||||
|
// the internal in-game vouch. They return the recorder so each test asserts its own
|
||||||
|
// codes and bodies.
|
||||||
|
func startOp(eh http.Handler, email string) *httptest.ResponseRecorder {
|
||||||
|
return do(eh, "POST", "/api/v1/auth/op-login/start", `{"email":"`+email+`"}`, jsonHeader)
|
||||||
|
}
|
||||||
|
func statusOp(eh http.Handler, id string) *httptest.ResponseRecorder {
|
||||||
|
return do(eh, "GET", "/api/v1/auth/op-login/status/"+id, "", nil)
|
||||||
|
}
|
||||||
|
func finishOp(eh http.Handler, id, code string) *httptest.ResponseRecorder {
|
||||||
|
return do(eh, "POST", "/api/v1/auth/op-login/finish",
|
||||||
|
`{"request_id":"`+id+`","code":"`+code+`"}`, jsonHeader)
|
||||||
|
}
|
||||||
|
func approveOp(ih http.Handler, id, approverUUID string) *httptest.ResponseRecorder {
|
||||||
|
return do(ih, "POST", "/api/v1/internal/op-login/"+id+"/approve",
|
||||||
|
`{"approver_uuid":"`+approverUUID+`"}`, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestOpLoginVertical walks the whole two-factor slice end to end: start mails a code
|
||||||
|
// (purpose op_login) to the staff address of record and mints a pending request; the
|
||||||
|
// browser polls status until an in-game admin approves; finish redeems code+approval
|
||||||
|
// into the same host-only session the other doors mint. All three legs audit by the
|
||||||
|
// account's username, and the request is single-use.
|
||||||
|
func TestOpLoginVertical(t *testing.T) {
|
||||||
|
api, repo, mailer := seedOpLoginAPI(t)
|
||||||
|
eh := api.ExternalHandler()
|
||||||
|
ih := api.InternalHandler()
|
||||||
|
|
||||||
|
// 1) start: 202 with a request handle + expiry, never the code itself.
|
||||||
|
w := startOp(eh, "[email protected]")
|
||||||
|
if w.Code != http.StatusAccepted {
|
||||||
|
t.Fatalf("start: code = %d, want 202 (%s)", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
b := acctBody(t, w)
|
||||||
|
reqID, _ := b["request_id"].(string)
|
||||||
|
if reqID == "" {
|
||||||
|
t.Fatal("start must return a request_id")
|
||||||
|
}
|
||||||
|
if _, leaked := b["code"]; leaked {
|
||||||
|
t.Error("start response must NEVER carry the code")
|
||||||
|
}
|
||||||
|
if s, _ := b["expires_at"].(string); s == "" {
|
||||||
|
t.Error("start must report expires_at")
|
||||||
|
}
|
||||||
|
// The code goes to the STORED casing (address of record), under the op_login purpose.
|
||||||
|
if mailer.calls != 1 || mailer.email != "[email protected]" {
|
||||||
|
t.Fatalf("mailer: calls=%d email=%q, want 1 send to the STORED casing [email protected]",
|
||||||
|
mailer.calls, mailer.email)
|
||||||
|
}
|
||||||
|
code := mailer.code
|
||||||
|
if len(repo.otps) != 1 {
|
||||||
|
t.Fatalf("persisted codes = %d, want 1", len(repo.otps))
|
||||||
|
}
|
||||||
|
for _, o := range repo.otps {
|
||||||
|
if o.purpose != otpPurposeOpLogin {
|
||||||
|
t.Errorf("otp purpose = %q, want %q", o.purpose, otpPurposeOpLogin)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Exactly one pending request row, owned by the staff account.
|
||||||
|
if len(repo.opLogins) != 1 {
|
||||||
|
t.Fatalf("op_login_requests rows = %d, want 1", len(repo.opLogins))
|
||||||
|
}
|
||||||
|
if got := repo.opLogins[reqID]; got == nil || got.userID != "a1" || got.status != "pending" {
|
||||||
|
t.Fatalf("request row = %+v, want {userID:a1, status:pending}", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) status before approval: not approved yet.
|
||||||
|
if sb := acctBody(t, statusOp(eh, reqID)); sb["approved"] != false {
|
||||||
|
t.Fatalf("status before approval: approved = %v, want false", sb["approved"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) finish before approval is REFUSED and must NOT burn the code (approval is read
|
||||||
|
// before the code is consumed).
|
||||||
|
if w := finishOp(eh, reqID, code); w.Code != http.StatusBadRequest || decodeErr(t, w) != "op_login_invalid" {
|
||||||
|
t.Fatalf("finish before approval: code = %d body %s, want 400 op_login_invalid", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if len(repo.sessions) != 0 {
|
||||||
|
t.Fatal("no session may be minted before approval")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4) an in-game admin approves via the internal face.
|
||||||
|
if w := approveOp(ih, reqID, opUUID); w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("approve: code = %d, want 200 (%s)", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if ab := acctBody(t, statusOp(eh, reqID)); ab["approved"] != true {
|
||||||
|
t.Fatalf("status after approval: approved = %v, want true", ab["approved"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5) finish with the preserved code: session minted, role=admin.
|
||||||
|
w = finishOp(eh, reqID, code)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("finish: code = %d, want 200 (%s)", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
vb := acctBody(t, w)
|
||||||
|
if vb["user_id"] != "a1" || vb["role"] != "admin" {
|
||||||
|
t.Fatalf("finish body = %v, want user_id:a1 role:admin", vb)
|
||||||
|
}
|
||||||
|
cookies := w.Result().Cookies()
|
||||||
|
if len(cookies) != 1 || cookies[0].Name != sessionCookieName || cookies[0].Value == "" {
|
||||||
|
t.Fatalf("want one non-empty %s cookie, got %v", sessionCookieName, cookies)
|
||||||
|
}
|
||||||
|
if s, ok := repo.sessions[hashCookie(cookies[0].Value)]; !ok || s.userID != "a1" {
|
||||||
|
t.Fatalf("session row for the cookie = %+v (ok=%v), want userID a1", s, ok)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Three audits by "op": otp_sent (start), approved (in-game vouch), op_login (finish).
|
||||||
|
if n := len(repo.audits); n != 3 {
|
||||||
|
t.Fatalf("want 3 audits, got %d: %+v", n, repo.audits)
|
||||||
|
}
|
||||||
|
wantActions := []string{"auth.op_login.otp_sent", "auth.op_login.approved", "auth.op_login"}
|
||||||
|
for i, want := range wantActions {
|
||||||
|
if repo.audits[i].Action != want || repo.audits[i].Actor != "op" {
|
||||||
|
t.Errorf("audit[%d] = %+v, want action %q by op", i, repo.audits[i], want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6) single-use: the consumed request finishes no second time, and status flips back
|
||||||
|
// to approved:false (consumed).
|
||||||
|
if w := finishOp(eh, reqID, code); w.Code != http.StatusBadRequest || decodeErr(t, w) != "op_login_invalid" {
|
||||||
|
t.Fatalf("replay finish: code = %d body %s, want 400 op_login_invalid", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if sb := acctBody(t, statusOp(eh, reqID)); sb["approved"] != false {
|
||||||
|
t.Errorf("status after consume: approved = %v, want false", sb["approved"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestOpLoginStartNeutral pins the start-side anti-enumeration contract: op.console is
|
||||||
|
// the STAFF door, so a non-admin account AND an unknown address both get a 202 carrying
|
||||||
|
// a request_id + expires_at, mint/mail nothing, and still burn the per-recipient
|
||||||
|
// cooldown — so neither the response nor the throttle tells a caller who is staff.
|
||||||
|
func TestOpLoginStartNeutral(t *testing.T) {
|
||||||
|
check := func(t *testing.T, seed func(*fakeRepo), email string) {
|
||||||
|
t.Helper()
|
||||||
|
repo := newFakeRepo()
|
||||||
|
repo.settings[LocalAuthEnabledKey] = []byte("true")
|
||||||
|
if seed != nil {
|
||||||
|
seed(repo)
|
||||||
|
}
|
||||||
|
mailer := &captureMailer{}
|
||||||
|
api := newTestAPI(repo, newFakeCluster())
|
||||||
|
api.Mailer = mailer
|
||||||
|
eh := api.ExternalHandler()
|
||||||
|
|
||||||
|
w := startOp(eh, email)
|
||||||
|
if w.Code != http.StatusAccepted {
|
||||||
|
t.Fatalf("neutral start: code = %d, want 202 (%s)", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
b := acctBody(t, w)
|
||||||
|
if id, _ := b["request_id"].(string); id == "" {
|
||||||
|
t.Error("neutral start must still return a plausible request_id")
|
||||||
|
}
|
||||||
|
if s, _ := b["expires_at"].(string); s == "" {
|
||||||
|
t.Error("neutral start must still return expires_at")
|
||||||
|
}
|
||||||
|
if len(repo.opLogins) != 0 || len(repo.otps) != 0 || mailer.calls != 0 || len(repo.audits) != 0 {
|
||||||
|
t.Errorf("neutral start must mint/mail/audit nothing: reqs=%d otps=%d mails=%d audits=%d",
|
||||||
|
len(repo.opLogins), len(repo.otps), mailer.calls, len(repo.audits))
|
||||||
|
}
|
||||||
|
// The reservation is KEPT: re-probing the same address is throttled like a resend.
|
||||||
|
if w := startOp(eh, email); w.Code != http.StatusTooManyRequests || decodeErr(t, w) != "otp_resend_cooldown" {
|
||||||
|
t.Fatalf("re-probe: code = %d body %s, want 429 otp_resend_cooldown", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("unknown address", func(t *testing.T) {
|
||||||
|
check(t, nil, "[email protected]")
|
||||||
|
})
|
||||||
|
t.Run("non-staff (role=user) address is ignored by the staff door", func(t *testing.T) {
|
||||||
|
check(t, func(repo *fakeRepo) {
|
||||||
|
repo.staff["p"] = &StaffUser{ID: "u9", Username: "p", Email: "[email protected]", Role: "user", EmailVerified: true}
|
||||||
|
}, "[email protected]")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestOpLoginStatusNeutral proves status is never an enumeration oracle: it returns
|
||||||
|
// approved:true ONLY for a genuinely approved, live, unconsumed request, and
|
||||||
|
// approved:false (never 404) for an unknown, expired, denied, or consumed handle — all
|
||||||
|
// indistinguishable from a real request still awaiting approval.
|
||||||
|
func TestOpLoginStatusNeutral(t *testing.T) {
|
||||||
|
api, repo, _ := seedOpLoginAPI(t)
|
||||||
|
eh := api.ExternalHandler()
|
||||||
|
future := time.Unix(1_700_000_600, 0)
|
||||||
|
past := time.Unix(1_699_999_999, 0)
|
||||||
|
|
||||||
|
repo.opLogins["pending"] = &fakeOpLogin{id: "pending", userID: "a1", email: "[email protected]", status: "pending", expiresAt: future, createdAt: future}
|
||||||
|
repo.opLogins["expired"] = &fakeOpLogin{id: "expired", userID: "a1", email: "[email protected]", status: "approved", expiresAt: past, createdAt: past}
|
||||||
|
repo.opLogins["consumed"] = &fakeOpLogin{id: "consumed", userID: "a1", email: "[email protected]", status: "approved", consumed: true, expiresAt: future, createdAt: future}
|
||||||
|
repo.opLogins["denied"] = &fakeOpLogin{id: "denied", userID: "a1", email: "[email protected]", status: "denied", expiresAt: future, createdAt: future}
|
||||||
|
repo.opLogins["live"] = &fakeOpLogin{id: "live", userID: "a1", email: "[email protected]", status: "approved", expiresAt: future, createdAt: future}
|
||||||
|
|
||||||
|
for _, id := range []string{"unknown-handle", "pending", "expired", "consumed", "denied"} {
|
||||||
|
w := statusOp(eh, id)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status %q: code = %d, want 200", id, w.Code)
|
||||||
|
}
|
||||||
|
if acctBody(t, w)["approved"] != false {
|
||||||
|
t.Errorf("status %q: approved = true, want false (must not be an oracle)", id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Only the genuinely-approved live request reads true.
|
||||||
|
if acctBody(t, statusOp(eh, "live"))["approved"] != true {
|
||||||
|
t.Error("status of an approved live request must read approved:true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestOpLoginFinishUniform is the redeem-side failure matrix. The anchor is uniformity:
|
||||||
|
// an unknown handle and a wrong code for an approved request answer with the SAME
|
||||||
|
// (code, message) envelope, so finish never doubles as an oracle. Two lifecycle
|
||||||
|
// invariants are pinned alongside: a correct code submitted BEFORE approval is
|
||||||
|
// preserved (approval read before consume), and a wrong code costs an attempt without
|
||||||
|
// burning the approval.
|
||||||
|
func TestOpLoginFinishUniform(t *testing.T) {
|
||||||
|
t.Run("unknown handle and wrong code are indistinguishable", func(t *testing.T) {
|
||||||
|
api, _, mailer := seedOpLoginAPI(t)
|
||||||
|
eh, ih := api.ExternalHandler(), api.InternalHandler()
|
||||||
|
reqID := acctBody(t, startOp(eh, "[email protected]"))["request_id"].(string)
|
||||||
|
code := mailer.code
|
||||||
|
if w := approveOp(ih, reqID, opUUID); w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("approve: %d (%s)", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
// Wrong code for a real, approved request.
|
||||||
|
wWrong := finishOp(eh, reqID, code+"x")
|
||||||
|
// Unknown handle.
|
||||||
|
wGhost := finishOp(eh, "deadbeefdeadbeefdeadbeefdeadbeef", code)
|
||||||
|
if wWrong.Code != http.StatusBadRequest || wGhost.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("codes = %d/%d, want 400/400", wWrong.Code, wGhost.Code)
|
||||||
|
}
|
||||||
|
wc, wm := errEnvelope(t, wWrong)
|
||||||
|
gc, gm := errEnvelope(t, wGhost)
|
||||||
|
if wc != "op_login_invalid" || wc != gc || wm != gm {
|
||||||
|
t.Errorf("envelopes differ: wrong=(%s,%q) unknown=(%s,%q) — must be identical", wc, wm, gc, gm)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("correct code before approval is preserved, not burned", func(t *testing.T) {
|
||||||
|
api, repo, mailer := seedOpLoginAPI(t)
|
||||||
|
eh, ih := api.ExternalHandler(), api.InternalHandler()
|
||||||
|
reqID := acctBody(t, startOp(eh, "[email protected]"))["request_id"].(string)
|
||||||
|
code := mailer.code
|
||||||
|
|
||||||
|
// Finish before approval: refused, and the code is NOT consumed.
|
||||||
|
if w := finishOp(eh, reqID, code); w.Code != http.StatusBadRequest || decodeErr(t, w) != "op_login_invalid" {
|
||||||
|
t.Fatalf("early finish: code = %d body %s, want 400 op_login_invalid", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
for _, o := range repo.otps {
|
||||||
|
if o.consumed || o.attempts != 0 {
|
||||||
|
t.Errorf("early finish must not touch the code: consumed=%v attempts=%d", o.consumed, o.attempts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Approve, then the same code completes.
|
||||||
|
if w := approveOp(ih, reqID, opUUID); w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("approve: %d (%s)", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if w := finishOp(eh, reqID, code); w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("finish with preserved code: code = %d, want 200 (%s)", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("wrong code charges an attempt without burning the approval", func(t *testing.T) {
|
||||||
|
api, repo, mailer := seedOpLoginAPI(t)
|
||||||
|
eh, ih := api.ExternalHandler(), api.InternalHandler()
|
||||||
|
reqID := acctBody(t, startOp(eh, "[email protected]"))["request_id"].(string)
|
||||||
|
code := mailer.code
|
||||||
|
if w := approveOp(ih, reqID, opUUID); w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("approve: %d (%s)", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
// Wrong code: refused, one attempt charged, request still approved+unconsumed.
|
||||||
|
if w := finishOp(eh, reqID, code+"x"); w.Code != http.StatusBadRequest || decodeErr(t, w) != "op_login_invalid" {
|
||||||
|
t.Fatalf("wrong code: code = %d body %s, want 400 op_login_invalid", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
for _, o := range repo.otps {
|
||||||
|
if o.consumed || o.attempts != 1 {
|
||||||
|
t.Errorf("wrong code must charge one attempt, not consume: consumed=%v attempts=%d", o.consumed, o.attempts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if r := repo.opLogins[reqID]; r.status != "approved" || r.consumed {
|
||||||
|
t.Errorf("a wrong code must not burn the approval: status=%q consumed=%v", r.status, r.consumed)
|
||||||
|
}
|
||||||
|
// The right code still completes.
|
||||||
|
if w := finishOp(eh, reqID, code); w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("retry with right code: code = %d, want 200 (%s)", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestOpLoginApproveGate pins the in-game approval gate: only a linked role=admin UUID
|
||||||
|
// may vouch (all refusals share one 403 not_admin), a missing/no-longer-pending request
|
||||||
|
// is 404, and a bare request without an approver UUID is 400.
|
||||||
|
func TestOpLoginApproveGate(t *testing.T) {
|
||||||
|
plantPending := func(repo *fakeRepo) string {
|
||||||
|
repo.opLogins["r1"] = &fakeOpLogin{
|
||||||
|
id: "r1", userID: "a1", email: "[email protected]", status: "pending",
|
||||||
|
expiresAt: time.Unix(1_700_000_600, 0), createdAt: time.Unix(1_700_000_000, 0),
|
||||||
|
}
|
||||||
|
return "r1"
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("unlinked approver UUID -> 403, request stays pending", func(t *testing.T) {
|
||||||
|
api, repo, _ := seedOpLoginAPI(t)
|
||||||
|
id := plantPending(repo)
|
||||||
|
if w := approveOp(api.InternalHandler(), id, "ffffffff-ffff-ffff-ffff-ffffffffffff"); w.Code != http.StatusForbidden || decodeErr(t, w) != "not_admin" {
|
||||||
|
t.Fatalf("unlinked approver: code = %d body %s, want 403 not_admin", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if repo.opLogins[id].status != "pending" {
|
||||||
|
t.Error("a refused approval must leave the request pending")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("linked non-admin approver -> 403", func(t *testing.T) {
|
||||||
|
api, repo, _ := seedOpLoginAPI(t)
|
||||||
|
id := plantPending(repo)
|
||||||
|
repo.staff["p"] = &StaffUser{ID: "u9", Username: "p", Email: "[email protected]", Role: "user", EmailVerified: true}
|
||||||
|
repo.links["bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"] = "u9"
|
||||||
|
if w := approveOp(api.InternalHandler(), id, "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"); w.Code != http.StatusForbidden || decodeErr(t, w) != "not_admin" {
|
||||||
|
t.Fatalf("non-admin approver: code = %d body %s, want 403 not_admin", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("missing approver_uuid -> 400", func(t *testing.T) {
|
||||||
|
api, repo, _ := seedOpLoginAPI(t)
|
||||||
|
id := plantPending(repo)
|
||||||
|
if w := do(api.InternalHandler(), "POST", "/api/v1/internal/op-login/"+id+"/approve", `{}`, nil); w.Code != http.StatusBadRequest || decodeErr(t, w) != "bad_request" {
|
||||||
|
t.Fatalf("missing approver_uuid: code = %d body %s, want 400 bad_request", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("unknown request id -> 404", func(t *testing.T) {
|
||||||
|
api, _, _ := seedOpLoginAPI(t)
|
||||||
|
if w := approveOp(api.InternalHandler(), "nosuchrequest", opUUID); w.Code != http.StatusNotFound || decodeErr(t, w) != "op_login_not_found" {
|
||||||
|
t.Fatalf("unknown request: code = %d body %s, want 404 op_login_not_found", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("re-approving an approved request -> 404 (first approval stands)", func(t *testing.T) {
|
||||||
|
api, repo, _ := seedOpLoginAPI(t)
|
||||||
|
id := plantPending(repo)
|
||||||
|
if w := approveOp(api.InternalHandler(), id, opUUID); w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("first approve: code = %d, want 200 (%s)", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if w := approveOp(api.InternalHandler(), id, opUUID); w.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("second approve: code = %d, want 404 (no longer pending)", w.Code)
|
||||||
|
}
|
||||||
|
if repo.opLogins[id].status != "approved" {
|
||||||
|
t.Error("the request must remain approved after a redundant re-approval")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestOpLoginPendingList covers the internal push list: live pending requests are
|
||||||
|
// returned oldest first, carrying the joined username, and an approved or expired
|
||||||
|
// request is absent.
|
||||||
|
func TestOpLoginPendingList(t *testing.T) {
|
||||||
|
api, repo, _ := seedOpLoginAPI(t)
|
||||||
|
ih := api.InternalHandler()
|
||||||
|
future := time.Unix(1_700_000_600, 0)
|
||||||
|
|
||||||
|
// Two pending (distinct createdAt so ordering is deterministic), one approved, one
|
||||||
|
// expired.
|
||||||
|
repo.opLogins["r2"] = &fakeOpLogin{id: "r2", userID: "a1", email: "[email protected]", status: "pending", expiresAt: future, createdAt: time.Unix(1_700_000_200, 0)}
|
||||||
|
repo.opLogins["r1"] = &fakeOpLogin{id: "r1", userID: "a1", email: "[email protected]", status: "pending", expiresAt: future, createdAt: time.Unix(1_700_000_100, 0)}
|
||||||
|
repo.opLogins["ap"] = &fakeOpLogin{id: "ap", userID: "a1", email: "[email protected]", status: "approved", expiresAt: future, createdAt: time.Unix(1_700_000_150, 0)}
|
||||||
|
repo.opLogins["ex"] = &fakeOpLogin{id: "ex", userID: "a1", email: "[email protected]", status: "pending", expiresAt: time.Unix(1_699_999_999, 0), createdAt: time.Unix(1_700_000_050, 0)}
|
||||||
|
|
||||||
|
w := do(ih, "GET", "/api/v1/internal/op-login/pending", "", nil)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("pending: code = %d, want 200 (%s)", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
body := acctBody(t, w)
|
||||||
|
pending, _ := body["pending"].([]any)
|
||||||
|
if len(pending) != 2 {
|
||||||
|
t.Fatalf("pending count = %d, want 2 (only live pending rows) — %v", len(pending), body["pending"])
|
||||||
|
}
|
||||||
|
// Oldest first: r1 (created earlier) before r2.
|
||||||
|
first, _ := pending[0].(map[string]any)
|
||||||
|
second, _ := pending[1].(map[string]any)
|
||||||
|
if first["request_id"] != "r1" || second["request_id"] != "r2" {
|
||||||
|
t.Errorf("order = [%v, %v], want [r1, r2] (oldest first)", first["request_id"], second["request_id"])
|
||||||
|
}
|
||||||
|
if first["username"] != "op" || first["email"] != "[email protected]" {
|
||||||
|
t.Errorf("row projection = %v, want username op / email [email protected]", first)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestOpLoginGates covers the shared front doors: the fail-closed local-auth toggle on
|
||||||
|
// all three public legs, the CSRF Content-Type guard on the credential-minting POSTs,
|
||||||
|
// and the input gates that must reject before any lookup or mint.
|
||||||
|
func TestOpLoginGates(t *testing.T) {
|
||||||
|
t.Run("local auth disabled -> 403 on start/status/finish", func(t *testing.T) {
|
||||||
|
api := newTestAPI(newFakeRepo(), newFakeCluster()) // no LocalAuthEnabledKey: fails closed
|
||||||
|
eh := api.ExternalHandler()
|
||||||
|
if w := startOp(eh, "[email protected]"); w.Code != http.StatusForbidden || decodeErr(t, w) != "local_auth_disabled" {
|
||||||
|
t.Errorf("start: code = %d body %s, want 403 local_auth_disabled", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if w := statusOp(eh, "anything"); w.Code != http.StatusForbidden || decodeErr(t, w) != "local_auth_disabled" {
|
||||||
|
t.Errorf("status: code = %d body %s, want 403 local_auth_disabled", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if w := finishOp(eh, "anything", "123456"); w.Code != http.StatusForbidden || decodeErr(t, w) != "local_auth_disabled" {
|
||||||
|
t.Errorf("finish: code = %d body %s, want 403 local_auth_disabled", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("non-JSON content type -> 415 on the minting POSTs", func(t *testing.T) {
|
||||||
|
api, _, _ := seedOpLoginAPI(t)
|
||||||
|
eh := api.ExternalHandler()
|
||||||
|
for _, ct := range []string{"", "text/plain", "application/x-www-form-urlencoded"} {
|
||||||
|
if w := do(eh, "POST", "/api/v1/auth/op-login/start", `{"email":"[email protected]"}`, ctHeader(ct)); w.Code != http.StatusUnsupportedMediaType {
|
||||||
|
t.Errorf("start Content-Type %q: code = %d, want 415", ct, w.Code)
|
||||||
|
}
|
||||||
|
if w := do(eh, "POST", "/api/v1/auth/op-login/finish", `{"request_id":"x","code":"1"}`, ctHeader(ct)); w.Code != http.StatusUnsupportedMediaType {
|
||||||
|
t.Errorf("finish Content-Type %q: code = %d, want 415", ct, w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("start bad email -> 400, nothing minted", func(t *testing.T) {
|
||||||
|
for _, body := range []string{`{}`, `{"email":""}`, `{"email":"notanemail"}`, `{"email":"[email protected]","x":1}`} {
|
||||||
|
api, repo, mailer := seedOpLoginAPI(t)
|
||||||
|
w := do(api.ExternalHandler(), "POST", "/api/v1/auth/op-login/start", body, jsonHeader)
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("start %q: code = %d, want 400 (%s)", body, w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if len(repo.opLogins) != 0 || len(repo.otps) != 0 || mailer.calls != 0 {
|
||||||
|
t.Errorf("start %q: a rejected start must mint nothing", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("finish missing request_id or code -> 400 bad_request", func(t *testing.T) {
|
||||||
|
api, _, _ := seedOpLoginAPI(t)
|
||||||
|
eh := api.ExternalHandler()
|
||||||
|
for _, body := range []string{`{"code":"123456"}`, `{"request_id":"x"}`, `{"request_id":"","code":""}`} {
|
||||||
|
if w := do(eh, "POST", "/api/v1/auth/op-login/finish", body, jsonHeader); w.Code != http.StatusBadRequest || decodeErr(t, w) != "bad_request" {
|
||||||
|
t.Errorf("finish %q: code = %d body %s, want 400 bad_request", body, w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestOpLoginFaceSeparation enforces the two-face split: the three public browser legs
|
||||||
|
// must 404 on the internal (service-token) face, and the two internal in-game legs must
|
||||||
|
// 404 on the external (Access-JWT) face.
|
||||||
|
func TestOpLoginFaceSeparation(t *testing.T) {
|
||||||
|
api, _, _ := seedOpLoginAPI(t)
|
||||||
|
eh, ih := api.ExternalHandler(), api.InternalHandler()
|
||||||
|
|
||||||
|
// Public legs must not appear on the internal face.
|
||||||
|
if w := do(ih, "POST", "/api/v1/auth/op-login/start", `{"email":"[email protected]"}`, jsonHeader); w.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("start on internal face: code = %d, want 404", w.Code)
|
||||||
|
}
|
||||||
|
if w := do(ih, "GET", "/api/v1/auth/op-login/status/x", "", nil); w.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("status on internal face: code = %d, want 404", w.Code)
|
||||||
|
}
|
||||||
|
if w := do(ih, "POST", "/api/v1/auth/op-login/finish", `{"request_id":"x","code":"1"}`, jsonHeader); w.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("finish on internal face: code = %d, want 404", w.Code)
|
||||||
|
}
|
||||||
|
// Internal legs must not appear on the external face.
|
||||||
|
if w := do(eh, "GET", "/api/v1/internal/op-login/pending", "", nil); w.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("pending on external face: code = %d, want 404", w.Code)
|
||||||
|
}
|
||||||
|
if w := do(eh, "POST", "/api/v1/internal/op-login/x/approve", `{"approver_uuid":"`+opUUID+`"}`, nil); w.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("approve on external face: code = %d, want 404", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,46 +8,47 @@ import (
|
|||||||
"errors"
|
"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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,460 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Pre-session Passkey (assertion) LOGIN tests (spec §B, console.<root_domain>
|
||||||
|
// returning-player door — the public sibling of the email-OTP login door). These drive
|
||||||
|
// the two Public routes against the fakeRepo challenge state machine and a fake
|
||||||
|
// PasskeyVerifier, so what they PROVE is the handler + login state machine (challenge
|
||||||
|
// stash → consume → session mint), not the pgrepo SQL nor the cryptographic assertion
|
||||||
|
// verification (both mirrored, not run here). The load-bearing properties, in flow order:
|
||||||
|
//
|
||||||
|
// - Session-data round-trip: the finish body carries only email+assertion, so the only
|
||||||
|
// path for the stashed blob into FinishLogin is store-stash → consume — the challenge
|
||||||
|
// is never client-echoed.
|
||||||
|
// - Anti-enumeration on finish: unknown email, no live challenge, expired challenge and
|
||||||
|
// a bad assertion all collapse to ONE passkey_login_invalid envelope, so the finish
|
||||||
|
// half is never an existence/state oracle.
|
||||||
|
// - Cooldown seals the accepted begin-side trade-off: has-passkey (200) vs no_passkey
|
||||||
|
// (400) is a status oracle, but one begin per recipient per window throttles probing.
|
||||||
|
// - Staff admitted: unlike the email door's staff_account refusal, a passkey stands
|
||||||
|
// alone (possession + user-verification), so role=admin mints a session here.
|
||||||
|
|
||||||
|
// seedLoginPasskeyAPI wires the public passkey-login door: local sessions enabled, a
|
||||||
|
// single verified player "player" (id u1) whose proven address is stored in MIXED case
|
||||||
|
// (so the resolve-on-typed-lowercase contract is exercised by default), one passkey
|
||||||
|
// credential bound to that account, and a verifier primed with fixed options + a verified
|
||||||
|
// assertion. Both routes are Public — no External principal wired, proving they are truly
|
||||||
|
// pre-session.
|
||||||
|
func seedLoginPasskeyAPI(t *testing.T) (*API, *fakeRepo, *fakePasskeyVerifier) {
|
||||||
|
t.Helper()
|
||||||
|
repo := newFakeRepo()
|
||||||
|
repo.settings[LocalAuthEnabledKey] = []byte("true")
|
||||||
|
repo.staff["player"] = &StaffUser{
|
||||||
|
ID: "u1", Username: "player", Email: "[email protected]",
|
||||||
|
Role: "user", EmailVerified: true,
|
||||||
|
}
|
||||||
|
repo.passkeyCreds["row1"] = PasskeyCredential{
|
||||||
|
ID: "row1", UserID: "u1", CredentialID: "cred-1", PublicKey: "k", CreatedAt: frozenNow,
|
||||||
|
}
|
||||||
|
v := &fakePasskeyVerifier{
|
||||||
|
options: json.RawMessage(`{"publicKey":{"challenge":"YXNzZXJ0"}}`),
|
||||||
|
assertion: VerifiedAssertion{CredentialID: "cred-1", UserVerified: true},
|
||||||
|
}
|
||||||
|
api := newTestAPI(repo, newFakeCluster())
|
||||||
|
api.Passkey = v
|
||||||
|
return api, repo, v
|
||||||
|
}
|
||||||
|
|
||||||
|
// plantLoginChallenge seeds a stashed LOGIN-purpose challenge for u1 directly, so the
|
||||||
|
// finish-side branches (expired, verification failure) are reachable under the frozen
|
||||||
|
// clock without running begin first. Mirrors plantPasskeyChallenge, but scoped to
|
||||||
|
// passkeyPurposeLogin so it is only ever consumed by the login door.
|
||||||
|
func plantLoginChallenge(repo *fakeRepo, id string, expiresAt time.Time) {
|
||||||
|
repo.passkeyChallenges[id] = &fakePasskeyChallenge{
|
||||||
|
id: id, userID: "u1", purpose: passkeyPurposeLogin,
|
||||||
|
sessionData: []byte("login-session:u1"), expiresAt: expiresAt, createdAt: expiresAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPasskeyLoginVertical walks the whole returning-player slice across the external
|
||||||
|
// face: begin resolves the mixed-case account from a lowercase-typed email, hands its
|
||||||
|
// bound credential to the verifier, returns the assertion options verbatim and stashes
|
||||||
|
// one login challenge; finish verifies the assertion against the SERVER-STASHED session
|
||||||
|
// data and mints the same host-only felis_session as the email door. The decisive
|
||||||
|
// assertion is the session-data round-trip: the finish body carries only email+assertion,
|
||||||
|
// so the only path for the stashed blob into FinishLogin is store-stash → consume,
|
||||||
|
// proving the challenge is never client-echoed.
|
||||||
|
func TestPasskeyLoginVertical(t *testing.T) {
|
||||||
|
api, repo, v := seedLoginPasskeyAPI(t)
|
||||||
|
eh := api.ExternalHandler()
|
||||||
|
|
||||||
|
// 1) begin: options verbatim, exactly one login-purpose challenge stashed for u1, and
|
||||||
|
// the account's bound credential handed to the verifier (so the authenticator can be
|
||||||
|
// asked to assert with a known key).
|
||||||
|
w := do(eh, "POST", "/api/v1/auth/passkey/login/begin", `{"email":"[email protected]"}`, jsonHeader)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("begin: code = %d, want 200 (%s)", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if b := acctBody(t, w); b["publicKey"] == nil {
|
||||||
|
t.Errorf("begin must return the assertion options verbatim, got %s", w.Body.String())
|
||||||
|
}
|
||||||
|
if v.lastUser.ID != "u1" {
|
||||||
|
t.Errorf("begin passed user id %q, want u1", v.lastUser.ID)
|
||||||
|
}
|
||||||
|
if len(v.lastUser.Credentials) != 1 || v.lastUser.Credentials[0].CredentialID != "cred-1" {
|
||||||
|
t.Errorf("begin must hand the account's bound credential to the verifier, got %+v", v.lastUser.Credentials)
|
||||||
|
}
|
||||||
|
if len(repo.passkeyChallenges) != 1 {
|
||||||
|
t.Fatalf("begin must stash exactly one challenge, got %d", len(repo.passkeyChallenges))
|
||||||
|
}
|
||||||
|
for _, c := range repo.passkeyChallenges {
|
||||||
|
if c.userID != "u1" || c.purpose != passkeyPurposeLogin {
|
||||||
|
t.Errorf("stashed challenge = %+v, want user u1 purpose %q", c, passkeyPurposeLogin)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) finish — typed in yet another casing, proving the finish-side resolver is
|
||||||
|
// case-insensitive too — verifies the assertion and mints the session. The body
|
||||||
|
// carries NO challenge, only email+assertion.
|
||||||
|
w = do(eh, "POST", "/api/v1/auth/passkey/login/finish",
|
||||||
|
`{"email":"[email protected]","assertion":{"id":"cred-1","type":"public-key"}}`, jsonHeader)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("finish: code = %d, want 200 (%s)", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
// THE security assertion: the blob the verifier saw at finish is exactly what begin
|
||||||
|
// stashed — it travelled store-stash → consume, never the client.
|
||||||
|
if !bytes.Equal(v.lastSession, []byte("login-session:u1")) {
|
||||||
|
t.Fatalf("finish session data = %q, want the server-stashed %q (challenge must not be client-echoed)",
|
||||||
|
v.lastSession, "login-session:u1")
|
||||||
|
}
|
||||||
|
vb := acctBody(t, w)
|
||||||
|
if vb["user_id"] != "u1" || vb["role"] != "user" {
|
||||||
|
t.Fatalf("finish body = %v, want user_id:u1 role:user", vb)
|
||||||
|
}
|
||||||
|
// The host-only HttpOnly cookie is the whole point — same contract as the email door.
|
||||||
|
cookies := w.Result().Cookies()
|
||||||
|
if len(cookies) != 1 || cookies[0].Name != sessionCookieName || cookies[0].Value == "" {
|
||||||
|
t.Fatalf("want one non-empty %s cookie, got %v", sessionCookieName, cookies)
|
||||||
|
}
|
||||||
|
s, ok := repo.sessions[hashCookie(cookies[0].Value)]
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("no session row for the issued cookie (must be stored hashed)")
|
||||||
|
}
|
||||||
|
if s.userID != "u1" {
|
||||||
|
t.Errorf("session userID = %q, want u1", s.userID)
|
||||||
|
}
|
||||||
|
if want := frozenNow.Add(sessionTTL); !s.expiresAt.Equal(want) {
|
||||||
|
t.Errorf("session expiresAt = %v, want now+sessionTTL = %v", s.expiresAt, want)
|
||||||
|
}
|
||||||
|
// Audited once, by the account's username (there is no principal yet); begin is silent.
|
||||||
|
if n := len(repo.audits); n != 1 {
|
||||||
|
t.Fatalf("want exactly 1 audit (passkey_login), got %d: %+v", n, repo.audits)
|
||||||
|
}
|
||||||
|
if repo.audits[0].Action != "auth.passkey_login" || repo.audits[0].Actor != "player" {
|
||||||
|
t.Errorf("audit = %+v, want auth.passkey_login by player", repo.audits[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) single-use: the consumed challenge buys nothing a second time.
|
||||||
|
if w := do(eh, "POST", "/api/v1/auth/passkey/login/finish",
|
||||||
|
`{"email":"[email protected]","assertion":{"id":"cred-1","type":"public-key"}}`, jsonHeader); w.Code != http.StatusBadRequest || decodeErr(t, w) != "passkey_login_invalid" {
|
||||||
|
t.Fatalf("replay of consumed challenge: code = %d body %s, want 400 passkey_login_invalid", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPasskeyLoginBeginNoPasskey pins the begin-side anti-enumeration floor: an unknown
|
||||||
|
// email and a KNOWN verified account that has enrolled no passkey answer the SAME
|
||||||
|
// no_passkey envelope, so the two are indistinguishable. (The remaining has-passkey-vs-not
|
||||||
|
// status split is the documented, accepted trade-off; the cooldown below makes probing
|
||||||
|
// it impractical.) Neither path stashes a challenge, and both KEEP the reservation.
|
||||||
|
func TestPasskeyLoginBeginNoPasskey(t *testing.T) {
|
||||||
|
begin := func(eh http.Handler, email string) *httptest.ResponseRecorder {
|
||||||
|
return do(eh, "POST", "/api/v1/auth/passkey/login/begin", `{"email":"`+email+`"}`, jsonHeader)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("unknown email and a passkey-less account answer the same no_passkey", func(t *testing.T) {
|
||||||
|
// Unknown email: no account at all.
|
||||||
|
apiU, repoU, _ := seedLoginPasskeyAPI(t)
|
||||||
|
wGhost := begin(apiU.ExternalHandler(), "[email protected]")
|
||||||
|
|
||||||
|
// Known verified account that has enrolled NO passkey.
|
||||||
|
apiN, repoN, _ := seedLoginPasskeyAPI(t)
|
||||||
|
delete(repoN.passkeyCreds, "row1")
|
||||||
|
wNone := begin(apiN.ExternalHandler(), "[email protected]")
|
||||||
|
|
||||||
|
if wGhost.Code != http.StatusBadRequest || wNone.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("codes = %d/%d, want 400/400", wGhost.Code, wNone.Code)
|
||||||
|
}
|
||||||
|
gc, gm := errEnvelope(t, wGhost)
|
||||||
|
nc, nm := errEnvelope(t, wNone)
|
||||||
|
if gc != "no_passkey" || gc != nc || gm != nm {
|
||||||
|
t.Errorf("envelopes differ: unknown=(%s,%q) no-cred=(%s,%q) — must be identical no_passkey", gc, gm, nc, nm)
|
||||||
|
}
|
||||||
|
// Neither may stash a challenge or reach BeginLogin.
|
||||||
|
if len(repoU.passkeyChallenges) != 0 || len(repoN.passkeyChallenges) != 0 {
|
||||||
|
t.Errorf("no_passkey paths must stash nothing, got unknown=%d no-cred=%d",
|
||||||
|
len(repoU.passkeyChallenges), len(repoN.passkeyChallenges))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("the no_passkey path KEEPS the reservation so probing is throttled", func(t *testing.T) {
|
||||||
|
api, _, _ := seedLoginPasskeyAPI(t)
|
||||||
|
eh := api.ExternalHandler()
|
||||||
|
if w := begin(eh, "[email protected]"); w.Code != http.StatusBadRequest || decodeErr(t, w) != "no_passkey" {
|
||||||
|
t.Fatalf("first probe: code = %d body %s, want 400 no_passkey", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
// Re-probing the same unknown address inside the window is throttled identically to
|
||||||
|
// a real begin — the response is not the only channel; the throttle is sealed too.
|
||||||
|
if w := begin(eh, "[email protected]"); w.Code != http.StatusTooManyRequests || decodeErr(t, w) != "otp_resend_cooldown" {
|
||||||
|
t.Fatalf("re-probe: code = %d body %s, want 429 otp_resend_cooldown", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPasskeyLoginBeginVerifierError pins the reserve→rollback path: a BeginLogin failure
|
||||||
|
// is a server-side fault, not a probe signal, so it answers passkey_login_failed AND
|
||||||
|
// RELEASES the reservation — the immediate retry is admitted, not 429'd. Distinguishing
|
||||||
|
// 400-not-429 on the retry is what proves the release: a kept reservation would 429 before
|
||||||
|
// ever reaching BeginLogin.
|
||||||
|
func TestPasskeyLoginBeginVerifierError(t *testing.T) {
|
||||||
|
api, repo, v := seedLoginPasskeyAPI(t)
|
||||||
|
v.beginLoginErr = errors.New("no assertable credential")
|
||||||
|
eh := api.ExternalHandler()
|
||||||
|
|
||||||
|
if w := do(eh, "POST", "/api/v1/auth/passkey/login/begin", `{"email":"[email protected]"}`, jsonHeader); w.Code != http.StatusBadRequest || decodeErr(t, w) != "passkey_login_failed" {
|
||||||
|
t.Fatalf("verifier error: code = %d body %s, want 400 passkey_login_failed", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if len(repo.passkeyChallenges) != 0 {
|
||||||
|
t.Errorf("a failed begin must stash no challenge, got %d", len(repo.passkeyChallenges))
|
||||||
|
}
|
||||||
|
if w := do(eh, "POST", "/api/v1/auth/passkey/login/begin", `{"email":"[email protected]"}`, jsonHeader); w.Code != http.StatusBadRequest || decodeErr(t, w) != "passkey_login_failed" {
|
||||||
|
t.Fatalf("retry after verifier error: code = %d body %s, want 400 passkey_login_failed (reservation must be released, not 429)", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPasskeyLoginGates covers the shared front doors of both halves: the fail-closed
|
||||||
|
// local-auth toggle, graceful degradation when no verifier is wired, the CSRF Content-Type
|
||||||
|
// guard (these are Public, credential-minting routes), and the input gates that must
|
||||||
|
// reject before any lookup or stash.
|
||||||
|
func TestPasskeyLoginGates(t *testing.T) {
|
||||||
|
const beginPath = "/api/v1/auth/passkey/login/begin"
|
||||||
|
const finishPath = "/api/v1/auth/passkey/login/finish"
|
||||||
|
const goodBegin = `{"email":"[email protected]"}`
|
||||||
|
const goodFinish = `{"email":"[email protected]","assertion":{"id":"cred-1"}}`
|
||||||
|
|
||||||
|
t.Run("local auth disabled -> 403 on both halves", func(t *testing.T) {
|
||||||
|
api := newTestAPI(newFakeRepo(), newFakeCluster()) // no LocalAuthEnabledKey: fails closed
|
||||||
|
api.Passkey = &fakePasskeyVerifier{}
|
||||||
|
eh := api.ExternalHandler()
|
||||||
|
if w := do(eh, "POST", beginPath, goodBegin, jsonHeader); w.Code != http.StatusForbidden || decodeErr(t, w) != "local_auth_disabled" {
|
||||||
|
t.Errorf("begin: code = %d body %s, want 403 local_auth_disabled", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if w := do(eh, "POST", finishPath, goodFinish, jsonHeader); w.Code != http.StatusForbidden || decodeErr(t, w) != "local_auth_disabled" {
|
||||||
|
t.Errorf("finish: code = %d body %s, want 403 local_auth_disabled", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("no verifier wired -> 503 passkey_unavailable on both halves", func(t *testing.T) {
|
||||||
|
api, _, _ := seedLoginPasskeyAPI(t)
|
||||||
|
api.Passkey = nil // unwire it: the degraded path must be a clean 503, not a panic
|
||||||
|
eh := api.ExternalHandler()
|
||||||
|
if w := do(eh, "POST", beginPath, goodBegin, jsonHeader); w.Code != http.StatusServiceUnavailable || decodeErr(t, w) != "passkey_unavailable" {
|
||||||
|
t.Errorf("begin: code = %d body %s, want 503 passkey_unavailable", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if w := do(eh, "POST", finishPath, goodFinish, jsonHeader); w.Code != http.StatusServiceUnavailable || decodeErr(t, w) != "passkey_unavailable" {
|
||||||
|
t.Errorf("finish: code = %d body %s, want 503 passkey_unavailable", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("non-JSON content type -> 415 on both halves", func(t *testing.T) {
|
||||||
|
api, _, _ := seedLoginPasskeyAPI(t)
|
||||||
|
eh := api.ExternalHandler()
|
||||||
|
for _, ct := range []string{"", "text/plain", "application/x-www-form-urlencoded"} {
|
||||||
|
if w := do(eh, "POST", beginPath, goodBegin, ctHeader(ct)); w.Code != http.StatusUnsupportedMediaType {
|
||||||
|
t.Errorf("begin with Content-Type %q: code = %d, want 415", ct, w.Code)
|
||||||
|
}
|
||||||
|
if w := do(eh, "POST", finishPath, goodFinish, ctHeader(ct)); w.Code != http.StatusUnsupportedMediaType {
|
||||||
|
t.Errorf("finish with Content-Type %q: code = %d, want 415", ct, w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("begin bad email -> 400, nothing stashed", func(t *testing.T) {
|
||||||
|
bad := map[string]string{
|
||||||
|
"missing email": `{}`,
|
||||||
|
"empty email": `{"email":""}`,
|
||||||
|
"no at-sign": `{"email":"notanemail"}`,
|
||||||
|
"two at-signs": `{"email":"a@[email protected]"}`,
|
||||||
|
"unknown field": `{"email":"[email protected]","x":1}`,
|
||||||
|
}
|
||||||
|
for name, body := range bad {
|
||||||
|
api, repo, _ := seedLoginPasskeyAPI(t)
|
||||||
|
w := do(api.ExternalHandler(), "POST", beginPath, body, jsonHeader)
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("%s: code = %d, want 400 (%s)", name, w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if len(repo.passkeyChallenges) != 0 {
|
||||||
|
t.Errorf("%s: a rejected begin must stash nothing (%d)", name, len(repo.passkeyChallenges))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("finish bad inputs -> 400", func(t *testing.T) {
|
||||||
|
// An assertion key of {} is non-empty (2 bytes), so it clears the len==0 gate and
|
||||||
|
// fails later at passkey_login_invalid — the "missing assertion" gate is only the
|
||||||
|
// absent key. These are the cases the input gate itself must catch.
|
||||||
|
cases := []struct{ name, body, wantCode string }{
|
||||||
|
{"bad email", `{"email":"notanemail","assertion":{"id":"x"}}`, "bad_request"},
|
||||||
|
{"missing assertion", `{"email":"[email protected]"}`, "bad_request"},
|
||||||
|
{"unknown field", `{"email":"[email protected]","assertion":{"id":"x"},"z":1}`, ""},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
api, _, _ := seedLoginPasskeyAPI(t)
|
||||||
|
w := do(api.ExternalHandler(), "POST", finishPath, c.body, jsonHeader)
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("%s: code = %d, want 400 (%s)", c.name, w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if c.wantCode != "" && decodeErr(t, w) != c.wantCode {
|
||||||
|
t.Errorf("%s: error code = %q, want %q", c.name, decodeErr(t, w), c.wantCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPasskeyLoginFinishRejections is the redeem-side failure matrix and the anchor for
|
||||||
|
// anti-enumeration: an unknown email, a known account with no live challenge, an expired
|
||||||
|
// challenge and an assertion that fails verification must ALL answer the byte-identical
|
||||||
|
// passkey_login_invalid envelope (code AND message) and mint no session — so the finish
|
||||||
|
// half never doubles as an existence or ceremony-state oracle. Expired state is planted
|
||||||
|
// directly: the frozen clock makes that the only deterministic route to that branch.
|
||||||
|
func TestPasskeyLoginFinishRejections(t *testing.T) {
|
||||||
|
const finishPath = "/api/v1/auth/passkey/login/finish"
|
||||||
|
finish := func(eh http.Handler, email string) *httptest.ResponseRecorder {
|
||||||
|
return do(eh, "POST", finishPath,
|
||||||
|
`{"email":"`+email+`","assertion":{"id":"cred-1","type":"public-key"}}`, jsonHeader)
|
||||||
|
}
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
email string
|
||||||
|
setup func(repo *fakeRepo, v *fakePasskeyVerifier)
|
||||||
|
}{
|
||||||
|
{"unknown email", "[email protected]", func(repo *fakeRepo, v *fakePasskeyVerifier) {}},
|
||||||
|
{"known account, no live challenge", "[email protected]", func(repo *fakeRepo, v *fakePasskeyVerifier) {}},
|
||||||
|
{"expired challenge", "[email protected]", func(repo *fakeRepo, v *fakePasskeyVerifier) {
|
||||||
|
plantLoginChallenge(repo, "ex", frozenNow.Add(-time.Second))
|
||||||
|
}},
|
||||||
|
{"assertion fails verification", "[email protected]", func(repo *fakeRepo, v *fakePasskeyVerifier) {
|
||||||
|
plantLoginChallenge(repo, "live", frozenNow.Add(passkeyChallengeTTL))
|
||||||
|
v.failErr = errors.New("bad assertion")
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
|
||||||
|
var envelopes [][2]string
|
||||||
|
for _, c := range cases {
|
||||||
|
api, repo, v := seedLoginPasskeyAPI(t)
|
||||||
|
c.setup(repo, v)
|
||||||
|
w := finish(api.ExternalHandler(), c.email)
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("%s: code = %d, want 400 (%s)", c.name, w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
code, msg := errEnvelope(t, w)
|
||||||
|
if code != "passkey_login_invalid" {
|
||||||
|
t.Errorf("%s: error code = %q, want passkey_login_invalid", c.name, code)
|
||||||
|
}
|
||||||
|
if len(repo.sessions) != 0 {
|
||||||
|
t.Errorf("%s: a rejected finish must mint no session (got %d)", c.name, len(repo.sessions))
|
||||||
|
}
|
||||||
|
if len(w.Result().Cookies()) != 0 {
|
||||||
|
t.Errorf("%s: a rejected finish must set no cookie", c.name)
|
||||||
|
}
|
||||||
|
envelopes = append(envelopes, [2]string{code, msg})
|
||||||
|
}
|
||||||
|
// The anchor: every envelope is identical (code AND message), so no branch is
|
||||||
|
// distinguishable from another.
|
||||||
|
for i := 1; i < len(envelopes); i++ {
|
||||||
|
if envelopes[i] != envelopes[0] {
|
||||||
|
t.Errorf("envelope for %q %v differs from %q %v — all rejections must be identical",
|
||||||
|
cases[i].name, envelopes[i], cases[0].name, envelopes[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPasskeyLoginBeginRateLimited closes the unauthenticated probing/DoS vector on the
|
||||||
|
// public door: one begin per recipient per window, keyed case-insensitively (a recased
|
||||||
|
// retype is the same mailbox), recovering after the window elapses. This is what makes the
|
||||||
|
// accepted has-passkey-vs-not status oracle impractical to farm.
|
||||||
|
func TestPasskeyLoginBeginRateLimited(t *testing.T) {
|
||||||
|
begin := func(eh http.Handler, email string) *httptest.ResponseRecorder {
|
||||||
|
return do(eh, "POST", "/api/v1/auth/passkey/login/begin", `{"email":"`+email+`"}`, jsonHeader)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("same recipient is throttled, then recovers after the cooldown", func(t *testing.T) {
|
||||||
|
api, _, _ := seedLoginPasskeyAPI(t)
|
||||||
|
clock := frozenNow
|
||||||
|
api.Now = func() time.Time { return clock }
|
||||||
|
eh := api.ExternalHandler()
|
||||||
|
|
||||||
|
if w := begin(eh, "[email protected]"); w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("first begin: code = %d, want 200 (%s)", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if w := begin(eh, "[email protected]"); w.Code != http.StatusTooManyRequests || decodeErr(t, w) != "otp_resend_cooldown" {
|
||||||
|
t.Fatalf("immediate re-begin: code = %d body %s, want 429 otp_resend_cooldown", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
clock = clock.Add(otpResendCooldown + time.Second)
|
||||||
|
if w := begin(eh, "[email protected]"); w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("post-cooldown begin: code = %d, want 200 (%s)", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("throttle key is case-insensitive", func(t *testing.T) {
|
||||||
|
api, _, _ := seedLoginPasskeyAPI(t)
|
||||||
|
eh := api.ExternalHandler()
|
||||||
|
if w := begin(eh, "[email protected]"); w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("first begin: code = %d, want 200 (%s)", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if w := begin(eh, "[email protected]"); w.Code != http.StatusTooManyRequests {
|
||||||
|
t.Fatalf("recased re-begin: code = %d, want 429 (key must be lowercased)", w.Code)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPasskeyLoginAllowsStaff pins the deliberate contrast with the email door: that door
|
||||||
|
// refuses role != user with 403 staff_account (op.console keeps its Zero-Trust in-game
|
||||||
|
// gate), but the passkey door ADMITS staff — a passkey is a strong two-factor authenticator
|
||||||
|
// (possession + user-verification), enough to stand alone. This guards against a future
|
||||||
|
// "make the doors consistent" change silently locking admins out of passkey login.
|
||||||
|
func TestPasskeyLoginAllowsStaff(t *testing.T) {
|
||||||
|
repo := newFakeRepo()
|
||||||
|
repo.settings[LocalAuthEnabledKey] = []byte("true")
|
||||||
|
repo.staff["boss"] = &StaffUser{
|
||||||
|
ID: "a1", Username: "boss", Email: "[email protected]",
|
||||||
|
Role: "admin", EmailVerified: true,
|
||||||
|
}
|
||||||
|
repo.passkeyCreds["row1"] = PasskeyCredential{
|
||||||
|
ID: "row1", UserID: "a1", CredentialID: "cred-a1", PublicKey: "k", CreatedAt: frozenNow,
|
||||||
|
}
|
||||||
|
v := &fakePasskeyVerifier{
|
||||||
|
options: json.RawMessage(`{"publicKey":{"challenge":"YXNzZXJ0"}}`),
|
||||||
|
assertion: VerifiedAssertion{CredentialID: "cred-a1", UserVerified: true},
|
||||||
|
}
|
||||||
|
api := newTestAPI(repo, newFakeCluster())
|
||||||
|
api.Passkey = v
|
||||||
|
eh := api.ExternalHandler()
|
||||||
|
|
||||||
|
if w := do(eh, "POST", "/api/v1/auth/passkey/login/begin", `{"email":"[email protected]"}`, jsonHeader); w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("begin for staff: code = %d, want 200 (%s)", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
w := do(eh, "POST", "/api/v1/auth/passkey/login/finish",
|
||||||
|
`{"email":"[email protected]","assertion":{"id":"cred-a1","type":"public-key"}}`, jsonHeader)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("finish for staff: code = %d, want 200 (passkey admits staff) (%s)", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if b := acctBody(t, w); b["user_id"] != "a1" || b["role"] != "admin" {
|
||||||
|
t.Fatalf("finish body = %v, want user_id:a1 role:admin", b)
|
||||||
|
}
|
||||||
|
if len(repo.sessions) != 1 {
|
||||||
|
t.Errorf("a staff passkey login must mint a session, got %d", len(repo.sessions))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPasskeyLoginFaceSeparation enforces that both halves are web-only: the internal
|
||||||
|
// (service-token) face must 404 them, never serve them.
|
||||||
|
func TestPasskeyLoginFaceSeparation(t *testing.T) {
|
||||||
|
api, _, _ := seedLoginPasskeyAPI(t)
|
||||||
|
ih := api.InternalHandler()
|
||||||
|
if w := do(ih, "POST", "/api/v1/auth/passkey/login/begin", `{"email":"[email protected]"}`, jsonHeader); w.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("begin on internal face: code = %d, want 404", w.Code)
|
||||||
|
}
|
||||||
|
if w := do(ih, "POST", "/api/v1/auth/passkey/login/finish", `{"email":"[email protected]","assertion":{"id":"x"}}`, jsonHeader); w.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("finish on internal face: code = %d, want 404", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -100,7 +100,7 @@ func TestReclaimProtectsAdminOnYggdrasil(t *testing.T) {
|
|||||||
const adminUUID = "0a11dead-0000-0000-0000-00000000ad11"
|
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())
|
||||||
|
|||||||
@@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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
@@ -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,66 +775,30 @@ func (p *PGRepo) UserByID(ctx context.Context, id string) (*StaffUser, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// UpsertOwner creates or resets the Owner account direct-to-Postgres (the
|
// 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
|
||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
+306
-5
@@ -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();
|
||||||
|
|||||||
Reference in new issue
Block a user