Skip to content

1.9.79: a new password ends the user's other sessions - #331

Merged
tracking202 merged 10 commits into
1.9.79from
1.9.79-password-ends-sessions
Oct 29, 2026
Merged

tracking202 merged 10 commits into
1.9.79from
1.9.79-password-ends-sessions

Conversation

@tracking202

Copy link
Copy Markdown
Owner

Backlog: S2IV

What was wrong

#325 revoked the remember-me keys on a new password, and a browser already signed in stayed signed in. So whoever had used a stolen cookie (or was signed in on a shared computer) kept that session after the owner changed or reset the password.

Measured live on #326's head (tests/live/password-ends-sessions.sh, below): after a change on Personal settings, a reset, PUT /users/{id} and Account › Users, the user's other browsers stayed signed in. A browser idle through a switch-off went on once the user was switched on again.

What changed

  • Schema. 202_users.user_session_generation INT UNSIGNED NOT NULL DEFAULT 0, in the table definition (UserTables::users()) and in the 1.9.78 → 1.9.79 rung. The rung change is one entry at the end of its table list, which reconciles 202_users against its definition.
    • It is a counter, not a time: there is no same-second race and no clock to skew between PHP and the database.
  • Sign-in. AUTH::begin_user_session() stores the user's generation in the session. Both sign-ins pass it: LOGIN_SELECT names the column, and the cookie's read is SELECT *.
  • Writers. Prosper202\User\SessionGeneration::bump() counts the generation up by one in the writer's transaction, beside RememberMeKeys::revokeAll(), and returns the new value.
    • A new password: PasswordChange::store() (Personal settings, the reset page), p202_account_save_user() (Account › Users), UsersController::update() (PUT /users/{id}, both CLIs), MysqlUserRepository::update().
    • A switch-off: the same three update writers. I decided to bump here as well, for a measured reason (below).
    • A removal: the soft delete itself counts the generation up (UserDataPurge::deleteUser()).
    • Not the rehash at sign-in (AUTH::upgrade_user_password()).
    • A count that fails rolls the change back: the old password still works, and the page or the API (500) says the change failed.
  • connect.php ends a stale session. UserStanding::READ, the user read connect.php already makes, now selects user_session_generation in the same statement. AUTH::sessionUserStands() ends a session whose stored generation is not the row's, at that request.
  • The browser that changes its own password on Personal settings keeps its session. After the commit, in the same request, it takes the new generation (AUTH::keepSessionAfterPasswordChange()). The flash says the other browsers are signed out.
    • An admin who changes another user's password (Account › Users, PUT) ends that user's sessions, not their own.
    • A reset page has no session to keep.

Decisions

  • Switch-off bumps too. 1.9.79: a signed-in session ends when its user is switched off or removed #323's standing check ends a session only at a request made while the user is off. A browser that made no request in that window went on once the user was switched on again. Measured:

    The bump costs one statement in a write that already revokes the keys. The switch-on does not bump. The removal bumps in its soft delete, so a row restored by hand stays signed out.

  • A session from before the upgrade is read as generation 0, which is every user's value until their first change, so the upgrade signs nobody out. Ending those sessions once would have signed every user out on upgrade. They end at the user's first change, like any other session.

  • The window between deploying the files and running the upgrade. In that window the column does not exist. UserStanding::readWithGeneration() catches only 1054 Unknown column 'user_session_generation', reads again without it on that request, and compares nothing. Any other failure, including an unknown user_deleted, still throws to the error page.

    • The pages that reach the user read in that window are the few connect.php lets past its upgrade redirect. The sign-in page is not among them, so LOGIN_SELECT naming the column cannot fail there.
    • A password change through the API in that window fails at the count and answers 500 (1.9.79's API names the pending upgrade).
  • An admin setting their own password on Account › Users ends their own other sessions, this one included (only Personal settings keeps its session). This follows from the code; I did not run it.

  • The generation key in the session is written only by begin_user_session() and keepSessionAfterPasswordChange() (SessionSignInPathsTest). Written elsewhere, a stale session could be given the current value.

  • A value no sign-in wrote (not a non-negative int) is read as -1, which no row holds, so the session is ended.

Statements per request

Measured as MariaDB Questions, by the session runner, for user 1. 1.9.79's tip and this branch are the same:

Page 1.9.79 this branch
Personal settings 20 20
Setup › Categories 16 16
Overview 17 17

This holds for a session holding a generation and for one holding none. The generation rides in the read that was already there. The only extra statement is the re-read without the column, on a request in the window before the upgrade.

Tests

  • tests/User/RememberMeKeysRevokedTest.php (structural, 63 tests):
    • Password writers: each writer listed as a change must revoke the keys and end the sessions, by a resolved call to SessionGeneration::bump( or by counting the generation up in its own statement. The rehash must do neither.
    • Standing writers: every writer of user_active/user_deleted must end the sessions too.
    • The column: every statement that writes user_session_generation must set it to itself + 1. Its writers are pinned with why.
    • Nine shapes run as data sets: bump() qualified and imported, another class, a method of the name, counted up in the statement, set to 0, counted down, and + 2.
  • tests/Auth/SessionSignInPathsTest.php: the session's user_session_generation is written only by begin_user_session() and keepSessionAfterPasswordChange(). Six new shapes, including a page writing it, a page counting it up, another AUTH method writing it, and handing it to AUTH's general writer.
  • tests/Auth/SessionGenerationIntegrationTest.php (@group integration, 12 tests). Two browsers, each request run as the web server runs it, through connect.php, with a browser's next request resuming the session file its last one left.
    • Personal settings: the changing browser keeps its session id at the new generation, and the other is ended at its next request with a new id.
    • Reset: both browsers end.
    • Admin change through PUT and through Account › Users: the user's browser ends and the admin's does not.
    • Sign-in after a change: the new session begins at the new generation and stays signed in.
    • Ended session: revokes no key a later sign-in made.
    • Rehash: nothing counted up, the browser stays signed in.
    • Session from before the upgrade: stays signed in, and ends at a change.
    • Idle through a switch-off: ended when the user is switched on again.
    • Failed read: the error page, with the session file kept; the next read ends it.
    • A count that fails (PasswordChange::store() and PUT): the password is not stored.
    • The window before the upgrade: column dropped, nothing compared, the session runs with its roles.
  • tests/Upgrade/SessionGenerationUpgradeIntegrationTest.php (@group integration). The rung runs from a stored 1.9.78 on a 202_users without the column. Afterwards:
    • the rung returned true and logged nothing, and the version is 1.9.79;
    • the column is the installer's (int(10) unsigned / int unsigned, NOT NULL, default 0);
    • SHOW CREATE TABLE 202_users equals the fresh install's, less the AUTO_INCREMENT counter;
    • an existing user is at 0;
    • a second run changes nothing and keeps a counted-up value.
  • tests/User/UserManagementSaveTest.php: the edit's statements now include the count and its read-back.
  • tests/Auth/fixtures/session-user-runner.php: gains P202_SU_SESSION_ID (resume a session), P202_SU_GENERATION, and the session's generation in its result.
  • tests/live/password-ends-sessions.sh: a new live pass with two browsers.

Plants: 15 of 15 caught by a named test. Each was planted in the real tree (each file linted first) and restored from a mktemp copy checked with cmp.

Plant Caught by
The generation not compared integration
An ended session revokes the keys again integration
The sign-in stores no generation integration
A page writes the session's generation SessionSignInPathsTest
Personal settings does not keep its session integration
PasswordChange ends no session structural, integration
PUT /users ends no session structural, integration
PUT /users bumps for a password only (not a switch-off) integration
Account › Users ends no session structural, integration
The repository ends no session structural
The removal ends no session structural
The rehash bumps structural, integration
BUMP sets the generation to 0 structural
The read before the upgrade is not tolerated integration
The rung adds no column upgrade test (an error: the column read reports it is not there)

Verification

.claude/skills/p202-verify/scripts/verify.sh --since origin/1.9.79 --changed on the branch (PHP 8.3.6, full vendor/; the schema tier pointed at a scratch database, sxr8_it_test). It runs --since origin/1.9.79 rather than the brief's --since origin/1.9.79-remember-me-roles: #326 is merged and 1.9.79 holds it, so 1.9.79 is this branch's base.

Scope report
------------
  openapi    PASS
  patterns   PASS
  phpcs      PASS
  phpstan    FAIL  phpstan.neon.dist failed, phpstan-strict.neon passed
  schema     PASS
  syntax     PASS
  unit       PASS

Not covered by this script:
  live      stand up an instance, drive the path a user would take
  agent     add and run a case under tests/fixtures/agent-eval/cases/
  ci        a workflow still in_progress has not passed; read the run

Report the lines above verbatim. Do not write "tests pass".
  • phpstan FAIL comes from 1.9.79's tip, not this branch. phpstan-baseline.neon expects 2 prosper202.numericCastOfRequestParam in 202-account/account.php and there are 3. The third is (int)$_POST['user_pref_same_ip_fallback'] (line 335), which arrived with 1.9.79's tip: origin/1.9.79's account.php has the same 3 casts, and this branch's diff touches only the password block. phpstan-strict.neon passed.
  • phpcs: 18 files examined, none made worse.
  • unit: Tests: 7589, Assertions: 149039, Skipped: 6.
  • schema: 13 tests.
  • CI: not yet observed.

Integration (tests/run-integration-suites.sh, every @group integration suite, one per fresh database; MariaDB 10.11 here, MySQL 8.0 in CI): 190 of 192 suites passed (1,813 tests), all four auth-chain suites among them.

Live (port 8177, database sxr8_test, PHP 8.3.6, MariaDB 10.11), tests/live/password-ends-sessions.sh:

  • this branch: 41 passed, 0 failed. I ran it before the merge with 1.9.79 and again after it, on a fresh install.
  • 1.9.79: the first request a remember-me cookie signs in has the user's roles #326's head (bb1fa6a3), same server and database: 31 passed, 10 failed. The other browser stayed signed in after a change (on the same session id); both stayed signed in after a reset; the user's browser stayed signed in after PUT and after Account › Users; the idle browser stayed signed in through a switch-off; and the generation (read from the database the branch installed) was never counted up.

The auth chain's earlier live passes also run on the merged branch:

  • session-ends-with-user.sh: 93 passed, 0 failed.
  • remember-me-revoked.sh: 28 passed, 0 failed.
  • password-change-revokes.sh: 41 passed, 0 failed.
  • remember-me-first-request.sh: 13 passed, 0 failed.

What ran without the licence, and what could not run

The licence stub is down. I installed the instance with the installer's own steps without the licence form: install_databases(), InstallAccount::create(), and a REST key.

The live passes ran through scratch copies of the committed scripts. The only change in each appends valid_key|b:1; to the session file after each sign-in, which is what the licence service's answer would have set. remember-me-first-request.sh instead ran unchanged, against a server whose outbound HTTPS goes to a closed port, so the licence check fails open.

tests/live/upgrade-equals-install.sh did not run. PHP 7.4 is installed here, but the script's fresh install goes through install-instance.sh, which needs the licence service (CI's licence-stub.sh, reached by name) to answer "Key valid". The stub is down, and I may not edit /etc/hosts. What stands in for it, for this change:

  • the upgrade integration test above (202_users upgraded from 1.9.78 equals the fresh table, on this server);
  • the schema tier.

The rung's other tables are other PRs'.

Also not run:

  • the Go and PHP CLIs (not changed; they reach the server through PUT /users/{id}, which ran);
  • the agent evals;
  • the instance-group suites;
  • MySQL 8.0 (CI's server).

Out of scope, found on the way

  • 1.9.79's tip fails its own phpstan run: the user_pref_same_ip_fallback cast in account.php is over the baseline's count (above).
  • Flaky concurrency test: WebhookDispatchIntegrationTest's overlapping-runs case lost a run to a deadlock once in a full integration run.

Generated by Claude Code

#325 revoked the remember-me keys on a new password and left every
browser already signed in signed in, so a thief who had used a stolen
cookie kept the session it made after the owner changed or reset the
password. Measured live on #326's head: after a change on Personal
settings, a reset, PUT /users/{id} and Account › Users, the other
browsers stayed signed in.

202_users gains user_session_generation (INT UNSIGNED NOT NULL DEFAULT
0), in the table definition and the 1.9.78 -> 1.9.79 rung. A counter,
not a time: no same-second race, no clock to skew between PHP and the
database.

- The sign-in (LOGIN_SELECT, and the cookie's SELECT *) stores the
  user's generation in the session (begin_user_session()).
- SessionGeneration::bump() counts it up, in the writer's transaction,
  wherever the user's remember-me keys are revoked: a new password
  (PasswordChange::store(), Account › Users, PUT /users/{id},
  MysqlUserRepository::update()), a switch-off (a session idle through
  the switch-off would otherwise go on once the user is switched on
  again: measured), and the removal's soft delete. A failed count rolls
  the change back. The rehash at sign-in does not count.
- connect.php's User read (UserStanding::READ) reads the generation in
  the same statement; AUTH ends a session holding another at that
  request, with the sign-out's teardown but without revoking keys again
  (the change revoked them; keys made since are a later sign-in's). A
  failed read is the error page. Before the upgrade has run the column
  is missing: the read retries without it (that request only) and
  compares nothing.
- A session from before 1.9.79 holds no generation and is read as 0,
  every user's value until a change, so the upgrade signs nobody out.
- Personal settings keeps the changing browser's session: it takes the
  new generation after the commit (keepSessionAfterPasswordChange()).
  An admin's change (Account › Users, PUT) ends the user's sessions,
  not the admin's.

RememberMeKeysRevokedTest holds every password change and every standing
writer to ending the sessions, the rehash to not, and every write of the
generation to counting it up by one; SessionSignInPathsTest holds the
session's generation to the sign-in and keepSessionAfterPasswordChange().
SessionGenerationIntegrationTest runs two browsers through each writer,
the rehash, a session from before the upgrade, a failed read, a failed
count and the window before the upgrade; SessionGenerationUpgrade-
IntegrationTest runs the rung. tests/live/password-ends-sessions.sh
drives it over HTTP.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
…ssions

The purge's access statements take 1.9.79's docblock and its offline
conversion feeds; the 1.9.79 rung keeps its tables and adds 202_users at
the end.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
…eneration

The users API page, the OpenAPI spec and the CLI guide say a new password
and a switch-off sign out every browser signed in as the user; the 1.9.79
changelog lines say so, after 1.9.79's own, and #325's line no longer says
a signed-in browser stays signed in. tests/live/README.md lists
password-ends-sessions.sh, and CLAUDE.md #5 says what the session was.

SessionGenerationIntegrationTest also holds a sign-in after a change to the
new generation, and an ended session to revoking no key a later sign-in
made.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
1.9.79's PHPStan job has been red since the merges at 15:46Z. #308 extended
ForbidNumericCastOfRequestParamRule to the pages and baselined the casts
already there. Three PRs merged beside it each added a cast that its baseline
does not count, and each PR passed on its own:

- Personal settings (#283) cast the same-IP fallback after checking it is a
  key of its choices. It now stores 1 for '1' and 0 for '0', the two keys the
  check admits.
- Setup > Offline Conversions (#293) cast a feed id after a regex test. It now
  reads it with PostedId::read().
- Setup > Traffic Sources (#271) cast the edited account's id to read its
  stored postbacks. The page's own ownership check compares the raw value in
  SQL, where MySQL reads '12abc' as 12. So a save from
  ?edit_ppc_account_id=12abc changed account 12 (one of the account's own, not
  the one named) and held that account's postbacks. Measured on a live
  instance: the old page saved the tampered edit onto account 1. The id is now
  read exactly, and anything else is refused in the page's words before
  anything is read or written.

The baseline counts are unchanged: each file now holds only the casts they
counted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
Copilot AI balanced review requested due to automatic review settings October 28, 2026 16:30

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ⚠️ Failed 2026-10-28T16:30:42.976605Z
We couldn’t complete this review. Comment @codex review to try again.
5d3f57d PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

…ds-sessions

1.9.79's PHPStan job is red on its own tip: three request casts that its
merges left. #328 fixes them, and it is merged here so this branch's
PHPStan check runs green. When #328 merges into 1.9.79, the commit is
already there and this merge adds nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr

Copy link
Copy Markdown
Owner Author

What is red here, and why it is not this PR's:


Generated by Claude Code

Brings in 1.9.79's new commits. The conflicts were in 202-config/changelogs.json, changelogs.txt, where both sides added lines: the incoming lines are kept as they are and this branch's follow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
… conflict marker

81c601f (#327's squash) took a merge an agent left unresolved:
202-config/changelogs.json held <<<<<<<, ======= and >>>>>>> around two
1.9.79 entries, so the file the in-app release notes read stopped being JSON.
Both entries are kept, 1.9.79's first, as each side wrote it.

Nothing CI runs would have refused it: PHP lint reads PHP, and no test decoded
the file. NoMergeConflictMarkersTest holds every tracked text file to having no
conflict-marker line, and every tracked JSON file to parsing. With the marked
file put back, both tests fail on it; the tree is otherwise clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
Brings in 1.9.79-changelog-json-markers (this branch's stacked base), and through it 1.9.79's new commits. The conflicts were in 202-config/changelogs.json, where both sides added lines: the incoming lines are kept as they are and this branch's follow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
@tracking202

Copy link
Copy Markdown
Owner Author

@copilot resolve the merge conflicts on this branch.

Brings in 1.9.79's new commits. The conflicts were in 202-config/changelogs.json, where both sides added lines: the incoming lines are kept as they are and this branch's follow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
@tracking202

Copy link
Copy Markdown
Owner Author

@claude resolve merge conflicts

Copy link
Copy Markdown
Owner Author

The conflicts are resolved: 3e54c71 merges 1.9.79 (e7e7300, #332) into this branch.

changelogs.json:

A merge from the base 81c601f cannot see that rewording on its own, because that base's changelogs.json is the broken one #332 fixed. Such a merge would put the old wording back beside the new; this merge removed it. Against 1.9.79, this branch now changes only its own lines.

If Copilot's run pushes after this, check that both copies of that line are not back.


Generated by Claude Code

@claude

claude Bot commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

Claude finished @tracking202's task in 17s —— View job


I couldn't resolve the merge conflicts

I didn't change the branch and pushed nothing.

  • No fetch: git fetch origin 1.9.79 needs approval in this run and was denied, so I couldn't update origin/1.9.79.
  • No local base ref: git merge-base HEAD origin/1.9.79 found nothing, so origin/1.9.79 isn't in this checkout and I can't see what conflicts.
  • No merging here: branch operations like merges are outside what I'm set up to do in this environment.
  • Uncommitted CLAUDE.md change: the checkout already has an uncommitted edit to CLAUDE.md that I didn't make. A merge would have to deal with it, and I didn't want to commit or discard it without asking.

Ways forward:

  1. Re-run me with git fetch and git merge allowed. I'd merge origin/1.9.79, resolve the conflicts, run the verification ladder, and push.
  2. Or merge locally:
    git checkout 1.9.79-password-ends-sessions
    git fetch origin
    git merge origin/1.9.79
    
    Then push.
  3. The earlier comment says @copilot was asked to do the same and may be a better fit.

If the conflicts are in CLAUDE.md, changelogs.json, or changelogs.txt, they're probably adjacent entries from other PRs merged into 1.9.79. Keep both sides there.

Brings in 1.9.79's new commits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
@tracking202
tracking202 merged commit 97a6d44 into 1.9.79 Oct 29, 2026
19 of 20 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants