|
|
|
|
@@ -0,0 +1,277 @@
|
|
|
|
|
---
|
|
|
|
|
phase: quick-261022-tb4
|
|
|
|
|
plan: 01
|
|
|
|
|
quick_id: 261022-tb4
|
|
|
|
|
slug: password-login-must-refuse-is-activated-
|
|
|
|
|
type: execute
|
|
|
|
|
mode: quick
|
|
|
|
|
wave: 1
|
|
|
|
|
depends_on: []
|
|
|
|
|
autonomous: false
|
|
|
|
|
requirements:
|
|
|
|
|
- tb4-1 # G2: password POST /login refuses is_activated=false while golem15.user.activation.require_activation is true (default), with the opaque invalid-login answer; deleted_at restore-on-login unchanged
|
|
|
|
|
files_modified:
|
|
|
|
|
# sm-user-plugin (module golem15.user), worked in the sm-demo-app submodule checkout
|
|
|
|
|
- ../sm-demo-app/plugins/golem15/user/controllers/api_controller.go
|
|
|
|
|
- ../sm-demo-app/plugins/golem15/user/session_test.go
|
|
|
|
|
- ../sm-demo-app/plugins/golem15/user/account_test.go
|
|
|
|
|
- ../sm-demo-app/plugins/golem15/user/email_verified_test.go
|
|
|
|
|
- ../sm-demo-app/plugins/golem15/user/admin_users_test.go
|
|
|
|
|
- ../sm-demo-app/plugins/golem15/user/README.md
|
|
|
|
|
# only after the user's go-ahead at Task 3
|
|
|
|
|
- ../sm-demo-app/plugins/golem15/user (submodule pointer)
|
|
|
|
|
- ../sm-demo-app/go.mod
|
|
|
|
|
- ../sm-demo-app/go.sum
|
|
|
|
|
- .planning/notes/2026-10-22-external-user-plugin-escalations.md
|
|
|
|
|
- .planning/todos/pending/2026-10-22-user-login-ignores-is-activated.md
|
|
|
|
|
- .planning/todos/done/2026-10-22-user-login-ignores-is-activated.md
|
|
|
|
|
|
|
|
|
|
estimate:
|
|
|
|
|
tokens: 110000
|
|
|
|
|
raw_tokens: 110000
|
|
|
|
|
tasks: 3
|
|
|
|
|
confidence: low
|
|
|
|
|
|
|
|
|
|
must_haves:
|
|
|
|
|
truths:
|
|
|
|
|
- "With golem15.user.activation.require_activation unset (default true) or true, POST /_user/api/v1/login with the correct password of an account whose is_activated is false answers 401 with the same status, body bytes and Content-Type/Cache-Control headers as a wrong password for that account ({\"error\":true,\"message\":\"Invalid email or password\"} by default, the invalid_login translation otherwise), carries no token and stamps no last_seen."
|
|
|
|
|
- "That refusal counts against the login throttle exactly like a wrong password: it adds one attempt to the user's user_throttle row, and attempt_limit such refusals suspend the account."
|
|
|
|
|
- "With require_activation false, a not-activated account signs in with its password (200, token, user payload) exactly as before this change."
|
|
|
|
|
- "An activated account signs in unchanged, and a deactivated (deleted_at set) activated account is still restored by its owner's password login, gets the reactivate mail and a working token."
|
|
|
|
|
- "An account that is both deactivated and not activated is refused under require_activation true before the restore runs: deleted_at stays set and no reactivate mail is sent. With require_activation false it is restored and signed in as before."
|
|
|
|
|
- "Login checks in this order: ban or suspension (RejectIfThrottled), password (bcrypt), activation, deleted_at restore, success bookkeeping, token. The Login doc comment and the plugin README state it, and the README says that activation gates login and deactivation does not."
|
|
|
|
|
- "The plugin's go vet and full Docker-backed go test are green at the single fix commit on sm-user-plugin master; nothing is pushed, tagged or pointer-bumped before the Task 3 decision."
|
|
|
|
|
artifacts:
|
|
|
|
|
- path: ../sm-demo-app/plugins/golem15/user/controllers/api_controller.go
|
|
|
|
|
provides: "Login activation gate and refuseLogin, the one refusal path shared by the wrong-password and not-activated branches"
|
|
|
|
|
contains: "requireActivation && !user.IsActivated"
|
|
|
|
|
- path: ../sm-demo-app/plugins/golem15/user/session_test.go
|
|
|
|
|
provides: "TestLoginRequiresActivation; insertUser creates an activated account; insertInactiveUser and inactiveSessionToken helpers"
|
|
|
|
|
contains: "func TestLoginRequiresActivation"
|
|
|
|
|
- path: ../sm-demo-app/plugins/golem15/user/README.md
|
|
|
|
|
provides: "activation gates login, deactivation does not; require_activation row; Testing entry"
|
|
|
|
|
contains: "TestLoginRequiresActivation"
|
|
|
|
|
key_links:
|
|
|
|
|
- from: ../sm-demo-app/plugins/golem15/user/controllers/api_controller.go (Login)
|
|
|
|
|
to: ../sm-demo-app/plugins/golem15/user/controllers/api_controller.go (registrationSettings, :810-831)
|
|
|
|
|
via: "Login reads the flag through the same parser /register uses, default true"
|
|
|
|
|
pattern: "requireActivation := registrationSettings\\(app\\)"
|
|
|
|
|
- from: ../sm-demo-app/plugins/golem15/user/controllers/api_controller.go (Login refusal branches)
|
|
|
|
|
to: ../sm-demo-app/plugins/golem15/user/classes/throttle.go (CheckAndRecordLogin, :107)
|
|
|
|
|
via: "refuseLogin records a failed attempt (ok=false) then writes writeInvalidLogin; used by both the wrong-password and the not-activated branch"
|
|
|
|
|
pattern: "refuseLogin\\("
|
|
|
|
|
- from: ../sm-demo-app/plugins/golem15/user/controllers/api_controller.go (activation gate)
|
|
|
|
|
to: ../sm-demo-app/plugins/golem15/user/controllers/api_controller.go (deleted_at restore in Login)
|
|
|
|
|
via: "the gate sits after the password check and before the user.DeletedAt.Valid restore block, so a refused account is never restored"
|
|
|
|
|
pattern: "requireActivation && !user.IsActivated"
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
<objective>
|
|
|
|
|
Close escalation G2: the user plugin's password login hands a token to an account that is not activated, although golem15.user.activation.require_activation defaults to true and is documented as "must be activated before login". After this plan, POST /_user/api/v1/login refuses such an account with the same opaque invalid-login answer a wrong password gets, while require_activation false keeps today's behaviour and deactivation (deleted_at) stays self-service reversible.
|
|
|
|
|
|
|
|
|
|
Purpose: admin-created users start not activated (users_admin_controller.go:266) and could sign in with the password the administrator typed; /register in admin mode already withholds the token, /login did not.
|
|
|
|
|
Output: one fix commit on sm-user-plugin master (code, tests, README together), then a blocking human decision on tag, push and the sm-demo-app pointer bump, then the escalation note and the todo closed with the shipped version.
|
|
|
|
|
|
|
|
|
|
Decisions (planner's, within the user's brief; the executor does not revisit them):
|
|
|
|
|
- QD-1 Check order. Login runs: LookupByEmail (includes soft-deleted rows), RejectIfThrottled (ban, active suspension), bcrypt password check, then the activation gate, then the existing deleted_at restore, CheckAndRecordLogin(ok=true), rehash, TouchLastSeen, mint. The gate goes after the password check so a wrong password and a not-activated account both pay the bcrypt cost and cannot be told apart by timing. This matches WinterCMS: Storm's validateInternal checks the throttle and the credentials, then login() refuses a not-activated user before afterLogin(), and afterLogin() is where Golem15's User model restores a trashed account and sends the reactivate mail (Winter starter: vendor/winter/storm/src/Auth/Manager.php:403-457 and :656-683, plugins/golem15/user/models/User.php:421-433).
|
|
|
|
|
- QD-2 Throttle. The activation refusal records a failed attempt, exactly like a wrong password (classes.CheckAndRecordLogin with ok=false). This deviates on purpose from Storm, which clears the attempts once the password matches and only then refuses. If a correct password reset the counter or left it alone, the throttle state, and the quick pre-bcrypt answer a suspended account gets, would show that a guess was right for a not-activated account. Counting the refusal keeps the two refusals indistinguishable and keeps guessing bounded. The cost is that a not-activated owner who retries attempt_limit times (default 5) is suspended for suspension_minutes (default 15). That account cannot sign in before activation anyway, and an administrator can unsuspend it.
|
|
|
|
|
- QD-3 Deactivated and not activated. Under require_activation true such an account is refused before the restore, so it stays deactivated and gets no reactivate mail (Winter parity, see QD-1). Restore-on-login itself is unchanged: an activated account, and any account when require_activation is false, is restored exactly as today.
|
|
|
|
|
- QD-4 Config read. Login reads the flag through the existing registrationSettings(app), the parser /register uses (key golem15.user.activation.require_activation, default true). No new config key and no second parser.
|
|
|
|
|
- QD-5 Same answer. A new helper, refuseLogin, records the failed attempt and calls writeInvalidLogin. Both refusal branches use it, so status 401, the Cache-Control header, the JSON and the invalid_login translation key are identical by construction. Winter's own text ("Cannot login user ... as they are not activated") is not ported: the escalation asks for an opaque answer, and the Go port already gives invalid-login for bans and suspensions.
|
|
|
|
|
- QD-6 Scope is POST /login only. Not changed: /refresh, the jwt guard and the API-token guard (a session minted earlier for a not-activated account keeps working until it expires, or until an administrator deactivates or bans the user, which revokes it through RevokeUserAccess); sociallogin (its checkCanSignIn already refuses every not-activated account whatever the key says); controllers.FinishLogin (its callers do the activation check, as documented); /register; the unknown-email path, which skips bcrypt (pre-existing). Record the first and the last as follow-up candidates in the SUMMARY; do not implement them here.
|
|
|
|
|
- QD-7 Test fixtures. insertUser (session_test.go:382) now creates an activated account, because nearly all of its ~30 callers model an ordinary account that signs in. Tests whose subject is activation use two new helpers: insertInactiveUser, which has the previous insertUser shape, and inactiveSessionToken, which mints the session of a not-activated account with controllers.IssueToken, the way /register does when require_activation is false. The shared harness config (sessionConfig, newSessionApp, sessionApp, newAdminEnv) keeps require_activation at its default, so the suite tests the default.
|
|
|
|
|
</objective>
|
|
|
|
|
|
|
|
|
|
<execution_context>
|
|
|
|
|
@~/.claude/msd-core/workflows/execute-plan.md
|
|
|
|
|
@~/.claude/msd-core/templates/summary.md
|
|
|
|
|
</execution_context>
|
|
|
|
|
|
|
|
|
|
<context>
|
|
|
|
|
@CLAUDE.md
|
|
|
|
|
@.planning/STATE.md
|
|
|
|
|
@.planning/todos/pending/2026-10-22-user-login-ignores-is-activated.md
|
|
|
|
|
@.planning/notes/2026-10-22-external-user-plugin-escalations.md
|
|
|
|
|
@../sm-demo-app/plugins/golem15/user/README.md
|
|
|
|
|
|
|
|
|
|
Working copies (observed at planning time, 2026-10-22):
|
|
|
|
|
- sm-user-plugin: ../sm-demo-app/plugins/golem15/user (absolute /media/nvme/dev/golem15/summercms.io/summercms/sm-demo-app/plugins/golem15/user). Detached HEAD at tag v0.1.8 = 930b158 = local master = origin/master, working tree clean. Latest tag v0.1.8. sm-demo-app's go.work does not list the plugin in `use` (it only replaces the module), so every go command in this checkout runs with GOWORK=off; the plugin's go.mod:176 replaces summercms with ../../../../summercms.go, so it builds against summercms.go's working tree.
|
|
|
|
|
- The escalation note says the six user-plugin quicks run sequentially, never in parallel on one tree. If master or origin/master has moved past 930b158 when execution starts, another escalation quick has landed: build on top of it (fast-forward only), and record the base and git log --oneline v0.1.8..master in the SUMMARY. The Task 3 release would then carry those commits too, and the checkpoint lists them.
|
|
|
|
|
- sm-demo-app (../sm-demo-app): another session has an uncommitted edit in main.go. Leave it alone. Stage only the paths named in Task 3.
|
|
|
|
|
- summercms.go: quick 261022-k9k (admin SPA keyboard shortcuts) is executing concurrently. This plan touches only .planning files here (the escalation note, the todo move, this quick directory), never admin/ or modules/boardwalk. Stage by explicit path, never git add -A, and update STATE.md only through msd_run query state.* handlers.
|
|
|
|
|
- Docker: docker info answered (server 29.9.1) from this user at planning time. If the socket is denied, run go test through sudo -u golem (CLAUDE.md).
|
|
|
|
|
|
|
|
|
|
Source facts (file:line in the plugin checkout):
|
|
|
|
|
- controllers/api_controller.go: invalidLoginBody const :44; Login :46-129: LookupByEmail :61, RejectIfThrottled block :67-76, wrong-password/unknown-email branch :77-86 (CheckAndRecordLogin ok=false only when user != nil, then writeInvalidLogin), deleted_at restore and reactivate mail :87-93, CheckAndRecordLogin ok=true :94-101, rehash :102-110, TouchLastSeen :111-114, mintFor/apiArray :115-127; registrationSettings :810-831 (require defaults to true, read from golem15.user.activation.require_activation); Register uses require at :782 (mode == "auto" || !require gets a token); writeInvalidLogin :1170-1175 (401, accountMessage "invalid_login").
|
|
|
|
|
- classes/throttle.go: RejectIfThrottled :67 (ban or active suspension, no attempt recorded); CheckAndRecordLogin :107 (a nil user or ID 0 is a no-op, ok=false increments attempts and suspends at attempt_limit, ok=true resets). classes/user_lookup.go:116 LookupByEmail loads Unscoped.
|
|
|
|
|
- models/user.go:21 IsActivated; updates/202609220005_extend_users.go:15 is_activated BOOLEAN NOT NULL DEFAULT FALSE; controllers/users_admin_controller.go:266 admin-created users start not activated; controllers/registration.go:107-132 FinishLogin (no activation check by contract), :185-195 IssueToken(app, user, r).
|
|
|
|
|
- Tests (package user, plugin root): session_test.go TestLogin :54 (fixtures ada-login :60, throttled :119, soft :133 with the restore assertions :144-156), TestLoginSixthAttempt :160 (fixture :166), postJSON :395, parseToken, captureMail, loginToken :364-380, insertUser :382-393, sessionApp :446, sessionConfig. The sessionDB Postgres container starts even under -short, so these tests need Docker either way. account_test.go TestActivate :82-96 (loginToken "activate-auth" :84; "activate-already" then sets is_activated true), TestActivateByCode :98 (insertUser :100), bearerJSON :152. email_verified_test.go TestEmailVerifiedAt :72 with subtests activate_by_code_stamps :131 (insertUser :132), activate_stamps :147 (loginToken), activate_wrong_code_no_proof :164 (loginToken), userIDByEmail :29. admin_users_test.go TestAdminUserPassword :977: the user created through the admin API is asserted not activated at :994, and :1003-1006 asserts its password login answers 200 (this is G2 as reported); the final check at :1097-1099 asserts the reset did not activate. Admin harness (admin_harness_test.go): seedUser :468 defaults IsActivated true, userLogin :637, call, bulk :603, invalidLoginBody const in session_test.go :40.
|
|
|
|
|
- Consumers checked: the demo seed inserts activated users (sm-demo-app/plugins/demo/console/seed.go:151-153); fonoteka creates users with RegisterOptions{Activate: true} (onboarding.go:119, console/dev_seed.go:362-367); no app config sets require_activation, so every app runs with the default true. sm-demo-app workspace tests that need a user session mint it with IssueToken or bouncer.Mint (demo_api_test.go:82, sociallogin harness_test.go:237, quizzes harness_test.go:195) and do not go through password login.
|
|
|
|
|
- Winter reference (../examples/golem15-wintercms-starter): vendor/winter/storm/src/Auth/Manager.php:403-457 (throttle check, credentials, addLoginAttempt on failure, clearLoginAttempts on success), :656-683 (login() refuses a not-activated user before afterLogin); plugins/golem15/user/models/User.php:421-433 (afterLogin restores a trashed user and sends the reactivate mail); plugins/golem15/user/classes/AuthManager.php:25 (requireActivation from the require_activation setting).
|
|
|
|
|
</context>
|
|
|
|
|
|
|
|
|
|
<tasks>
|
|
|
|
|
|
|
|
|
|
<task type="tracer" tdd="true">
|
|
|
|
|
<name>Task 1 (tracer): /login refuses a not-activated account under require_activation, pinned end to end on Postgres</name>
|
|
|
|
|
<files>../sm-demo-app/plugins/golem15/user/controllers/api_controller.go, ../sm-demo-app/plugins/golem15/user/session_test.go</files>
|
|
|
|
|
<precondition>Docker is reachable for testcontainers (directly or through sudo -u golem), and git -C ../sm-demo-app/plugins/golem15/user status --short is empty.</precondition>
|
|
|
|
|
<behavior>
|
|
|
|
|
New TestLoginRequiresActivation in session_test.go. Each case uses its own fixed email, and each app comes from sessionApp(t, "0s"). Cases that need require_activation false get their own app with app.Config.Set("golem15.user.activation.require_activation", false), so the setting never leaks into another case. Fixtures are models.User literals with an explicit IsActivated value (bcrypt of "correct-horse"), so this test does not depend on the insertUser change in Task 2.
|
|
|
|
|
- default_refuses: require_activation unset; a not-activated account with the correct password gets 401 and a body equal to invalidLoginBody, with no token key; its users.last_seen stays NULL; its user_throttle row has attempts 1.
|
|
|
|
|
- same_as_wrong_password: for one not-activated account, compare the correct-password refusal with a wrong-password refusal. The status, the body bytes and the Content-Type and Cache-Control headers are equal, and each refusal adds one attempt.
|
|
|
|
|
- explicit_true_refuses: require_activation explicitly true gives the same 401 invalid-login answer.
|
|
|
|
|
- refusals_suspend: attempt_limit (default 5) correct-password refusals of a not-activated account leave its throttle row is_suspended true, as five wrong passwords do.
|
|
|
|
|
- off_allows: require_activation false; a not-activated account with the correct password gets 200 with a token whose sub is the user's id.
|
|
|
|
|
- activated_allows: an activated account gets 200 with a token (default config).
|
|
|
|
|
- deactivated_activated_restores: an activated account with deleted_at set gets 200 with a token; deleted_at is cleared; captureMail (published on that app) holds golem15.user::mail.reactivate-en for preferred_locale "en".
|
|
|
|
|
- deactivated_inactive_refused: require_activation unset; a not-activated account with deleted_at set gets the 401 invalid-login answer; deleted_at stays set; captureMail is empty.
|
|
|
|
|
- deactivated_inactive_off_restores: require_activation false; the same kind of account gets 200 with a token, deleted_at is cleared and the reactivate mail is sent.
|
|
|
|
|
</behavior>
|
|
|
|
|
<action>
|
|
|
|
|
Branch setup first, because the checkout is detached. In ../sm-demo-app/plugins/golem15/user run git fetch origin, git switch master and git merge --ff-only origin/master. Never rebase or reset someone else's commits. If master is no longer 930b158 after this, follow the rule in the context section and continue. Every go command here runs with GOWORK=off.
|
|
|
|
|
|
|
|
|
|
RED: write TestLoginRequiresActivation per the behavior block. Reuse postJSON, parseToken, captureMail, sessionApp and the invalidLoginBody const, read the users row (last_seen, deleted_at) and the user_throttle row with gdb, and give each case a t.Run name. Run it. The default_refuses, same_as_wrong_password, explicit_true_refuses, refusals_suspend and deactivated_inactive_refused cases must fail against today's code, which answers 200. Do not commit a red test: the project keeps go test green at every commit.
|
|
|
|
|
|
|
|
|
|
GREEN, in controllers/api_controller.go:
|
|
|
|
|
- Add refuseLogin(ctx, w, r, app, gdb, user, ip) next to writeInvalidLogin, implementing QD-5. It calls classes.CheckAndRecordLogin(ctx, gdb, user, ip, false); an error that classes.IsLoginRejected does not cover answers writeOpaque500; otherwise it answers writeInvalidLogin. CheckAndRecordLogin already ignores a nil user, so an unknown email still creates no throttle row.
|
|
|
|
|
- In Login, replace the body of the wrong-password/unknown-email branch (:77-86) with a call to refuseLogin and a return. Behaviour there is unchanged.
|
|
|
|
|
- In Login, read the flag once with _, _, _, requireActivation := registrationSettings(app) (QD-4). Directly after the password branch and before the user.DeletedAt.Valid restore block, add the gate: when requireActivation && !user.IsActivated, call refuseLogin and return (QD-1, QD-2, QD-3).
|
|
|
|
|
- Leave the RejectIfThrottled block, the restore block, CheckAndRecordLogin(ok=true), the rehash, TouchLastSeen and the token mint as they are.
|
|
|
|
|
- Rewrite the Login doc comment to state the check order from QD-1. Say that with require_activation on (the default) a not-activated account gets the same 401 invalid-login answer as a wrong password and counts as a failed attempt, and that an account that is also deactivated stays deactivated. Say that require_activation off signs such an account in as before.
|
|
|
|
|
|
|
|
|
|
Run TestLoginRequiresActivation until it passes, then also run TestLogin and TestLoginSixthAttempt. Those two may now fail because their fixtures are not activated; Task 2 fixes them. Do not commit in this task: the commit comes at the end of Task 2, once the whole suite is green.
|
|
|
|
|
</action>
|
|
|
|
|
<verify>
|
|
|
|
|
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/sm-demo-app/plugins/golem15/user && GOWORK=off go vet ./... && GOWORK=off go test -count=1 -run '^TestLoginRequiresActivation$' .</automated>
|
|
|
|
|
</verify>
|
|
|
|
|
<done>Under the default config, a not-activated account's correct password gets the byte-identical invalid-login 401 and adds one throttle attempt, and a deactivated not-activated account stays deactivated without a mail. require_activation false and activated accounts sign in as before, and the deactivated-activated restore still runs. TestLoginRequiresActivation is green; changes are uncommitted on sm-user-plugin master.</done>
|
|
|
|
|
</task>
|
|
|
|
|
|
|
|
|
|
<task type="auto">
|
|
|
|
|
<name>Task 2: existing suite on the new default, README, one plugin commit</name>
|
|
|
|
|
<files>../sm-demo-app/plugins/golem15/user/session_test.go, ../sm-demo-app/plugins/golem15/user/account_test.go, ../sm-demo-app/plugins/golem15/user/email_verified_test.go, ../sm-demo-app/plugins/golem15/user/admin_users_test.go, ../sm-demo-app/plugins/golem15/user/README.md (plus any further *_test.go file that the full run in this task shows failing for the activation reason, and only those)</files>
|
|
|
|
|
<precondition>Task 1 is done: TestLoginRequiresActivation passes on the uncommitted sm-user-plugin master.</precondition>
|
|
|
|
|
<action>
|
|
|
|
|
Fixtures (QD-7). The sites below were found by reading the code at planning time:
|
|
|
|
|
- session_test.go: insertUser creates the account with IsActivated true. Its doc comment says it is an activated account that can sign in. Add insertInactiveUser(t, gdb, email, password), which has the previous insertUser shape (is_activated false). Add inactiveSessionToken(t, app, gdb, email) string: it inserts with insertInactiveUser and mints the token with controllers.IssueToken. The request is an httptest POST to /_user/api/v1/login with Host example.com, so the iss claim matches what parseToken and the guard expect. Its doc comment says this is the session such an account has when require_activation is false. loginToken keeps its shape; update its doc comment (it signs in an activated account).
|
|
|
|
|
- session_test.go TestLogin: add IsActivated: true to the ada-login (:60), throttled (:119) and soft (:133) literals, so success, the throttle and the restore stay the subjects of those assertions. TestLoginSixthAttempt: add IsActivated: true to the sixth literal (:166).
|
|
|
|
|
- account_test.go: TestActivate's activate-auth account (:84) uses inactiveSessionToken. activate-already keeps loginToken; its explicit is_activated update stays and is now redundant, which is harmless. TestActivateByCode (:100) uses insertInactiveUser.
|
|
|
|
|
- email_verified_test.go TestEmailVerifiedAt: activate_by_code_stamps (:132) uses insertInactiveUser; activate_stamps (:147) and activate_wrong_code_no_proof (:164) use inactiveSessionToken.
|
|
|
|
|
- admin_users_test.go TestAdminUserPassword: replace the 200 assertion at :1003-1006 and its comment. The account the administrator created, still not activated, now gets a 401 for its correct password: use env.call to POST /_user/api/v1/login and compare rec.Body.String() with invalidLoginBody. Then the administrator activates it with env.bulk(t, token, "activate", user) and expects 200, and the password login answers 200. The comment says this is the escalation G2 case and that a request still cannot turn the activation on. At :1097-1099, the final check becomes "the reset kept the activation and the email": after.IsActivated must be true and the email unchanged. Update its comment. Change nothing else in the test.
|
|
|
|
|
- These callers need no edit, because the insertUser change covers them: mail_test.go softDelete, account_test.go TestResetPassword, profile_test.go, last_seen_test.go, avatar_test.go and the remaining loginToken users.
|
|
|
|
|
|
|
|
|
|
Then run the full suite. For each further failure, read the test and classify it:
|
|
|
|
|
(1) The fixture of an ordinary account is now refused: activate the fixture (IsActivated: true in the literal, or the seedUser mutate).
|
|
|
|
|
(2) The test's subject is a not-activated account signing in with a password: assert the 401 invalid-login refusal instead.
|
|
|
|
|
(3) The test needs a session of a not-activated account: use inactiveSessionToken.
|
|
|
|
|
Never set require_activation false in sessionConfig, newSessionApp, sessionApp or newAdminEnv. A failure unrelated to activation is not to be patched here: stop and report it. List every touched test with its class in the SUMMARY.
|
|
|
|
|
|
|
|
|
|
README.md (in the same commit as the code):
|
|
|
|
|
- Features, "Registration and activation" bullet (:19): with require_activation on (the default), POST /login refuses a not-activated account with the same 401 invalid-login answer as a wrong password and counts the attempt for the throttle. Such an account is activated by the mailed code (POST /activate-by-code answers with a token), by the invitation link or by an administrator. POST /activate serves sessions of not-activated accounts, which exist when require_activation is off.
|
|
|
|
|
- "Deactivation and bans" bullet (:26): activation gates login, deactivation does not. While require_activation is on, an account that is both deactivated and not activated is refused and stays deactivated.
|
|
|
|
|
- Configuration row for golem15.user.activation.require_activation (:210): when true, /login refuses a not-activated account (opaque invalid-login, counted as a failed attempt) and /register in admin mode returns no token; when false, both sign such an account in.
|
|
|
|
|
- Testing section: one sentence on TestLoginRequiresActivation and the cases it pins.
|
|
|
|
|
- Check that every identifier the README names exists in the package.
|
|
|
|
|
|
|
|
|
|
Verify, then commit in the plugin checkout. Stage by explicit path: controllers/api_controller.go, session_test.go, account_test.go, email_verified_test.go, admin_users_test.go, README.md, plus any test file from the classification. Commit message subject: fix: login refuses a not-activated account while require_activation is on. The body has two or three plain lines: same 401 invalid-login answer as a wrong password, counted by the throttle; checked after the password and before the deactivation restore; require_activation false keeps the previous behaviour. No Co-Authored-By or any other trailer. No push, no tag. After this commit the sm-demo-app working tree shows plugins/golem15/user as modified; leave it uncommitted until Task 3.
|
|
|
|
|
</action>
|
|
|
|
|
<verify>
|
|
|
|
|
<automated>cd /media/nvme/dev/golem15/summercms.io/summercms/sm-demo-app/plugins/golem15/user && GOWORK=off go vet ./... && GOWORK=off go test -count=1 -run 'TestLogin|TestActivate|TestResetPassword|TestEmailVerifiedAt|TestMail|TestAdminUserPassword' ./... && GOWORK=off go test -count=1 ./... && ST=$(git status --short) && test -z "$ST" && S=$(git log -1 --format=%s) && grep -q 'not-activated account while require_activation' <<<"$S"</automated>
|
|
|
|
|
</verify>
|
|
|
|
|
<done>go vet and the full Docker-backed plugin suite are green. The plugin working tree is clean, with one new fix commit on master (code, tests, README) and no trailer. Nothing is pushed or tagged. The SUMMARY lists every test touched and why.</done>
|
|
|
|
|
</task>
|
|
|
|
|
|
|
|
|
|
<task type="checkpoint:decision" gate="blocking-human">
|
|
|
|
|
<name>Task 3: Go-ahead to release sm-user-plugin and bump sm-demo-app, then close G2</name>
|
|
|
|
|
<files>../sm-demo-app/plugins/golem15/user (submodule pointer), ../sm-demo-app/go.mod, ../sm-demo-app/go.sum, .planning/notes/2026-10-22-external-user-plugin-escalations.md, .planning/todos/pending/2026-10-22-user-login-ignores-is-activated.md, .planning/todos/done/2026-10-22-user-login-ignores-is-activated.md (all conditional on the option chosen)</files>
|
|
|
|
|
<decision>Whether to publish the fix: push sm-user-plugin master with a patch tag, commit (and optionally push) the sm-demo-app pointer bump, or keep everything local.</decision>
|
|
|
|
|
<context>
|
|
|
|
|
Present to the user: git -C ../sm-demo-app/plugins/golem15/user log --oneline origin/master..master (expected: the Task 2 fix commit only, plus any earlier escalation-quick commits recorded in Task 1) and the latest tag (v0.1.8 at planning time).
|
|
|
|
|
Behaviour change for consumers: every app runs with require_activation at its default true, so after a bump an account that is not activated and has a password can no longer sign in with it. Before bumping any app's pointer, the user may want to count such accounts in that app's database: SELECT count(*) FROM users WHERE is_activated = false AND deleted_at IS NULL AND password IS NOT NULL AND password != ''. The demo seed creates activated users (plugins/demo/console/seed.go:151-153).
|
|
|
|
|
Only sm-demo-app is in scope for the pointer bump. fonoteka.go (pinned at v0.1.6), sm-bm-app, sm-grzybyfunkcjonalne-app and sm-summercmsio-app (v0.1.8) are separate decisions; list them in the SUMMARY as follow-ups.
|
|
|
|
|
Changelog: the CLAUDE.md rule names framework release tags. Ask whether this plugin tag gets a summercms.io/changelog post. If yes, follow CLAUDE.md's changelog-post.py steps from ../sm-summercmsio-app and never print the token.
|
|
|
|
|
</context>
|
|
|
|
|
<options>
|
|
|
|
|
<option id="release">
|
|
|
|
|
<name>Tag and push the plugin; commit the sm-demo-app bump locally</name>
|
|
|
|
|
<pros>G2 ships as a version the external app can pin; the escalation reply can name it.</pros>
|
|
|
|
|
<cons>Behaviour change for every app that later bumps; sm-demo-app's bump stays unpushed until asked.</cons>
|
|
|
|
|
</option>
|
|
|
|
|
<option id="release-push-demo">
|
|
|
|
|
<name>As release, and also push sm-demo-app</name>
|
|
|
|
|
<pros>The demo app runs the fix as soon as it is next deployed.</pros>
|
|
|
|
|
<cons>Pushes sm-demo-app's whole unpushed range (show git log --oneline origin/master..master there first); deploying is still separate.</cons>
|
|
|
|
|
</option>
|
|
|
|
|
<option id="hold">
|
|
|
|
|
<name>Keep everything local</name>
|
|
|
|
|
<pros>Nothing leaves the machine; other escalation fixes can join the same tag.</pros>
|
|
|
|
|
<cons>G2 stays open for the external app; the note's G2 row and reply paragraph are not filled.</cons>
|
|
|
|
|
</option>
|
|
|
|
|
</options>
|
|
|
|
|
<action>
|
|
|
|
|
After the user's answer, carry out only the chosen option.
|
|
|
|
|
|
|
|
|
|
release / release-push-demo:
|
|
|
|
|
1. Plugin, in ../sm-demo-app/plugins/golem15/user: git fetch origin and confirm origin/master is an ancestor of master. Pick the next free patch tag, called TAG below: v0.1.9 unless git tag shows it taken, in which case the next one. Push master. Create an annotated tag whose message is a user-facing note in the style of v0.1.8's: "Password login refuses an account that is not activated while golem15.user.activation.require_activation is on (the default), with the same invalid-login answer as a wrong password; a deactivated account is still restored by its owner's login." Push the tag.
|
|
|
|
|
2. sm-demo-app, in ../sm-demo-app: the plugin checkout already sits at the tagged commit; confirm with git -C plugins/golem15/user describe --exact-match. Run GOPRIVATE='git.golem15.com/*' GOWORK=off go get git.golem15.com/golem15/sm-user-plugin@TAG. Check that git diff go.mod go.sum touches only sm-user-plugin lines, then run go build ./... and go vet ./... (workspace). Commit, staging only plugins/golem15/user, go.mod and go.sum, with the message chore: bump user to TAG and no trailer. Leave the other session's main.go edit alone. Push sm-demo-app only for release-push-demo.
|
|
|
|
|
3. Planning docs, in summercms.go:
|
|
|
|
|
- In .planning/notes/2026-10-22-external-user-plugin-escalations.md, set tracker row 1's Status to done and Shipped to the tag. Replace the G2 TODO line of the reply with one line: "Shipped in sm-user-plugin TAG: 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."
|
|
|
|
|
- git mv the todo from todos/pending/ to todos/done/ and append a "## Closed 2026-10-22" note: plugin commit sha, tag, sm-demo-app bump sha, pushed or not.
|
|
|
|
|
- Commit both with docs(quick-261022-tb4): close G2, sm-user-plugin TAG, staging these paths explicitly. If the quick workflow commits this quick directory and STATE.md itself, leave those to it.
|
|
|
|
|
- g15office: if a g15office tool is available, comment on task 3r4or1ex (shipped in TAG) and move it to Live. Otherwise record it as pending in the SUMMARY.
|
|
|
|
|
|
|
|
|
|
hold: change no planning doc. The todo stays pending, and the G2 row and reply paragraph stay as they are. Record the fix commit sha and "unreleased" in the SUMMARY. If a g15office tool is available, comment on 3r4or1ex that the fix is committed locally and awaits release.
|
|
|
|
|
</action>
|
|
|
|
|
<verify>
|
|
|
|
|
<automated>P=/media/nvme/dev/golem15/summercms.io/summercms/sm-demo-app/plugins/golem15/user; if T=$(git -C "$P" describe --exact-match 2>/dev/null); then R=$(git -C "$P" ls-remote --tags origin) && grep -q "refs/tags/$T" <<<"$R" && L=$(git -C /media/nvme/dev/golem15/summercms.io/summercms/sm-demo-app log -5 --format=%s) && grep -q "^chore: bump user to $T" <<<"$L" && test -f /media/nvme/dev/golem15/summercms.io/summercms/summercms.go/.planning/todos/done/2026-10-22-user-login-ignores-is-activated.md; else O=$(git -C "$P" log --oneline origin/master..master) && grep -q "not-activated account while require_activation" <<<"$O"; fi</automated>
|
|
|
|
|
</verify>
|
|
|
|
|
<done>release: the plugin tag is on origin, sm-demo-app holds one bump commit (pushed only for release-push-demo), the G2 row and reply name the tag, the todo is in todos/done/ with a Closed note, and planning docs are committed separately from code. hold: nothing pushed or tagged, and the SUMMARY records the local fix commit as unreleased.</done>
|
|
|
|
|
<resume-signal>Select: release, release-push-demo, or hold (optionally: whether the plugin tag gets a changelog post).</resume-signal>
|
|
|
|
|
</task>
|
|
|
|
|
|
|
|
|
|
</tasks>
|
|
|
|
|
|
|
|
|
|
<threat_model>
|
|
|
|
|
## Trust Boundaries
|
|
|
|
|
|
|
|
|
|
| Boundary | Description |
|
|
|
|
|
|----------|-------------|
|
|
|
|
|
| anonymous client -> POST /_user/api/v1/login | untrusted email and password; the answer and its side effects must not reveal account state |
|
|
|
|
|
| administrator -> Users screen | creates accounts that start not activated and may carry a password the administrator chose |
|
|
|
|
|
| session JWT holder -> jwt.auth routes, /refresh | sessions minted before this fix for accounts that are not activated |
|
|
|
|
|
| executor -> git remotes | tag and push of a core plugin used by every app |
|
|
|
|
|
|
|
|
|
|
## STRIDE Threat Register (ASVS L1, block on high)
|
|
|
|
|
|
|
|
|
|
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
|
|
|
|
|-----------|----------|-----------|----------|-------------|-----------------|
|
|
|
|
|
| T-tb4-01 | E | controllers.Login issues a token to a not-activated account (G2; admin-created accounts with a password) | high | mitigate | Task 1: activation gate under require_activation (default true) after the password check; TestLoginRequiresActivation default_refuses, explicit_true_refuses; Task 2 flips TestAdminUserPassword to assert the refusal |
|
|
|
|
|
| T-tb4-02 | I | account-state enumeration through the /login answer (activated or not) | medium | mitigate | QD-5: both refusals go through refuseLogin and writeInvalidLogin; same_as_wrong_password compares status, body bytes and headers |
|
|
|
|
|
| T-tb4-03 | I | timing difference between a wrong password and a not-activated account | low | mitigate | QD-1: the gate runs only after bcrypt CheckPassword, and both refusals do the same throttle write |
|
|
|
|
|
| T-tb4-04 | I | password oracle for not-activated accounts through throttle state (an attempt reset on a correct password) | medium | mitigate | QD-2: the refusal records a failed attempt like a wrong password; refusals_suspend pins it |
|
|
|
|
|
| T-tb4-05 | E | restore-on-login reviving an account that is deactivated and not activated | medium | mitigate | QD-3: the gate sits before the deleted_at restore; deactivated_inactive_refused checks deleted_at and the absent mail |
|
|
|
|
|
| T-tb4-06 | D | a not-activated owner retrying until suspended | low | accept | Such an account cannot sign in before activation anyway; the suspension lifts after suspension_minutes or an admin unsuspend (QD-2) |
|
|
|
|
|
| T-tb4-07 | E | sessions minted earlier for not-activated accounts keep working (jwt guard and /refresh do not check activation) | medium | accept | Out of the G2 scope (QD-6); the sessions expire within the refresh window, and an administrator's deactivate or ban revokes them (RevokeUserAccess); recorded as a follow-up candidate in the SUMMARY |
|
|
|
|
|
| T-tb4-08 | I | an unknown email skips bcrypt, so it answers faster than a known one (pre-existing) | low | accept | Unchanged by this plan (QD-6); recorded as a follow-up candidate in the SUMMARY |
|
|
|
|
|
| T-tb4-09 | T | unreviewed publish of a core plugin (tag, push, pointer bump) | medium | mitigate | Task 3 is checkpoint:decision with gate blocking-human; no tag or push before the user's answer |
|
|
|
|
|
| T-tb4-SC | T | npm/pip/cargo/go installs | low | accept | No new dependency; only existing packages of the plugin are used |
|
|
|
|
|
</threat_model>
|
|
|
|
|
|
|
|
|
|
<verification>
|
|
|
|
|
- sm-user-plugin (../sm-demo-app/plugins/golem15/user): GOWORK=off go vet ./... and GOWORK=off go test -count=1 ./... green with Docker at the fix commit; TestLoginRequiresActivation covers the nine cases in Task 1's behavior block.
|
|
|
|
|
- The README names only identifiers that exist in the plugin; the require_activation row, the activation and deactivation bullets and the Testing entry are updated in the fix commit.
|
|
|
|
|
- Only the Task 2 paths are in the plugin commit; summercms.go gets only .planning edits; no file under admin/ or modules/boardwalk is touched.
|
|
|
|
|
- No push, tag, changelog post, pointer bump or planning-doc closure before the Task 3 answer.
|
|
|
|
|
</verification>
|
|
|
|
|
|
|
|
|
|
<success_criteria>
|
|
|
|
|
- tb4-1: with require_activation on (the default), POST /login refuses a not-activated account with the byte-identical opaque invalid-login 401 a wrong password gets and counts it as a failed attempt; with it off, such an account signs in as before.
|
|
|
|
|
- Deactivation restore-on-login is unchanged for activated accounts (and for every account when require_activation is off); an account that is also not activated is refused before any restore while the gate is on.
|
|
|
|
|
- The existing suite passes on the new default without switching the harness to require_activation false.
|
|
|
|
|
- After a release decision: the plugin tag exists on origin, sm-demo-app has its bump commit, the escalation note's G2 row and reply paragraph name the tag, and the todo is closed. After a hold decision: everything stays local and is recorded as unreleased.
|
|
|
|
|
</success_criteria>
|
|
|
|
|
|
|
|
|
|
<output>
|
|
|
|
|
Create `.planning/quick/261022-tb4-password-login-must-refuse-is-activated-/261022-tb4-SUMMARY.md` when done. It records the plugin base commit and fix sha; every test touched, with its class; the Task 3 decision and the resulting tag and commit shas; the follow-up candidates from QD-6 (activation on /refresh and the jwt guard; bcrypt on the unknown-email path); the other apps' pointer bumps; and the g15office status.
|
|
|
|
|
</output>
|