From 5f76e5bbd238ae46e82ec2af0e6fdd4a17057191 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Sat, 17 Oct 2026 23:27:52 +0200 Subject: [PATCH] docs(19.6): capture phase context --- .../19.6-CONTEXT.md | 189 ++++++++++++++++++ .../19.6-DISCUSSION-LOG.md | 60 ++++++ 2 files changed, 249 insertions(+) create mode 100644 .planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/19.6-CONTEXT.md create mode 100644 .planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/19.6-DISCUSSION-LOG.md diff --git a/.planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/19.6-CONTEXT.md b/.planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/19.6-CONTEXT.md new file mode 100644 index 00000000..8977f695 --- /dev/null +++ b/.planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/19.6-CONTEXT.md @@ -0,0 +1,189 @@ +# Phase 19.6: Two-factor auth and device flow in sm-user-plugin - Context + +**Gathered:** 2026-10-17 +**Status:** Ready for planning + + +## Phase Boundary + +This phase ports the PHP user plugin's second factors and device login (wavepath and Horoskopia carry byte-identical copies): + +- TOTP, WebAuthn, email codes, recovery codes and trusted devices. +- The unauthenticated challenge step during login. +- The QR/short-code device authorization flow. + +The discussion moved most of it out of sm-user-plugin: + +- **2FA** goes into a **new shared plugin, `sm-mfa-plugin`**, which requires the user plugin. sm-user-plugin gets one additive login-gate seam. +- The **device flow** stays **in sm-user-plugin**. It has no third-party dependencies. + +Scope additions beyond the roadmap: +- Passwordless passkey login as API routes. +- An admin "Reset 2FA" action. +- A backend toggle that puts social login through the challenge. + +Repos: `sm-mfa-plugin` (new), `sm-user-plugin`, `sm-sociallogin-plugin` (it calls the gate; the plugin comes from 19.5), `sm-demo-app` and `sm-bm-app`. fonoteka.go must keep building and passing its tests at the bumped sm-user-plugin pin. + + + + +## Implementation Decisions + +### Placement and libraries +- **D-01:** **2FA lives in a separate plugin**: repo `sm-mfa-plugin`, Go plugin `golem15.mfa`, which `Require()`s the user plugin. This follows the 19.5 sociallogin and 19.4 oauthserver pattern. Apps without 2FA carry no WebAuthn/CBOR/COSE dependencies, no extra tables and no `/2fa` routes. The repo layout and commit order follow `.planning/notes/core-plugins-own-repos.md`. — **Reversibility:** costly — apps' `summer.yaml`, submodules and settings reference the plugin once it ships. +- **D-02:** **Login-gate seam in sm-user-plugin.** This is an additive registry of gates. `Login` consults them after `CheckAndRecordLogin` succeeds (`controllers/api_controller.go:94-101`) and before `mintFor` (`:115`). + - A gate may return a challenge payload. `Login` then answers HTTP 200 `{two_factor_required:true, challenge_token, available_methods[], expires_at}` (PHP `ApiController.php:55-76`) instead of a token. + - With no gate registered, login behaves exactly as today. + - The mfa plugin registers the gate and mounts the challenge-completion routes, which mint the token through the user plugin's exported helpers. + - sociallogin's `oauth-complete` consults the same gate (D-09). + - JWT refresh never consults the gate, as in PHP. +- **D-03:** The **device authorization flow stays in sm-user-plugin**. It is small and dependency-free, and its paths are already under `/_user/api/v1`. +- **D-04:** **Libraries** (a decision note under `.planning/notes/` names each with its pinned version before code lands; `.planning/research/STACK.md` is updated): + - TOTP: `github.com/pquerna/otp`. Issuer is the app name, the account is the user's email, the window is ±1 step, and digits and period stay at the defaults as in PHP. + - WebAuthn: `github.com/go-webauthn/webauthn`. The RP ID is the app URL's host and the RP name is the app name, as in PHP. + - QR: the **researcher picks** a library that can emit SVG, so the TOTP setup contract `{secret, qr_code_svg, qr_code_url}` stays. + +### Contract: verbatim vs hardened +- **D-05:** **Route paths, request fields and response shapes follow PHP** (`routes.php:303-336`, `:372-453`; `TwoFactorApiController.php`): + - Challenge group: `/_user/api/v1/2fa/{verify,recovery,email/send,webauthn/options,webauthn/verify}`, unauthenticated, in the **`2fa-verify` bucket (10/min per IP)**. + - Management group: `jwt.auth`, with `status`, `totp/setup|confirm`, `DELETE totp`, `email/enable`, `DELETE email`, `webauthn/register/options`, `webauthn/register`, `DELETE webauthn/{id}`, `GET recovery-codes?password=`, `POST recovery-codes/regenerate`, `DELETE trusted-devices/{id}` and `DELETE trusted-devices`. + - The method list and the trusted-device list stay inside `status`. + - Challenge: TTL 5 min, `max_attempts` 5, and every verify increments the counter. Recovery codes are always accepted through `/recovery`. + - Recovery codes: 10 per set, format `XXXXX-XXXXX` over the alphabet `23456789ABCDEFGHJKMNPQRSTUVWXYZ`, stored hashed, and listing returns only the remaining count. + - Email code: 6 digits stored hashed, template `two_factor_code` with vars `name`, `code`, `expiry_minutes`, registered with `-en` variants as the user plugin's mail templates are. + - Events: the PHP names (`golem15.user.2fa.challenged|failed|recovery_used|verified|enabled|disabled|webauthn.registered|webauthn.removed|recovery_regenerated`), then the user login event after verify. +- **D-06:** **Tokens at rest are SHA-256 hashes**: challenge tokens, trusted-device tokens and device-auth session tokens (the same `sha256Hex` scheme as wristband and API tokens). Clients see no difference. A future Winter import hashes the plaintext PHP rows on the way in. The TOTP secret is encrypted (`lagoon.Encrypted`). Recovery codes and email codes keep a slow hash; research picks bcrypt or argon2id to match the user plugin's password hashing. +- **D-07:** **Revocation on security events goes beyond PHP**: + - Any password change revokes all trusted devices: API change, reset or admin set. PHP only does this for a CMS password change. + - Disabling the user's last 2FA method revokes all trusted devices. + - Regenerating recovery codes invalidates the old set, as in PHP. + + The user plugin fires the password-change hook (additive), and mfa listens. +- **D-08:** **WebAuthn JSON uses go-webauthn's standard shapes**: base64url `PublicKeyCredentialCreationOptions`, `PublicKeyCredentialRequestOptions` and the attestation/assertion responses that `navigator.credentials` and `@simplewebauthn/browser` produce. The paths stay PHP's. This is a **deliberate, recorded deviation** from lbuchs' plain-base64 layout, acceptable because no client implements it yet. Attestation is `none`, the timeout is 60 s, and user verification is `preferred` for second-factor use. — **Reversibility:** costly — clients built against the shape would break. +- **D-09:** **Social login and 2FA.** A backend setting (D-12) decides whether `oauth-complete` goes through the challenge. The **default is skip, as PHP does**. When enabled, sociallogin's `oauth-complete` consults the login gate and returns the same `two_factor_required` shape instead of `{token,user,…}`. The SPA callback page must handle that shape before an app turns the setting on. +- **D-10:** **Trusted devices**: off by default (`trusted_device_enabled=false`), 30 days. With `trust_device` on a successful verify, the response adds `trusted_device_token` (PHP `TwoFactorApiController.php:578-588`), and the client sends it back in the login body. The CMS cookie variant (`g15_trusted_device`) is not ported, since there is no CMS. + +### Device flow (sm-user-plugin) +- **D-11:** **PHP's custom QR plus short-code flow is kept** (not RFC 8628), so QuestStream's `useDeviceAuth.ts` keeps working: + - Routes inside the `user-api` group: `POST devices/initiate`, `GET devices/status/{token}`, `POST devices/authorize` (JWT, accepts `short_code` or `token`), `GET devices` and `DELETE devices/{id}`. + - A 64-char token and an `XXXX-XXXX` short code (recovery alphabet), with a 5 min TTL. + - `auth_url` is a **configurable frontend URL template** (default `{frontend_url}/device/scan/{token}`), because Go has no CMS page. + - The model is `DeviceAuthSession`, with PHP's status enum `pending|confirmed|used|expired|revoked`. +- **D-12a:** **The polling device gets a one-time JWT.** On the first poll after confirmation, `devices/status/{token}` returns PHP's `{status:'confirmed', user_id}` plus `token` and `user`. An atomic `confirmed→used` transition guarantees that only one poll receives it, and later polls see `used`. PHP's API never issued a token (only its CMS component logged the device in), so this is an additive extension without which the API flow cannot sign anything in. +- **D-12b:** **Revoking a device session blacklists the issued token's JTI**, and its refresh lineage, on the existing JWT blacklist. The session row stores the JTI. A revoked TV or desktop is signed out at once. +- **D-12c:** **Authorizing needs only a valid JWT, as in PHP.** The authorizing user already passed login and 2FA, and the device binds to them. The frontend shows the device name and IP before confirming. There is no 2FA re-check. + +### Modes, settings and admin (mfa) +- **D-12:** **A backend Settings screen in mfa** (`pact.HasSettings`) holds PHP's keys and defaults, which config supplies as defaults: + - `two_factor_mode`: `disabled|optional|enforced`, default `optional` + - `two_factor_available_methods`: default `[totp,email]` + - `two_factor_enforce_groups` + - `two_factor_email_code_ttl`: 10 + - `two_factor_challenge_ttl`: 5 + - `two_factor_passwordless_login`: default off + - `trusted_device_enabled`: off + - `trusted_device_ttl_days`: 30 + - **new** social-login challenge toggle: default off/skip + + Changes apply without a restart. +- **D-13:** **The child-account exemption is dropped.** The Go user plugin has no child-account concept, so only `mode=disabled` exempts. Per-group exemptions may come much later; see deferred. +- **D-14:** **Passwordless passkey login ships as API routes now**: `/_user/api/v1/2fa/webauthn/passwordless/options` and `.../verify` (exact paths are the planner's call within the `/2fa` prefix and the `2fa-verify` bucket). They issue a JWT directly. Rules: + - Only **discoverable credentials registered with user verification `required`** may be used. Second-factor-only credentials are rejected. + - A successful passkey login satisfies 2FA, so no second challenge follows. + - It is gated by `two_factor_passwordless_login`, off by default. + - It still enforces activated/not-banned and the login event. + + It gets an explicit security review. — **Reversibility:** costly — once users register passkeys, removing the route strands them. +- **D-15:** **Admin "Reset 2FA" on the Users screen**, behind a new backend permission. It disables all methods, deletes the WebAuthn credentials and recovery codes, revokes trusted devices and fires `golem15.user.2fa.disabled`. It is additive: a cabana action contributed by mfa to the user plugin's Users screen. The mechanism (a cabana extension hook vs a new action) is researched, and if the user plugin needs a seam, it is additive. + +### Tables +- **D-16:** The six PHP tables are ported with PHP's names and columns (`golem15_user_two_factor_methods`, `_two_factor_challenges`, `_two_factor_recovery_codes`, `_webauthn_credentials`, `_trusted_devices` in mfa; `golem15_user_device_auth_sessions` in sm-user-plugin), adjusted for D-06 hashing and the D-12b JTI column. The names stay so a later Winter import is a near-straight copy. + +### Adoption +- **D-17:** **sm-demo-app and sm-bm-app** mount mfa in this phase, and both get the device flow through the user plugin pin bump. fonoteka.go does not mount mfa. Its guard test `fonoteka/phase07_coverage_test.go:79` (`TestNoDeferredRoutesResolve`) fails once `/devices` routes resolve, so it is updated in the fonoteka pointer-bump commit. Commit order: sm-user-plugin → sm-mfa-plugin and sociallogin pushed → app pointer bumps as separate commits. Push, tag and repo creation need the user's go-ahead. + +### Claude's Discretion +- Go identifiers for the gate interface, registry and payloads. +- The QR library (research, D-04). +- Slow-hash choice for codes (D-06). +- Exact passwordless route names inside `/2fa` (D-14). +- How the admin reset action plugs into the Users screen (D-15). +- Whether 19.6 lands before or after 19.5's sociallogin release. If before, the D-09 gate call in sociallogin is added when sociallogin exists. + + + + +## Canonical References + +**Downstream agents MUST read these before planning or implementing.** + +### PHP contract (authoritative; under `/media/nvme/dev/jz/wavepath.org/plugins/golem15/user`) +- `routes.php`: buckets `:8-18`, device routes `:191-224`, challenge group `:303-336`, management group `:372-453`. +- `controllers/ApiController.php`: login challenge `:55-76`, refresh `:149-174`, device authorize `:1233-1270`, initiate `:1277-1308`, status `:1315-1347`. +- `controllers/TwoFactorApiController.php`: validation `:37-218`, trusted-device token `:578-588`. +- `classes/TwoFactorService.php` (or wherever it lives in the plugin): exemptions `:33-47`, methods `:54-69`, enforced mode `:95-109`, challenge `:116-152`, trusted devices `:455-478`, status `:549-594`. +- `models/TwoFactorMethod.php`, `TwoFactorChallenge.php`, `TwoFactorRecoveryCode.php:59-112`, `TrustedDevice.php:36-56`, `WebAuthnCredential`, `DeviceAuthSession`; migrations under `updates/v3.0.0`, `v3.1.0`. +- `TotpMethod.php:34-54` (QR contract), `WebAuthnMethod.php:18-20,125-133,316-321`, `EmailMethod.php:52-68`, `views/mail/two_factor_code.htm`. +- `models/Settings.php:56-63`, `models/settings/fields.yaml:90-160`. +- `components/TwoFactor.php:278,293` (passwordless CMS handlers) and `components/DeviceAuth.php:214-247` (CMS device login). +- PHP has almost no functional tests here (only `tests/security/AuthenticationTest.php:149`), so the Go tests are designed from these decisions. + +### Clients +- `/media/nvme/dev/golem15/queststream.online/vue-queststream-app/composables/useDeviceAuth.ts`: the only API device-flow client (2 s polling `:117-124`). +- `/media/nvme/dev/jz/wavepath.org/vue-wavepath-app/src/types/auth.ts:157-162`: the `TwoFactorChallenge` type. +- `/media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/app/stores/auth.ts:328`: the `two_factor_required` stub. + +### Go code to extend +- `../fonoteka.go/plugins/golem15/user/controllers/api_controller.go`: Login `:47-127`, refresh `:179-213`, `sendTemplateVars` `:873-885`. +- `../fonoteka.go/plugins/golem15/user/plugin.go`: throttle buckets `:148-169`, mail templates `:187-198`; `classes/throttle.go`, `classes/mail.go`, `routes.go:10-30`. +- `../fonoteka.go/plugins/golem15/fonoteka/phase07_coverage_test.go:79`: the guard test to update. +- `modules/pact/capabilities.go:567-591` (`HasSettings`); analog `../sm-paymentgateway-plugin/admin_settings.go`. + +### Project decisions +- `.planning/phases/19.5-social-login-in-sm-user-plugin/19.5-CONTEXT.md`: the separate-plugin pattern, sociallogin's `oauth-complete` (D-09 hooks into it). +- `.planning/phases/19.4-oidc-provider-on-wristband/19.4-CONTEXT.md` D-05: additive-only changes to sm-user-plugin. +- `.planning/phases/07-*/07-CONTEXT.md`: the original deferral of the `2fa-verify` bucket and these routes. +- `.planning/notes/core-plugins-own-repos.md`, `.planning/notes/wavepath-framework-asks-division.md`. + + + + +## Existing Code Insights + +### Reusable Assets +- The user plugin's `mintFor`/`IssueToken`/`APIArray`: the challenge-completion and device-poll responses reuse the `{token,user}` shape. +- The JWT blacklist and refresh-lineage revocation (bouncer/user plugin): device revoke (D-12b). +- `postcard.Mailer` via `app.Lookup` and the `-en` template registration pattern: the email code. +- `surf.Bucket` map in `plugin.go`: add `2fa-verify` (10/min, per IP). +- `lagoon.Encrypted` for the TOTP secret, and `sha256Hex` from wristband/API tokens for hashed tokens. + +### Established Patterns +- Handlers as package-level funcs tested via httptest with `bouncer.WithUser`; DB tests on testcontainers Postgres, skipped under `-short`. +- Additive seams in sm-user-plugin (19.5 adds a provider-list service lookup; 19.6 adds the login-gate registry and a password-changed hook). + +### Integration Points +- `Login` (gate), the password-change paths (hook), the Users admin screen (reset action), and sociallogin `oauth-complete` (gate). + + + + +## Specific Ideas + +- The user wants the social-login 2FA choice as a backend setting, default skip, enableable per site. +- Per-group 2FA exemptions might come "in the long future" through user group settings. + + + + +## Deferred Ideas + +- Per-group 2FA exemptions (settings on user groups), replacing PHP's child-account exemption. Long term. +- Notification mails on 2FA enabled/disabled or a new trusted device (not in PHP). +- Winter import of the 2FA and device tables (Phase 20 `import:winter`, hashing plaintext tokens per D-06). + + + +--- + +*Phase: 19.6-two-factor-auth-and-device-flow-in-sm-user-plugin* +*Context gathered: 2026-10-17* diff --git a/.planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/19.6-DISCUSSION-LOG.md b/.planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/19.6-DISCUSSION-LOG.md new file mode 100644 index 00000000..a5c28f7f --- /dev/null +++ b/.planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/19.6-DISCUSSION-LOG.md @@ -0,0 +1,60 @@ +# Phase 19.6: Two-factor auth and device flow in sm-user-plugin - Discussion Log + +> **Audit trail only.** Do not use as input to planning, research, or execution agents. +> Decisions are captured in CONTEXT.md — this log preserves the alternatives considered. + +**Date:** 2026-10-17 +**Phase:** 19.6-two-factor-auth-and-device-flow-in-sm-user-plugin +**Areas discussed:** Placement & libraries, Verbatim vs hardened, Device flow semantics, Modes/settings/admin + +--- + +## Placement & libraries + +| Option | Description | Selected | +|--------|-------------|----------| +| Separate plugin + login-gate hook | Like sociallogin | ✓ | +| In sm-user-plugin (roadmap) | | | + +Device flow: in sm-user-plugin ✓ (alternatives: in the 2FA plugin, its own plugin). +Libraries: pquerna/otp + go-webauthn, QR chosen by research ✓. +Name: **mfa** ✓ (alternative: twofactor). + +## Verbatim vs hardened + +| Question | Options | Selected | +|----------|---------|----------| +| Social login + 2FA | Challenge with opt-out / Skip as PHP | **Other:** backend setting, default skip, can enable the challenge | +| Tokens at rest | SHA-256 / plaintext | SHA-256 ✓ | +| Revocation | All password changes + 2FA disable / as PHP | Revoke on all ✓ | +| WebAuthn JSON | go-webauthn standard / mimic lbuchs | Standard ✓ | + +## Device flow semantics + +| Question | Options | Selected | +|----------|---------|----------| +| Poll result | One-time JWT / PHP shape only | One-time JWT ✓ | +| Format | PHP custom QR + short code / RFC 8628 | PHP custom ✓ | +| Revoke | Blacklist JTI / mark only | Blacklist ✓ | +| Authorize | JWT only / 2FA re-check | JWT only ✓ | + +## Modes, settings & admin + +| Question | Options | Selected | +|----------|---------|----------| +| Settings | Backend screen / config only | Backend screen ✓ | +| Child exemption | Generic hook / drop | Drop. "We might include exemptions from 2FA in other ways in future (ie settings on user groups) but that's long future" | +| Passwordless | Defer / API routes now | API routes now ✓ | +| Admin reset | Users screen / not now / console only | Users screen ✓ | +| Passkey bar | Discoverable + UV required / you decide | Discoverable + UV ✓ | +| Adoption | demo / bm / fonoteka | sm-demo-app, sm-bm-app ✓ | + +## Claude's Discretion + +Gate identifiers, the QR library, slow-hash choice, passwordless route names, the admin reset mechanism, and ordering against 19.5. + +## Deferred Ideas + +- Per-group exemptions. +- 2FA notification mails. +- Winter import of 2FA tables (Phase 20).