diff --git a/docs/superpowers/plans/2026-09-16-mfa-local-signin.md b/docs/superpowers/plans/2026-09-16-mfa-local-signin.md new file mode 100644 index 0000000..a1595dc --- /dev/null +++ b/docs/superpowers/plans/2026-09-16-mfa-local-signin.md @@ -0,0 +1,3521 @@ +# MFA for Local Sign-in Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Give local and HQ-projected members TOTP and passkey multi-factor sign-in, an owner-enforced MFA policy, and step-up re-authentication before revealing secrets, downloading private keys or opening a console. + +**Architecture:** A password that verifies no longer mints a session when the user has MFA or must enrol. It mints a short-lived *pending-login ticket* in Redis, referenced by a `km_mfa_pending` cookie; only the MFA endpoints accept that ticket, and only a completed factor exchanges it for a `km_session`. A half-authenticated request therefore never becomes a `*Session` and can never reach a route under `auth.Middleware`. Step-up is a middleware that reads `StepUpAt` off the session and answers 403 with a machine-readable code the browser turns into a modal. + +**Tech Stack:** Go 1.26 (gin, mongo-driver v2, go-redis v9), `github.com/pquerna/otp`, `github.com/go-webauthn/webauthn`, Next.js 16 + TypeScript, `qrcode` (npm), Playwright. + +**Spec:** `docs/superpowers/specs/2026-09-15-mfa-local-signin-design.md` — read it before Task 1; every task argues from it. + +## Global Constraints + +- Repo root for all `server/` and `web/` paths: `/go-projects/vantage/vantage-app`. The shared module lives in a **separate repository**: `/go-projects/vantage/vantage-shared`. +- Branch: `feat/mfa-local-signin` (already created, holds the spec commit). +- No licence gate anywhere in this feature. MFA ships on every tier, including Free. +- New collections: `user_mfa`, `webauthn_credentials`. Both MUST be added to `services.ScopedCollections`, or they outlive a purged instance. +- TOTP: SHA-1, 6 digits, 30-second period, ±1 step skew. Issuer is the instance name, account name is the user's email. +- Recovery codes: exactly 10 per set, stored only as SHA-256 hashes, single use. +- Pending ticket TTL 5 minutes, 5 attempts. WebAuthn challenge TTL 5 minutes. TOTP replay guard 90 seconds. Step-up window 10 minutes. +- Sign-in rate limit: 20 requests per minute per `c.ClientIP()`, fixed window, 429 with `Retry-After`. +- WebAuthn requires `userVerification: "required"` on every ceremony, registration and assertion alike. +- Never log, audit or return a TOTP secret, a recovery code or a password. +- Every new REST handler carries swag annotations, and `openapi.json` is regenerated in Task 15. CI fails on drift. +- Go tests in this repo are pure-function tests with no live MongoDB or Redis. Keep new logic in pure helpers so it stays that way; do not add a database fixture. +- Commit after every task with a conventional-commit message. + +--- + +### Task 1: `vantage-shared` — the settings field and the backup mirror + +**Files (in `/go-projects/vantage/vantage-shared`):** +- Modify: `models/settings.go` +- Modify: `backup/verify.go:117-139` +- Test: `backup/ciphertext_fields_test.go` (create) + +**Interfaces:** +- Consumes: nothing. +- Produces: `shared.Settings.RequireMFA *bool` and `func shared.RequireMFA(s *Settings) bool`; `ciphertextFields["user_mfa"] = ["totp_secret_enc"]`. Task 2 re-exports the accessor; Task 11 writes the field. + +- [ ] **Step 1: Add the field and its accessor** + +In `models/settings.go`, after the `LocalLoginEnabled` field: + +```go + // RequireMFA forces every password-authenticated member to hold a second + // factor. A pointer for the same reason LocalLoginEnabled is: absent must + // mean off, and a plain bool read from an old document would lock out an + // entire instance at upgrade. + RequireMFA *bool `bson:"require_mfa,omitempty" json:"require_mfa,omitempty"` +``` + +And beside `LocalLoginEnabled()`: + +```go +// RequireMFA reads the MFA policy with its absent-means-off default. Every +// caller must go through this rather than dereferencing the field. +func RequireMFA(s *Settings) bool { + return s != nil && s.RequireMFA != nil && *s.RequireMFA +} +``` + +- [ ] **Step 2: Write the failing test for the backup mirror** + +Create `backup/ciphertext_fields_test.go`: + +```go +package backup + +import "testing" + +// user_mfa holds an encrypted TOTP secret. Absent from this map, verify's live +// probe reports "this database stores no ciphertext yet" and the one gate that +// catches a wrong encryption key becomes a no-op for MFA secrets. +func TestUserMFACiphertextIsMirrored(t *testing.T) { + fields, ok := ciphertextFields["user_mfa"] + if !ok { + t.Fatal("ciphertextFields has no entry for user_mfa") + } + found := false + for _, f := range fields { + if f == "totp_secret_enc" { + found = true + } + } + if !found { + t.Fatalf("user_mfa entry %v does not name totp_secret_enc", fields) + } +} +``` + +- [ ] **Step 3: Run it and watch it fail** + +Run: `cd /go-projects/vantage/vantage-shared && go test ./backup/ -run TestUserMFACiphertextIsMirrored -v` +Expected: FAIL, "ciphertextFields has no entry for user_mfa". + +- [ ] **Step 4: Add the entry and extend the doc comment** + +In `backup/verify.go`, add to the map and to the source list in the comment above it: + +```go +// user_mfa - models/user_mfa.go: totp_secret_enc +var ciphertextFields = map[string][]string{ + "keys": {"private_key_enc", "passphrase_enc"}, + "secrets": {"encrypted_value"}, + "auth_providers": {"client_secret_enc"}, + "console_sessions": {"rdp_user_enc", "rdp_pass_enc"}, + "user_mfa": {"totp_secret_enc"}, +} +``` + +- [ ] **Step 5: Run the test and the package build** + +Run: `cd /go-projects/vantage/vantage-shared && go test ./backup/ ./models/` +Expected: PASS. + +- [ ] **Step 6: Commit, tag and release** + +```bash +cd /go-projects/vantage/vantage-shared +git add models/settings.go backup/verify.go backup/ciphertext_fields_test.go +git commit -m "feat: require_mfa setting and user_mfa ciphertext mirror" +git tag v0.7.0 +git push origin HEAD --tags +``` + +- [ ] **Step 7: Bump the pin in `server/` and verify it resolves** + +```bash +cd /go-projects/vantage/vantage-app/server +go get gitea.hostxtra.co.uk/vantage/vantage-shared@v0.7.0 +go build ./... +cd /go-projects/vantage/vantage-app +git add server/go.mod server/go.sum +git commit -m "chore(server): bump vantage-shared to v0.7.0 for require_mfa" +``` + +If the tag is not fetchable (no push access, no network), stop and report it: nothing after this task compiles without the field. + +--- + +### Task 2: MFA models, indexes and tenant scoping + +**Files:** +- Create: `server/internal/models/user_mfa.go` +- Modify: `server/internal/models/settings.go` (re-export the accessor) +- Modify: `server/internal/services/coreindexes.go` +- Modify: `server/internal/services/migrate_instance.go:24` (`ScopedCollections`) +- Test: `server/internal/services/mfa_scoped_test.go` (create) + +**Interfaces:** +- Consumes: Task 1's shared release. +- Produces: `models.UserMFA`, `models.RecoveryCode`, `models.WebAuthnCredential`, `models.RequireMFA(*Settings) bool`, `services.EnsureMFAIndexes() error`. + +- [ ] **Step 1: Write the failing scoping test** + +Create `server/internal/services/mfa_scoped_test.go`: + +```go +package services + +import "testing" + +// A tenant-scoped collection missing from ScopedCollections outlives its +// instance when the instance is purged - here that means a former customer's +// TOTP secrets and passkeys stay in the database forever. +func TestMFACollectionsAreScoped(t *testing.T) { + for _, name := range []string{"user_mfa", "webauthn_credentials"} { + found := false + for _, got := range ScopedCollections { + if got == name { + found = true + break + } + } + if !found { + t.Errorf("%s is not in ScopedCollections", name) + } + } +} +``` + +- [ ] **Step 2: Run it and watch it fail** + +Run: `cd server && go test ./internal/services/ -run TestMFACollectionsAreScoped -v` +Expected: FAIL, "user_mfa is not in ScopedCollections". + +- [ ] **Step 3: Write the models** + +Create `server/internal/models/user_mfa.go`: + +```go +package models + +import ( + "time" + + "go.mongodb.org/mongo-driver/v2/bson" +) + +// UserMFA is one user's multi-factor enrolment. It is a separate collection +// rather than fields on User because User lives in vantage-shared, which Vantage +// HQ also writes: MFA is a control-plane concern per instance. +type UserMFA struct { + ID bson.ObjectID `bson:"_id,omitempty" json:"-"` + InstanceID string `bson:"instance_id" json:"-"` + UserID string `bson:"user_id" json:"-"` + + // WebAuthnHandle is a random 64 bytes, never the user ID: the handle is + // returned to any origin that asks a resident credential for it. + WebAuthnHandle []byte `bson:"webauthn_handle" json:"-"` + + // TOTPSecretEnc is AES-256-GCM hex via services.encryptString. Mirrored in + // vantage-shared's backup.ciphertextFields - change one, change the other. + TOTPSecretEnc string `bson:"totp_secret_enc,omitempty" json:"-"` + + // TOTPConfirmedAt nil means setup was started but never confirmed, which + // does not count as an enrolled factor. + TOTPConfirmedAt *time.Time `bson:"totp_confirmed_at,omitempty" json:"totp_confirmed_at,omitempty"` + + RecoveryCodes []RecoveryCode `bson:"recovery_codes,omitempty" json:"-"` + + UpdatedAt time.Time `bson:"updated_at" json:"updated_at"` +} + +// RecoveryCode stores only a SHA-256 hash: a leaked database yields no working +// codes, exactly as api_tokens and agent tokens do. +type RecoveryCode struct { + Hash string `bson:"hash"` + UsedAt *time.Time `bson:"used_at,omitempty"` +} + +// WebAuthnCredential is one passkey. Nothing here is secret - a public key is +// public - so no field is encrypted. +type WebAuthnCredential struct { + ID bson.ObjectID `bson:"_id,omitempty" json:"-"` + InstanceID string `bson:"instance_id" json:"-"` + UserID string `bson:"user_id" json:"-"` + + CredentialID []byte `bson:"credential_id" json:"-"` + PublicKey []byte `bson:"public_key" json:"-"` + SignCount uint32 `bson:"sign_count" json:"-"` + AAGUID []byte `bson:"aaguid" json:"-"` + Transports []string `bson:"transports,omitempty" json:"transports,omitempty"` + + // CredentialIDHex is the browser-facing identifier for rename and delete. + // The raw bytes never reach a URL. + CredentialIDHex string `bson:"credential_id_hex" json:"id"` + + Name string `bson:"name" json:"name"` + CreatedAt time.Time `bson:"created_at" json:"created_at"` + LastUsedAt *time.Time `bson:"last_used_at,omitempty" json:"last_used_at,omitempty"` +} +``` + +Append to `server/internal/models/settings.go`: + +```go +// RequireMFA re-exports shared.RequireMFA so services can read the MFA policy +// without importing shared/models directly. +func RequireMFA(s *Settings) bool { return shared.RequireMFA(s) } +``` + +- [ ] **Step 4: Add the collections to `ScopedCollections` and build the indexes** + +Add `"user_mfa"` and `"webauthn_credentials"` to the `ScopedCollections` slice in `server/internal/services/migrate_instance.go`. + +Append to `server/internal/services/coreindexes.go`: + +```go +// EnsureMFAIndexes is fatal on failure like EnsureAuthIndexes, and for the same +// reason: these unique indexes are a security property, not an optimisation. A +// duplicate (instance_id, user_id) would make "this user's factors" ambiguous, +// and a duplicate credential_id would let an assertion resolve to two users. +func EnsureMFAIndexes() error { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + if _, err := db.Col("user_mfa").Indexes().CreateOne(ctx, mongo.IndexModel{ + Keys: bson.D{{Key: "instance_id", Value: 1}, {Key: "user_id", Value: 1}}, + Options: options.Index().SetUnique(true), + }); err != nil { + return err + } + + _, err := db.Col("webauthn_credentials").Indexes().CreateMany(ctx, []mongo.IndexModel{ + { + Keys: bson.D{{Key: "instance_id", Value: 1}, {Key: "credential_id", Value: 1}}, + Options: options.Index().SetUnique(true), + }, + {Keys: bson.D{{Key: "instance_id", Value: 1}, {Key: "user_id", Value: 1}}}, + }) + return err +} +``` + +- [ ] **Step 5: Call it at boot** + +In `server/cmd/main.go`, immediately after the existing `EnsureAuthIndexes()` call, follow whatever fatal-on-error form that call uses, for example: + +```go + if err := services.EnsureMFAIndexes(); err != nil { + log.Fatalf("ensure mfa indexes: %v", err) + } +``` + +- [ ] **Step 6: Run the tests and build** + +Run: `cd server && go build ./... && go test ./internal/services/ -run TestMFACollectionsAreScoped -v` +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add server/internal/models/user_mfa.go server/internal/models/settings.go \ + server/internal/services/coreindexes.go server/internal/services/migrate_instance.go \ + server/internal/services/mfa_scoped_test.go server/cmd/main.go +git commit -m "feat(mfa): user_mfa and webauthn_credentials collections" +``` + +--- + +### Task 3: TOTP and recovery codes (pure logic plus store) + +**Files:** +- Create: `server/internal/services/mfa.go` +- Create: `server/internal/services/mfa_test.go` +- Modify: `server/go.mod` (add `github.com/pquerna/otp`) + +**Interfaces:** +- Consumes: `models.UserMFA`, `encryptString`/`decryptString`. +- Produces: + - `services.GenerateRecoveryCodes() ([]string, []models.RecoveryCode, error)` + - `services.NormaliseRecoveryCode(string) string` + - `services.HashRecoveryCode(string) string` + - `services.ConsumeRecoveryCode(codes []models.RecoveryCode, input string, now time.Time) (int, bool)` + - `services.GetUserMFA(instanceID, userID string) (*models.UserMFA, error)` + - `services.MFAMethods(instanceID, userID string) ([]string, error)` + - `services.StartTOTPSetup(instanceID, userID, issuer, account string) (secret, uri string, err error)` + - `services.ConfirmTOTP(instanceID, userID, code string) error` + - `services.VerifyTOTPCode(instanceID, userID, code string) error` + - `services.ClearMFA(instanceID, userID string) error` + - Errors: `services.ErrNoMFA`, `services.ErrBadCode`, `services.ErrCodeReplayed` + +- [ ] **Step 1: Add the dependency** + +Run: `cd server && go get github.com/pquerna/otp@latest` + +- [ ] **Step 2: Write the failing tests** + +Create `server/internal/services/mfa_test.go`: + +```go +package services + +import ( + "testing" + "time" + + "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models" +) + +func TestGenerateRecoveryCodesReturnsTenUniqueHashedCodes(t *testing.T) { + plain, stored, err := GenerateRecoveryCodes() + if err != nil { + t.Fatalf("generate: %v", err) + } + if len(plain) != 10 || len(stored) != 10 { + t.Fatalf("want 10 codes, got %d plain and %d stored", len(plain), len(stored)) + } + seen := map[string]bool{} + for i, p := range plain { + if seen[p] { + t.Fatalf("duplicate code %q", p) + } + seen[p] = true + if stored[i].Hash == p { + t.Fatal("code stored in plaintext") + } + if stored[i].Hash != HashRecoveryCode(p) { + t.Fatalf("stored hash does not match HashRecoveryCode for code %d", i) + } + if stored[i].UsedAt != nil { + t.Fatal("fresh code marked used") + } + } +} + +func TestConsumeRecoveryCodeIsSingleUseAndFormatTolerant(t *testing.T) { + plain, stored, _ := GenerateRecoveryCodes() + now := time.Now() + + idx, ok := ConsumeRecoveryCode(stored, plain[3], now) + if !ok || idx != 3 { + t.Fatalf("want index 3 consumed, got idx=%d ok=%v", idx, ok) + } + used := now + stored[3].UsedAt = &used + + if _, ok := ConsumeRecoveryCode(stored, plain[3], now); ok { + t.Fatal("a used recovery code was accepted a second time") + } + + // Users retype codes with different case and stray dashes or spaces. + messy := " " + strings.ToUpper(plain[4]) + " " + if _, ok := ConsumeRecoveryCode(stored, messy, now); !ok { + t.Fatal("normalisation rejected a valid code") + } +} + +func TestConsumeRecoveryCodeRejectsUnknownCode(t *testing.T) { + _, stored, _ := GenerateRecoveryCodes() + if _, ok := ConsumeRecoveryCode(stored, "not-a-real-code", time.Now()); ok { + t.Fatal("unknown code accepted") + } +} + +func TestHasFactorRequiresConfirmedTOTPOrAPasskey(t *testing.T) { + now := time.Now() + cases := []struct { + name string + mfa *models.UserMFA + passkeys int + want bool + }{ + {"nothing", nil, 0, false}, + {"unconfirmed totp only", &models.UserMFA{TOTPSecretEnc: "ab"}, 0, false}, + {"confirmed totp", &models.UserMFA{TOTPSecretEnc: "ab", TOTPConfirmedAt: &now}, 0, true}, + {"passkey only", &models.UserMFA{}, 1, true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := hasFactor(tc.mfa, tc.passkeys); got != tc.want { + t.Fatalf("hasFactor = %v, want %v", got, tc.want) + } + }) + } +} +``` + +Add `"strings"` to the test imports. + +- [ ] **Step 3: Run them and watch them fail** + +Run: `cd server && go test ./internal/services/ -run 'TestGenerateRecovery|TestConsumeRecovery|TestHasFactor' -v` +Expected: FAIL to compile, "undefined: GenerateRecoveryCodes". + +- [ ] **Step 4: Write `mfa.go`** + +Create `server/internal/services/mfa.go`: + +```go +package services + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/base32" + "encoding/hex" + "errors" + "strings" + "time" + + "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db" + "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models" + "github.com/pquerna/otp" + "github.com/pquerna/otp/totp" + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.mongodb.org/mongo-driver/v2/mongo/options" +) + +var ( + ErrNoMFA = errors.New("no multi-factor authentication is enrolled") + ErrBadCode = errors.New("that code is not valid") + ErrCodeReplayed = errors.New("that code has already been used") +) + +// Factor names travel to the browser, which chooses which prompt to draw. +const ( + FactorTOTP = "totp" + FactorWebAuthn = "webauthn" + FactorRecovery = "recovery" + FactorPassword = "password" +) + +const recoveryCodeCount = 10 + +func mfaCtx() (context.Context, context.CancelFunc) { + return context.WithTimeout(context.Background(), 5*time.Second) +} + +// GenerateRecoveryCodes returns the codes to show the user once, and the +// hashed records to store. The plaintext is never persisted. +func GenerateRecoveryCodes() ([]string, []models.RecoveryCode, error) { + plain := make([]string, 0, recoveryCodeCount) + stored := make([]models.RecoveryCode, 0, recoveryCodeCount) + for i := 0; i < recoveryCodeCount; i++ { + b := make([]byte, 5) + if _, err := rand.Read(b); err != nil { + return nil, nil, err + } + // Crockford-ish base32 without padding: 8 characters, no case to get + // wrong when read off paper. + code := strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(b)) + plain = append(plain, code) + stored = append(stored, models.RecoveryCode{Hash: HashRecoveryCode(code)}) + } + return plain, stored, nil +} + +// NormaliseRecoveryCode makes a code typed off paper comparable: no case, no +// spaces, no dashes. +func NormaliseRecoveryCode(code string) string { + r := strings.NewReplacer(" ", "", "-", "", "\t", "") + return strings.ToLower(r.Replace(strings.TrimSpace(code))) +} + +func HashRecoveryCode(code string) string { + sum := sha256.Sum256([]byte(NormaliseRecoveryCode(code))) + return hex.EncodeToString(sum[:]) +} + +// ConsumeRecoveryCode reports which unused code matches, if any. It does not +// write: the caller marks the index used so the database update and the audit +// event stay in one place. +func ConsumeRecoveryCode(codes []models.RecoveryCode, input string, now time.Time) (int, bool) { + want := HashRecoveryCode(input) + for i, c := range codes { + if c.UsedAt != nil { + continue + } + if subtle.ConstantTimeCompare([]byte(c.Hash), []byte(want)) == 1 { + return i, true + } + } + return 0, false +} + +func GetUserMFA(instanceID, userID string) (*models.UserMFA, error) { + ctx, cancel := mfaCtx() + defer cancel() + var m models.UserMFA + err := db.Col("user_mfa").FindOne(ctx, bson.M{"instance_id": instanceID, "user_id": userID}).Decode(&m) + if errors.Is(err, mongo.ErrNoDocuments) { + return nil, nil + } + if err != nil { + return nil, err + } + return &m, nil +} + +func CountPasskeys(instanceID, userID string) (int64, error) { + ctx, cancel := mfaCtx() + defer cancel() + return db.Col("webauthn_credentials").CountDocuments(ctx, bson.M{"instance_id": instanceID, "user_id": userID}) +} + +// hasFactor is the single definition of "this user has MFA". An unconfirmed +// TOTP secret does not count: a half-finished setup must not lock anyone out. +func hasFactor(m *models.UserMFA, passkeys int64) bool { + if passkeys > 0 { + return true + } + return m != nil && m.TOTPConfirmedAt != nil +} + +// MFAMethods lists the factors a user can present, newest-friendly order. An +// empty slice means they have no MFA at all. +func MFAMethods(instanceID, userID string) ([]string, error) { + m, err := GetUserMFA(instanceID, userID) + if err != nil { + return nil, err + } + passkeys, err := CountPasskeys(instanceID, userID) + if err != nil { + return nil, err + } + methods := []string{} + if passkeys > 0 { + methods = append(methods, FactorWebAuthn) + } + if m != nil && m.TOTPConfirmedAt != nil { + methods = append(methods, FactorTOTP) + } + if len(methods) > 0 && m != nil { + for _, c := range m.RecoveryCodes { + if c.UsedAt == nil { + methods = append(methods, FactorRecovery) + break + } + } + } + return methods, nil +} + +func HasMFA(instanceID, userID string) (bool, error) { + m, err := GetUserMFA(instanceID, userID) + if err != nil { + return false, err + } + passkeys, err := CountPasskeys(instanceID, userID) + if err != nil { + return false, err + } + return hasFactor(m, passkeys), nil +} + +// WebAuthnHandle returns the user's stable random handle, creating the user_mfa +// document on first use. +func WebAuthnHandle(instanceID, userID string) ([]byte, error) { + m, err := GetUserMFA(instanceID, userID) + if err != nil { + return nil, err + } + if m != nil && len(m.WebAuthnHandle) > 0 { + return m.WebAuthnHandle, nil + } + handle := make([]byte, 64) + if _, err := rand.Read(handle); err != nil { + return nil, err + } + ctx, cancel := mfaCtx() + defer cancel() + _, err = db.Col("user_mfa").UpdateOne(ctx, + bson.M{"instance_id": instanceID, "user_id": userID}, + bson.M{ + "$set": bson.M{"updated_at": time.Now()}, + "$setOnInsert": bson.M{"webauthn_handle": handle}, + }, + options.UpdateOne().SetUpsert(true)) + if err != nil { + return nil, err + } + // Re-read: a concurrent caller may have won the upsert. + m, err = GetUserMFA(instanceID, userID) + if err != nil { + return nil, err + } + return m.WebAuthnHandle, nil +} + +// StartTOTPSetup writes an unconfirmed secret and returns it with its otpauth +// URI. An existing unconfirmed secret is replaced; a confirmed one is not +// touched until ConfirmTOTP succeeds against the new secret. +func StartTOTPSetup(instanceID, userID, issuer, account string) (string, string, error) { + key, err := totp.Generate(totp.GenerateOpts{Issuer: issuer, AccountName: account}) + if err != nil { + return "", "", err + } + enc, err := encryptString(key.Secret()) + if err != nil { + return "", "", err + } + ctx, cancel := mfaCtx() + defer cancel() + _, err = db.Col("user_mfa").UpdateOne(ctx, + bson.M{"instance_id": instanceID, "user_id": userID}, + bson.M{"$set": bson.M{"totp_pending_enc": enc, "updated_at": time.Now()}}, + options.UpdateOne().SetUpsert(true)) + if err != nil { + return "", "", err + } + return key.Secret(), key.URL(), nil +} + +// ConfirmTOTP promotes the pending secret to the live one. +func ConfirmTOTP(instanceID, userID, code string) error { + ctx, cancel := mfaCtx() + defer cancel() + var raw struct { + Pending string `bson:"totp_pending_enc"` + } + if err := db.Col("user_mfa").FindOne(ctx, + bson.M{"instance_id": instanceID, "user_id": userID}).Decode(&raw); err != nil { + return ErrNoMFA + } + if raw.Pending == "" { + return ErrNoMFA + } + secret, err := decryptString(raw.Pending) + if err != nil { + return err + } + if !totp.Validate(code, secret) { + return ErrBadCode + } + now := time.Now() + _, err = db.Col("user_mfa").UpdateOne(ctx, + bson.M{"instance_id": instanceID, "user_id": userID}, + bson.M{ + "$set": bson.M{"totp_secret_enc": raw.Pending, "totp_confirmed_at": now, "updated_at": now}, + "$unset": bson.M{"totp_pending_enc": ""}, + }) + return err +} + +// VerifyTOTPCode checks a code against the confirmed secret and burns it, so +// the same code cannot be replayed inside its 30-second window by an attacker +// who shoulder-surfed it. +func VerifyTOTPCode(instanceID, userID, code string) error { + m, err := GetUserMFA(instanceID, userID) + if err != nil { + return err + } + if m == nil || m.TOTPConfirmedAt == nil || m.TOTPSecretEnc == "" { + return ErrNoMFA + } + secret, err := decryptString(m.TOTPSecretEnc) + if err != nil { + return err + } + valid, err := totp.ValidateCustom(code, secret, time.Now(), totp.ValidateOpts{ + Period: 30, Skew: 1, Digits: otp.DigitsSix, Algorithm: otp.AlgorithmSHA1, + }) + if err != nil || !valid { + return ErrBadCode + } + return burnTOTPCode(userID, code) +} + +// ClearMFA removes every factor. Used by an owner or admin reset. +func ClearMFA(instanceID, userID string) error { + ctx, cancel := mfaCtx() + defer cancel() + if _, err := db.Col("user_mfa").DeleteOne(ctx, + bson.M{"instance_id": instanceID, "user_id": userID}); err != nil { + return err + } + _, err := db.Col("webauthn_credentials").DeleteMany(ctx, + bson.M{"instance_id": instanceID, "user_id": userID}) + return err +} +``` + +Note the added `totp_pending_enc` field: add it to `models.UserMFA` as +`TOTPPendingEnc string \`bson:"totp_pending_enc,omitempty" json:"-"\`` and to the +`ciphertextFields` comment list is **not** required (the live secret is what +`verify` probes), but do add it to the shared map entry in Task 1 if you have +not yet released: `{"totp_secret_enc", "totp_pending_enc"}`. If v0.7.0 is already +tagged, leave it — an unconfirmed secret is transient. + +- [ ] **Step 5: Write the replay guard** + +Append to `server/internal/services/mfa.go`: + +```go +// burnTOTPCode makes a code single-use for 90 seconds - longer than the ±1 step +// window it could still validate in. Redis is already required for sessions. +func burnTOTPCode(userID, code string) error { + rdb := auth.Redis() + if rdb == nil { + return nil + } + ctx, cancel := mfaCtx() + defer cancel() + key := "km:totp:" + userID + ":" + code + ok, err := rdb.SetNX(ctx, key, 1, 90*time.Second).Result() + if err != nil { + return err + } + if !ok { + return ErrCodeReplayed + } + return nil +} +``` + +`services` must not import `auth` — `auth` already imports `services`, and Go has +no cycles. Instead take the client through a package variable set from +`main.go`, matching how `workflowsched.Deps` is injected: + +```go +// RedisClient is set by main.go after auth.InitRedis. services must not import +// auth: auth imports services, and Go has no import cycles. +var RedisClient *redis.Client +``` + +Use `RedisClient` in `burnTOTPCode` in place of `auth.Redis()`, and in +`main.go`, straight after `auth.InitRedis(...)` succeeds: + +```go + services.RedisClient = auth.Redis() +``` + +- [ ] **Step 6: Run the tests** + +Run: `cd server && go build ./... && go test ./internal/services/ -run 'TestGenerateRecovery|TestConsumeRecovery|TestHasFactor' -v` +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add server/internal/services/mfa.go server/internal/services/mfa_test.go \ + server/internal/models/user_mfa.go server/go.mod server/go.sum server/cmd/main.go +git commit -m "feat(mfa): TOTP secrets, recovery codes and factor lookup" +``` + +--- + +### Task 4: The pending-login ticket + +**Files:** +- Create: `server/internal/auth/mfaticket.go` +- Create: `server/internal/auth/mfaticket_test.go` + +**Interfaces:** +- Consumes: `session.go`'s `rdb` and `randomHex`. +- Produces: + - `auth.Ticket{UserID, InstanceID, Email, Methods []string, EnrolOnly bool, Attempts int}` + - `auth.CreateTicket(ctx, *Ticket) (string, error)` + - `auth.LoadTicket(ctx, id string) (*Ticket, error)` + - `auth.FailTicket(ctx, id string) (attemptsLeft int, err error)` + - `auth.DeleteTicket(ctx, id string) error` + - `auth.SetPendingCookie(c, id string)`, `auth.ClearPendingCookie(c)` + - `auth.ticketFromRequest(c) (*Ticket, string, bool)` + - `auth.ErrTicketExpired` + - Constants `ticketTTL = 5 * time.Minute`, `maxTicketAttempts = 5`, `pendingCookieName = "km_mfa_pending"` + +- [ ] **Step 1: Write the failing test for the attempt policy** + +Create `server/internal/auth/mfaticket_test.go`: + +```go +package auth + +import "testing" + +// The ticket's attempt cap is the only per-ticket brute-force guard: five +// wrong codes must destroy it rather than let an attacker keep guessing +// against a single stolen password. +func TestAttemptsLeftCountsDownAndHitsZero(t *testing.T) { + cases := []struct { + attempts int + want int + }{ + {0, 5}, {1, 4}, {4, 1}, {5, 0}, {9, 0}, + } + for _, tc := range cases { + if got := attemptsLeft(tc.attempts); got != tc.want { + t.Errorf("attemptsLeft(%d) = %d, want %d", tc.attempts, got, tc.want) + } + } +} + +// An enrol-only ticket exists because the instance requires MFA the user does +// not have. It must not satisfy a verification endpoint, and a verification +// ticket must not reach the enrolment endpoints - each would skip the other's +// purpose. +func TestTicketScopeIsEnforcedInBothDirections(t *testing.T) { + verify := &Ticket{Methods: []string{"totp"}} + enrol := &Ticket{EnrolOnly: true} + + if !verify.allows(scopeVerify) || verify.allows(scopeEnrol) { + t.Error("a verification ticket must allow only verification") + } + if !enrol.allows(scopeEnrol) || enrol.allows(scopeVerify) { + t.Error("an enrolment ticket must allow only enrolment") + } +} +``` + +- [ ] **Step 2: Run it and watch it fail** + +Run: `cd server && go test ./internal/auth/ -run 'TestAttemptsLeft|TestTicketScope' -v` +Expected: FAIL to compile, "undefined: attemptsLeft". + +- [ ] **Step 3: Write `mfaticket.go`** + +Create `server/internal/auth/mfaticket.go`: + +```go +package auth + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "time" + + "github.com/gin-gonic/gin" + "github.com/redis/go-redis/v9" +) + +const ( + ticketTTL = 5 * time.Minute + maxTicketAttempts = 5 + ticketPrefix = "km:mfa:" + pendingCookieName = "km_mfa_pending" +) + +// ErrTicketExpired covers every unusable ticket - missing, timed out, or +// destroyed by too many wrong codes. They are one message on purpose: which of +// the three it was tells an attacker whether the password was right. +var ErrTicketExpired = errors.New("this sign-in attempt has expired; start again") + +// Ticket is a password that verified but has not yet become a session. It is +// deliberately NOT a Session: nothing half-authenticated may reach a route +// under Middleware, and the way to guarantee that is for it never to be the +// type those routes read. +type Ticket struct { + UserID string `json:"user_id"` + InstanceID string `json:"instance_id"` + Email string `json:"email"` + Methods []string `json:"methods"` + EnrolOnly bool `json:"enrol_only"` + Attempts int `json:"attempts"` +} + +type ticketScope int + +const ( + scopeVerify ticketScope = iota + scopeEnrol +) + +func (t *Ticket) allows(s ticketScope) bool { + if t.EnrolOnly { + return s == scopeEnrol + } + return s == scopeVerify +} + +func attemptsLeft(attempts int) int { + if attempts >= maxTicketAttempts { + return 0 + } + return maxTicketAttempts - attempts +} + +func CreateTicket(ctx context.Context, t *Ticket) (string, error) { + id, err := randomHex(32) + if err != nil { + return "", err + } + data, err := json.Marshal(t) + if err != nil { + return "", err + } + if err := rdb.Set(ctx, ticketPrefix+id, data, ticketTTL).Err(); err != nil { + return "", err + } + return id, nil +} + +func LoadTicket(ctx context.Context, id string) (*Ticket, error) { + data, err := rdb.Get(ctx, ticketPrefix+id).Bytes() + if errors.Is(err, redis.Nil) { + return nil, ErrTicketExpired + } + if err != nil { + return nil, err + } + var t Ticket + if err := json.Unmarshal(data, &t); err != nil { + return nil, ErrTicketExpired + } + return &t, nil +} + +// FailTicket records a wrong code and returns how many attempts remain. At zero +// the ticket is destroyed rather than left to time out. +func FailTicket(ctx context.Context, id string) (int, error) { + t, err := LoadTicket(ctx, id) + if err != nil { + return 0, err + } + t.Attempts++ + if attemptsLeft(t.Attempts) == 0 { + _ = DeleteTicket(ctx, id) + return 0, nil + } + data, err := json.Marshal(t) + if err != nil { + return 0, err + } + // KEEPTTL: a wrong code must not extend the five-minute window. + if err := rdb.Set(ctx, ticketPrefix+id, data, redis.KeepTTL).Err(); err != nil { + return 0, err + } + return attemptsLeft(t.Attempts), nil +} + +func DeleteTicket(ctx context.Context, id string) error { + return rdb.Del(ctx, ticketPrefix+id).Err() +} + +func SetPendingCookie(c *gin.Context, id string) { + secure := c.Request.TLS != nil || c.GetHeader("X-Forwarded-Proto") == "https" + http.SetCookie(c.Writer, &http.Cookie{ + Name: pendingCookieName, + Value: id, + Path: "/", + HttpOnly: true, + Secure: secure, + SameSite: http.SameSiteLaxMode, + MaxAge: int(ticketTTL.Seconds()), + }) +} + +func ClearPendingCookie(c *gin.Context) { + secure := c.Request.TLS != nil || c.GetHeader("X-Forwarded-Proto") == "https" + http.SetCookie(c.Writer, &http.Cookie{ + Name: pendingCookieName, Value: "", Path: "/", + HttpOnly: true, Secure: secure, SameSite: http.SameSiteLaxMode, MaxAge: -1, + }) +} + +// ticketFromRequest resolves the pending ticket and enforces its scope. It +// writes the response and returns false when the ticket is unusable. +func ticketFromRequest(c *gin.Context, scope ticketScope) (*Ticket, string, bool) { + cookie, err := c.Request.Cookie(pendingCookieName) + if err != nil || cookie.Value == "" { + abortTicketExpired(c) + return nil, "", false + } + t, err := LoadTicket(c.Request.Context(), cookie.Value) + if err != nil { + abortTicketExpired(c) + return nil, "", false + } + if !t.allows(scope) { + abortTicketExpired(c) + return nil, "", false + } + return t, cookie.Value, true +} + +func abortTicketExpired(c *gin.Context) { + ClearPendingCookie(c) + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ + "error": ErrTicketExpired.Error(), "code": "mfa_ticket_expired", + }) +} +``` + +- [ ] **Step 4: Run the tests** + +Run: `cd server && go build ./... && go test ./internal/auth/ -run 'TestAttemptsLeft|TestTicketScope' -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add server/internal/auth/mfaticket.go server/internal/auth/mfaticket_test.go +git commit -m "feat(mfa): pending-login ticket store" +``` + +--- + +### Task 5: Login branching and the TOTP/recovery verification endpoints + +**Files:** +- Modify: `server/internal/auth/local.go:76-115` (`HandleLocalLogin`) +- Modify: `server/internal/auth/session.go:19-34` (`Session`) +- Create: `server/internal/auth/mfa_login.go` +- Modify: `server/internal/api/handlers.go:45` (route registration) +- Create: `server/internal/auth/mfa_login_test.go` + +**Interfaces:** +- Consumes: Task 3's `services.MFAMethods`, `services.VerifyTOTPCode`, `services.HasMFA`; Task 4's ticket API. +- Produces: + - `Session.AMR []string`, `Session.StepUpAt *time.Time` + - `auth.mintSession(c, u *models.User, amr []string) error` + - Routes `POST /auth/mfa/totp`, `POST /auth/mfa/recovery` + - `auth.loginDecision(hasMFA, requireMFA bool) string` returning `"session"`, `"verify"` or `"enrol"` + +- [ ] **Step 1: Write the failing decision test** + +Create `server/internal/auth/mfa_login_test.go`: + +```go +package auth + +import "testing" + +// The whole point of the feature is in this table: a user with MFA is never +// handed a session by a password alone, and require_mfa turns "no factor" into +// forced enrolment rather than a free pass. +func TestLoginDecision(t *testing.T) { + cases := []struct { + name string + hasMFA bool + requireMFA bool + want string + }{ + {"no mfa, not required", false, false, "session"}, + {"no mfa, required", false, true, "enrol"}, + {"has mfa, not required", true, false, "verify"}, + {"has mfa, required", true, true, "verify"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := loginDecision(tc.hasMFA, tc.requireMFA); got != tc.want { + t.Fatalf("loginDecision(%v,%v) = %q, want %q", tc.hasMFA, tc.requireMFA, got, tc.want) + } + }) + } +} +``` + +- [ ] **Step 2: Run it and watch it fail** + +Run: `cd server && go test ./internal/auth/ -run TestLoginDecision -v` +Expected: FAIL to compile, "undefined: loginDecision". + +- [ ] **Step 3: Extend `Session` and add `mintSession`** + +In `server/internal/auth/session.go`, add to `Session`: + +```go + // AMR records how this session authenticated: pwd, otp, webauthn, + // recovery, oidc. Step-up reads it to exempt OIDC sessions, whose IdP owns + // authentication policy. + AMR []string `json:"amr,omitempty"` + + // StepUpAt is the last successful re-authentication. Sign-in counts as one. + StepUpAt *time.Time `json:"step_up_at,omitempty"` +``` + +Add to `session.go`: + +```go +// TouchStepUp records a fresh re-authentication without disturbing the +// session's remaining lifetime. +func TouchStepUp(ctx context.Context, id string, sess *Session) error { + now := time.Now() + sess.StepUpAt = &now + data, err := json.Marshal(sess) + if err != nil { + return err + } + return rdb.Set(ctx, sessionPrefix+id, data, redis.KeepTTL).Err() +} +``` + +- [ ] **Step 4: Write `mfa_login.go`** + +Create `server/internal/auth/mfa_login.go`: + +```go +package auth + +import ( + "errors" + "net/http" + "time" + + "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models" + "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services" + "github.com/gin-gonic/gin" +) + +// loginDecision is the branch a verified password takes. +func loginDecision(hasMFA, requireMFA bool) string { + switch { + case hasMFA: + return "verify" + case requireMFA: + return "enrol" + default: + return "session" + } +} + +// mintSession is the single place a session is created from a user, so every +// path records amr and step-up freshness the same way. +func mintSession(c *gin.Context, u *models.User, amr []string) error { + now := time.Now() + sessionID, err := SaveSession(c.Request.Context(), &Session{ + UserID: u.UserID, InstanceID: u.InstanceID, Role: u.Role, Email: u.Email, + AMR: amr, StepUpAt: &now, + }) + if err != nil { + return err + } + _ = services.TouchLastLogin(u.UserID) + ClearPendingCookie(c) + SetSessionCookie(c, sessionID) + return nil +} + +// HandleMFATOTP completes a sign-in with a TOTP code. +// +// @Summary Complete sign-in with a TOTP code +// @Tags auth +// @Accept json +// @Produce json +// @Param body body object{code=string} true "Six-digit code" +// @Success 200 {object} object{ok=bool} +// @Failure 401 {object} object{error=string,code=string} +// @Router /auth/mfa/totp [post] +func HandleMFATOTP(c *gin.Context) { + handleMFAVerify(c, services.FactorTOTP) +} + +// HandleMFARecovery completes a sign-in with a recovery code. +// +// @Summary Complete sign-in with a recovery code +// @Tags auth +// @Accept json +// @Produce json +// @Param body body object{code=string} true "Recovery code" +// @Success 200 {object} object{ok=bool} +// @Failure 401 {object} object{error=string,code=string} +// @Router /auth/mfa/recovery [post] +func HandleMFARecovery(c *gin.Context) { + handleMFAVerify(c, services.FactorRecovery) +} + +func handleMFAVerify(c *gin.Context, factor string) { + t, ticketID, ok := ticketFromRequest(c, scopeVerify) + if !ok { + return + } + var body struct { + Code string `json:"code"` + } + if err := c.ShouldBindJSON(&body); err != nil || body.Code == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "code required"}) + return + } + + var err error + switch factor { + case services.FactorTOTP: + err = services.VerifyTOTPCode(t.InstanceID, t.UserID, body.Code) + case services.FactorRecovery: + err = services.UseRecoveryCode(t.InstanceID, t.UserID, body.Code) + } + if err != nil { + left, ferr := FailTicket(c.Request.Context(), ticketID) + services.LogEvent(t.InstanceID, "mfa.failed", t.Email, "", "", "factor="+factor) + if ferr != nil || left == 0 { + abortTicketExpired(c) + return + } + code := "invalid_code" + if errors.Is(err, services.ErrCodeReplayed) { + code = "invalid_code" + } + c.JSON(http.StatusUnauthorized, gin.H{ + "error": "that code is not valid", "code": code, "attempts_left": left, + }) + return + } + + u, err := services.GetUserInInstance(t.InstanceID, t.UserID) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"}) + return + } + _ = DeleteTicket(c.Request.Context(), ticketID) + if err := mintSession(c, u, []string{"pwd", factor}); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "session save failed"}) + return + } + if factor == services.FactorRecovery { + services.LogEvent(t.InstanceID, "mfa.recovery_used", u.Email, "", "", "") + } + c.JSON(http.StatusOK, gin.H{"ok": true}) +} +``` + +- [ ] **Step 5: Add `services.UseRecoveryCode`** + +Append to `server/internal/services/mfa.go`: + +```go +// UseRecoveryCode consumes one unused code, marking it used by index so a +// concurrent second attempt with the same code finds it spent. +func UseRecoveryCode(instanceID, userID, input string) error { + m, err := GetUserMFA(instanceID, userID) + if err != nil { + return err + } + if m == nil || len(m.RecoveryCodes) == 0 { + return ErrNoMFA + } + idx, ok := ConsumeRecoveryCode(m.RecoveryCodes, input, time.Now()) + if !ok { + return ErrBadCode + } + ctx, cancel := mfaCtx() + defer cancel() + now := time.Now() + res, err := db.Col("user_mfa").UpdateOne(ctx, + bson.M{ + "instance_id": instanceID, "user_id": userID, + "recovery_codes." + strconv.Itoa(idx) + ".used_at": bson.M{"$exists": false}, + }, + bson.M{"$set": bson.M{ + "recovery_codes." + strconv.Itoa(idx) + ".used_at": now, + "updated_at": now, + }}) + if err != nil { + return err + } + if res.MatchedCount == 0 { + return ErrBadCode + } + return nil +} +``` + +Add `"strconv"` to the imports. + +- [ ] **Step 6: Rewrite the tail of `HandleLocalLogin`** + +In `server/internal/auth/local.go`, replace everything from the `SaveSession` +call to the end of `HandleLocalLogin` with: + +```go + requireMFA := services.RequireMFAForInstance(instanceID) + hasMFA, err := services.HasMFA(u.InstanceID, u.UserID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "could not read MFA state"}) + return + } + + switch loginDecision(hasMFA, requireMFA) { + case "session": + if err := mintSession(c, u, []string{"pwd"}); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "session save failed"}) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true}) + + case "verify": + methods, err := services.MFAMethods(u.InstanceID, u.UserID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "could not read MFA state"}) + return + } + id, err := CreateTicket(c.Request.Context(), &Ticket{ + UserID: u.UserID, InstanceID: u.InstanceID, Email: u.Email, Methods: methods, + }) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "session save failed"}) + return + } + SetPendingCookie(c, id) + c.JSON(http.StatusOK, gin.H{"mfa_required": true, "methods": methods}) + + case "enrol": + id, err := CreateTicket(c.Request.Context(), &Ticket{ + UserID: u.UserID, InstanceID: u.InstanceID, Email: u.Email, EnrolOnly: true, + }) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "session save failed"}) + return + } + SetPendingCookie(c, id) + c.JSON(http.StatusOK, gin.H{"enrol_required": true}) + } +``` + +- [ ] **Step 7: Add `services.RequireMFAForInstance`** + +Append to `server/internal/services/mfa.go`: + +```go +// RequireMFAForInstance reads the policy, defaulting to off on any error: a +// database blip must not lock an entire instance out of its own control plane. +func RequireMFAForInstance(instanceID string) bool { + s, err := GetSettings(instanceID) + if err != nil { + return false + } + return models.RequireMFA(s) +} +``` + +Check the real accessor name in `server/internal/services/settings.go` and use +it; if settings are read by another name there, call that instead. + +- [ ] **Step 8: Register the routes** + +In `server/internal/api/handlers.go`, beside the existing `r.POST("/auth/login", ...)`: + +```go + r.POST("/auth/mfa/totp", auth.HandleMFATOTP) + r.POST("/auth/mfa/recovery", auth.HandleMFARecovery) +``` + +- [ ] **Step 9: Run the tests and build** + +Run: `cd server && go build ./... && go test ./internal/auth/ ./internal/services/` +Expected: PASS. + +- [ ] **Step 10: Commit** + +```bash +git add server/internal/auth/ server/internal/services/mfa.go server/internal/api/handlers.go +git commit -m "feat(mfa): second-factor sign-in with TOTP and recovery codes" +``` + +--- + +### Task 6: Forced enrolment through the ticket + +**Files:** +- Create: `server/internal/auth/mfa_enrol.go` +- Modify: `server/internal/api/handlers.go` (routes) + +**Interfaces:** +- Consumes: Task 4's `scopeEnrol`, Task 3's `StartTOTPSetup`/`ConfirmTOTP`/`GenerateRecoveryCodes`. +- Produces: routes `POST /auth/mfa/enrol/totp/setup`, `POST /auth/mfa/enrol/totp/confirm`; `services.IssueRecoveryCodes(instanceID, userID string) ([]string, error)`. + +- [ ] **Step 1: Add `services.IssueRecoveryCodes`** + +Append to `server/internal/services/mfa.go`: + +```go +// IssueRecoveryCodes replaces the user's set and returns the plaintext once. +// Callers must not persist or log the return value. +func IssueRecoveryCodes(instanceID, userID string) ([]string, error) { + plain, stored, err := GenerateRecoveryCodes() + if err != nil { + return nil, err + } + ctx, cancel := mfaCtx() + defer cancel() + _, err = db.Col("user_mfa").UpdateOne(ctx, + bson.M{"instance_id": instanceID, "user_id": userID}, + bson.M{"$set": bson.M{"recovery_codes": stored, "updated_at": time.Now()}}, + options.UpdateOne().SetUpsert(true)) + if err != nil { + return nil, err + } + return plain, nil +} + +// RecoveryCodesRemaining counts unused codes for the account page. +func RecoveryCodesRemaining(m *models.UserMFA) int { + if m == nil { + return 0 + } + n := 0 + for _, c := range m.RecoveryCodes { + if c.UsedAt == nil { + n++ + } + } + return n +} +``` + +- [ ] **Step 2: Write `mfa_enrol.go`** + +Create `server/internal/auth/mfa_enrol.go`: + +```go +package auth + +import ( + "net/http" + + "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services" + "github.com/gin-gonic/gin" +) + +// HandleEnrolTOTPSetup starts enrolment for a user the instance requires MFA +// from, before they hold a session. Only an enrol-only ticket reaches it. +// +// @Summary Start forced TOTP enrolment during sign-in +// @Tags auth +// @Produce json +// @Success 200 {object} object{secret=string,otpauth_uri=string} +// @Failure 401 {object} object{error=string,code=string} +// @Router /auth/mfa/enrol/totp/setup [post] +func HandleEnrolTOTPSetup(c *gin.Context) { + t, _, ok := ticketFromRequest(c, scopeEnrol) + if !ok { + return + } + inst, err := services.GetInstance(t.InstanceID) + issuer := "Vantage" + if err == nil && inst != nil && inst.Name != "" { + issuer = inst.Name + } + secret, uri, err := services.StartTOTPSetup(t.InstanceID, t.UserID, issuer, t.Email) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start enrolment"}) + return + } + c.JSON(http.StatusOK, gin.H{"secret": secret, "otpauth_uri": uri}) +} + +// HandleEnrolTOTPConfirm finishes forced enrolment and signs the user in. +// +// @Summary Confirm forced TOTP enrolment and sign in +// @Tags auth +// @Accept json +// @Produce json +// @Param body body object{code=string} true "Six-digit code" +// @Success 200 {object} object{ok=bool,recovery_codes=[]string} +// @Failure 401 {object} object{error=string,code=string} +// @Router /auth/mfa/enrol/totp/confirm [post] +func HandleEnrolTOTPConfirm(c *gin.Context) { + t, ticketID, ok := ticketFromRequest(c, scopeEnrol) + if !ok { + return + } + var body struct { + Code string `json:"code"` + } + if err := c.ShouldBindJSON(&body); err != nil || body.Code == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "code required"}) + return + } + if err := services.ConfirmTOTP(t.InstanceID, t.UserID, body.Code); err != nil { + left, ferr := FailTicket(c.Request.Context(), ticketID) + if ferr != nil || left == 0 { + abortTicketExpired(c) + return + } + c.JSON(http.StatusUnauthorized, gin.H{ + "error": "that code is not valid", "code": "invalid_code", "attempts_left": left, + }) + return + } + finishEnrolment(c, t, ticketID, services.FactorTOTP) +} + +// finishEnrolment issues recovery codes, mints the session and audits, so the +// TOTP and passkey enrolment paths cannot drift apart. +func finishEnrolment(c *gin.Context, t *Ticket, ticketID, factor string) { + codes, err := services.IssueRecoveryCodes(t.InstanceID, t.UserID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "could not issue recovery codes"}) + return + } + u, err := services.GetUserInInstance(t.InstanceID, t.UserID) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"}) + return + } + _ = DeleteTicket(c.Request.Context(), ticketID) + if err := mintSession(c, u, []string{"pwd", factor}); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "session save failed"}) + return + } + services.LogEvent(t.InstanceID, "mfa.enrolled", u.Email, "", "", "factor="+factor) + c.JSON(http.StatusOK, gin.H{"ok": true, "recovery_codes": codes}) +} +``` + +- [ ] **Step 3: Register the routes** + +```go + r.POST("/auth/mfa/enrol/totp/setup", auth.HandleEnrolTOTPSetup) + r.POST("/auth/mfa/enrol/totp/confirm", auth.HandleEnrolTOTPConfirm) +``` + +- [ ] **Step 4: Build and test** + +Run: `cd server && go build ./... && go test ./internal/auth/ ./internal/services/` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add server/internal/auth/mfa_enrol.go server/internal/services/mfa.go server/internal/api/handlers.go +git commit -m "feat(mfa): forced TOTP enrolment at sign-in" +``` + +--- + +### Task 7: WebAuthn foundation and second-factor passkey sign-in + +**Files:** +- Create: `server/internal/auth/webauthn.go` +- Create: `server/internal/auth/webauthn_test.go` +- Create: `server/internal/services/passkeys.go` +- Modify: `server/internal/api/handlers.go` (routes) +- Modify: `server/go.mod` + +**Interfaces:** +- Consumes: `models.WebAuthnCredential`, `services.WebAuthnHandle`, ticket API. +- Produces: + - `auth.rpConfig(c *gin.Context) (rpID, origin string)` + - `auth.webAuthnFor(c *gin.Context) (*webauthn.WebAuthn, error)` + - `auth.waUser` implementing `webauthn.User` + - `auth.saveCeremony(ctx, data *webauthn.SessionData) (string, error)` / `auth.loadCeremony(ctx, id string) (*webauthn.SessionData, error)` + - `services.ListPasskeys`, `services.GetPasskeyByCredentialID`, `services.SavePasskey`, `services.TouchPasskey`, `services.RenamePasskey`, `services.DeletePasskey` + - Routes `POST /auth/mfa/webauthn/begin`, `POST /auth/mfa/webauthn/finish` + +- [ ] **Step 1: Add the dependency** + +Run: `cd server && go get github.com/go-webauthn/webauthn@latest` + +- [ ] **Step 2: Write the failing RP test** + +Create `server/internal/auth/webauthn_test.go`: + +```go +package auth + +import ( + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" +) + +// A passkey is bound to its RP ID. Getting this wrong does not fail loudly - it +// silently makes every existing passkey unusable - so the port-stripping and +// scheme rules are pinned here. +func TestRPConfig(t *testing.T) { + cases := []struct { + name, host, proto string + wantID, wantOrig string + }{ + {"plain host", "acme.vantage.example.com", "https", "acme.vantage.example.com", "https://acme.vantage.example.com"}, + {"host with port", "vantage.acme.com:8443", "https", "vantage.acme.com", "https://vantage.acme.com:8443"}, + {"localhost dev", "localhost:3000", "", "localhost", "http://localhost:3000"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequest("POST", "/auth/passkey/begin", nil) + c.Request.Host = tc.host + if tc.proto != "" { + c.Request.Header.Set("X-Forwarded-Proto", tc.proto) + } + id, origin := rpConfig(c) + if id != tc.wantID || origin != tc.wantOrig { + t.Fatalf("rpConfig = (%q, %q), want (%q, %q)", id, origin, tc.wantID, tc.wantOrig) + } + }) + } +} +``` + +- [ ] **Step 3: Run it and watch it fail** + +Run: `cd server && go test ./internal/auth/ -run TestRPConfig -v` +Expected: FAIL to compile, "undefined: rpConfig". + +- [ ] **Step 4: Write `webauthn.go`** + +Create `server/internal/auth/webauthn.go`: + +```go +package auth + +import ( + "context" + "encoding/json" + "errors" + "net" + "time" + + "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models" + "github.com/gin-gonic/gin" + "github.com/go-webauthn/webauthn/protocol" + "github.com/go-webauthn/webauthn/webauthn" + "github.com/redis/go-redis/v9" +) + +const ( + ceremonyPrefix = "km:wa:" + ceremonyTTL = 5 * time.Minute +) + +// rpConfig derives the relying party from the request. The RP ID is the host +// without its port - WebAuthn forbids a port there - while the origin keeps it. +// +// This is why the reverse proxy must preserve Host: a proxy rewriting it makes +// every passkey on the instance fail to verify, with no error that says so. +func rpConfig(c *gin.Context) (string, string) { + host := c.Request.Host + rpID := host + if h, _, err := net.SplitHostPort(host); err == nil { + rpID = h + } + scheme := "https" + if c.Request.TLS == nil && c.GetHeader("X-Forwarded-Proto") != "https" { + // Only development is ever plain HTTP; WebAuthn permits it on localhost. + scheme = "http" + } + return rpID, scheme + "://" + host +} + +func webAuthnFor(c *gin.Context) (*webauthn.WebAuthn, error) { + rpID, origin := rpConfig(c) + return webauthn.New(&webauthn.Config{ + RPDisplayName: "Vantage", + RPID: rpID, + RPOrigins: []string{origin}, + AuthenticatorSelection: protocol.AuthenticatorSelection{ + ResidentKey: protocol.ResidentKeyRequirementRequired, + UserVerification: protocol.VerificationRequired, + }, + }) +} + +// waUser adapts our records to the library's interface. The handle is random +// and per-user: a resident credential hands its user handle to any origin that +// asks, so the user ID must not be it. +type waUser struct { + handle []byte + name string + credentials []webauthn.Credential +} + +func (u waUser) WebAuthnID() []byte { return u.handle } +func (u waUser) WebAuthnName() string { return u.name } +func (u waUser) WebAuthnDisplayName() string { return u.name } +func (u waUser) WebAuthnCredentials() []webauthn.Credential { return u.credentials } + +func toLibCredential(c models.WebAuthnCredential) webauthn.Credential { + return webauthn.Credential{ + ID: c.CredentialID, + PublicKey: c.PublicKey, + AttestationType: "none", + Authenticator: webauthn.Authenticator{ + AAGUID: c.AAGUID, + SignCount: c.SignCount, + }, + } +} + +func saveCeremony(ctx context.Context, data *webauthn.SessionData) (string, error) { + id, err := randomHex(32) + if err != nil { + return "", err + } + blob, err := json.Marshal(data) + if err != nil { + return "", err + } + if err := rdb.Set(ctx, ceremonyPrefix+id, blob, ceremonyTTL).Err(); err != nil { + return "", err + } + return id, nil +} + +// loadCeremony consumes the challenge: a WebAuthn challenge is single use, so +// it is deleted as it is read. +func loadCeremony(ctx context.Context, id string) (*webauthn.SessionData, error) { + blob, err := rdb.GetDel(ctx, ceremonyPrefix+id).Bytes() + if errors.Is(err, redis.Nil) { + return nil, ErrTicketExpired + } + if err != nil { + return nil, err + } + var data webauthn.SessionData + if err := json.Unmarshal(blob, &data); err != nil { + return nil, ErrTicketExpired + } + return &data, nil +} +``` + +- [ ] **Step 5: Write `services/passkeys.go`** + +Create `server/internal/services/passkeys.go`: + +```go +package services + +import ( + "context" + "encoding/hex" + "errors" + "time" + + "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db" + "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models" + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" +) + +var ErrNoPasskey = errors.New("no such passkey") + +func ListPasskeys(instanceID, userID string) ([]models.WebAuthnCredential, error) { + ctx, cancel := mfaCtx() + defer cancel() + cur, err := db.Col("webauthn_credentials").Find(ctx, + bson.M{"instance_id": instanceID, "user_id": userID}) + if err != nil { + return nil, err + } + defer cur.Close(ctx) + out := []models.WebAuthnCredential{} + if err := cur.All(ctx, &out); err != nil { + return nil, err + } + return out, nil +} + +// GetPasskeyByCredentialID resolves a credential inside one instance. The +// instance scope is not optional: an unscoped lookup would let a credential +// registered on one tenant assert on another. +func GetPasskeyByCredentialID(instanceID string, credID []byte) (*models.WebAuthnCredential, error) { + ctx, cancel := mfaCtx() + defer cancel() + var c models.WebAuthnCredential + err := db.Col("webauthn_credentials").FindOne(ctx, + bson.M{"instance_id": instanceID, "credential_id": credID}).Decode(&c) + if errors.Is(err, mongo.ErrNoDocuments) { + return nil, ErrNoPasskey + } + if err != nil { + return nil, err + } + return &c, nil +} + +func SavePasskey(instanceID, userID, name string, credID, publicKey, aaguid []byte, signCount uint32, transports []string) error { + ctx, cancel := mfaCtx() + defer cancel() + if name == "" { + name = "Passkey" + } + _, err := db.Col("webauthn_credentials").InsertOne(ctx, models.WebAuthnCredential{ + InstanceID: instanceID, + UserID: userID, + CredentialID: credID, + CredentialIDHex: hex.EncodeToString(credID), + PublicKey: publicKey, + AAGUID: aaguid, + SignCount: signCount, + Transports: transports, + Name: name, + CreatedAt: time.Now(), + }) + return err +} + +// TouchPasskey records use and the new signature counter. A counter that fails +// to advance can mean a cloned authenticator, so the caller checks it before +// calling this. +func TouchPasskey(instanceID string, credID []byte, signCount uint32) error { + ctx, cancel := mfaCtx() + defer cancel() + now := time.Now() + _, err := db.Col("webauthn_credentials").UpdateOne(ctx, + bson.M{"instance_id": instanceID, "credential_id": credID}, + bson.M{"$set": bson.M{"sign_count": signCount, "last_used_at": now}}) + return err +} + +func RenamePasskey(instanceID, userID, credIDHex, name string) error { + ctx, cancel := mfaCtx() + defer cancel() + res, err := db.Col("webauthn_credentials").UpdateOne(ctx, + bson.M{"instance_id": instanceID, "user_id": userID, "credential_id_hex": credIDHex}, + bson.M{"$set": bson.M{"name": name}}) + if err != nil { + return err + } + if res.MatchedCount == 0 { + return ErrNoPasskey + } + return nil +} + +func DeletePasskey(instanceID, userID, credIDHex string) error { + ctx, cancel := mfaCtx() + defer cancel() + res, err := db.Col("webauthn_credentials").DeleteOne(ctx, + bson.M{"instance_id": instanceID, "user_id": userID, "credential_id_hex": credIDHex}) + if err != nil { + return err + } + if res.DeletedCount == 0 { + return ErrNoPasskey + } + return nil +} + +// ContextFor wraps mfaCtx for callers outside mfa.go. +func passkeyCtx() (context.Context, context.CancelFunc) { return mfaCtx() } +``` + +Drop `passkeyCtx` if unused; it exists only to satisfy the import if you split +the file differently. + +- [ ] **Step 6: Write the second-factor endpoints** + +Append to `server/internal/auth/webauthn.go`: + +```go +// HandleMFAWebAuthnBegin offers an assertion challenge to a pending sign-in. +// +// @Summary Begin passkey verification during sign-in +// @Tags auth +// @Produce json +// @Success 200 {object} object{publicKey=object,ceremony_id=string} +// @Failure 401 {object} object{error=string,code=string} +// @Router /auth/mfa/webauthn/begin [post] +func HandleMFAWebAuthnBegin(c *gin.Context) { + t, _, ok := ticketFromRequest(c, scopeVerify) + if !ok { + return + } + creds, err := services.ListPasskeys(t.InstanceID, t.UserID) + if err != nil || len(creds) == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "no passkey is registered"}) + return + } + handle, err := services.WebAuthnHandle(t.InstanceID, t.UserID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start verification"}) + return + } + lib := make([]webauthn.Credential, 0, len(creds)) + for _, cr := range creds { + lib = append(lib, toLibCredential(cr)) + } + w, err := webAuthnFor(c) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start verification"}) + return + } + options, sessionData, err := w.BeginLogin(waUser{handle: handle, name: t.Email, credentials: lib}, + webauthn.WithUserVerification(protocol.VerificationRequired)) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start verification"}) + return + } + id, err := saveCeremony(c.Request.Context(), sessionData) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start verification"}) + return + } + c.JSON(http.StatusOK, gin.H{"publicKey": options.Response, "ceremony_id": id}) +} + +// HandleMFAWebAuthnFinish verifies the assertion and signs the user in. +// +// @Summary Complete sign-in with a passkey +// @Tags auth +// @Accept json +// @Produce json +// @Param body body object{ceremony_id=string,credential=object} true "Assertion" +// @Success 200 {object} object{ok=bool} +// @Failure 401 {object} object{error=string,code=string} +// @Router /auth/mfa/webauthn/finish [post] +func HandleMFAWebAuthnFinish(c *gin.Context) { + t, ticketID, ok := ticketFromRequest(c, scopeVerify) + if !ok { + return + } + cred, err := finishAssertion(c, t.InstanceID, t.UserID, t.Email) + if err != nil { + left, ferr := FailTicket(c.Request.Context(), ticketID) + services.LogEvent(t.InstanceID, "mfa.failed", t.Email, "", "", "factor=webauthn") + if ferr != nil || left == 0 { + abortTicketExpired(c) + return + } + c.JSON(http.StatusUnauthorized, gin.H{ + "error": "that passkey could not be verified", "code": "invalid_assertion", "attempts_left": left, + }) + return + } + _ = services.TouchPasskey(t.InstanceID, cred.ID, cred.Authenticator.SignCount) + u, err := services.GetUserInInstance(t.InstanceID, t.UserID) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"}) + return + } + _ = DeleteTicket(c.Request.Context(), ticketID) + if err := mintSession(c, u, []string{"pwd", services.FactorWebAuthn}); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "session save failed"}) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true}) +} + +// finishAssertion is shared by second-factor sign-in, passwordless sign-in and +// step-up, so the verification rules (user verification, clone detection, +// instance scope) exist once. +func finishAssertion(c *gin.Context, instanceID, userID, email string) (*webauthn.Credential, error) { + var body struct { + CeremonyID string `json:"ceremony_id"` + Credential json.RawMessage `json:"credential"` + } + if err := c.ShouldBindJSON(&body); err != nil || body.CeremonyID == "" { + return nil, errors.New("assertion required") + } + sessionData, err := loadCeremony(c.Request.Context(), body.CeremonyID) + if err != nil { + return nil, err + } + parsed, err := protocol.ParseCredentialRequestResponseBody(bytes.NewReader(body.Credential)) + if err != nil { + return nil, err + } + stored, err := services.GetPasskeyByCredentialID(instanceID, parsed.RawID) + if err != nil { + return nil, err + } + if userID != "" && stored.UserID != userID { + return nil, errors.New("credential belongs to another user") + } + handle, err := services.WebAuthnHandle(instanceID, stored.UserID) + if err != nil { + return nil, err + } + w, err := webAuthnFor(c) + if err != nil { + return nil, err + } + user := waUser{handle: handle, name: email, credentials: []webauthn.Credential{toLibCredential(*stored)}} + cred, err := w.ValidateLogin(user, *sessionData, parsed) + if err != nil { + return nil, err + } + if !cred.Flags.UserVerified { + return nil, errors.New("user verification was not performed") + } + // A counter that fails to advance is the library's clone signal. Zero on + // both sides means the authenticator does not keep one, which is normal. + if cred.Authenticator.CloneWarning { + return nil, errors.New("authenticator may be cloned") + } + return cred, nil +} +``` + +Add `"bytes"`, `"encoding/json"`, `"net/http"`, and the services import. + +- [ ] **Step 7: Register the routes** + +```go + r.POST("/auth/mfa/webauthn/begin", auth.HandleMFAWebAuthnBegin) + r.POST("/auth/mfa/webauthn/finish", auth.HandleMFAWebAuthnFinish) +``` + +- [ ] **Step 8: Run the tests** + +Run: `cd server && go build ./... && go test ./internal/auth/ -run TestRPConfig -v && go test ./internal/...` +Expected: PASS. + +- [ ] **Step 9: Commit** + +```bash +git add server/internal/auth/webauthn.go server/internal/auth/webauthn_test.go \ + server/internal/services/passkeys.go server/internal/api/handlers.go server/go.mod server/go.sum +git commit -m "feat(mfa): passkey verification as a second factor" +``` + +--- + +### Task 8: Passwordless passkey sign-in + +**Files:** +- Create: `server/internal/auth/passkey_login.go` +- Modify: `server/internal/api/handlers.go` (routes) + +**Interfaces:** +- Consumes: Task 7's `webAuthnFor`, `finishAssertion`, `saveCeremony`; `resolveLoginInstance`, `services.LocalLoginPermitted`. +- Produces: routes `POST /auth/passkey/begin`, `POST /auth/passkey/finish`. + +- [ ] **Step 1: Write the handlers** + +Create `server/internal/auth/passkey_login.go`: + +```go +package auth + +import ( + "errors" + "net/http" + + "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services" + "github.com/gin-gonic/gin" + "github.com/go-webauthn/webauthn/protocol" +) + +// HandlePasskeyLoginBegin starts a passwordless sign-in. +// +// It repeats every gate /auth/login applies - instance resolution, the locked +// instance refusal, and the local-login setting - because this is a second +// front door, and a front door that skips the locks is not a shortcut. +// +// @Summary Begin passwordless passkey sign-in +// @Tags auth +// @Produce json +// @Success 200 {object} object{publicKey=object,ceremony_id=string} +// @Failure 403 {object} object{error=string} +// @Router /auth/passkey/begin [post] +func HandlePasskeyLoginBegin(c *gin.Context) { + instanceID, err := resolveLoginInstance(c) + if errors.Is(err, ErrInstanceLocked) { + c.JSON(http.StatusForbidden, gin.H{"error": err.Error(), "locked": true}) + return + } + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if !services.LocalLoginPermitted(instanceID) { + c.JSON(http.StatusForbidden, gin.H{"error": "password sign-in is disabled for this instance"}) + return + } + w, err := webAuthnFor(c) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start sign-in"}) + return + } + // Discoverable login: no allowCredentials, so the authenticator offers + // whichever resident credential it holds for this RP ID. + options, sessionData, err := w.BeginDiscoverableLogin( + webauthn.WithUserVerification(protocol.VerificationRequired)) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start sign-in"}) + return + } + id, err := saveCeremony(c.Request.Context(), sessionData) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start sign-in"}) + return + } + c.JSON(http.StatusOK, gin.H{"publicKey": options.Response, "ceremony_id": id}) +} + +// HandlePasskeyLoginFinish verifies a discoverable assertion and mints a +// session. A user-verified passkey is possession plus a PIN or biometric, so it +// satisfies require_mfa on its own. +// +// @Summary Complete passwordless passkey sign-in +// @Tags auth +// @Accept json +// @Produce json +// @Param body body object{ceremony_id=string,credential=object} true "Assertion" +// @Success 200 {object} object{ok=bool} +// @Failure 401 {object} object{error=string,code=string} +// @Router /auth/passkey/finish [post] +func HandlePasskeyLoginFinish(c *gin.Context) { + instanceID, err := resolveLoginInstance(c) + if err != nil { + c.JSON(http.StatusForbidden, gin.H{"error": "sign-in is not available here"}) + return + } + if !services.LocalLoginPermitted(instanceID) { + c.JSON(http.StatusForbidden, gin.H{"error": "password sign-in is disabled for this instance"}) + return + } + cred, err := finishAssertion(c, instanceID, "", "") + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{ + "error": "that passkey could not be verified", "code": "invalid_assertion", + }) + return + } + stored, err := services.GetPasskeyByCredentialID(instanceID, cred.ID) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"}) + return + } + u, err := services.GetUserInInstance(instanceID, stored.UserID) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"}) + return + } + _ = services.TouchPasskey(instanceID, cred.ID, cred.Authenticator.SignCount) + if err := mintSession(c, u, []string{services.FactorWebAuthn}); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "session save failed"}) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true}) +} +``` + +Add the `webauthn` import. + +- [ ] **Step 2: Register the routes** + +```go + r.POST("/auth/passkey/begin", auth.HandlePasskeyLoginBegin) + r.POST("/auth/passkey/finish", auth.HandlePasskeyLoginFinish) +``` + +- [ ] **Step 3: Build and test** + +Run: `cd server && go build ./... && go test ./internal/...` +Expected: PASS. + +- [ ] **Step 4: Commit** + +```bash +git add server/internal/auth/passkey_login.go server/internal/api/handlers.go +git commit -m "feat(mfa): passwordless passkey sign-in" +``` + +--- + +### Task 9: Step-up middleware + +**Files:** +- Create: `server/internal/auth/stepup.go` +- Create: `server/internal/auth/stepup_test.go` +- Modify: `server/internal/api/handlers.go:110,117,122` + +**Interfaces:** +- Consumes: `Session.AMR`, `Session.StepUpAt`, `Session.TokenID`. +- Produces: `auth.RequireStepUp() gin.HandlerFunc`, `auth.stepUpFresh(sess *Session, now time.Time) bool`, `auth.StepUpWindow = 10 * time.Minute`. + +- [ ] **Step 1: Write the failing test** + +Create `server/internal/auth/stepup_test.go`: + +```go +package auth + +import ( + "testing" + "time" +) + +func TestStepUpFresh(t *testing.T) { + now := time.Now() + ago := func(d time.Duration) *time.Time { v := now.Add(-d); return &v } + + cases := []struct { + name string + sess *Session + want bool + }{ + {"just signed in", &Session{StepUpAt: ago(time.Minute)}, true}, + {"nine minutes ago", &Session{StepUpAt: ago(9 * time.Minute)}, true}, + {"eleven minutes ago", &Session{StepUpAt: ago(11 * time.Minute)}, false}, + {"never", &Session{}, false}, + // An API token has no human to prompt; the spec exempts it and records + // the bypass as a known limitation. + {"api token", &Session{TokenID: "tok_1"}, true}, + // An OIDC session's IdP owns authentication policy. + {"oidc session", &Session{AMR: []string{"oidc"}, StepUpAt: ago(time.Hour)}, true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := stepUpFresh(tc.sess, now); got != tc.want { + t.Fatalf("stepUpFresh = %v, want %v", got, tc.want) + } + }) + } +} +``` + +- [ ] **Step 2: Run it and watch it fail** + +Run: `cd server && go test ./internal/auth/ -run TestStepUpFresh -v` +Expected: FAIL to compile, "undefined: stepUpFresh". + +- [ ] **Step 3: Write `stepup.go`** + +Create `server/internal/auth/stepup.go`: + +```go +package auth + +import ( + "net/http" + "time" + + "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services" + "github.com/gin-gonic/gin" +) + +// StepUpWindow is how long one re-authentication covers. Ten minutes is long +// enough to open several consoles in a row and short enough that a walked-away +// laptop is not a fleet-wide credential. +const StepUpWindow = 10 * time.Minute + +func stepUpFresh(sess *Session, now time.Time) bool { + if sess == nil { + return false + } + // An API token authenticates per request and has no human to prompt. + if sess.TokenID != "" { + return true + } + for _, a := range sess.AMR { + if a == "oidc" { + return true + } + } + return sess.StepUpAt != nil && now.Sub(*sess.StepUpAt) < StepUpWindow +} + +// RequireStepUp guards the actions that hand out credentials rather than +// describe them: secret reveal, private key download, console connect. +// +// It answers a machine-readable code rather than a bare 403 so web/ can open +// the re-authentication modal and retry the original request. +func RequireStepUp() gin.HandlerFunc { + return func(c *gin.Context) { + sess := GetSessionFromContext(c) + if stepUpFresh(sess, time.Now()) { + c.Next() + return + } + methods, err := services.MFAMethods(sess.InstanceID, sess.UserID) + if err != nil || len(methods) == 0 { + methods = []string{services.FactorPassword} + } + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{ + "error": "re-authentication required", + "code": "step_up_required", + "methods": methods, + }) + } +} +``` + +- [ ] **Step 4: Mount it on the three routes** + +In `server/internal/api/handlers.go`: + +```go + apiGroup.POST("/secrets/:group/reveal", auth.RequireStepUp(), revealSecret) + apiGroup.GET("/keys/:id/private-key", auth.RequireStepUp(), getPrivateKey) + apiGroup.POST("/console/connect", auth.RequireStepUp(), RequireFeature("console"), consoleConnect) +``` + +- [ ] **Step 5: Run the tests** + +Run: `cd server && go build ./... && go test ./internal/auth/ -run TestStepUpFresh -v` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add server/internal/auth/stepup.go server/internal/auth/stepup_test.go server/internal/api/handlers.go +git commit -m "feat(mfa): step-up re-authentication on sensitive actions" +``` + +--- + +### Task 10: Account MFA endpoints and the step-up endpoint + +**Files:** +- Create: `server/internal/api/mfa.go` +- Modify: `server/internal/api/handlers.go` (routes) +- Create: `server/internal/services/mfa_policy_test.go` + +**Interfaces:** +- Consumes: everything from Tasks 3, 6, 7, 9. +- Produces: routes under `/api/me/*`; `services.CheckCanRemoveFactor(instanceID, userID, removing string) error`; `services.ErrMFARequiredByPolicy`. + +- [ ] **Step 1: Write the failing policy test** + +Create `server/internal/services/mfa_policy_test.go`: + +```go +package services + +import "testing" + +// Removing a factor under an MFA policy must leave at least one behind, or the +// user locks themselves out of an instance that will then demand enrolment +// they cannot complete without signing in. +func TestCanRemoveFactor(t *testing.T) { + cases := []struct { + name string + requireMFA bool + totp bool + passkeys int + removing string + wantRefusal bool + }{ + {"policy off, last factor", false, true, 0, FactorTOTP, false}, + {"policy on, totp plus passkey, drop totp", true, true, 1, FactorTOTP, false}, + {"policy on, last totp", true, true, 0, FactorTOTP, true}, + {"policy on, last passkey", true, false, 1, FactorWebAuthn, true}, + {"policy on, two passkeys, drop one", true, false, 2, FactorWebAuthn, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := canRemoveFactor(tc.requireMFA, tc.totp, tc.passkeys, tc.removing) + if tc.wantRefusal != (err != nil) { + t.Fatalf("canRemoveFactor refusal = %v, want %v", err != nil, tc.wantRefusal) + } + }) + } +} +``` + +The last case is the one that catches an off-by-one: dropping one of two +passkeys leaves one factor, so it must be allowed. + +- [ ] **Step 2: Run it and watch it fail** + +Run: `cd server && go test ./internal/services/ -run TestCanRemoveFactor -v` +Expected: FAIL to compile, "undefined: canRemoveFactor". + +- [ ] **Step 3: Implement the policy check** + +Append to `server/internal/services/mfa.go`: + +```go +var ErrMFARequiredByPolicy = errors.New("this instance requires multi-factor authentication; add another factor before removing this one") + +// canRemoveFactor is pure so the rule is testable without a database. +// removing is FactorTOTP or FactorWebAuthn, and for a passkey it means one of +// the counted passkeys. +func canRemoveFactor(requireMFA, hasTOTP bool, passkeys int, removing string) error { + if !requireMFA { + return nil + } + remaining := 0 + if hasTOTP && removing != FactorTOTP { + remaining++ + } + switch removing { + case FactorWebAuthn: + remaining += passkeys - 1 + default: + remaining += passkeys + } + if remaining > 0 { + return nil + } + return ErrMFARequiredByPolicy +} + +// CheckCanRemoveFactor reads the current state and applies the rule. +func CheckCanRemoveFactor(instanceID, userID, removing string) error { + m, err := GetUserMFA(instanceID, userID) + if err != nil { + return err + } + passkeys, err := CountPasskeys(instanceID, userID) + if err != nil { + return err + } + return canRemoveFactor(RequireMFAForInstance(instanceID), m != nil && m.TOTPConfirmedAt != nil, int(passkeys), removing) +} + +// RemoveTOTP clears only the TOTP factor, leaving passkeys and recovery codes. +func RemoveTOTP(instanceID, userID string) error { + ctx, cancel := mfaCtx() + defer cancel() + _, err := db.Col("user_mfa").UpdateOne(ctx, + bson.M{"instance_id": instanceID, "user_id": userID}, + bson.M{ + "$unset": bson.M{"totp_secret_enc": "", "totp_confirmed_at": "", "totp_pending_enc": ""}, + "$set": bson.M{"updated_at": time.Now()}, + }) + return err +} +``` + +- [ ] **Step 4: Write the API handlers** + +Create `server/internal/api/mfa.go`. Every handler reads the session for its +instance and user, so no path can act on somebody else's factors: + +```go +package api + +import ( + "errors" + "net/http" + + "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/auth" + "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models" + "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services" + "github.com/gin-gonic/gin" +) + +// oidcUser refuses MFA management for a user whose IdP owns authentication. +func oidcUser(c *gin.Context) bool { + u, err := services.GetUserInInstance(auth.InstanceID(c), auth.UserID(c)) + return err == nil && u.AuthSource == models.AuthOIDC +} + +// getMyMFA reports this user's factors. +// +// @Summary Get my MFA status +// @Tags mfa +// @Produce json +// @Success 200 {object} object{totp_enabled=bool,passkeys=[]models.WebAuthnCredential,recovery_remaining=int,require_mfa=bool,applicable=bool} +// @Router /me/mfa [get] +func getMyMFA(c *gin.Context) { + instanceID, userID := auth.InstanceID(c), auth.UserID(c) + m, err := services.GetUserMFA(instanceID, userID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + passkeys, err := services.ListPasskeys(instanceID, userID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{ + "totp_enabled": m != nil && m.TOTPConfirmedAt != nil, + "passkeys": passkeys, + "recovery_remaining": services.RecoveryCodesRemaining(m), + "require_mfa": services.RequireMFAForInstance(instanceID), + "applicable": !oidcUser(c), + }) +} + +// setupTOTP issues a new unconfirmed secret. +// +// @Summary Start TOTP setup +// @Tags mfa +// @Produce json +// @Success 200 {object} object{secret=string,otpauth_uri=string} +// @Failure 409 {object} object{error=string,code=string} +// @Router /me/mfa/totp/setup [post] +func setupTOTP(c *gin.Context) { + if oidcUser(c) { + c.JSON(http.StatusConflict, gin.H{"error": "your identity provider manages sign-in", "code": "mfa_not_applicable"}) + return + } + instanceID, userID := auth.InstanceID(c), auth.UserID(c) + issuer := "Vantage" + if inst, err := services.GetInstance(instanceID); err == nil && inst != nil && inst.Name != "" { + issuer = inst.Name + } + secret, uri, err := services.StartTOTPSetup(instanceID, userID, issuer, auth.GetSessionFromContext(c).Email) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"secret": secret, "otpauth_uri": uri}) +} + +// confirmTOTP activates the pending secret and issues recovery codes if this +// is the user's first factor. +// +// @Summary Confirm TOTP setup +// @Tags mfa +// @Accept json +// @Produce json +// @Param body body object{code=string} true "Six-digit code" +// @Success 200 {object} object{ok=bool,recovery_codes=[]string} +// @Failure 401 {object} object{error=string,code=string} +// @Router /me/mfa/totp/confirm [post] +func confirmTOTP(c *gin.Context) { + instanceID, userID := auth.InstanceID(c), auth.UserID(c) + var body struct { + Code string `json:"code"` + } + if err := c.ShouldBindJSON(&body); err != nil || body.Code == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "code required"}) + return + } + if err := services.ConfirmTOTP(instanceID, userID, body.Code); err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "that code is not valid", "code": "invalid_code"}) + return + } + services.LogEvent(instanceID, "mfa.enrolled", actorFromCtx(c), "", "", "factor=totp") + + m, _ := services.GetUserMFA(instanceID, userID) + if services.RecoveryCodesRemaining(m) > 0 { + c.JSON(http.StatusOK, gin.H{"ok": true}) + return + } + codes, err := services.IssueRecoveryCodes(instanceID, userID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true, "recovery_codes": codes}) +} + +// removeTOTP drops the TOTP factor. Step-up guarded at the route. +// +// @Summary Remove TOTP +// @Tags mfa +// @Produce json +// @Success 204 +// @Failure 409 {object} object{error=string,code=string} +// @Router /me/mfa/totp [delete] +func removeTOTP(c *gin.Context) { + instanceID, userID := auth.InstanceID(c), auth.UserID(c) + if err := services.CheckCanRemoveFactor(instanceID, userID, services.FactorTOTP); err != nil { + c.JSON(http.StatusConflict, gin.H{"error": err.Error(), "code": "mfa_required_by_policy"}) + return + } + if err := services.RemoveTOTP(instanceID, userID); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + services.LogEvent(instanceID, "mfa.removed", actorFromCtx(c), "", "", "factor=totp") + c.Status(http.StatusNoContent) +} + +// regenerateRecoveryCodes invalidates the old set. Step-up guarded. +// +// @Summary Regenerate recovery codes +// @Tags mfa +// @Produce json +// @Success 200 {object} object{recovery_codes=[]string} +// @Router /me/mfa/recovery/regenerate [post] +func regenerateRecoveryCodes(c *gin.Context) { + instanceID, userID := auth.InstanceID(c), auth.UserID(c) + codes, err := services.IssueRecoveryCodes(instanceID, userID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + services.LogEvent(instanceID, "mfa.recovery_regenerated", actorFromCtx(c), "", "", "") + c.JSON(http.StatusOK, gin.H{"recovery_codes": codes}) +} + +// renamePasskey and deletePasskey work on the hex credential ID. +// +// @Summary Rename a passkey +// @Tags mfa +// @Accept json +// @Produce json +// @Param id path string true "Credential ID" +// @Param body body object{name=string} true "New name" +// @Success 204 +// @Router /me/passkeys/{id} [patch] +func renamePasskey(c *gin.Context) { + var body struct { + Name string `json:"name"` + } + if err := c.ShouldBindJSON(&body); err != nil || body.Name == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "name required"}) + return + } + err := services.RenamePasskey(auth.InstanceID(c), auth.UserID(c), c.Param("id"), body.Name) + if errors.Is(err, services.ErrNoPasskey) { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.Status(http.StatusNoContent) +} + +// @Summary Delete a passkey +// @Tags mfa +// @Produce json +// @Param id path string true "Credential ID" +// @Success 204 +// @Failure 409 {object} object{error=string,code=string} +// @Router /me/passkeys/{id} [delete] +func deletePasskey(c *gin.Context) { + instanceID, userID := auth.InstanceID(c), auth.UserID(c) + if err := services.CheckCanRemoveFactor(instanceID, userID, services.FactorWebAuthn); err != nil { + c.JSON(http.StatusConflict, gin.H{"error": err.Error(), "code": "mfa_required_by_policy"}) + return + } + err := services.DeletePasskey(instanceID, userID, c.Param("id")) + if errors.Is(err, services.ErrNoPasskey) { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + services.LogEvent(instanceID, "mfa.removed", actorFromCtx(c), "", "", "factor=webauthn") + c.Status(http.StatusNoContent) +} + +// resetUserMFA lets an owner or admin clear somebody else's factors. +// +// @Summary Reset another member's MFA +// @Tags mfa +// @Produce json +// @Param id path string true "User ID" +// @Success 204 +// @Failure 403 {object} object{error=string} +// @Router /org/users/{id}/mfa [delete] +func resetUserMFA(c *gin.Context) { + instanceID := auth.InstanceID(c) + target, err := services.GetUserInInstance(instanceID, c.Param("id")) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "no such member"}) + return + } + // An admin resetting an owner's MFA would be a promotion path: clear the + // factor, phish the password, hold the instance. + if auth.Role(c) != models.RoleOwner && target.Role == models.RoleOwner { + c.JSON(http.StatusForbidden, gin.H{"error": "only an owner can reset an owner's MFA"}) + return + } + if err := services.ClearMFA(instanceID, target.UserID); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + services.LogEvent(instanceID, "mfa.reset", actorFromCtx(c), "", "", "target="+target.Email) + c.Status(http.StatusNoContent) +} +``` + +- [ ] **Step 5: Write the step-up endpoint and passkey registration** + +Append to `server/internal/api/mfa.go`: + +```go +// stepUp re-authenticates the current session. +// +// @Summary Re-authenticate before a sensitive action +// @Tags mfa +// @Accept json +// @Produce json +// @Param body body object{totp=string,recovery=string,password=string} true "One factor" +// @Success 200 {object} object{ok=bool} +// @Failure 401 {object} object{error=string,code=string} +// @Router /me/step-up [post] +func stepUp(c *gin.Context) { + sess := auth.GetSessionFromContext(c) + var body struct { + TOTP string `json:"totp"` + Recovery string `json:"recovery"` + Password string `json:"password"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "a factor is required"}) + return + } + + var err error + switch { + case body.TOTP != "": + err = services.VerifyTOTPCode(sess.InstanceID, sess.UserID, body.TOTP) + case body.Recovery != "": + err = services.UseRecoveryCode(sess.InstanceID, sess.UserID, body.Recovery) + case body.Password != "": + // Password is offered only to a user with no MFA at all; accepting it + // from an enrolled user would demote step-up to what they already did. + has, herr := services.HasMFA(sess.InstanceID, sess.UserID) + if herr != nil || has { + err = services.ErrBadCode + } else { + u, uerr := services.GetUserInInstance(sess.InstanceID, sess.UserID) + if uerr != nil || !services.VerifyPassword(u, body.Password) { + err = services.ErrBadCode + } + } + default: + c.JSON(http.StatusBadRequest, gin.H{"error": "a factor is required"}) + return + } + + if err != nil { + services.LogEvent(sess.InstanceID, "step_up.failed", actorFromCtx(c), "", "", "") + c.JSON(http.StatusUnauthorized, gin.H{"error": "that did not verify", "code": "invalid_code"}) + return + } + if err := auth.TouchStepUpFromRequest(c); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "could not record re-authentication"}) + return + } + services.LogEvent(sess.InstanceID, "step_up.ok", actorFromCtx(c), "", "", "") + c.JSON(http.StatusOK, gin.H{"ok": true}) +} +``` + +Add to `server/internal/auth/stepup.go` the helper that knows the cookie: + +```go +// TouchStepUpFromRequest records a fresh step-up against the session the +// request arrived on. It lives in auth because the cookie name and the Redis +// key are this package's business. +func TouchStepUpFromRequest(c *gin.Context) error { + cookie, err := c.Request.Cookie(sessionCookieName) + if err != nil { + return err + } + sess := GetSessionFromContext(c) + return TouchStepUp(c.Request.Context(), cookie.Value, sess) +} +``` + +Passkey registration for a signed-in user goes in +`server/internal/auth/webauthn.go` (it needs `webAuthnFor`), exported as +`auth.HandleRegisterPasskeyBegin` and `auth.HandleRegisterPasskeyFinish`: + +```go +// HandleRegisterPasskeyBegin starts registration for the signed-in user. +// +// @Summary Begin passkey registration +// @Tags mfa +// @Produce json +// @Success 200 {object} object{publicKey=object,ceremony_id=string} +// @Router /me/passkeys/begin [post] +func HandleRegisterPasskeyBegin(c *gin.Context) { + sess := GetSessionFromContext(c) + handle, err := services.WebAuthnHandle(sess.InstanceID, sess.UserID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start registration"}) + return + } + existing, err := services.ListPasskeys(sess.InstanceID, sess.UserID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start registration"}) + return + } + lib := make([]webauthn.Credential, 0, len(existing)) + for _, cr := range existing { + lib = append(lib, toLibCredential(cr)) + } + w, err := webAuthnFor(c) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start registration"}) + return + } + options, sessionData, err := w.BeginRegistration( + waUser{handle: handle, name: sess.Email, credentials: lib}, + webauthn.WithExclusions(protocol.CredentialDescriptorsFromCredentials(lib)), + ) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start registration"}) + return + } + id, err := saveCeremony(c.Request.Context(), sessionData) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start registration"}) + return + } + c.JSON(http.StatusOK, gin.H{"publicKey": options.Response, "ceremony_id": id}) +} + +// HandleRegisterPasskeyFinish stores the new credential. +// +// @Summary Complete passkey registration +// @Tags mfa +// @Accept json +// @Produce json +// @Param body body object{ceremony_id=string,name=string,credential=object} true "Attestation" +// @Success 200 {object} object{ok=bool,recovery_codes=[]string} +// @Router /me/passkeys/finish [post] +func HandleRegisterPasskeyFinish(c *gin.Context) { + sess := GetSessionFromContext(c) + var body struct { + CeremonyID string `json:"ceremony_id"` + Name string `json:"name"` + Credential json.RawMessage `json:"credential"` + } + if err := c.ShouldBindJSON(&body); err != nil || body.CeremonyID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "attestation required"}) + return + } + sessionData, err := loadCeremony(c.Request.Context(), body.CeremonyID) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "that registration expired", "code": "mfa_ticket_expired"}) + return + } + parsed, err := protocol.ParseCredentialCreationResponseBody(bytes.NewReader(body.Credential)) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "that passkey could not be read"}) + return + } + handle, err := services.WebAuthnHandle(sess.InstanceID, sess.UserID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "could not finish registration"}) + return + } + w, err := webAuthnFor(c) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "could not finish registration"}) + return + } + cred, err := w.CreateCredential(waUser{handle: handle, name: sess.Email}, *sessionData, parsed) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "that passkey could not be verified"}) + return + } + if !cred.Flags.UserVerified { + c.JSON(http.StatusBadRequest, gin.H{"error": "this passkey does not verify the user"}) + return + } + if err := services.SavePasskey(sess.InstanceID, sess.UserID, body.Name, + cred.ID, cred.PublicKey, cred.Authenticator.AAGUID, cred.Authenticator.SignCount, + parsed.Response.Transports); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "could not save the passkey"}) + return + } + services.LogEvent(sess.InstanceID, "mfa.enrolled", sess.Email, "", "", "factor=webauthn") + + // A first factor earns recovery codes; later ones do not reissue them. + m, _ := services.GetUserMFA(sess.InstanceID, sess.UserID) + if services.RecoveryCodesRemaining(m) == 0 { + codes, err := services.IssueRecoveryCodes(sess.InstanceID, sess.UserID) + if err == nil { + c.JSON(http.StatusOK, gin.H{"ok": true, "recovery_codes": codes}) + return + } + } + c.JSON(http.StatusOK, gin.H{"ok": true}) +} +``` + +Also add the enrol-ticket variants `auth.HandleEnrolPasskeyBegin` / +`auth.HandleEnrolPasskeyFinish` in `mfa_enrol.go`: identical, but resolving the +user from `ticketFromRequest(c, scopeEnrol)` instead of the session, and ending +in `finishEnrolment(c, t, ticketID, services.FactorWebAuthn)`. + +- [ ] **Step 6: Register every route** + +In `handlers.go`, inside the session-authenticated group: + +```go + apiGroup.GET("/me/mfa", getMyMFA) + apiGroup.POST("/me/mfa/totp/setup", auth.RequireStepUp(), setupTOTP) + apiGroup.POST("/me/mfa/totp/confirm", confirmTOTP) + apiGroup.DELETE("/me/mfa/totp", auth.RequireStepUp(), removeTOTP) + apiGroup.POST("/me/mfa/recovery/regenerate", auth.RequireStepUp(), regenerateRecoveryCodes) + apiGroup.POST("/me/passkeys/begin", auth.RequireStepUp(), auth.HandleRegisterPasskeyBegin) + apiGroup.POST("/me/passkeys/finish", auth.RequireStepUp(), auth.HandleRegisterPasskeyFinish) + apiGroup.PATCH("/me/passkeys/:id", renamePasskey) + apiGroup.DELETE("/me/passkeys/:id", auth.RequireStepUp(), deletePasskey) + apiGroup.POST("/me/step-up", stepUp) + apiGroup.DELETE("/org/users/:id/mfa", auth.RequireRole("owner", "admin"), auth.RequireStepUp(), resetUserMFA) +``` + +And the unauthenticated enrolment passkey routes beside the others: + +```go + r.POST("/auth/mfa/enrol/passkey/begin", auth.HandleEnrolPasskeyBegin) + r.POST("/auth/mfa/enrol/passkey/finish", auth.HandleEnrolPasskeyFinish) +``` + +- [ ] **Step 7: Run everything** + +Run: `cd server && go build ./... && go test ./internal/...` +Expected: PASS. + +- [ ] **Step 8: Commit** + +```bash +git add server/internal/api/mfa.go server/internal/api/handlers.go server/internal/auth/ server/internal/services/ +git commit -m "feat(mfa): account MFA management, passkey registration and step-up" +``` + +--- + +### Task 11: The `require_mfa` setting and the sign-in rate limit + +**Files:** +- Modify: `server/internal/services/settings.go:122` (`SaveSettings`) +- Modify: `server/internal/api/handlers.go:912-941` (settings handler) +- Create: `server/internal/api/ratelimit_auth.go` +- Modify: `server/internal/api/handlers.go` (mount the limiter) +- Modify: `server/internal/api/users.go` or wherever `GET /org/users` is handled (add `mfa_enabled`) + +**Interfaces:** +- Consumes: `models.RequireMFA`, `services.HasMFA`. +- Produces: `SaveSettings(..., requireMFA *bool)`; `api.RateLimitAuth() gin.HandlerFunc`; `mfa_enabled` on each user row. + +- [ ] **Step 1: Extend `SaveSettings`** + +Add a `requireMFA *bool` parameter after `localLoginEnabled`, and in the body: + +```go + if requireMFA != nil { + set["require_mfa"] = *requireMFA + } +``` + +Update every caller; the compiler will find them. + +- [ ] **Step 2: Extend the settings handler** + +In the settings handler's body struct add `RequireMFA *bool \`json:"require_mfa"\``, +pass it through to `SaveSettings`, and refuse a non-owner changing it: + +```go + if body.RequireMFA != nil && auth.Role(c) != models.RoleOwner { + c.JSON(http.StatusForbidden, gin.H{"error": "only an owner can change the MFA requirement"}) + return + } +``` + +Then audit it: + +```go + if body.RequireMFA != nil { + services.LogEvent(auth.InstanceID(c), "settings.require_mfa", actorFromCtx(c), "", "", + fmt.Sprintf("enabled=%v", *body.RequireMFA)) + } +``` + +- [ ] **Step 3: Write the rate limiter** + +Create `server/internal/api/ratelimit_auth.go`, modelled on the existing +`RateLimitTokens` in this package — read it first and copy its Redis fixed-window +shape exactly rather than inventing a second one: + +```go +package api + +import ( + "fmt" + "net/http" + "time" + + "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services" + "github.com/gin-gonic/gin" +) + +// authRateLimit is per client address per minute. It bounds how many tickets an +// attacker can start; the ticket's own five-attempt cap bounds guesses inside +// one. Neither alone is enough. +const authRateLimit = 20 + +// RateLimitAuth guards every unauthenticated sign-in endpoint. Without it, the +// per-ticket cap is trivially sidestepped by starting a new sign-in each time. +func RateLimitAuth() gin.HandlerFunc { + return func(c *gin.Context) { + rdb := services.RedisClient + if rdb == nil { + c.Next() + return + } + window := time.Now().Unix() / 60 + key := fmt.Sprintf("km:rl:auth:%s:%d", c.ClientIP(), window) + ctx := c.Request.Context() + n, err := rdb.Incr(ctx, key).Result() + if err != nil { + // A limiter that cannot reach Redis must not lock out sign-in. + c.Next() + return + } + if n == 1 { + rdb.Expire(ctx, key, time.Minute) + } + if n > authRateLimit { + c.Header("Retry-After", "60") + c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{ + "error": "too many sign-in attempts; try again in a minute", + }) + return + } + c.Next() + } +} +``` + +- [ ] **Step 4: Mount the limiter** + +Wrap every unauthenticated auth route registered in Tasks 5 to 8 plus +`/auth/login`, for example by grouping them: + +```go + authGroup := r.Group("", RateLimitAuth()) + { + authGroup.POST("/auth/login", auth.HandleLocalLogin) + authGroup.POST("/auth/mfa/totp", auth.HandleMFATOTP) + // ... every /auth/mfa/* and /auth/passkey/* route + } +``` + +Also apply it to `/api/me/step-up` by adding `RateLimitAuth()` to that route. + +- [ ] **Step 5: Add `mfa_enabled` to the users list** + +In the handler behind `GET /org/users`, after loading the users, fill a +parallel field so the settings table can show a column and offer a reset. Keep +it to one query rather than one per user: + +```go + // One aggregate over two small collections beats N round trips for a member + // list that renders on every settings page load. + enabled, err := services.UsersWithMFA(auth.InstanceID(c)) +``` + +And in `server/internal/services/mfa.go`: + +```go +// UsersWithMFA returns the set of user IDs in this instance holding a factor. +func UsersWithMFA(instanceID string) (map[string]bool, error) { + ctx, cancel := mfaCtx() + defer cancel() + out := map[string]bool{} + + cur, err := db.Col("user_mfa").Find(ctx, + bson.M{"instance_id": instanceID, "totp_confirmed_at": bson.M{"$exists": true}}) + if err != nil { + return nil, err + } + var rows []models.UserMFA + if err := cur.All(ctx, &rows); err != nil { + return nil, err + } + for _, r := range rows { + out[r.UserID] = true + } + + ids, err := db.Col("webauthn_credentials").Distinct(ctx, "user_id", bson.M{"instance_id": instanceID}) + if err != nil { + return nil, err + } + var userIDs []string + if err := ids.Decode(&userIDs); err == nil { + for _, id := range userIDs { + out[id] = true + } + } + return out, nil +} +``` + +Return the users with an added `mfa_enabled` boolean in whatever shape that +handler already uses (wrap each `models.User` in a small response struct rather +than adding a field to the shared model). + +- [ ] **Step 6: Build and test** + +Run: `cd server && go build ./... && go test ./internal/...` +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add server/internal/ && git commit -m "feat(mfa): require_mfa policy, sign-in rate limit and MFA column" +``` + +--- + +### Task 12: Web — the login flow + +**Files:** +- Modify: `web/app/login/page.tsx` +- Modify: `web/lib/api.ts` (the `auth` object) +- Create: `web/lib/webauthn.ts` +- Modify: `web/package.json` (add `qrcode`) + +**Interfaces:** +- Consumes: the endpoints from Tasks 5 to 8. +- Produces: + - `auth.login()` returning `{ok?: true} | {mfa_required: true, methods: string[]} | {enrol_required: true}` + - `auth.mfaTotp(code)`, `auth.mfaRecovery(code)`, `auth.mfaWebAuthnBegin()`, `auth.mfaWebAuthnFinish(...)` + - `auth.passkeyLoginBegin()`, `auth.passkeyLoginFinish(...)` + - `auth.enrolTotpSetup()`, `auth.enrolTotpConfirm(code)`, `auth.enrolPasskeyBegin()`, `auth.enrolPasskeyFinish(...)` + - `lib/webauthn.ts`: `toCreateOptions`, `toRequestOptions`, `credentialToJSON`, `isPasskeySupported()` + +- [ ] **Step 1: Add the browser WebAuthn helpers** + +Create `web/lib/webauthn.ts`. The server sends and expects base64url; the +browser needs `ArrayBuffer`s: + +```ts +function b64urlToBuffer(value: string): ArrayBuffer { + const padded = value.replace(/-/g, "+").replace(/_/g, "/"); + const binary = atob(padded.padEnd(padded.length + ((4 - (padded.length % 4)) % 4), "=")); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + return bytes.buffer; +} + +function bufferToB64url(buffer: ArrayBuffer): string { + const bytes = new Uint8Array(buffer); + let binary = ""; + for (const b of bytes) binary += String.fromCharCode(b); + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} + +export function isPasskeySupported(): boolean { + return typeof window !== "undefined" && !!window.PublicKeyCredential; +} + +export function toCreateOptions(options: any): PublicKeyCredentialCreationOptions { + return { + ...options, + challenge: b64urlToBuffer(options.challenge), + user: { ...options.user, id: b64urlToBuffer(options.user.id) }, + excludeCredentials: (options.excludeCredentials ?? []).map((c: any) => ({ + ...c, + id: b64urlToBuffer(c.id), + })), + }; +} + +export function toRequestOptions(options: any): PublicKeyCredentialRequestOptions { + return { + ...options, + challenge: b64urlToBuffer(options.challenge), + allowCredentials: (options.allowCredentials ?? []).map((c: any) => ({ + ...c, + id: b64urlToBuffer(c.id), + })), + }; +} + +// credentialToJSON produces the shape go-webauthn's parsers read. +export function credentialToJSON(cred: PublicKeyCredential): unknown { + const response = cred.response as AuthenticatorAttestationResponse & AuthenticatorAssertionResponse; + const json: any = { + id: cred.id, + rawId: bufferToB64url(cred.rawId), + type: cred.type, + clientExtensionResults: cred.getClientExtensionResults(), + response: { clientDataJSON: bufferToB64url(response.clientDataJSON) }, + }; + if (response.attestationObject) { + json.response.attestationObject = bufferToB64url(response.attestationObject); + if (typeof response.getTransports === "function") { + json.response.transports = response.getTransports(); + } + } + if (response.authenticatorData) { + json.response.authenticatorData = bufferToB64url(response.authenticatorData); + json.response.signature = bufferToB64url(response.signature); + json.response.userHandle = response.userHandle ? bufferToB64url(response.userHandle) : null; + } + return json; +} +``` + +- [ ] **Step 2: Add the API client methods** + +In `web/lib/api.ts`, extend the exported `auth` object using `authRequest` +(these are unauthenticated routes outside `/api`), for example: + +```ts + mfaTotp(code: string): Promise<{ ok: true }> { + return authRequest("/auth/mfa/totp", { method: "POST", body: JSON.stringify({ code }) }); + }, + + passkeyLoginBegin(): Promise<{ publicKey: any; ceremony_id: string }> { + return authRequest("/auth/passkey/begin", { method: "POST" }); + }, + + passkeyLoginFinish(ceremonyId: string, credential: unknown): Promise<{ ok: true }> { + return authRequest("/auth/passkey/finish", { + method: "POST", + body: JSON.stringify({ ceremony_id: ceremonyId, credential }), + }); + }, +``` + +Write the same shape for `mfaRecovery`, `mfaWebAuthnBegin`, `mfaWebAuthnFinish`, +`enrolTotpSetup`, `enrolTotpConfirm`, `enrolPasskeyBegin`, `enrolPasskeyFinish`, +and widen `login()`'s return type to the union above. + +- [ ] **Step 3: Add the QR dependency** + +Run: `cd web && npm install qrcode && npm install --save-dev @types/qrcode` + +- [ ] **Step 4: Rework the login page** + +`web/app/login/page.tsx` gains a `step` state: `"credentials" | "factor" | "enrol"`. + +- `"credentials"` is today's form plus, when `isPasskeySupported() && localEnabled`, + a "Sign in with passkey" button calling `passkeyLoginBegin`, + `navigator.credentials.get({publicKey: toRequestOptions(options)})`, then + `passkeyLoginFinish(ceremonyId, credentialToJSON(cred))`. +- On `{mfa_required, methods}` go to `"factor"`: a 6-digit input (`inputMode="numeric"`, + `autoComplete="one-time-code"`), a "Use passkey" button when `methods` includes + `webauthn`, and a "Use a recovery code" toggle when it includes `recovery`. +- On `{enrol_required}` go to `"enrol"`: the wizard from Task 13's shared + component, ending in the recovery-code panel with a required "I have saved + these" checkbox before it routes to `/`. +- An `ApiError` whose body carries `code: "mfa_ticket_expired"` returns the page + to `"credentials"` with the message "That sign-in attempt expired. Please sign + in again." + +Follow the page's existing error-message map and component vocabulary; do not +introduce a new form library. + +- [ ] **Step 5: Verify by hand** + +Run: `cd web && npm run build` +Expected: a clean build. Then, against a dev server, sign in as a user with no +MFA and confirm nothing about the flow changed. + +- [ ] **Step 6: Commit** + +```bash +git add web/app/login/page.tsx web/lib/api.ts web/lib/webauthn.ts web/package.json web/package-lock.json +git commit -m "feat(web): MFA and passkey sign-in on the login page" +``` + +--- + +### Task 13: Web — the security page and the enrolment wizard + +**Files:** +- Create: `web/app/(app)/account/security/page.tsx` +- Create: `web/components/mfa/EnrolWizard.tsx` +- Create: `web/components/mfa/RecoveryCodes.tsx` +- Modify: `web/components/Sidebar.tsx` (link to the page) +- Modify: `web/lib/api.ts` (the `/api/me/*` methods) + +**Interfaces:** +- Consumes: Task 10's endpoints. +- Produces: `me.mfa()`, `me.totpSetup()`, `me.totpConfirm(code)`, `me.removeTotp()`, + `me.regenerateRecovery()`, `me.passkeyRegisterBegin()`, `me.passkeyRegisterFinish(...)`, + `me.renamePasskey(id, name)`, `me.deletePasskey(id)`, `me.stepUp(body)`; + ``; ``. + +- [ ] **Step 1: Add the client methods** + +In `web/lib/api.ts`, add a `me` export using `request` (these are under `/api`), +mirroring the existing object style. + +- [ ] **Step 2: Build `RecoveryCodes`** + +A card listing the 10 codes in a monospace grid, with "Copy all" and "Download +as .txt", and an "I have saved these" checkbox that enables the continue button. +The codes are shown exactly once — say so on the card. + +- [ ] **Step 3: Build `EnrolWizard`** + +Two branches, TOTP and passkey. TOTP renders the `otpauth_uri` through +`qrcode`'s `toDataURL` into an ``, shows the secret as selectable text for +manual entry, and takes the confirmation code. Passkey calls the register-begin +and register-finish pair. `mode` picks which endpoints to call: `"session"` uses +`/api/me/*`, `"ticket"` uses `/auth/mfa/enrol/*`, so the login page and the +security page share one wizard. + +- [ ] **Step 4: Build the security page** + +Sections: TOTP (status, Set up or Remove), Passkeys (list with created and last +used dates, rename, remove, "Add a passkey"), Recovery codes (count remaining, +Regenerate). When `applicable` is false, render only an explanation that the +identity provider manages sign-in. When `require_mfa` is true, say so, and +disable the remove buttons that would leave no factor. + +- [ ] **Step 5: Link it** + +Add "Security" to the sidebar user menu, next to the existing API keys link, +visible at every role. + +- [ ] **Step 6: Build** + +Run: `cd web && npm run build` +Expected: clean. + +- [ ] **Step 7: Commit** + +```bash +git add web/app/\(app\)/account web/components/mfa web/components/Sidebar.tsx web/lib/api.ts +git commit -m "feat(web): account security page and MFA enrolment wizard" +``` + +--- + +### Task 14: Web — the step-up modal and the settings controls + +**Files:** +- Modify: `web/lib/api.ts:641-667` (`request`) +- Create: `web/components/mfa/StepUpModal.tsx` +- Create: `web/lib/stepup.ts` +- Modify: `web/app/(app)/settings/page.tsx` + +**Interfaces:** +- Consumes: Task 9's 403 shape, Task 10's `/api/me/step-up`. +- Produces: `requestStepUp(methods: string[]): Promise` resolving when the user re-authenticates; `request()` retrying once after it resolves. + +- [ ] **Step 1: Write the step-up broker** + +Create `web/lib/stepup.ts`. `request()` lives outside React, so the modal is +reached through a registered callback rather than a hook: + +```ts +type Prompt = (methods: string[]) => Promise; + +let prompt: Prompt | null = null; + +// The provider registers the real prompt at mount. Before that, or outside the +// app shell, step-up simply fails rather than hanging forever. +export function registerStepUpPrompt(fn: Prompt | null) { + prompt = fn; +} + +export async function requestStepUp(methods: string[]): Promise { + if (!prompt) throw new Error("re-authentication is required"); + return prompt(methods); +} +``` + +- [ ] **Step 2: Hook it into `request()`** + +In `web/lib/api.ts`, inside `request`, before throwing on a failed response: + +```ts + if (res.status === 403 && !options?.__retried) { + const body = JSON.parse(text || "{}"); + if (body?.code === "step_up_required") { + await requestStepUp(body.methods ?? ["password"]); + return request(path, { ...options, __retried: true } as RequestInit); + } + } +``` + +Extend the local options type with an optional `__retried` flag. Retry exactly +once: a loop here would prompt forever against a server that keeps refusing. + +- [ ] **Step 3: Build `StepUpModal`** + +A dialog using the existing UI components, offering whichever of TOTP, passkey, +recovery code or password the `methods` array names. It registers its prompt +with `registerStepUpPrompt` on mount inside the `(app)` layout, resolves the +promise on success and rejects on cancel. Explain why it appeared: "This action +reveals a credential, so please confirm it is you." + +- [ ] **Step 4: Settings page controls** + +Add the owner-only "Require MFA for password sign-in" toggle with a warning that +members without a factor must enrol at their next sign-in. Add the MFA column to +the members table and a "Reset MFA" action for owners and admins, with a +confirmation dialog naming the member. + +- [ ] **Step 5: Build and check by hand** + +Run: `cd web && npm run build` +Then, against a dev server: reveal a secret more than 10 minutes after signing +in and confirm the modal appears, the reveal completes after it, and a second +reveal within 10 minutes does not prompt. + +- [ ] **Step 6: Commit** + +```bash +git add web/lib/api.ts web/lib/stepup.ts web/components/mfa/StepUpModal.tsx web/app/\(app\)/settings/page.tsx +git commit -m "feat(web): step-up modal and MFA settings controls" +``` + +--- + +### Task 15: End-to-end tests, OpenAPI and documentation + +**Files:** +- Create: `web/e2e/mfa.spec.ts` (match the existing Playwright layout; if there is none, create the config the same way the repo's other browser tests would) +- Modify: `server/internal/api/docs/openapi.json` (generated) +- Modify: `CLAUDE.md` +- Modify: `../vantage-docs/**` (a user guide page) +- Modify: `../vantage-gap-review.html` + +- [ ] **Step 1: Write the Playwright tests** + +Cover, with Chrome's virtual authenticator for the passkey paths: + +```ts +test("TOTP sign-in", async ({ page }) => { /* enrol, sign out, sign in with a generated code */ }); +test("passkey second factor", async ({ page, context }) => { /* addVirtualAuthenticator, enrol, sign in */ }); +test("passwordless passkey sign-in", async ({ page, context }) => { /* no password typed */ }); +test("forced enrolment when require_mfa is on", async ({ page }) => { /* owner enables, member must enrol */ }); +test("step-up on secret reveal", async ({ page }) => { /* modal, then reveal succeeds */ }); +``` + +Register the authenticator with: + +```ts +const client = await context.newCDPSession(page); +await client.send("WebAuthn.enable"); +const { authenticatorId } = await client.send("WebAuthn.addVirtualAuthenticator", { + options: { protocol: "ctap2", transport: "internal", hasResidentKey: true, hasUserVerification: true, isUserVerified: true }, +}); +``` + +Generate TOTP codes in the test with the `otplib` dev dependency, seeded from +the secret the setup endpoint returned. + +- [ ] **Step 2: Run them** + +Run: `cd web && npx playwright test e2e/mfa.spec.ts` +Expected: PASS. Fix the implementation, not the test, when one fails. + +- [ ] **Step 3: Regenerate the OpenAPI document** + +Run the same command `server-deploy.yml` runs (check the workflow for the exact +`swag` invocation), then: + +Run: `cd server && git diff --exit-code internal/api/docs/openapi.json || echo "regenerated, commit it"` + +- [ ] **Step 4: Document it in `CLAUDE.md`** + +Add a "Multi-factor authentication" subsystem section covering: the ticket +rather than a flagged session and why; the two collections and their indexes; +`require_mfa` living in `vantage-shared` and the `ciphertextFields` mirror; the +RP ID being the request host and passkeys therefore breaking if an instance +moves; step-up's ten-minute window and the API-token bypass; and the rate +limiter. Add the new routes to the route table and the two collections to the +MongoDB collections list. + +- [ ] **Step 5: Write the user guide** + +In `/go-projects/vantage/vantage-docs`, add a page covering enrolment, recovery +codes, what an owner's "require MFA" does, resetting a locked-out member, and +the host-binding caveat for passkeys. Follow that repository's existing page +structure and navigation config. + +- [ ] **Step 6: Mark the gap review shipped** + +In `/go-projects/vantage/vantage-gap-review.html`: add the `done` class to +`article#r-mfa`, swap its pill for `p-done` "Shipped", add a `.shipped` note +naming what shipped, strike through the `li` in the Build-next tier, and change +the Vantage cell in the "MFA for local accounts" comparison row from `n` to `y`. +Then republish the artifact with the Artifact tool, passing +`https://claude.ai/artifact/XDvTDd7uktvo3QTE4vQqSS` as `url` after reading it. + +- [ ] **Step 7: Full test sweep and commit** + +```bash +cd /go-projects/vantage/vantage-app +(cd server && go build ./... && go test ./internal/...) +(cd web && npm run build) +git add -A +git commit -m "test(mfa): end-to-end coverage, OpenAPI and documentation" +``` + +--- + +## Self-review notes + +- Spec coverage: data model (2), TOTP and recovery (3, 6), ticket (4), second + factor (5, 7), forced enrolment (6, 10), passwordless (8), step-up (9, 10, 14), + policy and rate limit (11), UI (12, 13, 14), tests and docs (15). Every + numbered spec section maps to a task. +- The known API-token bypass is asserted as intended behaviour in Task 9's + table, so it cannot be "fixed" silently by a later reviewer who has not read + the spec. +- `totp_pending_enc` was added during Task 3 and is not in the spec's table; + Task 1 notes the shared-map consequence. Flag it when reviewing Task 3.