feat: change password while signed in - #27
Merged
Merged
Conversation
`POST /auth/password` — the counterpart to the forgot-password flow, without the email round-trip. Re-verifies the current password, applies `Password::defaults()` to the new one, revokes every OTHER session, fires `Events\PasswordChanged`. Asking for the current password is the whole security story. An attacker holding a stolen access token already has the account until that token expires; if the token alone could change the password they would have it permanently, and the owner would be locked out of their own recovery. Requiring the existing secret means the token is not enough. Because it checks the same secret as login and step-up, it runs on the SAME budget rather than a second one: the `lukk-confirm` throttle, and the `confirm` lockout counter when that feature is on. Two independent allowances for guessing one password is just a larger allowance. A success clears the counter — those failures were against a password that no longer exists — and the attempt is reserved before the credential check, like every other password path, so concurrency can't overrun the cap. Other sessions die, the current one survives. Changing a password is what someone does when they think another party is in the account, so leaving those sessions alive defeats the point — but logging the user out of the tab they just did it in is a bad answer to a good instinct. The session to keep is read from the caller's own VERIFIED token rather than from the request, so it can't be pointed at someone else's to spare it from the sweep. With no family id there is nothing to preserve, so nothing is revoked rather than everything. The new password must differ from the current one: accepting a no-op would report success for a change that didn't happen, and this endpoint revokes every other session, which is a lot of collateral for nothing. On by default (`features.change_password`), like `logout_all` — it needs no configuration, and refusing a signed-in user the ability to change their own password is not a sensible default. Off is a real configuration for apps whose passwords live in an identity provider, so `tests/DisabledFeatureTestCase` boots with a flag forced off: routes register during boot, so a `config()` call inside a test is too late to un-register one. It lives in tests/Isolated because Pest binds tests/Feature to the default TestCase.
Two reviews; no Critical or High, and the core control held under adversarial probing. What follows is what didn't. CHPW-3 (Low, reproduced) — a successful change released only the `confirm` counter, not `login`. So a user who was being brute-forced, noticed, and did exactly the right thing was still locked out of login on every other device; with release_after at 0 that is permanent, and the only escape was the reset email this endpoint exists to avoid. Verified before and after: change → 200, login with the NEW password → 423, lock row still present. Now releases both, which is what the reset path already did and for the same stated reason. CHPW-1 (Low) — a token with no `fid` skipped revocation entirely and still returned "password-changed": every session the user believed they had just killed stayed live, and nothing said so. Reachable without an attacker in the verify-only topology, where a co-issuer shares the secret. Such a token was not minted by this package's session flow, so there is no session of the caller's among those rows to protect — it now revokes all of them. Fail closed. CHPW-4 (Low) — a NUL byte in a new password made `Hash::make` throw, surfacing as a 500. Nothing in the rule set rejected one. Fixed across change, reset AND register, which all shared the hole. CHPW-2 / CP-01 — the test for the no-`fid` branch asserted nothing and passed on a 500. `withoutMiddleware()` stripped `auth:api` so the request TypeError'd before reaching the action; `withToken()` meant `fid` would have been present anyway; and the assertion counted rows, which revocation never changes because it is a soft update. It would have passed with the feature deleted. That is the fourth test this session to pass while exercising nothing, so both replacements were mutation-tested rather than trusted: reverting each fix makes its test fail (`2 is not 0` for the sweep; 423-instead-of-200 for the login lock). The no-`fid` case now mints a token by hand, since the issuer always stamps one — that being the entire point of the branch. CP-02 — `DisabledFeatureTestCase::$disable` was a mutable static written at file load and read at boot. PHPUnit loads every file before running anything, so a second test file would have silently won for the whole run. The reviewer proved it by adding one. Now an abstract method with a subclass per configuration, which is the idiom the rest of the suite already uses and is safe under --parallel. CP-03 — `throwLocked` had reached four near-identical copies of one policy: the 423, the two message branches, the ceil() math, the translation key. Extracted to `Actions\Concerns\ThrowsWhenLocked`. The guard is an abstract method rather than an assumed `$guard` property, because AttemptLogin derives its guard from the rate limiter and an implicit property would have fataled there. CP-06 — the `fid` extraction was triplicated and this copy had dropped the `!== null` guard its siblings carry. Extracted to `ResolvesCurrentFamily`. Also: unused import (CP-05); dropped the lone `env()` in the features block, which was undocumented and unlike every neighbour (CP-07); documented that the action requires an Eloquent model, since `forceFill` is not on the contract (CP-08); `no-store` and validation-shape tests, both absent (CP-04, CP-09); and corrected the config comment and CHANGELOG, which described the sweep unconditionally.
A reviewer flagged `PasswordController` for returning raw JSON instead of a
Response contract, citing CLAUDE.md — and the citation was accurate. The doc says
"Controllers are thin (run an Action, return a Response contract)", which is not
what the codebase does: 10 controllers return raw JSON and 8 return a contract.
The rule the code actually follows is about the body, not the layer. A contract
exists where the body is a swap seam — it carries a token pair, a user resource,
or branches on the request (EmailVerificationResponse is 204 for a JSON client
and a redirect for a browser). Fixed acknowledgements and data reads return JSON
directly, because there is nothing for a consumer to swap.
`{"status": "password-changed"}` is the same shape as reset-password's
`{"status": "password-reset"}` and forgot-password's
`{"status": "password-reset-link-sent"}`, both of which are raw. Adding a
contract would make it the only one wrapping a bare ack and would diverge from
the two endpoints it sits beside.
No code change. Documenting the actual rule so the next reviewer doesn't read
the summary and file the same defect — which has now happened more than once.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
POST /auth/password— the counterpart to password reset, without the email round-trip. Re-verifies the current password, appliesPassword::defaults()to the new one, revokes every other session, firesEvents\PasswordChanged.Closes the first Fortify-parity item on the roadmap.
Why it asks for the current password
This is the whole security story, and it's worth being explicit about: a stolen access token must not be enough to take an account over permanently.
An attacker with a token already has the account until it expires. If that token could also change the password, they'd have it forever — and the owner would be locked out of their own recovery flow. Requiring the existing secret means the token alone isn't enough.
One budget, not two
Because it verifies the same secret as login and step-up, it runs on the same throttle (
lukk-confirm) and the sameconfirmlockout counter — not a second set. Two independent allowances for guessing one password is just a larger allowance.A success clears the counter (those failures were against a password that no longer exists), and the attempt is reserved before the credential check, like every other password path, so concurrency can't overrun the cap.
Which session survives
Every other session is revoked; the one it was done from lives. Changing a password is what someone does when they suspect another party is in the account, so leaving those alive defeats the point — but logging the user out of the tab they just used is a bad answer to a good instinct.
The session to keep is read from the caller's own verified token, not the request body, so it can't be aimed at someone else's session to spare it from the sweep. Revocation denylists before revoking, so the other sessions' access tokens die immediately rather than at TTL.
Details worth flagging
features.change_password), likelogout_all: needs no configuration, and refusing a signed-in user the ability to change their own password isn't a sensible default. Off is a real configuration for apps whose passwords live in an identity provider.config()call inside a test is too late to un-register one.tests/DisabledFeatureTestCaseboots with flags forced off, and lives intests/Isolatedbecause Pest bindstests/Featureto the defaultTestCase.lukk-nuxthas no composable for this yet — the docs show theuseLukkFetchcall to use meanwhile.Verification
341 passed (897 assertions), 100% coverage, Pint clean. Docs in stsepelin/lukk-docs#9.Greptile Summary
The PR adds an authenticated password-change endpoint that verifies the current password, applies the default password policy, shares confirmation throttling and lockout state, and revokes other token families.
Confidence Score: 4/5
The PR appears safe to merge, with a non-blocking response-customization inconsistency in the new controller.
The password-change behavior is comprehensively implemented and tested, but its success response is hard-coded instead of using the package's required response-contract extension point.
Files Needing Attention: src/Http/Controllers/PasswordController.php
Important Files Changed
Sequence Diagram
sequenceDiagram participant Client participant Guard participant Controller participant Action participant Provider as UserProvider participant Sessions as SessionRepository Client->>Guard: POST /auth/password + bearer token Guard->>Controller: Authenticated request Controller->>Controller: Verify token and read family ID Controller->>Action: User, current password, new password, family ID Action->>Provider: Validate current credentials Provider-->>Action: Valid Action->>Action: Hash and persist new password Action->>Sessions: Revoke all other token families Action-->>Controller: Dispatch PasswordChanged Controller-->>Client: password-changedPrompt To Fix All With AI
Reviews (1): Last reviewed commit: "feat: change password while signed in" | Re-trigger Greptile
Context used: