168 lines
21 KiB
Markdown
168 lines
21 KiB
Markdown
# Phase 7: User plugin and authentication - Context
|
|
|
|
**Gathered:** 2026-09-22
|
|
**Status:** Ready for planning
|
|
|
|
<domain>
|
|
## Phase Boundary
|
|
|
|
The stub `golem15.user` Go plugin (JWT guard + 4-column `User`) becomes a real port of the PHP user plugin's SPA contract: register, login, logout, sliding JWT refresh, forgot/reset password, activation, profile update, change-password, avatar upload/remove, marketing consent and `oauth-providers`, with token minting, a jti blacklist and failed-login throttling. The user payload is extended through a fire-and-collect event (`golem15.user.getApiArray`) that `golem15.fonoteka` populates with organisation fields, `must_change_password` and `preferred_locale`. In `golem15.fonoteka` (PHP ownership, P6 D-07/08): personal-token CRUD (`/_fonoteka/api/v1/tokens`), `GET|PUT me/locale`, and the 423 lock with its exempt groups. Locale resolves per request from `preferred_locale` with header fallback (I18N-02).
|
|
|
|
Repos: `fonoteka.go` (plugins, fixtures, manifest) and `summercms.go` (any `bouncer`/`surf`/`tide` seams the port needs). Security-load-bearing: the security-review agent is applied.
|
|
|
|
Not in this phase: OAuth2.1 authorization server and the `oauth` guard (Phase 8), backend admin users (Phase 9), River-queued mail (Phase 11), everything listed under Deferred.
|
|
|
|
</domain>
|
|
|
|
<decisions>
|
|
## Implementation Decisions
|
|
|
|
### Carried forward (locked earlier, restated for the planner)
|
|
- **C-01:** The Phase 3/6 JWT verifier, its 401 bodies and the 423 body `{"error":"Password change required","must_change_password":true}` stay byte-identical (P6 D-10). Empty JWT secret fails boot (P3 D-11).
|
|
- **C-02:** `inv_token` guard, `inv.scope` and the 423 middleware live in `golem15.fonoteka`; token CRUD and `me/locale` go there too, exactly where PHP has them. Token CRUD is JWT-group only (a token can never mint a token). Phase 6's verifier is kept; Phase 7 adds production minting through a port of `ApiTokenManager` (`inv_` + base64url(32 random bytes), sha256 at rest, plaintext returned once in the 201).
|
|
- **C-03:** "Scope ceiling" for personal tokens means PHP's `MINTABLE_SCOPES = read|write|ai` with default `['read']`, validated `in:read,write,ai`. The `scope_ceiling` column/logic on OAuth clients is Phase 8.
|
|
- **C-04:** The user plugin declares its `user-api` bucket (120/min, key user id else IP) through the Phase 6 limiter API. `pin-login` and `2fa-verify` buckets are not declared (their routes are not ported).
|
|
- **C-05:** Mail through `postcard.Send` with WinterCMS-shape templates; the caller picks `-en` siblings from `preferred_locale` (P4 D-08/20). Models follow `Fillable/Hidden/Rules` (P5 D-05..09); shipped migrations are never edited, new columns arrive as appended migrations (P5 D-03).
|
|
|
|
### Route surface
|
|
- **D-01:** Ported on `/_user/api/v1` (group middleware `throttle:user-api`, no `jwt.auth`; auth is per-handler as in `ApiController::authorize()`): `OPTIONS` catch-all (204), `POST login`, `POST logout`, `GET fetch`, `POST refresh`, `POST register`, `POST forgot-password`, `POST reset-password`, `POST activate`, `POST activate-by-code`, `POST update`, `POST change-password`, `POST avatar`, `POST avatar/remove`, `POST marketing-consent`, `GET oauth-providers`. forgot/reset/activate have no Nuxt caller today but are required by roadmap success criterion 1.
|
|
- **D-02:** Register ports all three activation-mode branches as PHP returns them: auto/not-required → `{token,user}`; `user` mode → `{message:'Activation email sent'}`; `admin` mode → empty `{}` 200. `allow_registration` and the 3-per-IP-per-60-min register throttle (`created_ip_address` count) are ported; client IP comes from the Phase 6 trusted-proxy function.
|
|
- **D-03:** Social login: only `GET oauth-providers` is ported, returning the configured provider list (empty list when none configured) so the Nuxt login page renders. `/oauth/{provider}` redirect + callback, `oauth-complete`, `oauth-register-complete` and the password-bootstrap OTP branch of change-password (428/503) are deferred. Because no Go path can create a user with `has_self_set_password = false`, change-password ports the normal branch only; researcher states what happens for a migrated row that has the flag false (recommended: documented as deferred with the social flow, not silently treated as self-set).
|
|
- **D-04:** Avatar upload and remove are ported now on the Phase 5 attachment machinery (`system_files`, `Thumb`): payload `avatar`, `avatar_url` (128 thumb) and `has_avatar` are real. This is the port's first HTTP multipart endpoint and sets the pattern Phase 12 reuses (per-group `MaxBytesReader` upload cap from P6 D-18).
|
|
- **D-05:** Not ported, no 501 shells (P6 D-15 rule), columns stay in the schema: PIN login (3 routes), device auth (5), all 2FA challenge and management routes (17+5), `GET /api/user/batch`, `GET /_user/activate/{id}`. Login never returns the `two_factor_required` shape.
|
|
|
|
### JWT lifecycle
|
|
- **D-06:** Tokens are wire-compatible with PHP's `php-open-source-saver/jwt-auth` so users stay logged in across cutover: HS256 with the same `JWT_SECRET`, claims `iss, iat, exp, nbf, sub, jti, prv` (`prv` = the lock-subject hash of the PHP model class, reproduced as a constant; researcher confirms the exact hash input and the `iss` value PHP emits per endpoint), `sub` = user id, no custom claims, leeway 0. PHP-issued tokens must verify and refresh in Go. The PHP blacklist is not migrated; a token logged out in PHP shortly before cutover may revive until its `exp` — accepted.
|
|
- **D-07:** Refresh is PHP's sliding access token, no separate refresh token: `POST refresh` with a token that may be expired but is within `refresh_ttl` of its original `iat` returns `{token}` and blacklists the old jti; any failure is 401 `{error:'Could not refresh token', msg}`. Logout blacklists the presented token forever (and returns PHP's body). `blacklist_grace_period` semantics are ported (a just-rotated token stays usable for the grace window so parallel requests don't 401).
|
|
- **D-08:** The jti blacklist is a Postgres table (jti, expires_at), behind a small Store interface like the limiter's, with an expiry sweep. One indexed lookup per authenticated request is accepted; no cache layer this phase.
|
|
- **D-09:** Token extraction — deliberate, documented contract deviation per the Golem15 Stack security upgrade (no tokens in access logs): tokens are never read from the URL or body. `/_user/api/v1` handlers: `Authorization: Bearer` only. `jwt.auth` routes: Bearer, then the `token` and `auth_token` cookies (plain, not encrypted). Dropped on both surfaces: `?jwt_token=`, body `jwt_token`, jwt-auth's `?token=` query, input-source and route-param parsers. Verified: neither `vue-fonoteka-app` nor `fonoteka-mcp` sends any of them. The Bearer/cookie split itself is kept because it is used across Golem15 projects. The server never sets a cookie — the Nuxt app writes `auth_token` itself.
|
|
- **D-10:** TTLs are plugin config keys with the library defaults — `golem15.user.jwt.{ttl: 60, refresh_ttl: 20160, blacklist_grace: 0, leeway: 0}` (minutes / seconds as in PHP) — and `fonoteka.go`'s config sets Płytarium's real values (1440, 43200, 10 s). Same framework/app split as `app.locale` (P4 D-05).
|
|
|
|
### Parity evidence
|
|
- **D-11:** The `/_user/api/v1` routes are absent from the 154-route manifest. They are recorded against the isolated PHP instance with the Phase 2 `tide` tooling (capture rules, private 0600 vars store, no live JWT in git) and added to `fonoteka.go/parity/manifest.yaml`, going through the same pending → ported gate. The manifest total grows past 154; wording that treats 154 as fixed is corrected at plan time. `tokens` (3) and `me/locale` (2) entries already exist and flip to ported. Fixtures that exercise dropped extraction sources (D-09) are not recorded.
|
|
- **D-12:** Mail-dependent two-step flows (forgot → reset, register → activate-by-code) get their code through an app-owned tide seed/capture hook that reads `reset_password_code` / `activation_code` from the users row after step one and stores it as a `{{var}}`; it works the same against PHP (MariaDB) and Go (Postgres). Mail content is asserted separately through postcard's `memory` driver.
|
|
- **D-13:** Every distinct status + body per route is recorded, because PHP's error envelopes differ per endpoint (`{error: string}`, `{error:true,message}`, `{error,errors}`): bad credentials, unactivated, suspended/banned, 422 validation, registration closed, register throttle, bad/expired refresh, wrong old password, etc. Envelopes are reproduced per endpoint, not unified.
|
|
- **D-14:** A new recorded Nuxt client flow (`nuxt-auth`) sits beside `nuxt-browse`: register → fetch → update → change-password → refresh → logout → reuse of the logged-out token is refused; plus a `must_change_password` user hitting 423 on the authenticated surface, then `me/locale` succeeding, then change-password clearing the lock.
|
|
|
|
### Security deltas (wire-invisible hardening over exact parity)
|
|
- **D-15:** Reset and activation codes — verified in source that PHP codes never expire (`vendor/winter/storm/src/Auth/Models/User.php:258-292`, plain `===`, no timestamp column; `config/auth.php passwords.expire` is unused by this flow). Go keeps the `"{id}!{code}"` format and the same columns (PHP-issued codes keep working, schema diff stays explainable), compares in constant time, and adds a config TTL (defaults: reset 60 min, activation 72 h) tracked in an appended issued-at timestamp column. An expired code returns exactly the wrong-code response. Assumption stated to the user and not objected to: a code with a null issued-at (issued by PHP before cutover) counts as issued at cutover and gets one full TTL.
|
|
- **D-16:** Failed-login throttling is a port of Winter's Throttle onto the `user_throttle` table: per user + IP counters, `attemptLimit` 5 / `suspensionTime` 15 min from config, suspended and banned states with PHP's error bodies, `use_throttle` setting honoured. Durable across restarts and gives Phase 9 a real model for ban/unban. The `user-api` bucket sits in front as in PHP.
|
|
- **D-17:** Login restores a soft-deleted user and sends `mail.reactivate`, as PHP's `afterLogin()` does. Guest conversion is dropped: `is_guest` rows cannot log in, and register with an existing email gets the normal unique-email 422.
|
|
- **D-18:** Mail is sent inline through `postcard.Send` behind a small seam that Phase 11 swaps for a River job. forgot-password always returns its enumeration-safe 200 `{message:'If that email exists, a reset link has been sent.'}` and logs a send failure instead of surfacing it. `mail.activate` was already synchronous in PHP.
|
|
- **D-19:** Passwords: bcrypt with cost from config (default 10 = PHP); existing `$2y$` hashes verify unchanged; on successful login a hash with a lower cost than configured is rehashed silently. Validation rules stay PHP's (`between:8,255|confirmed`, `min_password_length` 8).
|
|
- **D-20:** Password change and password reset invalidate every previously issued JWT of that user: a per-user "tokens valid after" timestamp (appended column) checked by the `jwt` guard; a token with `iat` before it gets the normal 401. Response bodies of change-password / reset-password stay byte-identical. Researcher confirms whether PHP's change-password returns a fresh token; if it does not, the presenting token is kept alive (exempt jti or timestamp set just before its `iat`) so the calling device is not logged out mid-session.
|
|
- **D-21:** `must_change_password` has no PHP code path that sets it to true. Go adds a console command in the user plugin (scaffolded the Phase 4 way, e.g. `user:require-password-change <email>`) so the locked state is reachable without SQL; the admin form field comes with the Phase 9 user controller. change-password clears the flag, as in PHP.
|
|
|
|
### Claude's Discretion
|
|
- I18N-02 placement: where the per-request locale stage sits relative to the `jwt` guard and the 423 gate so it resolves `preferred_locale` → `Accept-Language` → `app.locale` even while locked; PHP has no such middleware, so this is additive and must not change any recorded body.
|
|
- Package homes: whether minting/blacklist primitives live in `bouncer` (generic, app-agnostic) with the user plugin owning config, tables and routes; Store interface shape; sweep interval.
|
|
- The `festival` event type for `getApiArray` (owned by the user plugin; fonoteka imports user, never the reverse) and the merge order of collected fields.
|
|
- Payload fields backed by tables with no Go source yet (`permissions`, `groups`, `role`, `is_onboarded`): port the minimal read models needed to reproduce the recorded payload exactly; no management endpoints.
|
|
- 423-exempt grouping: reproduce PHP's structure (the `/_user` group never carries the middleware; `me/locale` is its own `jwt.auth`-only group) and assert it over the Phase 6 route table.
|
|
- Exact names of new columns, config keys not named above, and the console command.
|
|
- Plan count and split, subject to the plan-count checkpoint, "unit tests are the last plan" and the security-review agent.
|
|
|
|
</decisions>
|
|
|
|
<canonical_refs>
|
|
## Canonical References
|
|
|
|
**Downstream agents MUST read these before planning or implementing.**
|
|
|
|
### Planning docs (paths relative to `summercms.go/`)
|
|
- `.planning/ROADMAP.md` §Phase 7 — goal, success criteria, repos
|
|
- `.planning/REQUIREMENTS.md` — AUTH-01..04, I18N-02
|
|
- `.planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-CONTEXT.md` — guard registry, `inv_token`/`inv.scope`, limiter buckets, trusted-proxy client IP, raw groups, response conventions
|
|
- `.planning/phases/05-data-layer-full-fidelity/05-CONTEXT.md` — Fillable/Hidden/Rules, append-only migrations, schema diff, attachments and thumbs
|
|
- `.planning/phases/04-cli-scaffolding-i18n-and-mail/04-CONTEXT.md` — mail template shape, caller-picked locale suffix, phrasebook, plugin directory layout, `make:*`
|
|
- `.planning/notes/plugin-layout-winter-directories.md` — where controllers/classes/middleware/console go
|
|
- `.planning/research/PITFALLS.md`, `.planning/research/STACK.md` — golang-jwt usage, dependency rules
|
|
|
|
### PHP contract — user plugin (absolute, `/media/nvme/dev/golem15/fonoteka/`)
|
|
- `plugins/golem15/user/routes.php` — groups, middleware, the three named limiters (lines 8-18), route list
|
|
- `plugins/golem15/user/controllers/ApiController.php` — every handler, bodies and status codes (`authorize()` :1412, login :42, logout :104, fetch :128, refresh :149, activate :180/:208, register :251, update :364, change-password :412, avatar :526/:561, marketing-consent :595, forgot :638, reset :679, oauth-providers :1066, `makeResetUrl` :1501)
|
|
- `plugins/golem15/user/Plugin.php` :329-358 — base user payload and the `golem15.user.getApiArray` merge; :233-246 mail templates
|
|
- `plugins/golem15/user/classes/TokenExtractor.php`, `classes/JwtServiceProvider.php` :44-52, `middleware/JwtAuthenticate.php` — the two extraction surfaces (see D-09 for what is dropped)
|
|
- `plugins/golem15/user/classes/AuthManager.php` — logout = session + `JWTAuth::invalidate(true)`, `withTrashed`, guest handling (dropped)
|
|
- `plugins/golem15/user/models/User.php` — fillable/hidden/rules, `afterLogin` restore (:497-517), `isRegisterThrottled` (:616-630), `getJWTCustomClaims` (:775)
|
|
- `plugins/golem15/user/models/Throttle.php`, `models/Organisation.php`, `updates/v3.2.0/*`, `updates/v3.2.1/*`, `updates/v2.3.7/*`
|
|
- `plugins/golem15/user/config/jwt.php`, root `config/jwt.php`, `.env` (`JWT_TTL=1440`, `JWT_REFRESH_TTL=43200`, grace 10 s), root `config/auth.php` (guards, throttle 5/15), `config/hashing.php`, `plugins/golem15/user/config/config.php`. Do NOT treat `plugins/golem15/user/config/auth.php` as authoritative (shadow copy).
|
|
- `vendor/winter/storm/src/Auth/Models/User.php` :209-292 — activation/reset code semantics (no expiry); Storm `Throttle` and `Manager::authenticate()`
|
|
- `plugins/golem15/user/views/mail/{activate,restore,reactivate}.htm`
|
|
- `plugins/golem15/user/tests/unit/controllers/*`, `tests/security/AuthenticationTest.php`
|
|
|
|
### PHP contract — fonoteka plugin side
|
|
- `plugins/golem15/fonoteka/Plugin.php` :240-252 (getApiArray listener), :218-221 and :284 (middleware aliases), :182 (`golem15.user.register` listener — invitation/provisioning is Phase 12; the event must exist)
|
|
- `plugins/golem15/feedback/Plugin.php` :38-43 — second getApiArray listener (`feedback_widget_hidden`), ported with the feedback plugin
|
|
- `plugins/golem15/fonoteka/middleware/RequirePasswordChange.php`, `routes.php` :61-67 (locale group), :282-284 (tokens)
|
|
- `plugins/golem15/fonoteka/controllers/api/TokenApiController.php`, `MeLocaleController.php`, `MeTokenController.php`, `classes/auth/ApiTokenManager.php`, `models/ApiToken.php`, `classes/OrgAccess.php`
|
|
- `plugins/golem15/fonoteka/tests/security/TokenSurfaceIsolationTest.php`
|
|
|
|
### Client contract
|
|
- `/media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/app/stores/auth.ts`, `app/utils/tokenRefresh.ts`, `app/plugins/token-refresh.client.ts`, `app/plugins/api.ts` (401 → refresh retry), `app/plugins/preferred-locale.client.ts`, `app/middleware/force-password-change.global.ts`, `shared/types/fonoteka.ts` :317-319
|
|
|
|
### Go code to extend
|
|
- `fonoteka.go/plugins/golem15/user/` (plugin.go, models/, classes/user_lookup.go, config/config.yaml, updates/)
|
|
- `fonoteka.go/plugins/golem15/fonoteka/middleware/must_change_password.go`, `middleware/token_scope.go`, `classes/auth/token_guard.go`
|
|
- `fonoteka.go/parity/manifest.yaml`, `parity/capture-rules.yaml`, `parity/fixtures/`
|
|
- `summercms.go/bouncer/` (jwt.go, guard.go, registry.go), `festival/bus.go` (`Collect`), `postcard/`, `phrasebook/`, `surf/` (limiter, route table), `tide/`
|
|
|
|
</canonical_refs>
|
|
|
|
<code_context>
|
|
## Existing Code Insights
|
|
|
|
### Reusable Assets
|
|
- `bouncer.NewJWTGuard` + named guard registry, `bouncer.User(ctx)` and the credential accessor — minting, blacklist and the "valid after" check attach here without changing 401 bodies.
|
|
- `festival.Bus.Collect[T]` — merges listener maps, later wins; the literal port of `Event::fire('golem15.user.getApiArray', …, false)` + `array_merge`.
|
|
- `postcard.Mailer` with `memory`/`log`/`smtp` drivers — mail assertions in unit tests, Mailpit integration test pattern.
|
|
- Phase 5 `system_files` attachments, `Thumb(w,h,mode)`, `lagoon.Fill`, `Rules()` translation, `lagoon.Encrypted`.
|
|
- Phase 6 `surf.Limiter` named buckets + inline `throttle:N,M`, trusted-proxy client IP, route table + `route:list`, JSON writer / Carbon time / nullable bool types.
|
|
- `tide` record/replay, capture rules, vars store, seed hooks (app-owned seam from Phase 2).
|
|
- `fonoteka` plugin's `MustChangePassword` middleware and `inv.scope` already ship with exact bodies.
|
|
|
|
### Established Patterns
|
|
- Routes written line by line from `routes.php` with PHP middleware names (P3 D-14, P6 D-05); groups are builders; only real handlers are mounted.
|
|
- Handlers build DTOs explicitly; nothing rewrites responses after the handler (P6 D-17).
|
|
- `models/` is a leaf package; services in `classes/`; GORM callbacks registered from `classes/` or `plugin.go` (P4 D-11).
|
|
- Secrets and keys fail boot when missing; tests use fixed test-only values.
|
|
- Framework never imports the app; anything Płytarium-specific (TTLs, locale list `pl,en`) lives in `fonoteka.go` config or call sites.
|
|
|
|
### Integration Points
|
|
- `golem15.user` plugin: new routes group, controllers, services, migrations (appended), console command, mail templates, lang files, limiter bucket, event types.
|
|
- `golem15.fonoteka` plugin: `Requires` user; listens to `getApiArray`; adds tokens CRUD, `me/locale` group, token manager.
|
|
- `bouncer`: mint/refresh/blacklist seam and extractor configuration (two surfaces).
|
|
- Parity: manifest growth, new capture rules for JWTs in user responses, DB-reading seed hook, `nuxt-auth` flow.
|
|
|
|
</code_context>
|
|
|
|
<specifics>
|
|
## Specific Ideas
|
|
|
|
- "jwt_token GET param should be deprecated by now per Golem15 Stack security upgrade, we don't want tokens in access logs. But we need to port the split for Bearer / Cookies as it's used through all projects." — this copy of the PHP plugin predates that upgrade; the Go port follows the upgraded rule.
|
|
- The user expected PHP reset codes to expire; source says they do not in this codebase. If another Golem15 project carries a newer user plugin with expiry, point the researcher at it and port its TTL values instead of the defaults in D-15.
|
|
- Presence will be built in a future milestone; the legacy CMS theme flows are legacy and are not carried over.
|
|
|
|
</specifics>
|
|
|
|
<deferred>
|
|
## Deferred Ideas
|
|
|
|
- Presence system including `GET /api/user/batch` — future milestone.
|
|
- Full social login: `/oauth/{provider}` redirect + callback, `oauth-complete`, `oauth-register-complete`, password-bootstrap OTP (428/503) and its mails — own phase/backlog; needs an OAuth-client dependency decision.
|
|
- PIN login, device auth and all 2FA routes (incl. the `two_factor_required` login branch) — not in v1.
|
|
- Admin-invite signed activation link (`/_user/activate/{id}`, `mail.invite`) — legacy; redesign with the Phase 9 admin if needed.
|
|
- Guest users and guest → user conversion — revisit for keios.eu.
|
|
- Account deletion / GDPR export lifecycle and `user:process-scheduled-deletions` — no HTTP route exposes them today.
|
|
- Queued mail via River — Phase 11 swaps the D-18 seam.
|
|
- Migrating the PHP JWT blacklist at cutover — explicitly not done (D-06).
|
|
- Admin form field for `must_change_password`, ban/unban UI — Phase 9.
|
|
- `golem15.user.register` consumers (invitation token, `CollectionProvisioner`) — Phase 12; `feedback_widget_hidden` — feedback plugin phase.
|
|
|
|
</deferred>
|
|
|
|
---
|
|
|
|
*Phase: 7-User plugin and authentication*
|
|
*Context gathered: 2026-09-22*
|