Files
summercms/.planning/phases/07-user-plugin-and-authentication/07-CONTEXT.md
2026-09-22 00:24:21 +02:00

21 KiB

Phase 7: User plugin and authentication - Context

Gathered: 2026-09-22 Status: Ready for planning

## 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.

## 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.

<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>

## 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.
## 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.

Phase: 7-User plugin and authentication Context gathered: 2026-09-22