diff --git a/.planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/19.6-PATTERNS.md b/.planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/19.6-PATTERNS.md new file mode 100644 index 00000000..3208df43 --- /dev/null +++ b/.planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/19.6-PATTERNS.md @@ -0,0 +1,544 @@ +# Phase 19.6: Two-factor auth and device flow - Pattern Map + +**Mapped:** 2026-10-21 +**Files analyzed:** 38 new or modified files in 6 repos +**Analogs found:** 36 / 38 + +Path roots used below (all siblings of `summercms.go`, under `/media/nvme/dev/golem15/summercms.io/summercms/`): +- `USER` = `fonoteka.go/plugins/golem15/user` (the sm-user-plugin checkout you edit and push from, v0.1.5 / `35cf166`; sociallogin's `go.work` already replaces the module with this path) +- `SOC` = `sm-sociallogin-plugin` (the template for the new plugin) +- `MFA` = `sm-mfa-plugin` (new repo, plugin `golem15.mfa`) +- `DEMO` = `sm-demo-app`, `BM` = `sm-bm-app`, `VDEMO` = `vue-demo-app` (mounted in DEMO as the `vue-demo-app` submodule) + +All analog paths are git-tracked in their own repos (checked with `git ls-files`). + +## File Classification + +### sm-user-plugin (additive seams + device flow) + +| New/Modified File | Role | Data Flow | Closest Analog | Match Quality | +|---|---|---|---|---| +| `USER/classes/login_gate.go` (new) | service registry | request-response | `USER/classes/oauth_providers.go` (published seam interface) | role-match | +| `USER/classes/events.go` (mod: `LoginEvent`, `PasswordChangedEvent`, `TwoFactorAdmin` iface) | model/event | event-driven | same file, `DeactivatingEvent` :59-67 | exact | +| `USER/controllers/registration.go` (mod: `FireLoginEvent`, `FirePasswordChangedEvent`) | utility | event-driven | same file, `FireDeactivatingEvent` :166-171 | exact | +| `USER/controllers/api_controller.go` (mod: Login gate :86-115, Refresh JTI hand-over :194-211, ResetPassword :327-343 / ChangePassword :580-597 fire hook) | controller | request-response | itself | exact | +| `USER/controllers/users_admin_controller.go` (mod: `reset_two_factor` action, admin password hook :238-247) | controller (cabana) | CRUD | same file `AdminRecordActions` :662-720 | exact | +| `USER/controllers/users/config_form.yaml` (mod: `recordActions`) | config | — | itself :17 | exact | +| `USER/admin_permissions.go` (mod: `golem15.users.reset_two_factor`) | config | — | itself, `manage_privileged_groups` entry | exact | +| `USER/controllers/devices.go` (new: initiate/status/authorize/list/revoke) | controller | request-response + CRUD | `USER/controllers/api_tokens.go` | exact | +| `USER/models/device_auth_session.go` (new) | model | CRUD | `USER/models/api_token.go` | exact | +| `USER/updates/2026102X0001_create_device_auth_sessions.go` (new) | migration | — | `USER/updates/202610040001_create_api_tokens.go` | exact | +| `USER/classes/device_auth.go` (new: token/short-code gen, hash, state UPDATEs) | service | CRUD | `USER/classes/api_tokens.go` (`MintAPIToken`, `HashAPIToken`) | exact | +| `USER/routes.go` (mod: 5 device routes) | route | — | itself :17-37 | exact | +| `USER/config/config.yaml` (mod: `device_auth.auth_url`, ttl) | config | — | itself | exact | +| `USER/README.md` (mod) | docs | — | itself | exact | + +### sm-mfa-plugin (new repo) + +| New File | Role | Data Flow | Closest Analog | Match Quality | +|---|---|---|---|---| +| `MFA/plugin.go` | plugin entry | — | `SOC/plugin.go` | exact | +| `MFA/routes.go` | route | — | `SOC/routes.go` | exact | +| `MFA/buckets.go` (`2fa-verify`) | middleware config | request-response | `sm-oauthserver-plugin/buckets.go` | exact | +| `MFA/admin_settings.go`, `MFA/admin_permissions.go` | config | — | `SOC/admin_settings.go`, `SOC/admin_permissions.go` | exact | +| `MFA/config.go`, `MFA/config/config.yaml` | config | — | `SOC/config.go`, `SOC/config/config.yaml` | exact | +| `MFA/mail.go` + `MFA/views/mail/two_factor_code{,-en}.htm` | config + template | — | `sm-paymentgateway-plugin/mail.go`; template `USER/views/mail/restore-en.htm` | exact | +| `MFA/boot.go` (listeners: `PasswordChangedEvent`, `DeletingEvent`) | event listener | event-driven | `sm-chatbots-plugin/boot.go` :82-98 | exact | +| `MFA/classes/gate.go` (implements `userclasses.LoginGate`) | service | request-response | Research Code Example 1 + `SOC/plugin.go` :101 publish | role-match | +| `MFA/classes/challenge.go` (create, guarded UPDATEs) | service | CRUD | `USER/classes/api_tokens.go` (hash), Research Code Example 2 | role-match | +| `MFA/classes/totp.go`, `qr.go` | utility | transform | Research Code Examples 3, 4 | no analog (new lib) | +| `MFA/classes/webauthn.go` | service | request-response | Research Code Examples 5, 6; cooler use from `SOC/classes/codes.go` :171-215 | partial | +| `MFA/classes/recovery.go`, `email.go`, `trusted.go`, `events.go` | service | CRUD / event | `USER/classes/api_tokens.go`; `USER/classes/events.go` | role-match | +| `MFA/controllers/helpers.go` | utility | — | `SOC/controllers/helpers.go` :1-26 | exact | +| `MFA/controllers/challenge.go`, `management.go`, `passwordless.go` | controller | request-response | `USER/controllers/api_tokens.go` (jwt.auth deps), `SOC/controllers/complete.go` (unauth) | exact | +| `MFA/models/*.go` (5 tables + `Settings`), `MFA/models/registry.go` | model | CRUD | `USER/models/api_token.go`; `SOC/models/settings.go`; `SOC/models/registry.go` | exact | +| `MFA/models/settings/fields.yaml` | config | — | `SOC/models/settings/fields.yaml` | exact | +| `MFA/updates/*.go`, `MFA/updates/registry.go` | migration | — | `USER/updates/202610040001_create_api_tokens.go`; `SOC/updates/*` | exact | +| `MFA/lang/{en,pl}/lang.yaml` | config | — | `SOC/lang/*/lang.yaml` | exact | +| `MFA/go.mod`, `MFA/go.work` | config | — | `SOC/go.mod`, `SOC/go.work` | exact | +| `MFA/testmain_test.go`, `MFA/harness_test.go`, `*_test.go` | test | — | `SOC/testmain_test.go`, `SOC/harness_test.go` | exact | +| `MFA/README.md` | docs | — | `SOC/README.md` | exact | + +### sociallogin, apps, frontend + +| File | Role | Data Flow | Closest Analog | Match Quality | +|---|---|---|---|---| +| `SOC/classes/flow.go` (mod: gate in `signIn` :311-324) | service | request-response | itself + `USER` Login gate | exact | +| `SOC/classes/codes.go` (mod: `CompletionPayload.Challenge`) | model | — | itself :35-40 | exact | +| `SOC/controllers/complete.go` (mod: write challenge as 200) | controller | request-response | itself :17-35 | exact | +| `SOC/models/settings.go` + fields.yaml (or mfa settings: social toggle lives in **mfa** per D-12) | — | — | — | — | +| `DEMO/summer.yaml`, `DEMO/go.work`, `DEMO/.gitmodules`, `DEMO/plugins.gen.go`, `DEMO/config/golem15.mfa.yaml` | config | — | sociallogin entries in the same files | exact | +| `DEMO/mfa_test.go` (boot + routes resolve) | test | — | `DEMO/sociallogin_test.go` | exact | +| `BM/summer.yaml` etc. | config | — | sociallogin entry in `BM/summer.yaml` :6-7 | exact | +| `fonoteka.go/plugins/golem15/fonoteka/phase07_coverage_test.go` (drop `"/devices"`) | test | — | itself :79-112 | exact | +| `VDEMO/app/composables/useDemoAuth.ts` (mod: challenge-aware login + verify) | composable | request-response | itself | exact | +| `VDEMO/app/pages/login.vue` (mod: challenge step) | component | request-response | itself | exact | +| `VDEMO/app/pages/account/security.vue` (new: TOTP setup/confirm, codes, disable) | component | request-response | `VDEMO/app/pages/login.vue` (form) + `subscriptions.vue` (logged-in gate) | role-match | +| `VDEMO/app/composables/useTwoFactor.ts` (new) | composable | request-response | `VDEMO/app/composables/useDemoAuth.ts` | role-match | +| `VDEMO/app/layouts/default.vue` (mod: "Security" link) | component | — | itself :22-25 | exact | + +--- + +## Pattern Assignments + +### `USER/classes/login_gate.go` (service registry, request-response) + +**Analog:** `USER/classes/oauth_providers.go` :13-31: a seam interface another plugin publishes, documented with the explicit-type-argument warning. +```go +// OAuthProviderSource is what a social-login plugin publishes so the user +// plugin can list its providers ... The user plugin looks it up on every +// request, so a plugin that boots later is still found. +// +// Publish it with an explicit type argument, +// +// app.Publish[classes.OAuthProviderSource](src) +// +// because a bare app.Publish(src) keys the service on its concrete type and +// the lookup misses it (the list then stays empty). +type OAuthProviderSource interface { ... } +``` +Lookup side (`USER/controllers/api_controller.go` :236-242): +```go +func oauthProviderSource(app *backpack.App) (classes.OAuthProviderSource, bool) { + if app == nil { + return nil, false + } + src, ok := app.Lookup[classes.OAuthProviderSource]() + return src, ok && src != nil +} +``` +Registry body: copy Research Code Example 1 (`LoginGates` with `sync.RWMutex`, `Add`, `Check` fail-closed). The user plugin publishes `*classes.LoginGates` in `Register`; mfa calls `.Add` in `Boot`. `TwoFactorAdmin` (D-15) is a second interface in the same style, published by mfa with `app.Publish[userclasses.TwoFactorAdmin](…)`. + +--- + +### `USER/classes/events.go` + `USER/controllers/registration.go` (events, event-driven) + +**Analog event type** (`classes/events.go` :59-67), copy for `PasswordChangedEvent{User, Tx, Source}` and `LoginEvent{User, Source}`; add `LoginEventName`/`PasswordChangedEventName` constants like :8-15: +```go +// DeactivatingEvent fires after a user is soft-deleted ... inside the same +// transaction ... A listener error rolls the deactivation back. +type DeactivatingEvent struct { + User *models.User + Tx *gorm.DB +} +``` +**Fire helper** (`controllers/registration.go` :166-171), copy as `FirePasswordChangedEvent` / `FireLoginEvent` (exported so mfa and sociallogin can fire the login event after verify/passwordless): +```go +func FireDeactivatingEvent(ctx context.Context, app *backpack.App, user *models.User, tx *gorm.DB) error { + if app == nil || app.Events == nil { + return nil + } + return app.Events.Fire(ctx, &classes.DeactivatingEvent{User: user, Tx: tx}) +} +``` + +--- + +### `USER/controllers/api_controller.go` (controller, request-response) + +**Login** :47-127. Insert the gate after `CheckAndRecordLogin(…, true)` (:94-101) and before `mintFor` (:115). `fields` is already parsed at :53 (`readFields(r)`), so pass it into `LoginAttempt` (Pitfall 2). Per D-18 A10, **move** the soft-delete restore block (:87-93) after the gate: +```go + if user.DeletedAt.Valid { + if err := gdb.WithContext(ctx).Unscoped().Model(user).Update("deleted_at", nil).Error; err != nil { + writeOpaque500(w) + return + } + sendTemplateVars(ctx, app, mailName(ctx, user, "golem15.user::mail.reactivate"), user.Email, mailVars(displayName(user), "", "")) + } + if err := classes.CheckAndRecordLogin(ctx, gdb, user, ip, true); err != nil { + ... + } + // <- gate goes here (Research Code Example 1), then the restore block, rehash, TouchLastSeen, mintFor + token, err := mintFor(app, secret, user, requestURL(r)) + ... + writeJSON(w, http.StatusOK, map[string]any{"token": token, "user": payload}) +``` +Error idiom everywhere: `writeOpaque500(w); return`. Fire `LoginEvent` just before the final `writeJSON`. + +**Refresh** :179-213. After `bouncer.Refresh` succeeds, add the JTI hand-over (Research Code Example 8) next to the existing best-effort `TouchLastSeen` block, with the same "log, never fail" idiom: +```go + if sub, _, _, _, err := bouncer.VerifyClaims(next, secret); err == nil { + if id, err := strconv.ParseUint(sub, 10, 64); err == nil && id != 0 { + if err := classes.TouchLastSeen(r.Context(), gdb, uint(id)); err != nil { + slog.Default().Warn("golem15.user: stamp last_seen on refresh", "user_id", id, "err", err) + } + } + } +``` +`bouncer.VerifyClaims(tokenString, secret) (sub, iat, exp, jti, err)` at `summercms.go/modules/bouncer/jwt.go:194`; `bouncer.Mint(secret, sub, issuerURL, ttl) (token, jti, err)` at `bouncer/mint.go:31`. + +**Password-write sites** for `FirePasswordChangedEvent`: ResetPassword :335-343 (`Updates(map[string]any{"password": hashed, "tokens_valid_after": now, "email_verified_at": now})`), ChangePassword :589-597. Wrap the `Updates` + fire in `gdb.Transaction` so a listener error rolls back (Pattern 5). Do **not** fire from the rehash at :102-110. + +**Blacklist on revoke** (copy from Logout :137-157): +```go + if bl, ok := app.Lookup[bouncer.BlacklistStore](); ok && bl != nil { + if err := bl.Add(r.Context(), jti, exp, time.Now()); err != nil { + writeOpaque500(w) + return + } + } +``` + +**Mail send helper** :931-943 (`sendTemplateVars`): mfa copies it verbatim with its own log prefix. + +--- + +### `USER/controllers/users_admin_controller.go` + `config_form.yaml` + `admin_permissions.go` (cabana, CRUD) + +**Analog:** `AdminRecordActions` :662-720. Add one more entry built like the `action(...)` closure but with `Permissions: []string{PermissionResetTwoFactor}`, `Danger: true`, `Applies` = lookup `classes.TwoFactorAdmin` (false when absent) then `HasTwoFactor`, `Run` = `cabana.TxFromContext(ctx)` then `svc.Reset(ctx, tx, user)`: +```go + return pact.AdminRecordAction{ + Name: name, + Label: usersLang + label, + Confirm: usersLang + confirm, + Permissions: []string{PermissionAccessUsers}, + Applies: func(ctx context.Context, record any) (bool, error) { ... }, + Run: func(ctx context.Context, in pact.AdminRecordActionInput) (pact.AdminRecordActionResult, error) { + user, err := asUser(in.Record) + ... + tx, ok := cabana.TxFromContext(ctx) + if !ok { + return pact.AdminRecordActionResult{}, errors.New("users admin: a record action needs its transaction") + } + ... + return pact.AdminRecordActionResult{Message: usersLang + success}, nil + }, + } +``` +`config_form.yaml` :17 becomes `recordActions: [activate, unban, unsuspend, reset_two_factor]`. Permission entry copies `golem15.users.manage_privileged_groups` in `admin_permissions.go` (no `Roles`). Lang keys go in `USER/lang/*/lang.yaml`. + +Admin password change :238-247: `changed, err := c.storePassword(ctx, user)` → when `changed`, fire `PasswordChangedEvent` with `requestDB(ctx, c.db)` as `Tx`. + +--- + +### `USER/controllers/devices.go` (controller, request-response + CRUD) + +**Analog:** `USER/controllers/api_tokens.go`. + +**Imports** (:3-17): +```go +import ( + "errors" + "net/http" + "strconv" + "strings" + "time" + + "git.golem15.com/golem15/sm-user-plugin/classes" + "git.golem15.com/golem15/sm-user-plugin/models" + "git.golem15.com/golem15/summercms/modules/backpack" + "git.golem15.com/golem15/summercms/modules/bouncer" + "git.golem15.com/golem15/summercms/modules/wire" + "gorm.io/gorm" +) +``` +**Auth deps** (:113-129), reuse for `authorize`, `GET devices`, `DELETE devices/{id}` (routes carry `"jwt.auth"`): +```go +func apiTokenDeps(w http.ResponseWriter, r *http.Request, app *backpack.App) (*gorm.DB, *bouncer.Principal, bool) { + ... + db, ok := app.Lookup[*gorm.DB]() + ... + user, ok := bouncer.User(r.Context()) + if !ok || user == nil || user.ID == 0 { + writeUnauthorized(w) + return nil, nil, false + } + return db, user, true +} +``` +**List** (:66-85) uses `wire.Slice(data)` for an empty `[]`. **Destroy by id** (:88-111): `strconv.ParseUint(r.PathValue("id"), 10, 64)`, 404 on `gorm.ErrRecordNotFound`, scoped `Where("user_id = ?", user.ID)`. Device `initiate`/`status` are unauthenticated; status uses the guarded `confirmed→used` UPDATE (Research Code Example 7) and mints with `bouncer.Mint` to keep the JTI (`mintFor` discards it). + +### `USER/routes.go` (route) +Add inside the existing `/_user/api/v1` group (:17-37). Keep each `g.Where("id", "[0-9]+")` **directly after** its `{id}` route (Pitfall 17), as at :35-36: +```go + g.Delete("/tokens/{id}", controllers.APITokenDestroy(p.app), "jwt.auth") + g.Where("id", "[0-9]+") +``` + +### `USER/models/device_auth_session.go` (model) +**Analog:** `USER/models/api_token.go` :10-50: explicit `gorm:"column:…"` tags, `TokenHash string … json:"-"`, `TableName()`, `Fillable()`, `Hidden()`, `func init() { Register(APIToken{}) }`. Table name `golem15_user_device_auth_sessions`; columns token_hash, short_code, status, user_id, device_name (bounded, Pitfall 14), ip, `jwt_jti`, expires_at, confirmed_at, used_at, last_activity_at. + +### `USER/updates/2026102X0001_create_device_auth_sessions.go` (migration) +**Analog:** `USER/updates/202610040001_create_api_tokens.go` :8-36: +```go +var apiTokenMigrations = []*gormigrate.Migration{ + { + ID: "202610040001_create_user_api_tokens", + Migrate: func(tx *gorm.DB) error { + return tx.Exec(`CREATE TABLE user_api_tokens ( + id BIGSERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + ... + token_hash VARCHAR(64) NOT NULL UNIQUE, + ... + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX user_api_tokens_user_id_index ON user_api_tokens (user_id);`).Error + }, + Rollback: func(tx *gorm.DB) error { + return tx.Exec("DROP TABLE IF EXISTS user_api_tokens").Error + }, + }, +} + +func init() { Register(apiTokenMigrations...) } +``` +`user_id` is nullable for device sessions (pending rows have no user). + +### `USER/classes/device_auth.go` (service) +**Analog:** `USER/classes/api_tokens.go` :75-111: `crypto/rand` bytes, hash with `HashAPIToken` (SHA-256 hex = the D-06 `sha256Hex` scheme), never store plaintext: +```go + raw := make([]byte, 32) + if _, err := rand.Read(raw); err != nil { + return "", nil, err + } + ... +func HashAPIToken(secret string) string { + sum := sha256.Sum256([]byte(secret)) + return hex.EncodeToString(sum[:]) +} +``` +Reuse `HashAPIToken` (or an exported alias) for device, challenge and trusted-device tokens instead of a new hash func. + +--- + +### `MFA/plugin.go` (plugin entry) + +**Analog:** `SOC/plugin.go`. Copy package doc, the interface assertion block (:26-35, add `pact.HasMailTemplates`, `surf.BucketProvider`), `PluginID` const, `//go:embed config` / `lang`, `Requires() []string{"golem15.user"}` (:72), and the Boot shape that **needs no database** (:91-102; DEMO `boot_test.go` boots without a DB, Pitfall 20): +```go +func (p *Plugin) Boot(app *backpack.App) error { + p.app = app + copyLegacyEnv(app) + if _, err := LoadConfig(app); err != nil { + return err + } + return app.Publish[userclasses.OAuthProviderSource](&providerSource{app: app, reg: p.reg}) +} +... +func (p *Plugin) Migrations() []*gormigrate.Migration { return updates.All() } +func (p *Plugin) Models() []any { return models.All() } +func init() { party.Register(&Plugin{}) } +``` +mfa's Boot: look up `*userclasses.LoginGates` and `Add` the gate, `app.Publish[userclasses.TwoFactorAdmin](…)`, register listeners. DB, cache and mailer are looked up per request. + +### `MFA/routes.go` (route) +**Analog:** `SOC/routes.go` :12-26: +```go +func (p *Plugin) Routes(r pact.Router) error { + r.Group("/_user/api/v1", surf.Use("throttle:user-api"), func(g pact.Router) { + g.Get("/oauth-complete", controllers.Complete(p.app)) + ... + }) + return nil +} +``` +mfa: two groups on `/_user/api/v1/2fa`: challenge + passwordless with `surf.Use("throttle:2fa-verify")`; management routes with `"jwt.auth"` per route (as `USER/routes.go` :33-36) and `g.Where("id", "[0-9]+")` after each `{id}` route. + +### `MFA/buckets.go` (`2fa-verify`, 10/min per IP) +**Analog:** `sm-oauthserver-plugin/buckets.go` :29-111, the per-IP variant at :96-99: +```go +var _ surf.BucketProvider = (*Plugin)(nil) + +func (p *Plugin) Buckets() map[string]surf.Bucket { + trusted := surf.TrustedProxies(nil) + if p.app != nil { + trusted = surf.TrustedProxies(p.app.Config) + } + ip := func(r *http.Request) string { return surf.ClientIP(r, trusted) } + ... + BucketMCPRegister: { + Name: BucketMCPRegister, Max: 10, Decay: time.Minute, + Key: func(r *http.Request) string { return "oauth-mcp-register-ip:" + ip(r) }, + }, +``` +Bucket names are global; mfa owns `"2fa-verify"`. + +### `MFA/admin_settings.go`, `MFA/admin_permissions.go`, `MFA/models/settings.go`, `MFA/models/settings/fields.yaml`, settings migration +**Analog:** `SOC/admin_settings.go` (whole file), `SOC/admin_permissions.go` (whole file), `SOC/models/settings.go`, `SOC/updates/202610200001_create_golem15_sociallogin_settings.go`. +```go +func (p *Plugin) Settings() []pact.SettingsItem { + return []pact.SettingsItem{{ + Code: SettingsCode, + Label: models.LangPrefix + "settings.label", + ... + Permissions: []string{PermissionAccessSettings}, + Form: "models/settings/fields.yaml", + NewModel: func() any { return &models.Settings{} }, + }} +} +``` +Settings model essentials (`SOC/models/settings.go`): singleton `ID` 1, nullable pointer columns where NULL = config default, `TableName`, `Fillable`, `Rules` (`"nullable|max:255"` style), `BeforeValidate` returning `&cabana.ValidationError{Details: map[string]any{"field": []string{"…"}}}` (:84-91; use it for the methods/groups allow-lists), and `LoadSettings(ctx, db)` per request (:129-141): +```go +func LoadSettings(ctx context.Context, db *gorm.DB) (*Settings, error) { + ... + var rows []Settings + if err := db.WithContext(ctx).Where("id = ?", 1).Limit(1).Find(&rows).Error; err != nil { + return nil, fmt.Errorf("golem15.sociallogin: settings: %w", err) + } + if len(rows) == 0 { + return &Settings{ID: 1}, nil + } + return &rows[0], nil +} +``` +Migration seeds row 1: `INSERT INTO … (id, created_at, updated_at) VALUES (1, NOW(), NOW())`, executed through `run(tx, []string{…})` from `SOC/updates/registry.go`. Field types: `dropdown` (mode), `taglist` `mode: array` (methods, groups), `number`, `switch` (Research §Q8). `TOTP secret` column uses `lagoon.Encrypted` with `gorm:"…;type:text" json:"-"` like `SOC/models/settings.go` :39. + +### `MFA/models/*.go` + `MFA/updates/*.go` (5 tables) +Model: `USER/models/api_token.go` (see above). Registry files: `SOC/models/registry.go` and `SOC/updates/registry.go` (whole files, `Register`/`All`, `run` helper). Migration SQL: `USER/updates/202610040001_create_api_tokens.go` style with `REFERENCES users(id) ON DELETE CASCADE`. Table names keep PHP's `golem15_user_*` (D-16). + +### `MFA/mail.go` + `MFA/views/mail/two_factor_code{,-en}.htm` +**Analog:** `sm-paymentgateway-plugin/mail.go` :1-24: +```go +//go:embed views/mail +var mailFS embed.FS + +func (p *Plugin) MailTemplatesFS() fs.FS { return mailFS } + +func (p *Plugin) MailTemplates() []string { + return []string{ + "golem15.paymentgateway::mail.order.canceled", + "golem15.paymentgateway::mail.order.canceled-en", +``` +mfa lists `golem15.mfa::mail.two_factor_code` and `…-en` (postcard requires the owning plugin prefix, Pitfall 18). Template front-matter from `USER/views/mail/restore-en.htm`: +``` +subject = "Reset your password" +description = "Password reset" +layout = "user" +== +Hello **{{ .Name }}**. +``` +mfa cannot use the `user` layout alias; use the default layout or ship `views/mail/layouts/…` with its own `MailLayouts()`. Pass both cases of vars (`"name"`/`"Name"`, `"code"`/`"Code"`, `"expiry_minutes"`/`"ExpiryMinutes"`) as `USER mailVars` (:924-929) does. Locale: `userclasses.MailTemplate(base, userclasses.ResolveMailLocale(ctx, user))`. + +### `MFA/boot.go` listeners +**Analog:** `sm-chatbots-plugin/boot.go` :82-98: +```go + app.Events.Listen[*userclasses.DeletingEvent](PluginID, p.onUserDeleting) +... +func (p *Plugin) onUserDeleting(ctx context.Context, ev *userclasses.DeletingEvent) error { + if p == nil || ev == nil || ev.User == nil { + return nil + } + db := ev.Tx + if db == nil { + db = p.db + } + ... + return db.WithContext(ctx).Where("user_id = ?", ev.User.ID).Delete(&models.BotFlag{}).Error +} +``` +mfa: `PasswordChangedEvent` → delete trusted devices through `ev.Tx`. + +### `MFA/controllers/*.go` (controller, request-response) +- **Helpers:** copy `SOC/controllers/helpers.go` :22-26 (`writeJSON` with `Cache-Control: no-cache, private` + `wire.WriteJSON`). +- **Management (jwt.auth):** `USER/controllers/api_tokens.go` `apiTokenDeps` pattern (above). Management errors are PHP's 401 `{"error":"Unauthorized"}`. +- **Challenge (unauth):** shape of `SOC/controllers/complete.go` :17-35: read input, call a classes function, log with `slog.Default().Error("golem15.mfa: …", "err", err)` + 500 `{"error":"Server error"}`, map not-found to the PHP status. +- **Token mint after verify:** `usercontrollers.FinishLogin` → `usercontrollers.IssueToken` → `usercontrollers.APIArray` exactly as `SOC/classes/flow.go` :311-324 (`signIn`), then `FireLoginEvent`. +- **Validation:** 422 `{error, errors}` via a local `writeValidation` copied from `USER/controllers/api_controller.go` :945-950. + +### `MFA/classes/webauthn.go` ceremony state in cooler +**Analog:** `SOC/classes/codes.go` :171-215 (`cooler.FromApp(app)`, `cache.Set(ctx, key, value, ttl)`, `cache.Pull(ctx, key)`, reject `len(code) > 128`). Ceremony state needs no AEAD sealing (it holds no secret), so a plain JSON `Set`/`Pull` suffices. + +### `MFA/testmain_test.go`, `MFA/harness_test.go` +**Analog:** `SOC/testmain_test.go` (TestMain with `flag.Parse()` + `testing.Short()`, `postgres:16-alpine`, `freshDB(t)` at :81-83) and `SOC/harness_test.go` :34-80 (`bootSocialWith`: freshDB → `lagoon.Use` → `httptest.NewUnstartedServer` → temp config dir with `app.yaml` → `party.Activate`). Rename DB/user to `golem15_mfa`. Blank-import `_ "git.golem15.com/golem15/sm-user-plugin"` as SOC does (:22). + +### `MFA/go.mod`, `MFA/go.work` +**Analog:** `SOC/go.work`: +``` +go 1.27.0 + +use . + +replace git.golem15.com/golem15/summercms => ../summercms.go + +replace git.golem15.com/golem15/sm-user-plugin => ../fonoteka.go/plugins/golem15/user +``` +`SOC/go.mod` requires `sm-user-plugin v0.1.5` and `summercms v0.3.1`; mfa requires the **new** user-plugin tag carrying the seams, plus pquerna/otp v1.5.0, go-webauthn v0.18.2, boombuler/barcode v1.1.0 (after the decision note). + +--- + +### `SOC/classes/flow.go`, `SOC/classes/codes.go`, `SOC/controllers/complete.go` (D-09) +`signIn` (:311-324) is where the gate goes, before `IssueToken`, with `Source: "social"`, and only when the mfa social toggle is on (the gate itself reads the setting; sociallogin just calls `gates.Check`). `linkIdentity` (flow.go ~:128-165) stays ungated. +```go +func signIn(ctx context.Context, app *backpack.App, r *http.Request, db *gorm.DB, user *usermodels.User, action, returnTo string) (Outcome, error) { + if err := usercontrollers.FinishLogin(ctx, app, db, user, ClientIP(app, r)); err != nil { + return Outcome{}, err + } + token, err := usercontrollers.IssueToken(app, user, r) + ... + return Outcome{Kind: OutcomeLogin, Token: token, User: payload, Action: action, ReturnTo: returnTo}, nil +} +``` +Note: `FinishLogin` restores soft-deleted accounts, so per D-18 A10 the gate must run **before** `FinishLogin`. Add `Challenge map[string]any \`json:"challenge,omitempty"\`` to `CompletionPayload` (codes.go :35-40); in `Complete` (:34), when `p.Challenge != nil`, `writeJSON(w, http.StatusOK, p.Challenge)` instead of `writeSession`. + +### DEMO / BM adoption +- `DEMO/summer.yaml`: add `- id: golem15.mfa` / `module: git.golem15.com/golem15/sm-mfa-plugin` after `golem15.sociallogin` (pattern: existing entries). +- `DEMO/go.work`: add `./plugins/golem15/mfa` to `use (…)`. +- `DEMO/.gitmodules`: copy the `plugins/golem15/user` stanza (`url = git@git.golem15.com:golem15/sm-mfa-plugin.git`, `branch = master`); add via `ssu`. +- Regenerate `plugins.gen.go`/`main.go` with `summer build`. +- `DEMO/config/golem15.mfa.yaml`: WebAuthn `rp_id`/`origins` override for local use (Pitfall 7; app url is `http://127.0.0.1:8097`, frontend `http://127.0.0.1:3020`). +- Test: copy `DEMO/sociallogin_test.go` :16-40 (`testConfig` → `backpack.New` → `party.Activate(app, PluginIDs)` → `surf.BuildRouter` → assert `/_user/api/v1/2fa/...` and `/devices` routes resolve). +- `BM/summer.yaml` :4-7 same entry; bm stays API-only. + +### `fonoteka.go/plugins/golem15/fonoteka/phase07_coverage_test.go` +Remove `"/devices",` from `banned` (:97-105); keep `"2fa"`. Separate pointer-bump commit in fonoteka.go. + +### `VDEMO` (D-20) +**`app/composables/useDemoAuth.ts`** (whole file, 20 lines). Extend `login` to detect `two_factor_required` and return the challenge instead of throwing; add `verify(challengeToken, method, code)` → `POST /_user/api/v1/2fa/verify` and `recovery(challengeToken, code)` → `/2fa/recovery`, both setting `token.value`: +```ts +export function useDemoAuth() { + const token = useCookie(DEMO_TOKEN_COOKIE, { maxAge: 60 * 60 * 24, sameSite: 'lax' }) + const loggedIn = computed(() => !!token.value) + + async function login(email: string, password: string) { + const res = await $fetch<{ token?: string; ... }>( + '/_user/api/v1/login', + { method: 'POST', body: { email, password } }, + ) + const value = res.token ?? res.access_token ?? res.data?.token ?? res.data?.access_token + if (!value) throw new Error('No token in the login answer') + token.value = value + } +``` +**`app/pages/login.vue`**: keep the `submit`/`busy`/`error` idiom and the `next` redirect guard (:15-22); add a second form shown when a challenge is pending (code input with `autocomplete="one-time-code"`, a "use a recovery code" toggle). Error extraction `e?.data?.message || e?.data?.error`. +**New `app/pages/account/security.vue`** + **`app/composables/useTwoFactor.ts`**: `$fetch` with `headers: { Authorization: \`Bearer ${token.value}\` }` to `/2fa/status`, `/2fa/totp/setup` (render `qr_code_svg` with `v-html`, the SVG is server-generated), `/2fa/totp/confirm` (show `recovery_codes` from D-19 once), `DELETE /2fa/totp`. Logged-out notice copied from `subscriptions.vue` :21-23. Markup classes: `wrap section section--narrow`, `panel`, `form`, `button`, `form__error`, `notice`. +**`app/layouts/default.vue`** :22-25: add a `NuxtLink to="/account/security"` next to "Log out". `/_user` is already in `goPaths` in `nuxt.config.ts`, so no proxy change. + +--- + +## Shared Patterns + +### JSON responses +**Source:** `SOC/controllers/helpers.go` :22-26; user plugin `writeJSON`/`writeOpaque500`/`writeUnauthorized`. +**Apply to:** every mfa and device handler. Timestamps through `wire.Time` (`+00:00`), lists through `wire.Slice`. + +### Cross-plugin service seams +**Source:** `USER/classes/oauth_providers.go` :13-31 and `SOC/plugin.go` :101. Always `app.Publish[Iface](impl)` with the explicit type and look up per request. **Apply to:** `LoginGates`, `TwoFactorAdmin`. + +### Typed events +**Source:** `USER/classes/events.go`, `USER/controllers/registration.go` :129-171, listener `sm-chatbots-plugin/boot.go` :82-98. Events carry `Tx`; listener errors roll the write back. **Apply to:** `LoginEvent`, `PasswordChangedEvent`, all `golem15.user.2fa.*` events (as typed structs with `…EventName` constants). + +### Hashed tokens at rest +**Source:** `USER/classes/api_tokens.go` :75-99 (`crypto/rand` 32 bytes, `HashAPIToken` SHA-256 hex, `token_hash VARCHAR(64) NOT NULL UNIQUE`). **Apply to:** challenge, trusted-device and device-session tokens. Slow hash for recovery/email codes: `bouncer.HashPassword(cost, plain)` / `bouncer.CheckPassword(hash, plain)` with the cost read as in `USER/controllers/api_controller.go` :102-105. + +### Guarded single-statement state changes +**Source:** Research Code Examples 2 and 7 (no existing analog; the user plugin uses read-modify-write). **Apply to:** challenge attempts/completion, recovery consume, device confirm/used, email send count. + +### Raw-SQL migrations with registry +**Source:** `SOC/updates/registry.go`, `USER/updates/202610040001_create_api_tokens.go`. **Apply to:** all six tables. Never GORM AutoMigrate. + +### Docker-gated tests +**Source:** `SOC/testmain_test.go` (flag.Parse + testing.Short). **Apply to:** mfa and user-plugin DB tests. + +## No Analog Found + +| File | Role | Data Flow | Reason | +|---|---|---|---| +| `MFA/classes/totp.go`, `MFA/classes/qr.go` | utility | transform | First use of pquerna/otp and boombuler; use Research Code Examples 3 and 4 | +| `MFA/classes/webauthn.go` (ceremony logic) | service | request-response | First WebAuthn use; use Research §Q6 and Code Examples 5-6 (only the cooler storage has an analog) | + +## Metadata + +**Analog search scope:** `fonoteka.go/plugins/golem15/user`, `sm-sociallogin-plugin`, `sm-oauthserver-plugin`, `sm-paymentgateway-plugin`, `sm-chatbots-plugin`, `sm-demo-app`, `sm-bm-app`, `vue-demo-app`, `fonoteka.go/plugins/golem15/fonoteka`, `summercms.go/modules/bouncer` +**Files scanned:** ~45 +**Pattern extraction date:** 2026-10-21