diff --git a/.planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/19.6-01-SUMMARY.md b/.planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/19.6-01-SUMMARY.md new file mode 100644 index 00000000..61ed0452 --- /dev/null +++ b/.planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/19.6-01-SUMMARY.md @@ -0,0 +1,290 @@ +--- +phase: 19.6-two-factor-auth-and-device-flow-in-sm-user-plugin +plan: 01 +subsystem: auth +tags: [sm-user-plugin, login-gate, device-flow, jwt, jti, blacklist, 2fa, gormigrate] + +requires: + - phase: 19.4 + provides: sm-user-plugin v0.1.x as its own repository (core plugin contract, additive changes only) + - phase: 19.5 + provides: user API login/refresh/logout and the bouncer JWT blacklist +provides: + - D-04 decision note for pquerna/otp v1.5.0, go-webauthn/webauthn v0.18.2, boombuler/barcode v1.1.0 (human package check passed) + - Login-gate registry (classes.LoginGates) consulted by password Login before restore and mint + - classes.LoginEvent fired once per successful password login + - Password-change hook through (*classes.AccessRevokedEvent).PasswordChanged() + - Reset-2FA admin slot (permission golem15.users.reset_two_factor, record action reset_two_factor, classes.TwoFactorAdmin service) + - Device authorization by QR or short code (5 routes, golem15_user_device_auth_sessions table) + - Device list and revoke, with the device jti handed over on refresh +affects: [19.6-02, 19.6-03, 19.6-04, sm-mfa-plugin, sm-sociallogin-plugin] + +actuals: + tokens: 27169 # chars/4 over added lines: sm-user-plugin 50da92c..3b22134 (101994 chars) + summercms.go ecc34bc..24d9d2a (6683 chars) + tasks: 5 + commits: 1 # MEASURED in summercms.go: git rev-list --count ecc34bc..24d9d2a; sm-user-plugin measured separately: git rev-list --count 50da92c..3b22134 = 5 +plan_head_before: ecc34bc35a653fee7241255e055d09b20f1f09b5 +plan_head_after: 24d9d2a +sm_user_plugin_head_before: 50da92c +sm_user_plugin_head_after: 3b22134 + +tech-stack: + added: [] # the three libraries are decided and approved, not installed; plan 02 installs them in sm-mfa-plugin only + patterns: + - "Login-gate registry: a service published in Register that first-factor paths consult before minting" + - "Guarded single-row UPDATE for one-time state transitions (pending->confirmed, confirmed->used)" + - "Optional service lookup decides whether an admin record action Applies" + +key-files: + created: + - .planning/notes/2026-10-21-mfa-otp-webauthn.md + - ../fonoteka.go/plugins/golem15/user/classes/login_gate.go + - ../fonoteka.go/plugins/golem15/user/classes/two_factor_admin.go + - ../fonoteka.go/plugins/golem15/user/classes/device_auth.go + - ../fonoteka.go/plugins/golem15/user/controllers/login_gates.go + - ../fonoteka.go/plugins/golem15/user/controllers/devices.go + - ../fonoteka.go/plugins/golem15/user/models/device_auth_session.go + - ../fonoteka.go/plugins/golem15/user/updates/202610260001_create_device_auth_sessions.go + - ../fonoteka.go/plugins/golem15/user/login_gate_smoke_test.go + - ../fonoteka.go/plugins/golem15/user/password_changed_smoke_test.go + - ../fonoteka.go/plugins/golem15/user/reset_two_factor_smoke_test.go + - ../fonoteka.go/plugins/golem15/user/device_flow_smoke_test.go + - ../fonoteka.go/plugins/golem15/user/device_revoke_smoke_test.go + modified: + - .planning/research/STACK.md + - ../fonoteka.go/plugins/golem15/user/classes/events.go + - ../fonoteka.go/plugins/golem15/user/controllers/api_controller.go + - ../fonoteka.go/plugins/golem15/user/controllers/registration.go + - ../fonoteka.go/plugins/golem15/user/controllers/users_admin_controller.go + - ../fonoteka.go/plugins/golem15/user/controllers/users/config_form.yaml + - ../fonoteka.go/plugins/golem15/user/admin_permissions.go + - ../fonoteka.go/plugins/golem15/user/plugin.go + - ../fonoteka.go/plugins/golem15/user/routes.go + - ../fonoteka.go/plugins/golem15/user/config/config.yaml + - ../fonoteka.go/plugins/golem15/user/lang/en/lang.yaml + - ../fonoteka.go/plugins/golem15/user/lang/pl/lang.yaml + - ../fonoteka.go/plugins/golem15/user/README.md + +key-decisions: + - "D-07 reuses classes.AccessRevokedEvent (already fired in-transaction at all three password-write sites) plus (*AccessRevokedEvent).PasswordChanged(); no PasswordChangedEvent type exists. Plan 02 listens on *classes.AccessRevokedEvent and filters with ev.PasswordChanged()." + - "Device-session migration ID is 202610260001_create_device_auth_sessions so it sorts after the existing 202610220001 (the planned 202610210002 would have sorted before it)." + - "Device auth_url reuses golem15.user.frontend_url / classes.FrontendURL; the only new config keys are golem15.user.device_auth.auth_url and golem15.user.device_auth.ttl_minutes." + - "Task 5 package check approved by the user: plan 02 may install pquerna/otp v1.5.0, go-webauthn/webauthn v0.18.2 and boombuler/barcode v1.1.0 in sm-mfa-plugin." + +patterns-established: + - "LoginGates: plugins Add a LoginGate in Register; first-factor handlers call controllers.CheckLoginGates before restore and mint, and controllers.FireLoginEvent after the mint" + - "One-time device JWT: confirmed->used guarded UPDATE, exactly one poll receives the token" + +requirements-completed: [SC-2, SC-3, SC-4, D-02, D-03, D-04, D-06, D-07, D-11, D-12a, D-12b, D-12c, D-15, D-16, D-17, D-18] + +coverage: + - id: D1 + description: "D-04 decision note names the three pinned libraries, binary delta, linked modules, govulncheck run and the human package check; STACK.md lists the pins" + requirement: "D-04" + verification: + - kind: manual_procedural + ref: "Task 5 checkpoint reply: approved" + status: pass + human_judgment: true + rationale: "Package legitimacy is a human sign-off by design (blocking-human gate); given as \"approved\"." + - id: D2 + description: "Login consults the gate registry after CheckAndRecordLogin and the rehash, before restore and mint; empty registry keeps old responses; erroring gate fails closed; challenged soft-deleted account stays deleted" + requirement: "D-02" + verification: + - kind: integration + ref: "../fonoteka.go/plugins/golem15/user/login_gate_smoke_test.go#TestLoginGatesSmoke, TestLoginGateSmoke (5 sub)" + status: pass + human_judgment: false + - id: D3 + description: "Password writes (reset, change, admin set) fire the revocation event in-transaction with PasswordChanged() true; listener error rolls back; rehash fires nothing" + requirement: "D-07" + verification: + - kind: integration + ref: "../fonoteka.go/plugins/golem15/user/password_changed_smoke_test.go#TestPasswordChangedEventSmoke (5 sub)" + status: pass + human_judgment: false + - id: D4 + description: "Reset-2FA permission and record action, shown only when a TwoFactorAdmin service is published" + requirement: "D-15" + verification: + - kind: integration + ref: "../fonoteka.go/plugins/golem15/user/reset_two_factor_smoke_test.go#TestResetTwoFactorSlotSmoke" + status: pass + human_judgment: false + - id: D5 + description: "Device initiate/status/authorize with hashed token, XXXX-XXXX short code, 5-minute TTL, one-time JWT on first confirmed poll" + requirement: "D-12a" + verification: + - kind: integration + ref: "../fonoteka.go/plugins/golem15/user/device_flow_smoke_test.go#TestDeviceCodesSmoke, TestDeviceFlowSmoke" + status: pass + human_judgment: false + - id: D6 + description: "Device list and revoke; revoke blacklists the jti, also after refresh moved it" + requirement: "D-12b" + verification: + - kind: integration + ref: "../fonoteka.go/plugins/golem15/user/device_revoke_smoke_test.go#TestDeviceRevokeSmoke (3 sub)" + status: pass + human_judgment: false + +duration: 61min +completed: 2026-10-26 +status: complete +--- + +# Phase 19.6 Plan 01: Foundations Summary + +**sm-user-plugin gains a login-gate registry wired into password Login, a login event, a password-change hook on the existing revocation event, a Reset-2FA admin slot, and QR/short-code device authorization with a one-time JWT and jti-tracked revoke; the OTP, WebAuthn and QR libraries are decided and approved for plan 02.** + +## Performance + +- **Duration:** about 61 min +- **Started:** 2026-10-26T08:48:46Z +- **Completed:** 2026-10-26T09:49Z +- **Tasks:** 5 (4 code/docs tasks, 1 human checkpoint) +- **Files modified:** 31 in sm-user-plugin, 2 in summercms.go + +## Base commits + +- **sm-user-plugin** (`../fonoteka.go/plugins/golem15/user`): phase base `50da92c` (v0.1.11). Plan 04's additive check diffs against it. HEAD after this plan: `3b22134`. +- **summercms.go**: base `ecc34bc` (ledger `.git/msd-plan-head-before-19.6-01`). + +## Task Commits + +In order, planning docs first: + +| # | Repository | Commit | Message | +|---|------------|--------|---------| +| 1a | summercms.go | `24d9d2a` | docs(19.6): decide the OTP, WebAuthn and QR libraries | +| 1b | sm-user-plugin | `8501f34` | feat: login gates and the login event (tracer) | +| 2 | sm-user-plugin | `7900f47` | feat: password-change hook and the reset-2FA admin slot | +| fix | sm-user-plugin | `3d4c8d2` | style: gofmt the login gate smoke test | +| 3 | sm-user-plugin | `4e3e5b0` | feat: device authorization by QR or short code | +| 4 | sm-user-plugin | `3b22134` | feat: device list and revoke with the jti followed through refresh | +| 5 | none | none | Package check, resolved by the user's reply (below) | + +Nothing was pushed or tagged. + +## Task 5 reply + +The user replied, verbatim: + +> approved + +Before the checkpoint, the module proxy confirmed the pins: pquerna/otp v1.5.0 (Apache-2.0, tag v1.5.0, `5971b1ef1d66`), go-webauthn/webauthn v0.18.2 (BSD-3-Clause, tag v0.18.2, `a4c6fc6a4399`, latest), boombuler/barcode v1.1.0 (MIT, tag v1.1.0, `11e32e438ffc`). Nothing was installed and no go.mod requires any of them. Plan 02 may now add them to sm-mfa-plugin's go.mod only. + +## Accomplishments + +- **Login gates (tracer, D-02, D-18).** `classes.LoginGates` is published in Register. Login checks it after `CheckAndRecordLogin(..., true)` and the bcrypt rehash, and before the soft-delete restore and the mint. A challenge answers the gate's own status and body with no token. A gate error returns the opaque 500. An empty registry leaves responses unchanged. Refresh never consults gates. A challenged soft-deleted account stays deleted and gets no reactivate mail. +- **Login event.** `classes.LoginEvent` (`golem15.user.login`) fires once after each successful mint, and never for a challenged login. +- **Password-change hook (D-07).** Uses the existing in-transaction `AccessRevokedEvent` plus `PasswordChanged()` (see Deviations). +- **Reset-2FA slot (D-15).** Adds the permission `golem15.users.reset_two_factor` (no default role) and the record action `reset_two_factor` (Danger) on the Users screen. The action appears only when a `classes.TwoFactorAdmin` service is published, and `Run` calls `Reset` inside cabana's transaction. +- **Device authorization (D-03, D-06, D-11, D-12a, D-12c, D-16).** Five routes in the user-api group: + - `POST devices/initiate`, `GET devices/status/{token}`, `POST devices/authorize`, `GET devices`, `DELETE devices/{id}`. + - The 64-character token is stored as SHA-256 hex. The short code is `XXXX-XXXX` over `23456789ABCDEFGHJKMNPQRSTUVWXYZ` and the TTL is 5 minutes. + - Statuses match PHP (`pending|confirmed|used|expired|revoked`), plus a `jwt_jti` column. + - Guarded UPDATEs make pending->confirmed and confirmed->used happen once, so exactly one poll gets the JWT. +- **Revoke (D-12b).** `DELETE devices/{id}` marks the row revoked and blacklists its `jwt_jti`. Refresh hands the jti over to the new token (`HandOverDeviceJTI`), so a device that refreshed is still signed out. +- **Additive only (D-17).** No v0.1.11 route, body, table, column, migration ID or config key changed. fonoteka.go, sm-sociallogin-plugin and sm-oauthserver-plugin compile against the checkout. + +## Exported identifiers and config keys (for plans 02 and 03) + +Package `classes`: +- Login gates: `LoginAttempt`, `LoginGateResult`, `LoginGate` (`CheckLogin`), `LoginGates` (`NewLoginGates`, `Add`, `Check`) +- Login sources: `LoginSourcePassword`, `LoginSourceSocial`, `LoginSourcePasskey`, `LoginSourceTwoFactor`, `LoginSourceDevice` +- Events: `LoginEvent`, `LoginEventName` (`"golem15.user.login"`), `(*AccessRevokedEvent).PasswordChanged()` +- Admin slot: `TwoFactorAdmin` (`HasTwoFactor`, `Reset`), `PermissionResetTwoFactor` +- Device helpers: `DeviceShortCodeAlphabet`, `NewDeviceToken`, `NewDeviceShortCode`, `NormalizeDeviceShortCode`, `DeviceAuthTTL`, `DeviceAuthURL`, `DeviceDisplayName`, `HandOverDeviceJTI` + +Package `controllers`: `CheckLoginGates`, `FireLoginEvent`, `DeviceInitiate`, `DeviceStatus`, `DeviceAuthorize`, `DeviceIndex`, `DeviceDestroy` + +Package `models`: `DeviceAuthSession`, `DeviceStatusPending`, `DeviceStatusConfirmed`, `DeviceStatusUsed`, `DeviceStatusExpired`, `DeviceStatusRevoked` + +Permission `golem15.users.reset_two_factor`; record action `reset_two_factor`. + +New config keys: `golem15.user.device_auth.auth_url` (default `{frontend_url}/device/scan/{token}`), `golem15.user.device_auth.ttl_minutes` (default 5). The existing `golem15.user.frontend_url` is reused. + +## Tests + +The full sm-user-plugin suite passed with `-short` and without it (Docker ran; no Docker-backed test was NOT RUN). + +- New: TestLoginGatesSmoke, TestLoginGateSmoke (5 sub), TestPasswordChangedEventSmoke (5 sub), TestResetTwoFactorSlotSmoke, TestDeviceCodesSmoke, TestDeviceFlowSmoke, TestDeviceRevokeSmoke (3 sub). +- Unchanged and green: TestLogin, TestRefresh, TestLogout, TestAdminUserActions, TestAdminUserPassword, TestAdminRegistration. +- Downstream builds: fonoteka.go, sm-sociallogin-plugin and sm-oauthserver-plugin build against the checkout. + +## Deviations from Plan + +### Auto-fixed Issues + +**1. [Rule 1 - Bug avoidance] D-07 reuses AccessRevokedEvent; no PasswordChangedEvent** +- **Found during:** Task 2 +- **Issue:** All three password-write sites already fire `classes.AccessRevokedEvent` inside their transaction, with Reasons `password_reset`, `password_changed` and `password_set_by_admin`. A second event would duplicate it. +- **Fix:** Added `(*AccessRevokedEvent).PasswordChanged()`. A listener error rolls the write back, and the login rehash fires nothing. The test keeps the name TestPasswordChangedEventSmoke. Task 2's greps for `PasswordChangedEvent` fail by design, but D-07 is met. +- **Impact on plan 02:** use `Listen[*classes.AccessRevokedEvent]` and filter with `ev.PasswordChanged()`. +- **Committed in:** `7900f47` + +**2. [Rule 1 - Bug] Migration ID renamed to 202610260001_create_device_auth_sessions** +- **Found during:** Task 3 +- **Issue:** The planned `202610210002` sorts before the existing `202610220001`, which breaks ordering. +- **Fix:** Used `updates/202610260001_create_device_auth_sessions.go`. +- **Committed in:** `4e3e5b0` + +**3. [Rule 3 - Blocking] Config: frontend_url reused** +- **Issue:** `golem15.user.frontend_url` / `classes.FrontendURL` already exist. +- **Fix:** Reused them. The only new keys are `golem15.user.device_auth.auth_url` and `golem15.user.device_auth.ttl_minutes`. + +**4. [Rule 3 - Blocking] sm-user-plugin checkout was detached at v0.1.10, behind origin** +- **Fix:** Checked out master and fast-forwarded to `50da92c` (v0.1.11), which is the phase base. + +**5. [Rule 3 - Blocking] Downstream build checks used a scratch go.work** +- **Issue:** The sm-sociallogin-plugin checkout is owned by user `jin`, so go.work.sum is unwritable. sm-oauthserver-plugin's go.work points at sm-demo-app, which has another session's edit. +- **Fix:** Built both through a scratch go.work. Neither repository was modified. + +**6. [Rule 2 - Missing critical] Hardening** +- Device rows record the client IP through `surf.ClientIP` with trusted proxies. +- The bcrypt rehash runs before the gates and is `Unscoped()`. +- `devices/authorize` reads only the body. +- Hand-over refuses deleted, deactivated or banned users and marks the row revoked. +- A row that has already expired answers with PHP's flat expired body. +- `ShortCode` is hidden in JSON. +- Hand-over runs FinishLogin and fires LoginEvent (Source device). +- A shared `sessionTTL` helper is added. +- **Committed in:** `4e3e5b0`, `3b22134` + +**7. [Rule 1 - Bug] Pinned tests updated for additive items** +- TestAdminRegistration and TestAdminUsersTracer (permission catalogue), TestAdminUserActions (record actions), TestAdminUsersControllerGuards, TestPasswordPublicRoutes (23 to 28 routes), and four migration-count tests in `updates/`. +- **Committed in:** the task commits + +**8. Separate style commit** +- `3d4c8d2`: gofmt of the login gate smoke test. + +--- + +**Total deviations:** 8 (naming/reuse 3, environment 2, hardening 1, pinned tests 1, style 1) +**Impact on plan:** All changes are additive and keep the plan's decisions. Plan 02 must use the AccessRevokedEvent route for D-07 and the new migration ID. + +## Issues Encountered + +None beyond the environment items in Deviations 4 and 5. + +## g15office + +This session posted no comment on milestone task `avoroeb4`; the orchestrator will post it. + +## User Setup Required + +None. + +## Next Phase Readiness + +- Plan 02 can create sm-mfa-plugin, install the three approved libraries there, register a LoginGate, listen on `*classes.AccessRevokedEvent` with `PasswordChanged()`, and publish `classes.TwoFactorAdmin`. +- sm-user-plugin changes are local commits (`8501f34`..`3b22134`), not pushed or tagged. Plan 03 asks for the go-ahead. + +--- +*Phase: 19.6-two-factor-auth-and-device-flow-in-sm-user-plugin* +*Completed: 2026-10-26* + +## Self-Check: PASSED + +All seven commits (24d9d2a; sm-user-plugin 50da92c base, 8501f34, 7900f47, 3d4c8d2, 4e3e5b0, 3b22134) and the created files listed above exist.