29 KiB
phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, user_setup, must_haves
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | user_setup | must_haves | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 07-user-plugin-and-authentication | 01 | execute | 1 |
|
false |
|
|
Purpose: every later plan (07-02..07-04) needs these signatures fixed before it can compile against them; building them first, tested in isolation, keeps the handler plans focused on wiring rather than JWT algorithm design.
Output: bouncer.Mint/Refresh/BlacklistStore/HashPassword family, extended Principal, surf.LocaleFromPrincipal, extended lagoon.Validate.
<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_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.mdFrom bouncer/jwt.go (current):
func Middleware(secret string, users UserProvider) func(http.Handler) http.Handler // UNCHANGED, Bearer-only, kept for back-compat
func NewJWTGuard(secret string, users UserProvider) Guard // SIGNATURE CHANGES this plan
func Verify(tokenString, secret string) (string, error) // UNCHANGED, returns sub only
func bearerToken(r *http.Request) (string, error) // UNCHANGED, Bearer-only helper
From bouncer/context.go (current):
type Principal struct {
ID uint
MustChangePassword bool
}
From bouncer/guard.go / registry.go (current, unchanged this plan):
type Guard interface{ Authenticate(r *http.Request) (*Principal, error) }
type CredentialGuard interface{ AuthenticateCredential(r *http.Request) (*Principal, any, error) }
type UnauthorizedWriter interface{ WriteUnauthorized(w http.ResponseWriter, err error) }
func (reg *Registry) Register(pluginID, name string, g any) error
func (reg *Registry) Middleware(name string) (func(http.Handler) http.Handler, error)
From surf/router.go (current, BuildRouter): the throttle and body.limit factories are registered unconditionally near the top of BuildRouter, before the plugin loop:
if err := r.RegisterMiddlewareFactory("surf", "throttle", func(param string) pact.Middleware { ... }); err != nil { ... }
if err := r.RegisterMiddlewareFactory("surf", "body.limit", func(param string) pact.Middleware { ... }); err != nil { ... }
locale.from-principal must be registered the same way (a plain RegisterMiddleware, not a factory) so every plugin can reference it in Use(...) without declaring it themselves.
From surf/limiter_store.go (Store interface shape to mirror for BlacklistStore):
type Store interface {
Attempt(key string, max int, decay time.Duration) (allowed bool, attempts int, retryAfter time.Duration)
}
From lagoon/validate.go (current validateField, signature CHANGES this plan):
func validateField(ctx context.Context, tx *gorm.DB, model any, field, rule string, val any, tr *phrasebook.Translator) ([]string, error)
must become (deriving val internally) so confirmed/different can read sibling fields:
func validateField(ctx context.Context, tx *gorm.DB, model any, field, rule string, values map[string]any, tr *phrasebook.Translator) ([]string, error)
Validate()'s call site msgs, err := validateField(ctx, tx, model, field, rule, val, tr) becomes validateField(ctx, tx, model, field, rule, values, tr).
Create `bouncer/refresh.go`: `func Refresh(secret, tokenString string, refreshTTL time.Duration, bl BlacklistStore, grace time.Duration, issuerURL string) (string, error)`. Parse with `jwt.NewParser(jwt.WithValidMethods([]string{"HS256"}), jwt.WithoutClaimsValidation())` (Pitfall 1 — never require `exp` here). Extract `jti`, `iat`, `sub` from `jwt.MapClaims`. Return an error if `iat` is more than `refreshTTL` in the past. If `bl != nil`, check `bl.IsBlacklisted(ctx, jti)` first and error if true (a forever-blacklisted — logged-out — token must never refresh). Mint a new token with the SAME `sub`, a fresh `issuerURL` (the refresh endpoint's own URL, passed by the caller — Pitfall 3, never reuse the old token's `iss`). On success, blacklist the OLD jti: `expiresAt` = the old token's own `exp` claim (fall back to `now.Add(grace)` if `exp` is unparseable), `validUntil = now.Add(grace)` (Pitfall 2 — grace defaults to 0 from the framework config key but Płytarium's app config sets 10s per D-10).
Create `bouncer/blacklist.go`: `type 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 }`. `MemoryBlacklist` (mutex-guarded map, for tests) mirrors `surf.MemoryStore`'s lazy-expiry-on-read shape. `PostgresBlacklist` wraps a `*sql.DB` and a caller-supplied table name (kept generic — `bouncer` must not hardcode a Płytarium-specific table; the owning plugin's migration decides the name and passes it to the constructor): `func NewPostgresBlacklist(db *sql.DB, table string) *PostgresBlacklist`. `Add` does an upsert (`INSERT ... ON CONFLICT (jti) DO UPDATE`) so a repeated logout call on the same jti does not error. `IsBlacklisted` does one indexed `SELECT valid_until FROM <table> WHERE jti = $1`; returns `true` only when a row exists AND `now >= valid_until` (Pitfall 2 — row existence alone is not enough during the grace window). `Sweep` runs `DELETE FROM <table> WHERE expires_at < $1`. Table/column identifiers come only from the constructor argument (validate with the same `identName` regex idiom `lagoon.Validate`'s `uniqueOK` uses, to avoid building a SQL string from unchecked input) — table name is a compile-time constant from the calling plugin, not user input, but validate it defensively anyway.
Extend `bouncer/jwt.go`: change `NewJWTGuard`'s signature to `func NewJWTGuard(secret string, users UserProvider, bl BlacklistStore, cookieNames ...string) Guard` (`bl` may be `nil` to skip the blacklist check; `cookieNames` empty means Bearer-only). Add an unexported `extractToken(r *http.Request, cookieNames []string) (string, error)`: try `bearerToken(r)` first; on failure, if `cookieNames` is non-empty, try each cookie name in order via `r.Cookie(name)`, first non-empty `Value` wins; if nothing found, return the existing `msgTokenNotProvided` error. Add an EXPORTED `func VerifyClaims(tokenString, secret string) (sub string, iat, exp time.Time, jti string, err error)` — same parser as `Verify` (HS256, `WithExpirationRequired`) but also extracting `iat`/`exp`/`jti` from the already-validated `jwt.MapClaims`, reusing `subject()`; this is exported deliberately (not kept package-private) because 07-02's `logout`/`refresh` handlers and 07-03's `change-password` handler all need the presenting token's `iat`/`jti`/`exp` outside the `bouncer` package — one parse implementation, no duplicated JWT-claims code in the user plugin. In `jwtGuard.Authenticate`: use `extractToken` instead of the bare `bearerToken` call, `VerifyClaims` instead of `Verify`, then after `FindByID` succeeds: if `bl != nil`, check `bl.IsBlacklisted(r.Context(), jti)` and fail with the existing generic path (reuse whatever `write401`/error text the guard already uses for "token not usable" — do not invent new wire text); then if `!principal.TokensValidAfter.IsZero() && iat.Before(principal.TokensValidAfter)`, fail with `errors.New(msgUserNotFound)` (D-20's "the normal 401"). Update every existing in-package call site of `NewJWTGuard` (tests) to pass `nil` for `bl` and no cookie names unless the test specifically exercises cookies/blacklist. Leave the top-level `Middleware` function and `Verify` untouched (C-01 — still Bearer-only, still `exp`-required, still the exact existing 401 bodies) since nothing in this phase repoints its callers.
Extend `bouncer/context.go`: add `PreferredLocale string` and `TokensValidAfter time.Time` to `Principal` (zero value = no override / no cutoff, both purely additive per RESEARCH.md's Pattern 3 and D-20).
go vet ./... && go test ./bouncer/... -run 'TestMint|TestRefresh|TestBlacklist|TestJWTGuard|TestRegistry|TestContext' -v
- `bouncer/mint.go` contains `const prvHash = "a867434cbc213adfbe78a02bed7082a6bd99c883"`
- `Mint(secret, "42", issuerURL, ttl)` produces a token whose decoded `iss`/`sub`/`prv` equal the inputs exactly and whose `jti` matches the returned jti string
- `Refresh` on an expired-but-within-refreshTTL token returns a new token with no error; on a past-refreshTTL token returns a non-nil error
- `MemoryBlacklist.IsBlacklisted(jti)` is false before `validUntil` and true at/after it, for the same `Add` call
- `NewJWTGuard(secret, users, bl, "token", "auth_token")` authenticates a request carrying only a `token` cookie, and one carrying only `auth_token`
- `go vet ./... && go test ./bouncer/... -run 'TestMint|TestRefresh|TestBlacklist|TestJWTGuard|TestRegistry|TestContext'` exits 0
bouncer/{mint,refresh,blacklist}.go exist with the signatures above; Principal carries PreferredLocale and TokensValidAfter; NewJWTGuard's cookie fallback, blacklist check and TokensValidAfter cutoff are all exercised by passing tests; `go vet ./...` and `go test ./bouncer/...` are green.
Task 3: Password hashing, post-auth locale override, and lagoon.Validate extensions
bouncer/password.go, bouncer/password_test.go, surf/locale_from_principal.go, surf/locale_from_principal_test.go, surf/router.go, lagoon/validate.go, lagoon/validate_test.go, go.mod, go.sum
bouncer/context.go (PreferredLocale, now present from Task 2), surf/router.go (`BuildRouter`'s unconditional factory registration block, `locale()` middleware, `pact.Middleware` usage), towel/context.go (`WithLocale`), lagoon/validate.go (current `validateField` switch and `validateMessage`),
.planning/phases/07-user-plugin-and-authentication/07-RESEARCH.md (Pattern 3, Pitfall 6, the "Don't Hand-Roll" table's email/confirmed/different/mimes row, Open Question 2)
- Test (password): `HashPassword(10, "secret")` then `CheckPassword(hash, "secret")` is `true`, `CheckPassword(hash, "wrong")` is `false`.
- Test (password, Assumption A1): a hardcoded real PHP `$2y$10$...` bcrypt hash of a known plaintext (obtained via `php -r 'echo password_hash("golem15-a1-check", PASSWORD_BCRYPT);'` and pasted as a literal test fixture) verifies `true` against `CheckPassword` for `"golem15-a1-check"` — this is the load-bearing cross-language check RESEARCH.md's Assumption A1 asks for; keep this test permanently, not just as a one-off.
- Test (password): `NeedsRehash(hash, configuredCost)` is `true` when the hash's own cost is lower than `configuredCost`, `false` when equal or higher.
- Test (locale middleware): a request whose context already carries `bouncer.WithUser(ctx, &Principal{PreferredLocale: "pl"})` gets `towel.Locale(ctx)` == `"pl"` after `LocaleFromPrincipal` runs, overriding whatever `Accept-Language` set earlier in the pipeline.
- Test (locale middleware): a request with no `Principal` in context, or a `Principal` with `PreferredLocale == ""`, leaves the existing `towel.Locale(ctx)` value (from the header-only `locale` stage) untouched.
- Test (validate): `email` rejects `"not-an-email"` and accepts `"a@b.com"`.
- Test (validate): `confirmed` on field `password` passes when `values["password"] == values["password_confirmation"]`, fails otherwise.
- Test (validate): `different:current_password` fails when `values["password"] == values["current_password"]`, passes when they differ.
- Test (validate): `mimes:jpeg,jpg,png,webp,gif` passes when `values["avatar"]` (a string, the caller-supplied detected extension, no leading dot, case-insensitive) is a member, fails for `"svg"`.
Create `bouncer/password.go`: `func HashPassword(cost int, plain string) (string, error)` wrapping `bcrypt.GenerateFromPassword([]byte(plain), cost)`; `func CheckPassword(hash, plain string) bool` wrapping `bcrypt.CompareHashAndPassword` (returns `false` on any error, never panics on a malformed hash); `func NeedsRehash(hash string, configuredCost int) bool` using `bcrypt.Cost(hash)` (treat a `Cost` error as "needs rehash" — a hash bcrypt can't parse is not currently valid). Import `golang.org/x/crypto/bcrypt` (now a direct dependency per the checkpoint).
Create `surf/locale_from_principal.go`: `func LocaleFromPrincipal(next http.Handler) http.Handler` per RESEARCH.md Pattern 3 exactly — reads `bouncer.User(r.Context())`, if present and `PreferredLocale != ""` calls `r = r.WithContext(towel.WithLocale(r.Context(), p.PreferredLocale))`, then `next.ServeHTTP`. This file imports `bouncer` and `towel`; `surf` already imports both elsewhere so no import-cycle risk.
Extend `surf/router.go`'s `BuildRouter`: immediately after the existing `body.limit` factory registration (before the plugin `HasMiddleware` loop), add `if err := r.RegisterMiddleware("surf", "locale.from-principal", LocaleFromPrincipal); err != nil { return nil, err }` — so every plugin can reference `"locale.from-principal"` in `Use(...)` without declaring it. This mirrors the existing unconditional `throttle`/`body.limit` registration pattern exactly (same function, same place).
Extend `lagoon/validate.go`: change `validateField`'s signature to take the full `values map[string]any` instead of a single `val any` (update `Validate()`'s call site accordingly, deriving `val := values[field]` as `validateField`'s first line — every existing `case` keeps using the local `val` variable unchanged). Add four new cases to the token switch: `case "email":` appends `"email"` to `tags` (delegates to `go-playground/validator`'s built-in tag, already imported). `case "confirmed":` compares `val` against `values[field+"_confirmation"]` using a string-normalized equality (`fmt.Sprint` both sides, or a direct type switch matching how `val`/`numericString` already normalize elsewhere) and returns `[]string{validateMessage(ctx, tr, "confirmed", field, nil)}` on mismatch. `case "different":` (arg from `strings.Cut(tok, ":")`, already available as `arg` in the loop) compares `val` against `values[arg]` the same way and returns `validateMessage(ctx, tr, "different", field, nil)` when EQUAL. `case "mimes":` splits `arg` on `,`, compares the lowercased, dot-trimmed string form of `val` against the list, returns `validateMessage(ctx, tr, "mimes", field, nil)` when absent — this token intentionally does NOT touch file size (RESEARCH.md Open Question 2 — size stays a transport-level `http.MaxBytesReader` cap, not a `lagoon.Validate` rule). Extend `validateMessage`'s rule-name `switch` with English fallbacks for `"confirmed"`, `"different"`, `"mimes"`, `"email"` (e.g. `"The " + field + " confirmation does not match."`, `"The " + field + " and " + params["other"] + " must be different."` — simplest correct fallback text is fine, these are default-locale fallbacks per the existing `phrasebook`-first pattern, not the PHP wire text itself since these are framework validation primitives, not endpoint-specific bodies).
Run `go get golang.org/x/crypto@latest && go mod tidy` in `summercms.go` per the approved checkpoint (Task 1), confirming `go.mod` now lists `golang.org/x/crypto` as a direct (non-indirect) requirement.
go vet ./... && go test ./bouncer/... ./surf/... ./lagoon/... -run 'TestPassword|TestNeedsRehash|TestLocaleFromPrincipal|TestValidate' -v
- `CheckPassword(HashPassword(10,"secret"), "secret")` is true; a hardcoded real PHP `$2y$` hash verifies true for its known plaintext (Assumption A1 regression test)
- `surf/router.go`'s `BuildRouter` contains a call registering `"locale.from-principal"` via `RegisterMiddleware`
- A request context carrying `Principal{PreferredLocale:"pl"}` has `towel.Locale(ctx) == "pl"` after `LocaleFromPrincipal` runs; one with `PreferredLocale:""` leaves the prior locale value untouched
- `lagoon.Validate` rejects `"not-an-email"` under an `email` rule and accepts `"a@b.com"`
- `go.mod` lists `golang.org/x/crypto` without a `// indirect` comment
- `go vet ./... && go test ./bouncer/... ./surf/... ./lagoon/... -run 'TestPassword|TestNeedsRehash|TestLocaleFromPrincipal|TestValidate'` exits 0
bouncer/password.go, surf/locale_from_principal.go exist and are wired into BuildRouter under the name "locale.from-principal"; lagoon.Validate accepts email/confirmed/different/mimes; golang.org/x/crypto is a direct go.mod dependency; go vet and go test are green across bouncer, surf and lagoon.
<threat_model>
Trust Boundaries
| Boundary | Description |
|---|---|
| Client → bouncer.Verify/jwtGuard | Untrusted bearer token / cookie value crosses into JWT parsing and claim trust decisions |
| golang.org/x/crypto supply chain | A new direct dependency crosses into the password-hashing trust boundary |
STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|---|---|---|---|---|
| T-07-01 | Spoofing / Elevation of Privilege | bouncer.jwtGuard.Authenticate | mitigate | BlacklistStore.IsBlacklisted checked on every authenticated request when a store is wired (Task 2); a forever-blacklisted (logged-out) jti is rejected with the existing generic 401 shape |
| T-07-02 | Denial of Service (self-inflicted) | bouncer.Refresh + BlacklistStore | mitigate | Grace-windowed blacklist (valid_until, not mere row existence) so a just-rotated token stays usable for blacklist_grace seconds — Pitfall 2 |
| T-07-07 | Tampering | bouncer.Verify / NewJWTGuard | accept (already mitigated, re-asserted) | Empty JWT secret already fails boot since Phase 3 (C-01); this plan does not touch secret loading, only extraction and the blacklist/cutoff checks layered on top |
| T-07-11 | Tampering / Race | bouncer.PostgresBlacklist | mitigate | Add is an upsert (ON CONFLICT (jti) DO UPDATE), IsBlacklisted/Sweep are single indexed statements — no read-then-write race window inside the store itself |
| T-07-SC | Tampering (supply chain) | golang.org/x/crypto (new direct dependency) | mitigate | Blocking checkpoint:human-verify (Task 1) before promotion, citing the Package Legitimacy Audit override rationale (official Go team module, already transitively present, slopcheck [SUS] verdict is a documented false positive) |
</threat_model>
`go vet ./...` and `go test ./... -short` green in `summercms.go`. `go test ./bouncer/... ./surf/... ./lagoon/... -race` green. No production call site of `NewJWTGuard` changed behavior for the Bearer-only, no-blacklist, no-cutoff case (existing bouncer tests for the pre-Phase-7 shape still pass unmodified in their assertions, only their constructor call sites gain a trailing `nil`).<success_criteria>
Every later Phase 7 plan can import bouncer.Mint, bouncer.Refresh, bouncer.BlacklistStore/NewPostgresBlacklist, bouncer.HashPassword/CheckPassword/NeedsRehash, bouncer.Principal.PreferredLocale/TokensValidAfter, surf.LocaleFromPrincipal (registered as "locale.from-principal"), and the four new lagoon.Validate tokens without any further framework-level design work.
</success_criteria>