docs(19.6-01): complete foundations plan summary
This commit is contained in:
@@ -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.
|
||||
Reference in New Issue
Block a user