docs(19.6): capture phase context
This commit is contained in:
@@ -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
|
||||
|
||||
<domain>
|
||||
## 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.
|
||||
|
||||
</domain>
|
||||
|
||||
<decisions>
|
||||
## 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.
|
||||
|
||||
</decisions>
|
||||
|
||||
<canonical_refs>
|
||||
## 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`.
|
||||
|
||||
</canonical_refs>
|
||||
|
||||
<code_context>
|
||||
## 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).
|
||||
|
||||
</code_context>
|
||||
|
||||
<specifics>
|
||||
## 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.
|
||||
|
||||
</specifics>
|
||||
|
||||
<deferred>
|
||||
## 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).
|
||||
|
||||
</deferred>
|
||||
|
||||
---
|
||||
|
||||
*Phase: 19.6-two-factor-auth-and-device-flow-in-sm-user-plugin*
|
||||
*Context gathered: 2026-10-17*
|
||||
@@ -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).
|
||||
Reference in New Issue
Block a user