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.