Skip to content

feat: change password while signed in - #27

Merged
stsepelin merged 3 commits into
mainfrom
feat/change-password
Aug 21, 2026
Merged

stsepelin merged 3 commits into
mainfrom
feat/change-password

Conversation

@stsepelin

@stsepelin stsepelin commented Aug 21, 2026 •

Copy link
Copy Markdown
Owner

POST /auth/password — the counterpart to password reset, without the email round-trip. Re-verifies the current password, applies Password::defaults() to the new one, revokes every other session, fires Events\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 same confirm lockout 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

  • The new password must differ from the current one — a no-op would report success for a change that didn't happen, having revoked every other session for nothing.
  • On by default (features.change_password), like logout_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.
  • Testing "off" needed a new harness: routes register during boot, so a config() call inside a test is too late to un-register one. tests/DisabledFeatureTestCase boots with flags forced off, and lives in tests/Isolated because Pest binds tests/Feature to the default TestCase.
  • lukk-nuxt has no composable for this yet — the docs show the useLukkFetch call 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.

  • Adds the ChangePassword action, request validation, event, controller, route, feature flag, and service binding.
  • Adds coverage for authentication, validation, throttling, lockout handling, session revocation, event dispatch, and feature disabling.
  • Documents the new default-enabled route and upgrade considerations.

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

Filename Overview
src/Actions/ChangePassword.php Implements current-password verification, lockout accounting, password persistence, other-session revocation, and event dispatch.
src/Http/Controllers/PasswordController.php Extracts the caller's token-family claim and invokes the action, but bypasses the repository's configurable response-contract convention.
src/Http/Requests/ChangePasswordRequest.php Applies confirmation, difference, length, and default password-policy validation.
src/routes/api.php Registers the feature-gated authenticated endpoint on the shared confirmation throttle.
tests/Feature/ChangePasswordTest.php Covers the primary password-change, security, lockout, throttling, revocation, and event behaviors.
tests/DisabledFeatureTestCase.php Provides a pre-boot feature-disabling harness for route-registration tests.

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-changed
Loading

Fix all with Greploop Fix All in Claude Code

Prompt To Fix All With AI
### Issue 1
src/Http/Controllers/PasswordController.php:39
**Hard-coded success response**

This controller constructs a raw `JsonResponse` instead of returning a response contract, preventing consumers from customizing the new endpoint's successful response through the package's established extension seam.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Reviews (1): Last reviewed commit: "feat: change password while signed in" | Re-trigger Greptile

Greptile also left 1 inline comment on this PR.

Context used:

  • Context used - CLAUDE.md (source)

`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.
Comment thread src/Http/Controllers/PasswordController.php
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.
@stsepelin
stsepelin merged commit de46aba into main Aug 21, 2026
13 checks passed
@stsepelin
stsepelin deleted the feat/change-password branch August 21, 2026 13:42
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.

1 participant