294 lines
36 KiB
Markdown
294 lines
36 KiB
Markdown
---
|
|
phase: 07-user-plugin-and-authentication
|
|
plan: 03
|
|
type: execute
|
|
wave: 3
|
|
depends_on: ["07-02"]
|
|
files_modified:
|
|
- ../fonoteka.go/plugins/golem15/user/classes/codes.go
|
|
- ../fonoteka.go/plugins/golem15/user/classes/codes_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/console/require_password_change.go
|
|
- ../fonoteka.go/plugins/golem15/user/console/require_password_change_test.go
|
|
- ../fonoteka.go/plugins/golem15/user/views/mail/activate.htm
|
|
- ../fonoteka.go/plugins/golem15/user/views/mail/activate-en.htm
|
|
- ../fonoteka.go/plugins/golem15/user/views/mail/restore.htm
|
|
- ../fonoteka.go/plugins/golem15/user/views/mail/restore-en.htm
|
|
- ../fonoteka.go/plugins/golem15/user/views/mail/reactivate.htm
|
|
- ../fonoteka.go/plugins/golem15/user/views/mail/reactivate-en.htm
|
|
- ../fonoteka.go/plugins/golem15/user/views/mail/layouts/user.htm
|
|
- ../fonoteka.go/plugins/golem15/user/plugin.go
|
|
- ../fonoteka.go/plugins/golem15/user/config/config.yaml
|
|
autonomous: true
|
|
requirements: [AUTH-01]
|
|
|
|
must_haves:
|
|
truths:
|
|
- "A user can request a password reset by email (always the enumeration-safe 200), reset it with the {id}!{code} code, and log in with the new password"
|
|
- "A user can activate an account via the authenticated activate route (fire-and-forget, always 200, per ApiController.php:180-196's missing if-check) and via the public activate-by-code route (which DOES check the result, per ApiController.php:208-245)"
|
|
- "An authenticated user can update their name/surname/email, change their password (current-password verified, D-20 tokens-valid-after invalidation with the presenting token exempted), upload/remove an avatar, and toggle marketing consent"
|
|
- "Reset and activation codes compare in constant time and expire per a configured TTL with a null-issued-at cutover row counted as issued now (D-15)"
|
|
- "mail.activate/mail.restore/mail.reactivate render and send through postcard.Send, with the caller picking the -en sibling from preferred_locale via mailTemplate/resolveMailLocale (C-05)"
|
|
- "change-password ports the normal (self-set-password) branch only; a has_self_set_password=false row 500s with an opaque body rather than being silently treated as self-set, since no Go path can create such a row and the social-login OTP branch is deferred, per D-03"
|
|
- "Avatar upload and remove are ported on the Phase 5 attachment machinery (system_files, Thumb): payload avatar/avatar_url (128 thumb)/has_avatar are real, bounded by the existing per-group MaxBytesReader upload cap, per D-04"
|
|
- "Mail is sent inline through postcard.Send behind a small seam Phase 11 later swaps for a River job; forgot-password always returns its enumeration-safe 200 body and logs a send failure instead of surfacing it, per D-18"
|
|
artifacts:
|
|
- path: "../fonoteka.go/plugins/golem15/user/classes/codes.go"
|
|
provides: "IssueResetCode/IssueActivationCode/VerifyResetCode/VerifyActivationCode with constant-time compare and TTL"
|
|
- path: "../fonoteka.go/plugins/golem15/user/console/require_password_change.go"
|
|
provides: "user:require-password-change <email> console command (D-21)"
|
|
- path: "../fonoteka.go/plugins/golem15/user/views/mail/{activate,restore,reactivate}(-en).htm"
|
|
provides: "the three mail templates plus layout, registered via pact.HasMailTemplates"
|
|
key_links:
|
|
- from: "../fonoteka.go/plugins/golem15/user/controllers/api_controller.go change-password handler"
|
|
to: "bouncer.Principal.TokensValidAfter / VerifyClaims"
|
|
via: "cutover set to presentingIat-1s so the calling device is not logged out mid-session (D-20, Open Question 1)"
|
|
pattern: "TokensValidAfter"
|
|
- from: "../fonoteka.go/plugins/golem15/user/controllers/api_controller.go avatar handlers"
|
|
to: "summercms.go/lagoon/attach File/Thumb"
|
|
via: "attach.File row per upload, f.Thumb(ctx, bucket, 128, 128, \"auto\") for avatar_url"
|
|
pattern: "attach\\.(File|Thumb)"
|
|
---
|
|
|
|
<objective>
|
|
Complete the `golem15.user` plugin's account-management surface: forgot/reset password, activation (both the authenticated and public-code routes), profile update, change-password (with the D-20 session-invalidation exemption), avatar upload/remove, and marketing consent — plus the mail templates and the `must_change_password` console command those flows need.
|
|
|
|
Purpose: this is the last user-plugin-owned slice; after this plan every `/_user/api/v1` route in D-01 has a real handler and `golem15.user`'s own code is feature-complete for Phase 7.
|
|
Output: `classes/codes.go`, the remaining `api_controller.go` handlers, three mail templates, and the `user:require-password-change` command.
|
|
</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-02-SUMMARY.md
|
|
|
|
<interfaces>
|
|
<!-- Contracts from 07-01/07-02 this plan consumes directly. -->
|
|
```go
|
|
func bouncer.VerifyClaims(tokenString, secret string) (sub string, iat, exp time.Time, jti string, err error)
|
|
func bouncer.HashPassword(cost int, plain string) (string, error)
|
|
func bouncer.CheckPassword(hash, plain string) bool
|
|
func bouncer.NeedsRehash(hash string, configuredCost int) bool
|
|
func apiArray(ctx context.Context, app *backpack.App, user *models.User) (map[string]any, error) // 07-02 Task 3, same package
|
|
```
|
|
From `summercms.go/lagoon/attach/file.go` and `thumb.go` (read in full before the avatar task — no HTTP handler analog exists yet, this plan writes the first one):
|
|
```go
|
|
type attach.Owner interface{ MorphName() string }
|
|
type attach.File struct { ID uint; DiskName, FileName string; FileSize int64; ContentType string; Field, AttachmentID, AttachmentType string; IsPublic *bool; SortOrder int; CreatedAt, UpdatedAt time.Time }
|
|
func (f *attach.File) Thumb(ctx context.Context, bucket *blob.Bucket, w, h int, mode string) (string, error)
|
|
func attach.DeleteForOwner(tx *gorm.DB, owner attach.Owner, ownerID string, afterCommit func([]string) error) error
|
|
func attach.BlobKey(diskName string) string
|
|
func attach.PartitionDirectory(diskName string) string
|
|
```
|
|
`models.User` has no `MorphName()` yet — this plan adds `func (User) MorphName() string { return "Golem15\\User\\Models\\User" }` (the PHP class string, per the `Owner` interface contract other Phase 5 models already implement) so `attach.File` rows and their `blobKeysFor` cleanup match cutover-imported rows.
|
|
</interfaces>
|
|
</context>
|
|
|
|
<tasks>
|
|
|
|
<task type="auto" tdd="true">
|
|
<name>Task 1: Reset/activation codes and their four handlers</name>
|
|
<files>
|
|
../fonoteka.go/plugins/golem15/user/classes/codes.go,
|
|
../fonoteka.go/plugins/golem15/user/classes/codes_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/config/config.yaml
|
|
</files>
|
|
<read_first>
|
|
../fonoteka.go/plugins/golem15/fonoteka/classes/auth/token_guard.go (sha256-hex hashing convention; this task's compare is constant-time string compare, not hash compare — codes are short and never hashed at rest, matching PHP's plain-text `reset_password_code`/`activation_code` columns),
|
|
../fonoteka.go/plugins/golem15/user/classes/mail.go (07-02 Task 2 — mailTemplate/resolveMailLocale, the locale-suffix helper every mail send in this plan must route through, per C-05/P4 D-08),
|
|
../fonoteka.go/plugins/golem15/user/models/user.go (from 07-02 — ResetPasswordCode/ResetPasswordCodeIssuedAt/ActivationCode/ActivationCodeIssuedAt fields),
|
|
/media/nvme/dev/golem15/fonoteka/plugins/golem15/user/controllers/ApiController.php lines 180-245, 638-715 (activate/activateByCode/forgotPassword/resetPassword — already read this session),
|
|
/media/nvme/dev/golem15/fonoteka/vendor/winter/storm/src/Auth/Models/User.php lines 209-292 (activation/reset code semantics, no expiry — already read this session),
|
|
.planning/phases/07-user-plugin-and-authentication/07-CONTEXT.md (D-15, D-18, D-20)
|
|
</read_first>
|
|
<behavior>
|
|
- Test (codes): `IssueResetCode`/`IssueActivationCode` store a non-empty code plus an issued-at timestamp equal to `time.Now()` (within a small tolerance) and return the raw code.
|
|
- Test (codes): `VerifyResetCode(ctx, db, id, code)` succeeds for the exact issued code within the configured TTL (`reset_ttl_minutes`, default 60), fails for a wrong code, and fails once `issued_at + TTL` has passed.
|
|
- Test (codes): a row whose `reset_password_code_issued_at` is `NULL` (simulating a PHP-issued, pre-cutover code) is treated as issued "now" and gets one full TTL (D-15's null-issued-at rule) — insert such a row directly via SQL in the test, bypassing the Go issuing path, to prove this.
|
|
- Test (codes): the compare uses `crypto/subtle.ConstantTimeCompare` (assert via a code-reading check, or a timing-insensitive behavioral test that a code differing only in its last byte fails identically to one differing in its first byte — do not assert on timing directly, that is flaky; assert on the constant-time helper being called via a small refactor seam if easier, e.g. a package-level `compareFunc` variable swapped in the test).
|
|
- Test (forgot-password handler): both an existing and a nonexistent email return the IDENTICAL `{"message":"If that email exists, a reset link has been sent."}`,200; a malformed email returns 422 `{"error":"...","errors":{"email":[...]}}`.
|
|
- Test (reset-password handler): a valid `"{id}!{code}"` plus a confirmed new password returns 200 `{"message":"Password has been reset"}`, and the user can then log in with the new password but NOT the old one; a malformed code (no `!`) returns 422 `{"error":"Invalid reset code"}`; a wrong or expired code returns 422 `{"error":"Invalid or expired reset code"}`.
|
|
- Test (activate handler, authenticated): calling it with a WRONG code still returns 200 `{"user":{...}}` with `is_activated` unchanged (PHP's `attemptActivation()` call has no `if` guard — ApiController.php:180-196, confirmed this session; do not add a check Go-side that PHP doesn't have).
|
|
- Test (activate-by-code handler, public): a valid `"{id}!{code}"` returns 200 `{"message":"Account activated","token":"...","user":{...}}` with `is_activated=true`; an invalid one returns 422 `{"error":"This activation link is invalid or has expired"}`; a missing `code` field returns 422 `{"error":"...","errors":{"code":[...]}}`.
|
|
</behavior>
|
|
<action>
|
|
Extend `../fonoteka.go/plugins/golem15/user/config/config.yaml`'s `activation` section (already present from 07-02) is sufficient — do not duplicate `reset_ttl_minutes`/`activation_ttl_hours` under a new key.
|
|
|
|
Create `classes/codes.go`: `func newCode() (string, error)` — 32 random bytes via `crypto/rand`, hex-encoded (64 chars; PHP's own generation algorithm need not be reproduced bit-for-bit, only the stored plain-text format and the `"{id}!{code}"` link format matter for cutover compatibility, D-15). `func IssueResetCode(ctx, db *gorm.DB, user *models.User) (code string, err error)` and `IssueActivationCode(...)` — generate, `UpdateColumns` the code + its issued-at column to `time.Now()`, return the raw code. `func VerifyResetCode(ctx, db, userID uint, code string) (*models.User, bool)` and `VerifyActivationCode(...)`: load the user by id (do not filter `deleted_at` for activation — a trashed user restoring via a matching activation code is a real PHP path, `attemptActivation`'s trashed branch), compute the TTL cutoff (`issuedAt` = the column's value, or `time.Now()` when the column is `NULL` — D-15's cutover rule — compared against `time.Now()`), reject if past cutoff, `subtle.ConstantTimeCompare([]byte(code), []byte(stored)) == 1` (pad/compare lengths safely — `ConstantTimeCompare` requires equal-length slices; a length mismatch is simply "not equal", checked before calling it, still without a data-dependent branch on the code's actual bytes) . On success for reset: also flip `has_self_set_password=true` (D-15/k7ut351s-equivalent parity, mirrors PHP's `attemptResetPassword`) and clear the code; caller sets the new password. On success for activation: set `is_activated=true`, `activated_at=now`, clear `activation_code`; if the user was soft-deleted, `db.Unscoped().Model(user).Update("deleted_at", nil)` first (the trashed branch).
|
|
|
|
In `controllers/api_controller.go`, add:
|
|
- `ForgotPassword(app)`: validate `email` (`required|email|between:6,255`) → 422 on failure. Look up by email; if found, `IssueResetCode`, build the reset link (`golem15.user.reset_url_base` config, default `<app.url>/reset-password`, `?code=<id>!<code>`), send `mailTemplate("golem15.user::mail.restore", resolveMailLocale(ctx, user))` (vars `name, link, code`) through `postcard.Mailer` resolved via `app.Lookup[postcard.Mailer]()` — log-and-continue on a send error (D-18, never surface it). `mailTemplate`/`resolveMailLocale` are the 07-02 Task 2 helpers (`classes/mail.go`) — reuse them here verbatim, do not hardcode `"golem15.user::mail.restore"` as the final template name. Always return `{"message":"If that email exists, a reset link has been sent."}`,200 regardless of any branch above.
|
|
- `ResetPassword(app)`: validate `code` (`required`) and `password` (`required|between:8,255|confirmed`) → 422 on failure with the flattened-errors envelope. Split `code` on `!`; not exactly 2 parts → `{"error":"Invalid reset code"}`,422. `VerifyResetCode`; failure → `{"error":"Invalid or expired reset code"}`,422. Success: hash the new password, save, set `TokensValidAfter = time.Now()` (D-20 — unauthenticated flow, no presenting token to exempt), `{"message":"Password has been reset"}`,200.
|
|
- `Activate(app)`: per-handler Bearer-only auth (`bouncer.NewJWTGuard(secret, users, blacklist).Authenticate(r)`, failure → the standard `{"error":true,"message":"Unauthorized"}`,401). Call `VerifyActivationCode(ctx, db, user.ID, r.Form.Get("code"))` and IGNORE its boolean result — always return `{"user": <apiArray>}`,200 (reproducing the missing `if` at ApiController.php:184, confirmed this session — this is a deliberate PHP quirk, not a bug to silently fix).
|
|
- `ActivateByCode(app)`: no auth. Validate `code` (`required`) → 422. Split on `!`; not 2 parts → `{"error":"Invalid activation code"}`,422. Load user by id; `VerifyActivationCode` fails or user missing → `{"error":"This activation link is invalid or has expired"}`,422. Success: mint a token (`bouncer.Mint`, `issuerURL` = this endpoint's own URL), `{"message":"Account activated","token":token,"user":<apiArray>}`,200.
|
|
|
|
Add the four routes to the existing `/_user/api/v1` group in `routes.go`: `g.Post("/forgot-password", controllers.ForgotPassword(p.app))`, `g.Post("/reset-password", controllers.ResetPassword(p.app))`, `g.Post("/activate", controllers.Activate(p.app))`, `g.Post("/activate-by-code", controllers.ActivateByCode(p.app))`.
|
|
</action>
|
|
<verify>
|
|
<automated>go vet ./... && go test ./plugins/golem15/user/... -run 'TestCodes|TestForgotPassword|TestResetPassword|TestActivate' -short</automated>
|
|
</verify>
|
|
<acceptance_criteria>
|
|
- `ForgotPassword` returns the byte-identical 200 body for both an existing and a nonexistent email
|
|
- `ResetPassword` with a malformed code (no `!`) returns 422 `{"error":"Invalid reset code"}`; a wrong/expired code returns 422 `{"error":"Invalid or expired reset code"}`; success returns 200 `{"message":"Password has been reset"}` and the user can log in with the new password
|
|
- `Activate` (authenticated) returns 200 `{"user":{...}}` even when the supplied code is wrong, with `is_activated` unchanged — no `if` guard on the boolean result
|
|
- `ActivateByCode` on a valid `"{id}!{code}"` returns 200 with a fresh token and `is_activated:true`
|
|
- A row with `reset_password_code_issued_at = NULL` inserted directly via SQL is treated as issued now and accepts its code within one full TTL
|
|
- `ForgotPassword`'s `mail.restore` template-name computation calls `mailTemplate`/`resolveMailLocale`, not a hardcoded string literal
|
|
- `go test ./plugins/golem15/user/... -run 'TestCodes|TestForgotPassword|TestResetPassword|TestActivate'` exits 0
|
|
</acceptance_criteria>
|
|
<done>All four handlers reproduce their PHP status/body pairs including the asymmetric authenticated-activate no-op-on-failure behavior; codes compare in constant time and honor the TTL and null-issued-at cutover rule.</done>
|
|
</task>
|
|
|
|
<task type="auto" tdd="true">
|
|
<name>Task 2: Profile update, change-password with D-20 exemption, marketing consent</name>
|
|
<files>
|
|
../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
|
|
</files>
|
|
<read_first>
|
|
../fonoteka.go/plugins/golem15/user/controllers/api_controller.go (Login/Logout from 07-02 — the per-handler-auth idiom to copy verbatim for Update/ChangePassword/MarketingConsent),
|
|
summercms.go/bouncer/jwt.go and mint.go (VerifyClaims, Mint — for the D-20 cutover math),
|
|
/media/nvme/dev/golem15/fonoteka/plugins/golem15/user/controllers/ApiController.php lines 364-515, 595-626 (update/changePassword/updateMarketingConsent — already read this session),
|
|
.planning/phases/07-user-plugin-and-authentication/07-CONTEXT.md (D-20, its resolved Open Question 1)
|
|
</read_first>
|
|
<behavior>
|
|
- Test (update): valid `name/surname/email` returns 200 `{"message":"Profile updated","user":{...}}` with the new values reflected; re-saving the CALLER's own unchanged email does not trip the uniqueness check; a duplicate OTHER user's email returns 422; missing `name` returns 422 with `{"error":"...","errors":{"name":[...]}}`.
|
|
- Test (change-password, self-set path): correct `current_password` + a `password`/`password_confirmation` pair different from the current one returns 200 `{"message":"Password changed","user":{...}}`; the OLD token used to make this exact call still authenticates a subsequent `fetch` (D-20 exemption); a DIFFERENT older token for the same user (minted before this call) now gets 401 on `fetch`.
|
|
- Test (change-password): wrong `current_password` returns 422 `{"error":"Invalid email or password","errors":{"current_password":["Invalid email or password"]}}` (reusing the login-failure message text per ApiController.php:451, confirmed this session).
|
|
- Test (change-password): `password` equal to `current_password` (the `different:current_password` rule) returns 422.
|
|
- Test (change-password, `has_self_set_password=false` row): the handler 500s with an opaque body rather than silently treating the row as self-set (D-03's "documented as deferred, not silently treated as self-set" — Open Question 3's confirmed low-risk resolution: no Go-created row can reach this state today, so a 500 fail-loud guard is correct and should never fire in practice).
|
|
- Test (marketing-consent): `{"marketing_consent": true}` returns 200 `{"message":"Marketing consent updated","user":{...}}` with `marketing_consent: true`; `false` clears it; a non-boolean value returns 422.
|
|
</behavior>
|
|
<action>
|
|
Add `Update(app)`: per-handler Bearer-only auth. Validate `name` (`required|between:2,255`), `surname` (`required|between:2,255`), `email` (`required|between:6,255|email|unique:users` — `lagoon.Validate`'s `uniqueOK` already excludes the caller's own row via `modelUintID`, matching PHP's `unique:users,email,{id}`). On failure, `{"error": <first>, "errors": {...}}`,422. On success, set the three fields directly (never mass-assign the whole request body — matches PHP's explicit-field-write comment) and save; `{"message":"Profile updated","user": <apiArray>}`,200.
|
|
|
|
Add `ChangePassword(app)`: per-handler auth via `bouncer.VerifyClaims` (not the plain guard — this handler needs the presenting token's `iat` for the D-20 exemption). On auth failure, `{"error":true,"message":"Unauthorized"}`,401. Compute `hasSelfSetPassword := user.HasSelfSetPassword` — if `false`, return an opaque `500` (`wire.WriteOpaque500`), log server-side, per D-03/Open Question 3 (this branch is provably unreachable for any Go-created row but must fail loud, not silently succeed, if a cutover-migrated row ever hits it). Validate: `current_password` `required`, `password` `required|between:<min_password_length>,255|confirmed|different:current_password` (config `golem15.user.password.min_length`, default 8). On validation failure, `{"error": <first>, "errors": {...}}`,422. Check `bouncer.CheckPassword(user.Password, currentPassword)`; on mismatch, `{"error":"Invalid email or password","errors":{"current_password":["Invalid email or password"]}}`,422 (reuses the login-failure text verbatim, per source). On success: hash the new password, `MustChangePassword=false`, `HasSelfSetPassword=true` (already true on this path, set anyway to mirror PHP), save. THEN apply D-20: `cutoff := iat.Add(-1 * time.Second)` (the presenting token's own `iat`, minus one second, so `iat.Before(cutoff)` is false for THIS token and true for any older one — the Open Question 1 exemption), `user.TokensValidAfter = cutoff`, save that column too. Return `{"message":"Password changed","user": <apiArray>}`,200 — NOT a fresh token (confirmed PHP never returns one from this endpoint).
|
|
|
|
Add `MarketingConsent(app)`: per-handler Bearer-only auth. Validate `marketing_consent` (`required|boolean`) → 422 on failure. Set the column, save, reload; `{"message":"Marketing consent updated","user": <apiArray>}`,200.
|
|
|
|
Add the three routes to the `/_user/api/v1` group: `g.Post("/update", controllers.Update(p.app))`, `g.Post("/change-password", controllers.ChangePassword(p.app))`, `g.Post("/marketing-consent", controllers.MarketingConsent(p.app))`.
|
|
</action>
|
|
<verify>
|
|
<automated>go vet ./... && go test ./plugins/golem15/user/... -run 'TestUpdate|TestChangePassword|TestMarketingConsent' -short</automated>
|
|
</verify>
|
|
<acceptance_criteria>
|
|
- `Update` with a duplicate OTHER user's email returns 422; re-saving the caller's own unchanged email does not
|
|
- `ChangePassword` with the correct current password and a new, different, confirmed password returns 200 `{"message":"Password changed","user":{...}}` with no `token` key
|
|
- The presenting token used to CALL `ChangePassword` still authenticates a subsequent `fetch`; a token minted for the same user BEFORE that call gets 401 on `fetch` afterward
|
|
- Wrong `current_password` returns 422 `{"error":"Invalid email or password","errors":{"current_password":["Invalid email or password"]}}`
|
|
- `MarketingConsent` with `{"marketing_consent":true}` returns 200 with `user.marketing_consent == true`
|
|
</acceptance_criteria>
|
|
<done>change-password's D-20 exemption is proven by a passing test (presenting token survives, a prior token does not); update/marketing-consent match their PHP bodies exactly.</done>
|
|
</task>
|
|
|
|
<task type="auto" tdd="true">
|
|
<name>Task 3: Avatar upload/remove, mail templates, require-password-change command</name>
|
|
<files>
|
|
../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/models/user.go,
|
|
../fonoteka.go/plugins/golem15/user/console/require_password_change.go,
|
|
../fonoteka.go/plugins/golem15/user/console/require_password_change_test.go,
|
|
../fonoteka.go/plugins/golem15/user/views/mail/activate.htm,
|
|
../fonoteka.go/plugins/golem15/user/views/mail/activate-en.htm,
|
|
../fonoteka.go/plugins/golem15/user/views/mail/restore.htm,
|
|
../fonoteka.go/plugins/golem15/user/views/mail/restore-en.htm,
|
|
../fonoteka.go/plugins/golem15/user/views/mail/reactivate.htm,
|
|
../fonoteka.go/plugins/golem15/user/views/mail/reactivate-en.htm,
|
|
../fonoteka.go/plugins/golem15/user/views/mail/layouts/user.htm,
|
|
../fonoteka.go/plugins/golem15/user/plugin.go
|
|
</files>
|
|
<read_first>
|
|
summercms.go/lagoon/attach/file.go, summercms.go/lagoon/attach/thumb.go, summercms.go/lagoon/attach/bucket.go (Owner, File, Thumb, OpenBucket — no HTTP handler analog exists, this is net new per PATTERNS.md),
|
|
summercms.go/examples/hello/plugins/base/plugin.go and views/mail/{hello.htm,hello-en.htm} and views/mail/layouts/hello.htm (the exact template file format: INI header, `==` separators, markdown body — read the actual .htm files, not just the plugin.go, before authoring new ones),
|
|
summercms.go/postcard/mailer.go, postcard/templates.go (Catalog.Register, Message, Mailer.Send),
|
|
summercms.go/bonfire/command.go (Command, Arg, Input.Argument),
|
|
summercms.go/lagoon/commands.go (a command taking a parameter, for the Run-closure shape),
|
|
/media/nvme/dev/golem15/fonoteka/plugins/golem15/user/controllers/ApiController.php lines 517-584 (updateAvatar/removeAvatar — already read this session),
|
|
.planning/phases/07-user-plugin-and-authentication/07-CONTEXT.md (D-04, D-21, C-05)
|
|
</read_first>
|
|
<behavior>
|
|
- Test (avatar upload): a multipart POST with a valid small JPEG under `avatar` returns 200 `{"message":"Avatar updated","user":{...,"has_avatar":true,"avatar_url":"<128 thumb URL>"}}`; a `.svg` (or any non-`jpeg,jpg,png,webp,gif` sniffed content type) returns 422 `{"error":"...","errors":{"avatar":[...]}}`; an oversized file (>4000 KB) returns 422 with an avatar-size error; a request with no `avatar` field returns 422.
|
|
- Test (avatar remove): removing an existing avatar returns 200 `{"message":"Avatar removed","user":{...,"has_avatar":false}}` and the underlying `system_files` row plus its blob are gone; removing when none exists returns 422 `{"error":"Your account has no display picture to remove.","errors":{"avatar":["Your account has no display picture to remove."]}}`.
|
|
- Test (mail): `postcard`'s `memory` driver captures a `mail.activate` send with `Vars["link"]` containing the generated activation code, when `Register`'s `user`-activation-mode branch runs (wire this test through the shared `postcard.Mailer` the plugin now publishes/resolves, matching the Phase 4 `memory`-driver assertion pattern).
|
|
- Test (mail locale suffix, BLOCKER fix): register two users, one with `PreferredLocale="pl"` and one with `PreferredLocale="en"`, and trigger `ForgotPassword` for each. The `memory` driver's captured `Message.Template` is exactly `"golem15.user::mail.restore"` for the `pl` user and exactly `"golem15.user::mail.restore-en"` for the `en` user -- proving `mailTemplate`/`resolveMailLocale` actually drive the real send, not just the pure-function unit test from 07-02. Repeat the same assertion shape for `mail.activate` (Register, user-mode) and `mail.reactivate` (Login, soft-delete restore) so all three call sites are covered by an end-to-end locale-suffix test, not just one.
|
|
- Test (console command): `user:require-password-change alice@example.com` sets `must_change_password=true` for that user and prints a success line; an unknown email returns a command error, not a panic; the command is discoverable via `pact.HasCommands`.
|
|
</behavior>
|
|
<action>
|
|
Add `func (User) MorphName() string { return "Golem15\\User\\Models\\User" }` to `models/user.go` (the `attach.Owner` interface, PHP class string verbatim so cutover-imported `system_files.attachment_type` rows keep matching, mirroring how Phase 5 models already implement this).
|
|
|
|
In `api_controller.go`, add `UploadAvatar(app)`: per-handler Bearer-only auth. `r.ParseMultipartForm(10 << 20)`; `file, header, err := r.FormFile("avatar")`; missing/parse-error → `{"error":"...","errors":{"avatar":["The avatar field is required."]}}`,422. Read the first 512 bytes for `http.DetectContentType` (do not trust the client-supplied filename extension or `Content-Type` header), map the sniffed MIME to an extension in `{jpeg,jpg,png,webp,gif}` (jpeg/jpg both map from `image/jpeg`); a type outside that set → 422 `{"error":"...","errors":{"avatar":["The avatar must be a file of type: jpeg, jpg, png, webp, gif."]}}`. `header.Size > 4_096_000` (4000 KB) → 422 `{"error":"...","errors":{"avatar":["The avatar must not be greater than 4000 kilobytes."]}}`. On success: remove any existing avatar first (reuse the remove logic below, ignoring "none exists"), write the new file's bytes to the storage bucket at `attach.BlobKey(diskName)` (a generated disk name, e.g. hex-random + detected extension), insert an `attach.File{DiskName: ..., FileName: header.Filename, FileSize: header.Size, ContentType: detectedMIME, Field: "avatar", AttachmentID: strconv.FormatUint(uint64(user.ID),10), AttachmentType: user.MorphName(), SortOrder: 0}` row, call `f.Thumb(ctx, bucket, 128, 128, "auto")` for the `avatar_url` payload value. `{"message":"Avatar updated","user": <apiArray with has_avatar:true, avatar/avatar_url populated>}`,200. Resolve the blob bucket via `app.Lookup[*blob.Bucket]()` (published by Phase 5's `attach.Publish`, already wired at app boot).
|
|
|
|
Add `RemoveAvatar(app)`: per-handler auth. Look up the user's `attach.File` row (`WHERE attachment_type = ? AND attachment_id = ? AND field = 'avatar'`); none found → `{"error":"Your account has no display picture to remove.","errors":{"avatar":["Your account has no display picture to remove."]}}`,422. Found: `attach.DeleteForOwner(tx, user, id, afterCommit)` inside a transaction, then in `afterCommit` call `attach.DeleteKeys(ctx, bucket, keys)` after the transaction commits (two-phase contract, per `file.go`'s documented contract — never delete blobs before commit). `{"message":"Avatar removed","user": <apiArray with has_avatar:false>}`,200.
|
|
|
|
Add the two routes: `g.Post("/avatar", controllers.UploadAvatar(p.app))`, `g.Post("/avatar/remove", controllers.RemoveAvatar(p.app))`.
|
|
|
|
Author the three mail templates plus layout, copying the exact `.htm` file format from `examples/hello/plugins/base/views/mail/{hello,hello-en}.htm` and `views/mail/layouts/hello.htm` (INI header with `subject`/`layout` keys, `==` separator, markdown body with `{{.Name}}`/`{{.Link}}`/`{{.Code}}` placeholders — `postcard`'s `execHTML`/`execText` use Go's `html/template`/`text/template` syntax, not PHP's `{name}`). `activate`/`activate-en`: subject "Activate your account" / "Activate your account", body links to the activation URL. `restore`/`restore-en`: subject "Reset your password", body links to the reset URL. `reactivate`/`reactivate-en`: subject "Welcome back", informs the user their account was reactivated on login (D-17). `layouts/user.htm`: a plain wrapper mirroring `layouts/hello.htm`'s three-section format exactly. Mail content is NOT parity-critical (D-18 — asserted only through the `memory` driver in tests, never byte-diffed against PHP), so reasonable English copy is sufficient; Polish (`pl`) is the default locale content, `-en` is the English sibling per C-05's caller-picks-suffix convention.
|
|
|
|
Extend `plugin.go`: add `pact.HasMailTemplates` conformance (`MailTemplatesFS() fs.FS` via `//go:embed views/mail`, `MailTemplates() []string` listing all six dotted names, `MailLayouts() map[string]string{"user": "golem15.user::mail.layouts.user"}`), matching `examples/hello/plugins/base/plugin.go`'s shape exactly. Wire `Register(app)` or `Boot(app)` to call `postcard.Activate`-published catalog's `Register` (check whether `postcard.Activate` already runs at the kernel level before plugin `Boot` per its doc comment — if so, this plugin only needs to declare the capability interfaces, not call `Register` itself; confirm against `postcard/mailer.go`'s `Activate` doc comment and an existing plugin that already ships mail templates, e.g. `golem15.hello`, before writing any manual `Register` call).
|
|
|
|
Create `console/require_password_change.go`: a `pact.HasCommands` implementation (or extend `plugin.go`'s existing `Commands()` if a scaffolded stub already exists — none does yet, this is genuinely new) registering `bonfire.Command{Name: "user:require-password-change", Description: "Force a user to change their password on next login (D-21)", Args: []bonfire.Arg{{Name: "email", Required: true}}, Run: func(ctx, in, out) error { ... }}` per the `migrate:rollback`/positional-arg shape in `bonfire/command.go` and `lagoon/commands.go`. `Run` looks up the user by email, 404-equivalent command error if not found, sets `must_change_password=true`, `out.Success(...)`. Wire `func (p *Plugin) Commands() []bonfire.Command` on the plugin and declare `var _ pact.HasCommands = (*Plugin)(nil)`.
|
|
</action>
|
|
<verify>
|
|
<automated>go vet ./... && go test ./plugins/golem15/user/... -run 'TestUploadAvatar|TestRemoveAvatar|TestMail|TestRequirePasswordChange' -short</automated>
|
|
</verify>
|
|
<acceptance_criteria>
|
|
- A valid small JPEG upload returns 200 with `user.has_avatar == true` and a non-empty `avatar_url`
|
|
- A `.svg` (or any content type outside jpeg/jpg/png/webp/gif by sniffed MIME) returns 422 with an `errors.avatar` key
|
|
- `RemoveAvatar` with no existing avatar returns 422 `{"error":"Your account has no display picture to remove.","errors":{"avatar":["Your account has no display picture to remove."]}}`
|
|
- The `postcard` memory driver records a `mail.activate` send with a non-empty `link`/`code` var when register's user-mode branch runs
|
|
- A `pl`-locale user's forgot-password send captures `Message.Template == "golem15.user::mail.restore"`; an `en`-locale user's captures `"golem15.user::mail.restore-en"` -- same pl/en pair proven for mail.activate and mail.reactivate
|
|
- `user:require-password-change alice@example.com` sets `must_change_password=true` for that row and is listed by `Commands()`
|
|
</acceptance_criteria>
|
|
<done>Avatar upload/remove round-trip through Phase 5's attach primitives with the exact D-04 payload shape; all three mail templates render and send through the memory driver in tests, with pl/en locale-suffix selection proven end to end for all three call sites; the console command sets must_change_password and is discoverable via Commands().</done>
|
|
</task>
|
|
|
|
</tasks>
|
|
|
|
<threat_model>
|
|
## Trust Boundaries
|
|
|
|
| Boundary | Description |
|
|
|----------|--------------|
|
|
| Client → reset/activation codes | Untrusted "{id}!{code}" strings cross into a database lookup and a password/activation state change |
|
|
| Client → avatar upload | Untrusted multipart file bytes cross into storage and are later served back as a public URL |
|
|
|
|
## STRIDE Threat Register
|
|
|
|
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
|
|-----------|----------|-----------|-------------|------------------|
|
|
| T-07-03 | Information Disclosure | ForgotPassword | mitigate | Identical 200 body regardless of account existence (D-18), matching the already-shipped PHP behavior — never regress to a distinguishable response |
|
|
| T-07-05 | Spoofing | codes.go VerifyResetCode/VerifyActivationCode | mitigate | `crypto/subtle.ConstantTimeCompare` (D-15 hardening over PHP's plain `===`) plus a new TTL (reset 60 min / activation 72 h) |
|
|
| T-07-09 | Denial of Service | UploadAvatar | mitigate | `mimes` sniffed-content-type check (not client-supplied extension) + explicit 4000 KB size check on top of the existing automatic 128 MiB `defaultBytes` transport cap (P6 D-18) |
|
|
| T-07-13 | Elevation of Privilege | ChangePassword's `has_self_set_password=false` branch | mitigate | Fail-loud opaque 500 instead of silently treating the row as self-set (D-03/Open Question 3) — provably unreachable for any Go-created row today, so this is defence-in-depth against a future cutover-migrated row |
|
|
|
|
</threat_model>
|
|
|
|
<verification>
|
|
`go vet ./...` and `go test ./... -short` green in `fonoteka.go`. Full manual sequence: register (user-mode config override) → forgot-password → reset-password with the seeded code → login with the new password → change-password → old token now 401s on fetch, the change-password call's own token does not.
|
|
</verification>
|
|
|
|
<success_criteria>
|
|
Every `/_user/api/v1` route named in D-01 now has a real handler; `golem15.user`'s own plugin code is feature-complete for AUTH-01.
|
|
</success_criteria>
|
|
|
|
<output>
|
|
Create `.planning/phases/07-user-plugin-and-authentication/07-03-SUMMARY.md` when done
|
|
</output>
|