docs(07): create phase plan
Six plans for the user plugin and authentication phase: - 07-01: bouncer JWT lifecycle, password hashing, I18N-02 locale override, lagoon.Validate extensions (summercms.go) - 07-02: User/Throttle schema, core session loop (login/logout/ fetch/refresh/register) (fonoteka.go) - 07-03: account management (forgot/reset, activation, update, change-password, avatar, mail) (fonoteka.go) - 07-04: personal API tokens, me/locale, 423-exempt route-table proof (fonoteka.go) - 07-05: parity evidence recording against the isolated PHP instance (fonoteka.go) - 07-06: full unit coverage and validation sign-off (both repos) Plan count and scope confirmed at the plan-count checkpoint.
This commit is contained in:
305
.planning/phases/07-user-plugin-and-authentication/07-02-PLAN.md
Normal file
305
.planning/phases/07-user-plugin-and-authentication/07-02-PLAN.md
Normal file
@@ -0,0 +1,305 @@
|
||||
---
|
||||
phase: 07-user-plugin-and-authentication
|
||||
plan: 02
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on: ["07-01"]
|
||||
files_modified:
|
||||
- ../fonoteka.go/plugins/golem15/user/models/user.go
|
||||
- ../fonoteka.go/plugins/golem15/user/models/throttle.go
|
||||
- ../fonoteka.go/plugins/golem15/user/updates/202609220005_extend_users.go
|
||||
- ../fonoteka.go/plugins/golem15/user/updates/202609220006_create_user_throttle.go
|
||||
- ../fonoteka.go/plugins/golem15/user/updates/202609220007_create_jwt_blacklist.go
|
||||
- ../fonoteka.go/plugins/golem15/user/updates/user_session_test.go
|
||||
- ../fonoteka.go/plugins/golem15/user/config/config.yaml
|
||||
- ../fonoteka.go/config/golem15.user.yaml
|
||||
- ../fonoteka.go/plugins/golem15/user/classes/throttle.go
|
||||
- ../fonoteka.go/plugins/golem15/user/classes/throttle_test.go
|
||||
- ../fonoteka.go/plugins/golem15/user/classes/user_lookup.go
|
||||
- ../fonoteka.go/plugins/golem15/user/classes/events.go
|
||||
- ../fonoteka.go/plugins/golem15/user/classes/events_test.go
|
||||
- ../fonoteka.go/plugins/golem15/user/controllers/api_controller.go
|
||||
- ../fonoteka.go/plugins/golem15/user/controllers/api_controller_test.go
|
||||
- ../fonoteka.go/plugins/golem15/user/routes.go
|
||||
- ../fonoteka.go/plugins/golem15/user/plugin.go
|
||||
- ../fonoteka.go/plugins/golem15/fonoteka/plugin.go
|
||||
autonomous: true
|
||||
requirements: [AUTH-01, AUTH-02]
|
||||
user_setup: []
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "A user can log in with email+password and receive a wire-compatible JWT (D-06), and use it to fetch their own profile and log out"
|
||||
- "A user can register (auto-activation path) and immediately receive a token, per D-02's auto/not-required branch"
|
||||
- "Failed logins are throttled per (user_id, ip) with Winter's 5-attempt/15-minute algorithm, keyed and ordered exactly per Pitfall 5, and PHP's login() funnels every AuthException (bad creds, suspended, banned, unknown user) into the same generic 401 body (verified at controllers/ApiController.php:81-88, overriding the 07-CONTEXT.md paraphrase 'PHP's error bodies' as plural)"
|
||||
- "The fonoteka plugin's getApiArray listener adds organisation_id, organisation_role, must_change_password, preferred_locale without golem15.user importing golem15.fonoteka (AUTH-02)"
|
||||
- "The /_user/api/v1 group carries only throttle:user-api at the group level; login/logout/fetch/refresh/register each resolve auth per-handler via bouncer.NewJWTGuard(secret, users, blacklist) Bearer-only (D-01, D-09)"
|
||||
artifacts:
|
||||
- path: "../fonoteka.go/plugins/golem15/user/models/user.go"
|
||||
provides: "Full User model (name, surname, email, is_activated, codes+issued-at, has_self_set_password, marketing_consent, is_onboarded, organisation_id/role, preferred_locale, tokens_valid_after) with Fillable/Hidden/Rules"
|
||||
- path: "../fonoteka.go/plugins/golem15/user/classes/throttle.go"
|
||||
provides: "CheckAndRecordLogin(ctx, db, user, ip, ok) error per Pitfall 5"
|
||||
- path: "../fonoteka.go/plugins/golem15/user/controllers/api_controller.go"
|
||||
provides: "login, logout, fetch, refresh, register, oauth-providers handlers"
|
||||
- path: "../fonoteka.go/plugins/golem15/user/classes/events.go"
|
||||
provides: "GetApiArrayEvent (festival.Collectable) and RegisterEvent"
|
||||
key_links:
|
||||
- from: "../fonoteka.go/plugins/golem15/fonoteka/plugin.go"
|
||||
to: "../fonoteka.go/plugins/golem15/user/classes GetApiArrayEvent"
|
||||
via: "app.Events.Listen[*classes.GetApiArrayEvent] registered in fonoteka's Boot"
|
||||
pattern: "golem15.user.getApiArray|GetApiArrayEvent"
|
||||
- from: "../fonoteka.go/plugins/golem15/user/controllers/api_controller.go login handler"
|
||||
to: "../fonoteka.go/plugins/golem15/user/classes/throttle.go"
|
||||
via: "CheckAndRecordLogin called before AND after the password check per Pitfall 5"
|
||||
pattern: "CheckAndRecordLogin"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Land the working end-to-end session slice of the `golem15.user` plugin: the real `User`/`Throttle` models and their appended migrations, the failed-login throttle, and the login → fetch → refresh → logout loop plus registration, wired through the `bouncer` primitives from 07-01. After this plan, a real user (seeded in a test or via a future console command) can register or log in against Postgres, receive a PHP-wire-compatible JWT, call authenticated endpoints, and log out — the first working vertical slice of AUTH-01/AUTH-02.
|
||||
|
||||
Purpose: prove the JWT lifecycle, throttle and event-collect primitives against a real plugin boot and real handlers before the account-management slice (07-03) and the fonoteka-owned token/locale slice (07-04) build on top.
|
||||
Output: a real `users`/`user_throttle`/`jwt_blacklist` schema, a real `/_user/api/v1` route group with `login/logout/fetch/refresh/register/oauth-providers`, and the `golem15.user.getApiArray` fire-and-collect event consumed by `golem15.fonoteka`.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/07-user-plugin-and-authentication/07-CONTEXT.md
|
||||
@.planning/phases/07-user-plugin-and-authentication/07-RESEARCH.md
|
||||
@.planning/phases/07-user-plugin-and-authentication/07-PATTERNS.md
|
||||
@.planning/phases/07-user-plugin-and-authentication/07-01-SUMMARY.md
|
||||
|
||||
<interfaces>
|
||||
<!-- Exact bouncer contracts this plan consumes, shipped by 07-01. -->
|
||||
```go
|
||||
func bouncer.Mint(secret, sub, issuerURL string, ttl time.Duration) (token, jti string, err error)
|
||||
func bouncer.Refresh(secret, tokenString string, refreshTTL time.Duration, bl BlacklistStore, grace time.Duration, issuerURL string) (string, error)
|
||||
type bouncer.BlacklistStore interface {
|
||||
Add(ctx context.Context, jti string, expiresAt, validUntil time.Time) error
|
||||
IsBlacklisted(ctx context.Context, jti string) (bool, error)
|
||||
Sweep(ctx context.Context, now time.Time) error
|
||||
}
|
||||
func bouncer.NewPostgresBlacklist(db *sql.DB, table string) *PostgresBlacklist
|
||||
func bouncer.NewJWTGuard(secret string, users UserProvider, bl BlacklistStore, cookieNames ...string) Guard
|
||||
func bouncer.HashPassword(cost int, plain string) (string, error)
|
||||
func bouncer.CheckPassword(hash, plain string) bool
|
||||
func bouncer.NeedsRehash(hash string, configuredCost int) bool
|
||||
type bouncer.Principal struct { ID uint; MustChangePassword bool; PreferredLocale string; TokensValidAfter time.Time }
|
||||
```
|
||||
`bouncer.Verify(tokenString, secret string) (sub string, err error)` is unchanged (Bearer parsing only reads `Authorization`; cookie fallback is a `NewJWTGuard` constructor option only golem15.fonoteka's `jwt.auth` registration uses in 07-04 — this plan's per-handler calls are Bearer-only, per D-09, matching `bouncer.Verify`'s existing behavior). `bouncer.VerifyClaims(tokenString, secret string) (sub string, iat, exp time.Time, jti string, err error)` (new in 07-01) is the exported claims-parsing entry point Logout/Refresh use to get `iat`/`exp`/`jti` outside the `bouncer` package.
|
||||
|
||||
<!-- Existing plugin scaffolding this plan extends, read in full before editing. -->
|
||||
From ../fonoteka.go/plugins/golem15/user/plugin.go (current): Boot registers "jwt" via `reg.Register(p.ID(), "jwt", bouncer.NewJWTGuard(secret, classes.GormUsers{App: app}))` — this call site's arity changes this plan (add `bl` and cookie names).
|
||||
From ../fonoteka.go/plugins/golem15/fonoteka/plugin.go (current): `Buckets()` shows the exact `map[string]surf.Bucket` shape to copy for `user-api`.
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 1: User/Throttle schema — models, appended migrations, config keys</name>
|
||||
<files>
|
||||
../fonoteka.go/plugins/golem15/user/models/user.go,
|
||||
../fonoteka.go/plugins/golem15/user/models/throttle.go,
|
||||
../fonoteka.go/plugins/golem15/user/updates/202609220005_extend_users.go,
|
||||
../fonoteka.go/plugins/golem15/user/updates/202609220006_create_user_throttle.go,
|
||||
../fonoteka.go/plugins/golem15/user/updates/202609220007_create_jwt_blacklist.go,
|
||||
../fonoteka.go/plugins/golem15/user/updates/user_session_test.go,
|
||||
../fonoteka.go/plugins/golem15/user/config/config.yaml,
|
||||
../fonoteka.go/config/golem15.user.yaml
|
||||
</files>
|
||||
<read_first>
|
||||
../fonoteka.go/plugins/golem15/user/models/user.go (current 4-column stub), ../fonoteka.go/plugins/golem15/user/models/registry.go,
|
||||
../fonoteka.go/plugins/golem15/user/updates/00_base.go, ../fonoteka.go/plugins/golem15/user/updates/10_organisations.go, ../fonoteka.go/plugins/golem15/user/updates/postgres_test.go, ../fonoteka.go/plugins/golem15/user/updates/organisations_test.go,
|
||||
../fonoteka.go/plugins/golem15/fonoteka/updates/10_album_slice.go (execStmts ALTER pattern),
|
||||
../fonoteka.go/plugins/golem15/fonoteka/models/api_token.go (Fillable/Hidden shape),
|
||||
../fonoteka.go/plugins/golem15/fonoteka/models/artist.go (Fillable/Rules/BeforeValidate convention to copy),
|
||||
summercms.go/compass/config_test.go (TestDottedPluginNamespaceAndTypedSection — the app-level `config/golem15.user.yaml` override precedence this plan relies on),
|
||||
/media/nvme/dev/golem15/fonoteka/plugins/golem15/user/models/User.php (lines 1-158: $fillable/$hidden/$casts already read this session),
|
||||
.planning/phases/07-user-plugin-and-authentication/07-CONTEXT.md (D-06, D-10, D-15, D-16, D-19, D-20, D-21)
|
||||
</read_first>
|
||||
<behavior>
|
||||
- Test (migration): `TestExtendUsersMigration` runs `All()` against a fresh Postgres, asserts every new column exists (`name, surname, is_activated, activated_at, activation_code, activation_code_issued_at, reset_password_code, reset_password_code_issued_at, has_self_set_password, marketing_consent, is_onboarded, organisation_id, organisation_role, preferred_locale, tokens_valid_after, created_ip_address, last_ip_address`) and that `has_self_set_password` defaults `true` and `is_activated`/`marketing_consent`/`is_onboarded` default `false` for a freshly inserted row.
|
||||
- Test (migration): `TestCreateUserThrottleMigration` asserts table `user_throttle` exists with columns `id, user_id, ip_address, attempts, is_suspended, suspended_at, is_banned, created_at, updated_at`.
|
||||
- Test (migration): `TestCreateJwtBlacklistMigration` asserts table `jwt_blacklist` exists with columns `jti (primary key), expires_at, valid_until`.
|
||||
- Test (migration): rollback of each new migration (in reverse ID order) leaves `users`/`user_throttle`/`jwt_blacklist` in their pre-migration shape (no orphaned columns/tables).
|
||||
</behavior>
|
||||
<action>
|
||||
Extend `models/user.go`'s `User` struct with the fields listed in the migration behavior above (pointer types for nullable columns, matching the `Artist`/`ApiToken` pointer convention already in this codebase), keeping `ID/Email/Password/MustChangePassword` unchanged. Add `Fillable() []string` returning `{"name","surname","email","password","created_ip_address","last_ip_address","is_onboarded","preferred_locale","marketing_consent","organisation_id","organisation_role","must_change_password"}` (deliberately narrower than PHP's list — `username`/`pin`/`login`/GDPR-consent columns are out of Phase 7 scope: no ported handler reads or writes them). `Hidden() []string` returns `{"password","reset_password_code","activation_code"}`. `Rules() map[string]string` returns the register()-time rules only: `{"email": "required|between:6,255|email|unique:users", "password": "required|between:8,255|confirmed"}` (Laravel's `required:create`/`required_with` context modifiers have no `lagoon.Validate` equivalent and are dropped; `confirmed` alone already enforces the password/password_confirmation pairing). Keep the existing `TableName()`/`init(){ Register(User{}) }`.
|
||||
|
||||
Create `models/throttle.go`: `Throttle` struct (`ID uint`, `UserID uint`, `IPAddress *string`, `Attempts int`, `IsSuspended bool`, `SuspendedAt *time.Time`, `IsBanned bool`, `CreatedAt/UpdatedAt time.Time`), `TableName() string { return "user_throttle" }`, `init(){ Register(Throttle{}) }`.
|
||||
|
||||
Create three appended migration files in `updates/`, each a new `[]*gormigrate.Migration` var + its own `init(){ Register(...) }` (never touch `00_base.go`/`10_organisations.go`, P5 D-03):
|
||||
- `202609220005_extend_users.go`: one migration, `ALTER TABLE users ADD COLUMN ...` for every new column via the `execStmts` helper convention from `10_album_slice.go` (copy that helper if not already package-visible), with `organisation_id` as `INTEGER REFERENCES golem15_user_organisations(id)`, booleans `NOT NULL DEFAULT`, everything else nullable. Rollback drops columns in reverse order.
|
||||
- `202609220006_create_user_throttle.go`: `CREATE TABLE user_throttle (id SERIAL PRIMARY KEY, user_id INTEGER NOT NULL REFERENCES users(id), ip_address TEXT, attempts INTEGER NOT NULL DEFAULT 0, is_suspended BOOLEAN NOT NULL DEFAULT FALSE, suspended_at TIMESTAMPTZ, is_banned BOOLEAN NOT NULL DEFAULT FALSE, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW())` plus a plain (non-unique) index on `(user_id, ip_address)` — Pitfall 5's NULL-ip fallback row rules out a unique constraint. Rollback drops the table.
|
||||
- `202609220007_create_jwt_blacklist.go`: `CREATE TABLE jwt_blacklist (jti TEXT PRIMARY KEY, expires_at TIMESTAMPTZ NOT NULL, valid_until TIMESTAMPTZ NOT NULL)`. Rollback drops the table.
|
||||
|
||||
Extend `../fonoteka.go/plugins/golem15/user/config/config.yaml` (library defaults, matching PHP's `config()` fallback values read this session) with `jwt: {ttl: 60, refresh_ttl: 20160, blacklist_grace: 0, leeway: 0}` (minutes/minutes/seconds/seconds, D-10 — `secret` key already present, keep it), `activation: {require_activation: true, activate_mode: auto, reset_ttl_minutes: 60, activation_ttl_hours: 72}` (D-15), `registration: {allow_registration: true, use_register_throttle: true}`, `throttle: {attempt_limit: 5, suspension_minutes: 15, use_throttle: true}` (D-16), `password: {bcrypt_cost: 10, min_length: 8}` (D-19).
|
||||
|
||||
Create `../fonoteka.go/config/golem15.user.yaml` (new app-level dotted-namespace override file, per the `compass` convention confirmed in `TestDottedPluginNamespaceAndTypedSection`) with Płytarium's real values (D-10): `jwt: {ttl: 1440, refresh_ttl: 43200, blacklist_grace: 10}`. Do not put the JWT secret in this file — it stays env-only (`SUMMER_GOLEM15__USER__JWT__SECRET`).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>go vet ./... && go test ./plugins/golem15/user/updates/... -run 'TestExtendUsersMigration|TestCreateUserThrottleMigration|TestCreateJwtBlacklistMigration'</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `gdb.Migrator().HasColumn("users", col)` is true for every column named in this task's behavior list, after `All()` migrates up
|
||||
- A freshly inserted `users` row has `has_self_set_password = true` and `is_activated = false` by column default
|
||||
- `user_throttle` and `jwt_blacklist` tables exist with exactly the columns specified
|
||||
- Rolling back all three new migrations (reverse ID order) leaves `users` with no orphaned columns and drops both new tables
|
||||
- `../fonoteka.go/config/golem15.user.yaml` exists and contains `jwt.ttl: 1440`, `jwt.refresh_ttl: 43200`, `jwt.blacklist_grace: 10`
|
||||
</acceptance_criteria>
|
||||
<done>All three migrations run up and down cleanly against a real Postgres; models.User/Throttle compile with the new columns; config.yaml and the new app-level override file carry every D-10/D-15/D-16/D-19 default and Płytarium value.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: Core session loop — throttle port, login/logout/fetch/refresh</name>
|
||||
<files>
|
||||
../fonoteka.go/plugins/golem15/user/classes/throttle.go,
|
||||
../fonoteka.go/plugins/golem15/user/classes/throttle_test.go,
|
||||
../fonoteka.go/plugins/golem15/user/classes/user_lookup.go,
|
||||
../fonoteka.go/plugins/golem15/user/controllers/api_controller.go,
|
||||
../fonoteka.go/plugins/golem15/user/controllers/api_controller_test.go,
|
||||
../fonoteka.go/plugins/golem15/user/routes.go,
|
||||
../fonoteka.go/plugins/golem15/user/plugin.go
|
||||
</files>
|
||||
<read_first>
|
||||
../fonoteka.go/plugins/golem15/fonoteka/controllers/genre_controller.go (handler-factory / writeJSON / write401 idioms to copy),
|
||||
../fonoteka.go/plugins/golem15/fonoteka/classes/auth/token_guard.go (remoteIP helper to copy verbatim),
|
||||
../fonoteka.go/plugins/golem15/fonoteka/plugin.go (Buckets() shape to copy for user-api),
|
||||
summercms.go/bouncer/jwt.go, summercms.go/bouncer/mint.go, summercms.go/bouncer/refresh.go, summercms.go/bouncer/blacklist.go (all from 07-01),
|
||||
summercms.go/wire/response.go,
|
||||
/media/nvme/dev/golem15/fonoteka/plugins/golem15/user/controllers/ApiController.php (lines 42-174, 1412-1437: login/logout/fetch/refresh/authorize — already read this session),
|
||||
/media/nvme/dev/golem15/fonoteka/vendor/winter/storm/src/Auth/Manager.php (lines 318-458: findThrottleByLogin/validateInternal),
|
||||
.planning/phases/07-user-plugin-and-authentication/07-RESEARCH.md (Pitfall 5, the CheckAndRecordLogin code example)
|
||||
</read_first>
|
||||
<behavior>
|
||||
- Test (throttle): 5 failed `CheckAndRecordLogin(ctx, db, user, ip, false)` calls for the same `(user, ip)` succeed (no error, `attempts` increments 1..5); the 6th returns a non-nil error and further attempts keep erroring until `suspended_at + 15m` has passed.
|
||||
- Test (throttle): a row with `is_banned = true` always errors regardless of attempt count.
|
||||
- Test (throttle): a successful login (`ok=true`) resets `attempts` to 0 and clears `is_suspended`.
|
||||
- Test (throttle): an unknown login (no matching user row) never creates or touches a throttle row (Pitfall 5 — throttle keys off an existing user).
|
||||
- Test (login handler, httptest): valid email+password returns 200 `{"token":"...","user":{...}}` with a token that decodes to `sub=<user id>`, `prv=a867434cbc213adfbe78a02bed7082a6bd99c883`, `iss` equal to the request's own full URL.
|
||||
- Test (login handler): wrong password, throttled account, and unknown email all return the IDENTICAL body `{"error":true,"message":"Invalid email or password"}`, status 401 (Pitfall 5 / the ApiController.php:81-88 generic catch — not per-cause bodies).
|
||||
- Test (logout handler): a valid bearer token returns 200 `{"message":"Logged out"}` and the SAME token immediately fails a subsequent `fetch` call with 401 `{"error":true,"message":"Unauthorized"}` (forever-blacklist).
|
||||
- Test (fetch handler): valid bearer returns 200 `{"user":{...}}`; missing/invalid bearer returns 401 `{"error":true,"message":"Unauthorized"}`.
|
||||
- Test (refresh handler): no token present returns 401 `{"error":"Token not found"}` (string `error`, no `message`/`msg` key — distinct envelope from login/logout/fetch); an expired-but-within-`refresh_ttl` token returns 200 `{"token":"..."}` with a NEW jti, and the OLD token then fails IsBlacklisted-gated auth; a token past its refresh window returns 401 `{"error":"Could not refresh token","msg":"..."}`.
|
||||
- Test (group wiring): `/_user/api/v1` carries exactly `throttle:user-api` at the group level and no `jwt.auth`/`inv.must-change-password` (D-01); a boot-smoke test asserts this over the real route table (mirror the Phase 6 `TestAllRouteGroupsBoot` boot-probe idiom if the group is otherwise empty of a distinguishing assertion).
|
||||
</behavior>
|
||||
<action>
|
||||
Create `classes/throttle.go`: `func CheckAndRecordLogin(ctx context.Context, db *gorm.DB, user *models.User, ip string, ok bool) error` implementing RESEARCH.md's `CheckAndRecordLogin` algorithm exactly — lookup `WHERE user_id = ? AND (ip_address = ? OR ip_address IS NULL)`, create-if-absent, check `is_banned` then `is_suspended && now < suspended_at+15m` (config `golem15.user.throttle.suspension_minutes`) BEFORE the caller's credential check has even run (the caller — `login()` — calls this ONCE to gate, then again after the password check to record the outcome; see below), increment/suspend at `attempt_limit` (config `golem15.user.throttle.attempt_limit`, default 5) on failure, reset on success. Honor `golem15.user.throttle.use_throttle` — when `false`, always return `nil` without touching the table (D-16). Reuse `remoteIP(r)`'s exact body from `fonoteka`'s `token_guard.go` (copy, do not cross-import — the user plugin does not depend on the fonoteka plugin).
|
||||
|
||||
Extend `classes/user_lookup.go`'s `GormUsers.FindByID`: populate the new `Principal` fields — `PreferredLocale: row.PreferredLocale`, `TokensValidAfter` from `row.TokensValidAfter` (zero `time.Time{}` when the column is `NULL`). Add `func LookupByEmail(ctx context.Context, db *gorm.DB, email string) (*models.User, error)` (plain `Where("email = ?", email).Take(...)`, `nil, nil` on not-found — soft-deleted rows ARE returned here, since `afterLogin`'s restore-on-login (D-17) needs to see them; do not add a `deleted_at IS NULL` filter to this specific lookup).
|
||||
|
||||
Create `controllers/api_controller.go` (package `controllers`, mirroring `genre_controller.go`'s `func Xxx(app *backpack.App) http.HandlerFunc` factory shape and `wire.WriteJSON`/local `writeJSON`/`writeOpaque500` aliases):
|
||||
- `Login(app)`: parse `email`/`password` from the request body (JSON or form, matching `$request->get(...)`'s dual support — decode JSON body if `Content-Type` is `application/json`, else `r.ParseForm()`). Look up the user by email (`LookupByEmail`); if found, call `CheckAndRecordLogin(ctx, db, user, ip, false)` BEFORE checking the password to enforce the ban/suspend gate ahead of the credential check (Pitfall 5's ordering); if that errors, OR the user is nil, OR `user.IsGuest`-equivalent (skip — guest rows don't exist in Go's reduced schema, D-17 drops guest conversion entirely), OR `!bouncer.CheckPassword(user.Password, password)`, return the generic 401 body (record the failed attempt via `CheckAndRecordLogin(ctx, db, user, ip, false)` again ONLY when a user row was found — an unknown email never touches the throttle table). On success: if `user.DeletedAt` is set, restore it (`db.Unscoped().Model(user).Update("deleted_at", nil)`) and send `mail.reactivate` through the D-18 seam (07-03 wires the real mailer; this plan may leave a TODO-free no-op interface call since `postcard.Mailer` lookup can return "not configured" gracefully — do not block this task on mail; if the mailer isn't yet resolvable, skip sending rather than fail the login). Call `CheckAndRecordLogin(ctx, db, user, ip, true)` to clear the throttle. Silently rehash the password if `bouncer.NeedsRehash` is true (D-19). Mint via `bouncer.Mint(secret, strconv.FormatUint(uint64(user.ID),10), requestURL(r), ttl)` where `requestURL(r)` builds the full scheme+host+path URL of THIS request (Pitfall 3 — never a constant). Build the `getApiArray`-equivalent payload (Task 3 supplies the real `GetApiArrayEvent`; for this task, build the payload inline with the base fields RESEARCH.md's Pattern 2 lists, collecting through `app.Events.Collect` if `app.Events != nil`, else the base fields alone) and return `{"token": token, "user": payload}`, 200.
|
||||
- `Logout(app)`: extract the bearer token, then `bouncer.VerifyClaims(token, secret)` for `sub/iat/exp/jti`, then `classes.GormUsers{App:app}.FindByID(ctx, id)` for the principal (the per-handler Bearer-only equivalent of `jwtGuard.Authenticate`, using `VerifyClaims` directly since the raw claims are needed too) — on any failure, `{"error":true,"message":"Unauthorized"}`,401. On success, forever-blacklist the presented token's jti: `blacklist.Add(ctx, jti, exp, time.Now())` (`validUntil=now` makes it immediately blacklisted); `{"message":"Logged out"}`,200.
|
||||
- `Fetch(app)`: same per-handler guard call; success `{"user": payload}`,200; failure `{"error":true,"message":"Unauthorized"}`,401.
|
||||
- `Refresh(app)`: extract the bearer token — Bearer-only, D-09 — via a small local, package-private Bearer-only extractor in this controller (do not import bouncer's unexported `bearerToken`; a two-line `strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")` copy is enough, matching the exact idiom already in `bouncer/jwt.go`'s `bearerToken`). If absent: `{"error":"Token not found"}`,401 (STRING error key, no `message`/`msg`). Otherwise call `bouncer.Refresh(secret, token, refreshTTL, blacklist, grace, requestURL(r))`; on error, `{"error":"Could not refresh token","msg": err.Error()}`,401; on success, `{"token": newToken}`,200.
|
||||
- `OAuthProviders(app)`: always `{"success": true, "providers": []}`,200 (Płytarium configures no OAuth providers — D-03; the shape must stay a JSON array, not `null`, per `wire.Slice`).
|
||||
|
||||
Wire `routes.go`: `r.Group("/_user/api/v1", surf.Use("throttle:user-api"), func(g pact.Router) { g.Post("/login", controllers.Login(p.app)); g.Post("/logout", controllers.Logout(p.app)); g.Get("/fetch", controllers.Fetch(p.app)); g.Post("/refresh", controllers.Refresh(p.app)); g.Post("/register", controllers.Register(p.app)); g.Get("/oauth-providers", controllers.OAuthProviders(p.app)) })` — `Register` is a Task 3 handler; declare its call site now, implement in Task 3 (interface-first ordering within this plan's own scope is fine since Task 3 immediately follows).
|
||||
|
||||
Extend `plugin.go`'s `Boot`: construct `bl := bouncer.NewPostgresBlacklist(sqlDB, "jwt_blacklist")` (resolve `*sql.DB` via `app.Lookup[*sql.DB]()`) and `app.Publish[bouncer.BlacklistStore](bl)`; change the existing `reg.Register(p.ID(), "jwt", bouncer.NewJWTGuard(secret, classes.GormUsers{App: app}))` call to `bouncer.NewJWTGuard(secret, classes.GormUsers{App: app}, bl, "token", "auth_token")` (D-09 cookie fallback belongs on the Registry-resolved "jwt" guard, used by `/_fonoteka/api/v1`'s `jwt.auth`; the user plugin's OWN per-handler calls inside `api_controller.go` construct a SEPARATE Bearer-only `bouncer.NewJWTGuard(secret, users, bl)` with no cookie names). Add `func (p *Plugin) Buckets() map[string]surf.Bucket` implementing `surf.BucketProvider`: `"user-api": {Max: 120, Decay: time.Minute, Key: func(r *http.Request) string { if sub, err := bouncer.Verify(bearerFrom(r), secret); err == nil { return "u:" + sub }; return surf.ClientIP(r, trusted) }}` (C-04 — a lightweight signature-only parse for keying, no DB hit; falls back to `ClientIP` on any failure including a missing header). Declare `var _ surf.BucketProvider = (*Plugin)(nil)`.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>go vet ./... && go test ./plugins/golem15/user/... -run 'TestCheckAndRecordLogin|TestLogin|TestLogout|TestFetch|TestRefresh' -short</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- Valid credentials return 200 `{"token":"...","user":{...}}`; the token decodes to `sub=<user id>` and `prv=a867434cbc213adfbe78a02bed7082a6bd99c883`
|
||||
- Wrong password, a throttled account, and an unknown email all return the byte-identical body `{"error":true,"message":"Invalid email or password"}`, status 401
|
||||
- `POST logout` followed immediately by `GET fetch` with the SAME token returns 401 `{"error":true,"message":"Unauthorized"}` on the fetch call
|
||||
- `POST refresh` with no token returns 401 `{"error":"Token not found"}` (no `message`/`msg` key); a past-refresh-window token returns 401 `{"error":"Could not refresh token","msg":"..."}`
|
||||
- The 6th failed login for the same `(user,ip)` within 15 minutes is rejected by `CheckAndRecordLogin` before any password comparison runs
|
||||
- `surf.RouteInfo` for every `/_user/api/v1` route lists `Middleware == ["throttle:user-api"]` (no `jwt.auth`, no `inv.must-change-password`)
|
||||
</acceptance_criteria>
|
||||
<done>login/logout/fetch/refresh all pass their httptest behaviors above against a real Postgres-backed plugin boot; the throttle suspends after 5 failed attempts per (user,ip); the group carries only throttle:user-api.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 3: Registration slice — register handler, getApiArray event, fonoteka listener</name>
|
||||
<files>
|
||||
../fonoteka.go/plugins/golem15/user/classes/events.go,
|
||||
../fonoteka.go/plugins/golem15/user/classes/events_test.go,
|
||||
../fonoteka.go/plugins/golem15/user/controllers/api_controller.go,
|
||||
../fonoteka.go/plugins/golem15/user/controllers/api_controller_test.go,
|
||||
../fonoteka.go/plugins/golem15/user/routes.go,
|
||||
../fonoteka.go/plugins/golem15/user/plugin.go,
|
||||
../fonoteka.go/plugins/golem15/fonoteka/plugin.go
|
||||
</files>
|
||||
<read_first>
|
||||
summercms.go/festival/bus.go, summercms.go/examples/hello/plugins/greeter/plugin.go (HelloEvent — Collectable/Handleable idiom),
|
||||
../fonoteka.go/plugins/golem15/fonoteka/Plugin.php lines 224-253 (already read this session — the exact flat-merge listener shape and comment about halt=false/array_merge order),
|
||||
/media/nvme/dev/golem15/fonoteka/plugins/golem15/user/controllers/ApiController.php lines 251-346 (register — already read this session, including the ApplicationException/SafeExceptionResponse interaction),
|
||||
/media/nvme/dev/golem15/fonoteka/plugins/golem15/user/Plugin.php lines 316-364 (getApiArray base payload — already read this session),
|
||||
/media/nvme/dev/golem15/fonoteka/.../SafeExceptionResponse.php (already read this session),
|
||||
.planning/phases/07-user-plugin-and-authentication/07-CONTEXT.md (D-02, A4)
|
||||
</read_first>
|
||||
<behavior>
|
||||
- Test (events): firing `GetApiArrayEvent{User: u}` through `app.Events.Collect` with a fonoteka-style listener registered returns a map containing the listener's keys merged onto the base map; a second listener registered at a later priority overwrites a colliding key (later wins, matching PHP `array_merge` order per RESEARCH Pattern 2).
|
||||
- Test (events, import direction): a `go list -deps` (or an equivalent static check) over `plugins/golem15/user/...` contains no `plugins/golem15/fonoteka` import (AUTH-02's "without golem15.user importing golem15.fonoteka").
|
||||
- Test (register handler): valid registration data with `activate_mode=auto` (the config default) returns 200 `{"token":"...","user":{...}}`, and the new user can immediately `fetch` with that token.
|
||||
- Test (register handler): a request missing `email` returns 422 `{"error":"<first message>","errors":{"email":[...]}}`.
|
||||
- Test (register handler): `allow_registration=false` (test override) returns 500 `{"error":"Internal server error"}` — NOT the literal "Registrations are currently disabled." text — reproducing `SafeExceptionResponse`'s production-mode (app.debug=false) degradation of any non-Validation/Http/Authentication exception, confirmed by reading the trait this session; the literal text only surfaces when `app.debug=true` (also test this branch).
|
||||
- Test (getApiArray via fonoteka listener): registering `golem15.user` + `golem15.fonoteka` together and firing `GetApiArrayEvent` for a user with `OrganisationID`/`OrganisationRole`/`MustChangePassword`/`PreferredLocale` set produces a payload containing exactly those four extra keys with those values (AUTH-02's acceptance shape).
|
||||
</behavior>
|
||||
<action>
|
||||
Create `classes/events.go`: `type GetApiArrayEvent struct { User *models.User; data map[string]any }` with `func (e *GetApiArrayEvent) Collected() map[string]any` (lazy-init `data`, per RESEARCH Pattern 2 — festival.Collectable). Add `type RegisterEvent struct { User *models.User }` (no `Collected()` — a plain `Fire`-only event mirroring PHP's `Event::fire('golem15.user.register', [$user, $data])`; D-02/plan-table says register this fire-and-forget event with no consumers this phase).
|
||||
|
||||
In `controllers/api_controller.go`, add `Register(app)`: read config `golem15.user.registration.allow_registration`/`use_register_throttle`. If registration is disabled OR the caller's IP has 3+ prior registrations in the last 60 minutes when throttling is on (D-02 — count `created_ip_address` matches on `users` in the last hour, client IP via the Phase 6 trusted-proxy `surf.ClientIP` function), build the SafeExceptionResponse-equivalent: if `app.Config.Bool("app.debug")` is true, return the literal PHP message (`"Registrations are currently disabled."` / `"Registration is throttled. Please try again later."`) at status 500 with body `{"error": "<message>"}`; if false (production, matches the pinned parity recorder), return `{"error":"Internal server error"}`,500 — do not leak the specific cause in production, matching `SafeExceptionResponse::safeExceptionMessage`'s exact behavior read this session. Otherwise validate the request body against `models.User{}.Rules()` via `lagoon.Validate` (using the extended `email`/`confirmed` tokens from 07-01); on failure, `{"error": <first message from the flattened errors map, any deterministic pick>, "errors": <full map>}`,422. On success: hash the password (`bouncer.HashPassword`), set `CreatedIPAddress`/`LastIPAddress` from `surf.ClientIP`, insert the row via `lagoon.Fill` against `Fillable()` (never raw `db.Create(&input)`), fire `RegisterEvent` (best-effort, ignore its error per "no consumers yet"). Then branch on `activate_mode`: `auto` or `!require_activation` → mint a token exactly like `Login` does and return `{"token":..., "user": <apiArray>}`,200; `user` → send `mail.activate` (07-03 wires the real send; this task may no-op if the mailer isn't resolvable yet, matching Task 2's guidance) and return `{"message":"Activation email sent"}`,200; `admin` → return `{}` (empty JSON object, NOT `null` or `[]`), 200.
|
||||
|
||||
Build the shared `apiArray(ctx, app, user *models.User) (map[string]any, error)` helper (used by `Login`/`Fetch`/`Register`/future 07-03 handlers): base fields exactly per `Plugin.php:329-359` — `id, name, surname, email, is_activated, permissions: []string{}, avatar: nil, avatar_url: nil, has_avatar: false, marketing_consent, groups: map[string]string{}, role: nil, is_onboarded, has_self_set_password` (A4 — `permissions`/`groups`/`role` stub empty/nil since no Go RBAC model exists; `avatar`/`avatar_url`/`has_avatar` are wired for real once 07-03 lands the attachment — this task's stub values must still be present as literal keys so the payload shape is stable across plans). Then `if app.Events != nil { extra, _ := app.Events.Collect(ctx, &classes.GetApiArrayEvent{User: user}); for k, v := range extra { payload[k] = v } }` (array_merge order — later listener wins, per RESEARCH Pattern 2).
|
||||
|
||||
In `../fonoteka.go/plugins/golem15/fonoteka/plugin.go`'s `Boot`, add the listener (only when `app.Events != nil`): `app.Events.Listen[*userclasses.GetApiArrayEvent]("golem15.fonoteka", func(ctx context.Context, e *userclasses.GetApiArrayEvent) error { m := e.Collected(); m["organisation_id"] = e.User.OrganisationID; m["organisation_role"] = e.User.OrganisationRole; m["must_change_password"] = e.User.MustChangePassword; m["preferred_locale"] = e.User.PreferredLocale; return nil })` (import alias `userclasses "git.golem15.com/golem15/fonoteka/plugins/golem15/user/classes"` — `golem15.fonoteka` already `Requires()` `golem15.user`, so this import direction is already established; `golem15.user` must never import `golem15.fonoteka` back).
|
||||
|
||||
Confirm `../fonoteka.go/config/http.yaml`'s `cors.paths` already lists `_user/api/*` (it does, verified this session) — add a one-line assertion in `api_controller_test.go` (a plain string-contains check over the loaded CORS config, or reuse whatever CORS-config test helper 06-03 shipped) rather than re-wiring anything, since D-01's CORS requirement is already satisfied.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>go vet ./... && go test ./plugins/golem15/... -run 'TestGetApiArray|TestRegister|TestRegisterEvent' -short</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `activate_mode=auto` registration returns 200 `{"token":"...","user":{...}}` and the returned token authenticates an immediate `fetch`
|
||||
- `allow_registration=false` returns 500 `{"error":"Internal server error"}` when `app.debug=false`, and the literal `"Registrations are currently disabled."` text only when `app.debug=true`
|
||||
- A `GetApiArrayEvent` collected with a `golem15.fonoteka`-style listener registered contains `organisation_id`, `organisation_role`, `must_change_password`, `preferred_locale` as the ONLY extra keys beyond the base payload
|
||||
- `go list -deps ./plugins/golem15/user/...` contains no `plugins/golem15/fonoteka` path segment
|
||||
- A missing `email` field in the register request returns 422 with `errors.email` present
|
||||
</acceptance_criteria>
|
||||
<done>register() reproduces all three D-02 branches plus the confirmed SafeExceptionResponse degradation; GetApiArrayEvent is collected across the golem15.user→golem15.fonoteka boundary with the exact four extra keys; golem15.user has zero import of golem15.fonoteka.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|--------------|
|
||||
| Client → login/register | Untrusted email/password crosses into credential verification and account creation |
|
||||
| Client → refresh/logout | Untrusted bearer token crosses into the sliding-refresh and forever-blacklist paths |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|-------------|------------------|
|
||||
| T-07-01 | Spoofing / Elevation of Privilege | Logout → jwt_blacklist | mitigate | Logout forever-blacklists the presented jti; Fetch immediately rejects a reused, just-logged-out token via the guard's blacklist check (07-01) |
|
||||
| T-07-04 | Spoofing (credential stuffing) | Login | mitigate | Two independent layers: `throttle:user-api` bucket (120/min, C-04) in front of the handler, and `CheckAndRecordLogin`'s per-(user,ip) 5-attempt/15-minute suspend (D-16) inside it — neither substitutes for the other |
|
||||
| T-07-07 | Tampering | Empty JWT secret | accept (already mitigated) | `classes.JWTSecret` already fails boot on empty (unchanged this plan) |
|
||||
| T-07-12 | Information Disclosure | register()'s disabled/throttled branches | mitigate | Reproduces `SafeExceptionResponse`'s production-mode message hiding (generic "Internal server error" under `app.debug=false`) instead of leaking the specific cause — matches PHP's actual deployed behavior, verified by reading the trait this session |
|
||||
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
`go vet ./...` and `go test ./... -short` green in `fonoteka.go`. A manual httptest sequence — register → fetch → logout → fetch-with-same-token (expect 401) — passes end to end against a real (testcontainers) Postgres.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
A user can register, log in, fetch their own profile (with organisation/locale fields present via the fonoteka listener), refresh a near-expired token, and log out with the old token permanently rejected afterward — the full AUTH-01/AUTH-02 session loop working against real Postgres.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/07-user-plugin-and-authentication/07-02-SUMMARY.md` when done
|
||||
</output>
|
||||
Reference in New Issue
Block a user