16 KiB
title, date, context, g15office
| title | date | context | g15office |
|---|---|---|---|
| External user-plugin escalations | 2026-10-22 | /msd-explore of an external app's "escalate to summercms devs" list. Six /msd-quick tasks; send this reply when they are all done. | bhoyg8z4 |
External user-plugin escalations
Reviewed 2026-10-22. The list is golem15.user (sm-user-plugin), not cabana admin auth. Phase 19.6 (2FA) is not mid-flight.
Released (2026-10-23): sm-user-plugin v0.1.9 carries all six quicks and still requires SummerCMS v0.4.3, so the external app needs no framework upgrade for these fixes. The earlier hold for a v0.5.0 wave is withdrawn: v0.5.0 is reserved for the finished MCP phase (19.7), and the framework's own next patch is v0.4.4, which is not needed for anything below. The reply is ready to send.
Do not run the six quicks in parallel on one working tree. They share sm-user-plugin (api_controller.go, plugin.go, routes.go, README). Sequential fresh sessions.
Tracker
| # | Their label | Verdict | Quick / todo | g15office | Status | Shipped |
|---|---|---|---|---|---|---|
| 1 | G2 login ignores is_activated |
Fix | 261022-tb4 · todo | 3r4or1ex | released | 15ff979 → user v0.1.9 |
| 2 | G3 cookie-only 401 | Fix remaining four; logout already done | 261022-tto · todo | kkknstxl | released | 8e4b863 → user v0.1.9 |
| 3 | G4 logout blacklist under serve |
Fix | 261022-usi · todo | ud0gficu | released | 4bf44fc → user v0.1.9 |
| 4 | G6 /update email without proof |
Decline | — | — | declined | n/a |
| 5 | G7 Users form has no role field | Decline | — | — | declined | n/a |
| 6 | G8 API token guard ignores tokens_valid_after |
Decline | — | — | declined | n/a |
| 7 | Cookie option on /login |
Feature | 261022-wah · todo | strnsccy | released | 846087d, 8bdf2c7 → user v0.1.9 |
| 8 | Password-routes 404 switch | Feature | 261022-x7a · todo | h7yrafgd | released | 3828a65 → user v0.1.9 |
| 9 | Lockout keyed on RemoteAddr |
Fix | 261022-vfz · todo | sniewiif | released | b6f8055 → user v0.1.9 |
| 10 | Same-second token refusal | Fix | 261022-vfz · todo | sniewiif | released | a484d0e → user v0.1.9 |
Status values: open · done · declined. Shipped: plugin commit → sm-user-plugin tag.
Six invocations (fresh session each, sequential)
/msd-quick --validate Password /login must refuse is_activated=false when golem15.user.activation.require_activation is true (default), same opaque invalid-login body as a bad password. Do not change deleted_at restore-on-login. See .planning/todos/pending/2026-10-22-user-login-ignores-is-activated.md and .planning/notes/2026-10-22-external-user-plugin-escalations.md
/msd-quick --validate /fetch /refresh /update /change-password must accept the token/auth_token cookies the same way jwt.auth and EndSession already do. Logout is already fixed. See .planning/todos/pending/2026-10-22-user-session-routes-ignore-cookie.md and .planning/notes/2026-10-22-external-user-plugin-escalations.md
/msd-quick --discuss --validate Under serve, user-plugin Boot runs before lagoon.Publish so the jwt blacklist is never wired and logout does not revoke. Wire BlacklistStore and RegisterHooks through lagoon.OnDatabase; test Activate-then-Publish. When re-registering the jwt guard, pass classes.SessionCookieNames() instead of the hardcoded token/auth_token names (G3 leftover). See .planning/todos/pending/2026-10-22-user-logout-blacklist-missing-under-serve.md and .planning/notes/2026-10-22-external-user-plugin-escalations.md
/msd-quick --validate User-plugin /login throttle must use surf.ClientIP (trusted proxies), not RemoteAddr. Leave exported RemoteIP as the RemoteAddr helper. Remaining tokens_valid_after writers (reset-password, admin password set) must Truncate(time.Second) like RevokeUserAccess; leave change-password and bouncer's comparison alone. See .planning/todos/pending/2026-10-22-user-login-lockout-and-same-second-cutoff.md and .planning/notes/2026-10-22-external-user-plugin-escalations.md
/msd-quick --discuss --validate Opt-in HttpOnly session cookie on user-plugin POST /login: golem15.user.jwt.token_cookie (default off), attributes from auth.cookie via bouncer.LoadCookieConfig, omit body token when the cookie is set (sociallogin writeSession). Cookie-carried POST /refresh must rotate that cookie (G3 left body-only); bearer-only refresh stays body-only. SameSite=Lax, no CSRF. Base a484d0e. See .planning/todos/pending/2026-10-22-user-login-opt-in-session-cookie.md and .planning/notes/2026-10-22-external-user-plugin-escalations.md
/msd-quick --validate User-plugin golem15.user.password.public_routes (default true). When false, do not register POST /login /forgot-password /reset-password so they 404 like unknown paths. Leave /register and the rest. No framework wrap, no custom 404. Base 8bdf2c7. See .planning/todos/pending/2026-10-22-user-password-routes-404-switch.md and .planning/notes/2026-10-22-external-user-plugin-escalations.md
Suggested order if you care about risk: G4 (serve blacklist), then G3, G2, 9+10, then the two features. All six are done locally; item 8 (password-routes 404) was the last. Do not overlap on the same plugin files. Released in sm-user-plugin v0.1.9.
Verdicts (kept for the sessions)
- G2.
require_activationis documented and/registerhonors it;/logindoes not. Deactivation (deleted_at) stays reversible. - G3. Done locally (
8e4b863). Cookie extractors areclasses.SessionToken/SessionCookieNames. Item 7 (846087d) made a/refreshcarried by theauth.cookiecookie rewrite it; bearer- andauth_token-carried refreshes stay body-only. CSRF was not added (SameSite=Lax + current-password on change-password). - G4. Done locally (
4bf44fc). LazyappBlacklist+lagoon.OnDatabasefor hooks, Postgres store and sweep. Guard usesSessionCookieNames(). - G6. D-27: email change clears
email_verified_atand does not write a new proof. A confirm-the-new-inbox flow would be a later feature. - G7. Stock form omission was a 12.1 decision. Stale
organisation_roleis already cleared (ClearOnChange, v0.1.8). Apps extend the form. - G8. Personal tokens die via
revoked_at.RevokeUserAccessalready sets that on deactivate/ban. Do not teach the API-token guard a JWT cutoff. Password reset killing API tokens would be a separate, explicit revoke. - 7. Done locally (
846087d,8bdf2c7).846087d: a/refreshcarried by theauth.cookiecookie (tokenby default, no bearer) rewrites it and answers{}, whatevertoken_cookiesays; bearer and other-cookie refreshes stay body-only; refusals write no cookie.8bdf2c7:golem15.user.jwt.token_cookie(default off) makes /login, /register (auto sign-in) and /activate-by-code set the HttpOnlyauth.cookiecookie viabouncer.LoadCookieConfigand omit bodytoken; off keeps today's bodies. SameSite=Lax, no CSRF; API tokens untouched. - 8. Done locally (
3828a65). Plugin config; no framework wrap.golem15.user.password.public_routes(default true); false leavesPOST /login,/forgot-passwordand/reset-passwordunregistered, so they get the router's own 404 like any unknown path (aGETtoo, no 405); no custom handler, nopact/surfchange./registerstays (allow_registrationgates it,golem15.user.turnstilestill guards it), and so do/activate*,/fetch,/refresh,/logout,/change-passwordand/tokens. Read when the routes are built (a restart applies it); a value other than true or false fails boot. - 9. Done locally (
b6f8055). /login keys the throttle on surf.ClientIP with http.trusted_proxies (the /register expression); RemoteIP stays the RemoteAddr helper; without trusted proxies X-Forwarded-For is ignored. - 10. Done locally (
a484d0e). /reset-password and the admin password set truncate the cutoff to whole seconds like RevokeUserAccess; change-password and bouncer unchanged.
Reply to send
Ready to send as-is (all items filled, released in sm-user-plugin v0.1.9).
Thanks for the escalation list. We reviewed the current golem15.user plugin (and the framework boot order). Here is what we shipped, what is already true, and what we are not changing. The fixes and the two new options are in sm-user-plugin v0.1.9, which still requires SummerCMS v0.4.3: bump the plugin, no framework upgrade is needed.
G2 — login ignores is_activated
Agreed. require_activation defaults to true and /register already withholds a token in admin mode; /login did not check is_activated. That is not the same as deactivation (deleted_at), which stays self-service: a later password login still restores the account.
Ships in sm-user-plugin v0.1.9 (15ff979): with require_activation on (the default), /login answers an account that is not activated with the same opaque invalid-login response as a bad password (counted as a failed attempt); deactivation stays self-service. /activate still serves a not-activated session so the mailed-code flow works.
G3 — some routes ignore the cookie
Partly already fixed, rest agreed. POST /logout already accepts the token/auth_token cookies (and blacklists every JWT the request carries). GET /fetch, POST /refresh, POST /update and POST /change-password still required the Authorization header.
Ships in sm-user-plugin v0.1.9 (8e4b863): GET /fetch, POST /refresh, POST /update and POST /change-password (and the other signed-in routes: /activate, /marketing-consent, /avatar, /avatar/remove) read the session token like jwt.auth: the Authorization header first, then the token and auth_token cookies. A /refresh carried by the session cookie named in auth.cookie (token by default) rewrites that cookie with the rotated token and answers {} (see "Cookie on /login" below). A /refresh carried by the Authorization header, or by the auth_token cookie, returns the rotated token in the body and writes no cookie; the previous token is revoked, so store the new one yourself.
G4 — logout does not blacklist under serve
Agreed. Plugin Boot runs before serve publishes the database, so the in-process blacklist store was never wired in production (the jwt_blacklist table exists; nothing wrote to it). Tests hid this because they publish the DB before Boot.
Ships in sm-user-plugin v0.1.9 (4bf44fc): the plugin installs its jwt_blacklist store, the blacklist sweep and its GORM hooks when serve publishes the database, and the jwt guard reads that store on every request. Logout under serve now writes the jti to jwt_blacklist, and the next request with that token (jwt.auth routes, /fetch, /refresh) answers 401. A regression test boots in serve order (plugins first, database second). The jwt guard also takes its cookie names from the same list as the session routes (token, auth_token).
G6 — /update changes email without proof
As designed, not a bug. A new address clears email_verified_at and stays unproven until the owner completes an activation code or a reset-password. We are not adding a “confirm the new inbox before the change sticks” flow in this round. Say if you need that as a later feature.
G7 — stock Users form has no role field
As designed. organisation_role is intentionally off the stock form (an app extends the form). v0.1.8 already clears organisation_role when the organisation changes (members manager and the Users form). No further stock-form change.
G8 — API token guard ignores tokens_valid_after
As designed. Personal API tokens are revoked through revoked_at, not the JWT cutoff. Deactivate and ban already revoke those rows (RevokeUserAccess). A password reset is not supposed to kill API tokens; if you want that, say so and we will revoke the rows, not mix the two mechanisms.
Cookie on /login
Agreed as an opt-in. Sociallogin already has token_cookie for OAuth complete; password /login now has the same kind of flag (default off, so body-token clients stay unchanged).
Ships in sm-user-plugin v0.1.9 (846087d and 8bdf2c7): set golem15.user.jwt.token_cookie: true (default false) and POST /login, POST /register (when it signs the new account in) and POST /activate-by-code set the HttpOnly session cookie with the auth.cookie attributes (bouncer.LoadCookieConfig: name token, path /, SameSite=Lax, Secure from app.url, Max-Age the refresh window) and leave token out of the body; user (and message) stay. A POST /refresh carried by that cookie rewrites it with the rotated token and answers {}; a bearer refresh is unchanged. With the key off, /login, /register and /activate-by-code return the body token as before; the /refresh cookie rewrite applies either way, because it follows the cookie the refresh arrived with, not this key. That is a change for a client that refreshes with only the token cookie and no Authorization header: it now gets {} and a rewritten cookie instead of the token in the body. SameSite stays Lax and there is no CSRF token. Keep auth.cookie.name at token or auth_token, the cookies the session routes read.
Password-routes switch
Agreed, in the user plugin, not as a framework “wrap another plugin’s routes” seam. A config key stops registering /login, /forgot-password and /reset-password so they 404.
Ships in sm-user-plugin v0.1.9 (3828a65): set golem15.user.password.public_routes: false (default true; SUMMER_GOLEM15__USER__PASSWORD__PUBLIC_ROUTES=false in the environment) and POST /login, POST /forgot-password and POST /reset-password are not registered, so they answer the same 404 as any unknown path, whatever the method. POST /register stays (allow_registration still decides whether it takes accounts, and golem15.user.turnstile still guards it), as do /activate, /activate-by-code, /fetch, /refresh, /logout, /update, /change-password and /tokens. With the key true or unset nothing changes. The key is read when the routes are built, so a change needs a restart; a value other than true or false stops boot with an error naming the key.
Login lockout keyed on RemoteAddr
Agreed. /login used RemoteAddr; /register already uses the trusted-proxy client address. Behind a reverse proxy five failures locked everyone.
Ships in sm-user-plugin v0.1.9 (b6f8055): /login counts failed logins per account and client address, and the client address is now the one /register and the user-api rate limit use: behind a proxy listed in http.trusted_proxies, the X-Forwarded-For client; otherwise the connection address. An X-Forwarded-For header from an address not in http.trusted_proxies is ignored, so it cannot dodge or move a lockout. List your nginx/Caddy address in http.trusted_proxies; without it every visitor still counts as the proxy.
Same-second token refusal
Agreed. JWT iat is whole seconds; some tokens_valid_after writers stamped sub-second time. RevokeUserAccess already truncates. Reset-password and the admin password set now do too.
Ships in sm-user-plugin v0.1.9 (a484d0e): POST /reset-password and an administrator's password set on the Users form stamp tokens_valid_after with the current whole second, as deactivate and ban already do, so a token minted in the same second as the reset (the login right after it) is accepted and every token from an earlier second is still refused. As with deactivate and ban, a token issued earlier within that same second also stays valid, because iat has no finer resolution. /change-password and the guard's comparison are unchanged.