Repository navigation
fix(auth): answer a deactivated user's reset token like an unknown token - #929
Merged
Merged
Conversation
POST /api/auth/reset-password returned 403 "account is deactivated" for a live, unexpired reset token of an admin-deactivated user, while unknown, used and expired tokens get 400. It was the only response that revealed the deactivated state, and it disagreed with the GET status endpoint, which already reports such a token as "used" (LeanerCloud#421). ConfirmPasswordReset still refuses the reset (A03-006) and still consumes the token, but now returns ErrInvalidResetToken, the same sentinel the unknown-token paths use, so the handler maps it to the same 400 body. ErrAccountDeactivated and its 403 mapping are removed. Closes LeanerCloud#595 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Contributor
|
Note Currently processing new changes in this PR. This may take a few minutes, please wait... ⚙️ Run configuration
📒 Files selected for processing: 7
✨ Finishing Touches
Comment |
cristim
reviewed
Oct 16, 2026
cristim
left a comment
Member
There was a problem hiding this comment.
Independent review of 6d20289: draft: blocks ready
No blocking correctness or security findings in the diff. Remaining blocks are gate items only.
Findings
- Nit, pre-existing, out of scope: an expired token answers 400 "reset token has expired" (service_password.go:533), while unknown/used/deactivated answer 400 "invalid or expired reset token". Expiry vs unknown is therefore distinguishable, as it was before this PR. Deactivated is now indistinguishable from unknown, which is what #595 asks for. Optional follow-up.
- Nit: a deactivated live token runs one extra DB write (ConsumePasswordResetToken, service_password.go:398) that an unknown token does not. This is a theoretical timing signal for someone who already holds a live 256-bit token; no practical exposure. Consuming the token is the right call (no replay after reactivation).
What I attacked
- Status/body/headers: new HandleRequest test (internal/server/reset_token_status_deactivated_test.go) compares deactivated vs unknown on status, headers and JSON body, and asserts the body lacks "deactivated". Handler maps the error via isResetPasswordClientError substring "invalid or expired reset token" (handler_auth.go:304), so 400 is retained.
- Fail-before: restored the 3 production files from origin/main with the new test; it fails (403, body "account is deactivated"). Passes at HEAD.
- Consumption and replay: deactivated path still calls ConsumePasswordResetToken before returning (service_password.go:397-401), so the token cannot be replayed after reactivation. Password write is unreachable: the return precedes processPasswordReset/CompletePasswordReset; the mock fails on any other store call.
- Other ErrAccountDeactivated callers: grep over the tree finds none left (only the removed handler branch). Status endpoint already reports "used" for deactivated (service_password.go:503-508); forgot-password already answers identically (service_password.go:324-327). Login was not changed here and was not in #595 scope.
- go test ./internal/auth/... ./internal/api/... ./internal/server/...: 4587 passed.
- One mutation probe attempt (drop the consume call) was malformed and reverted without running; not claimed as evidence. Worktree clean.
Gate status
- Independent review: this comment, on the SHA above.
- Findings resolved: none actionable.
- CI on SHA: pending when checked (E2E, pre-commit, terraform checks pass; Unit Tests, Lint, Integration, Security still pending).
- Labels: match #595 (triaged, priority/p3, severity/low, urgency/eventually, impact/few, effort/xs, type/bug).
- Local real-path evidence: the HandleRequest test above with mocked store, fail-before/pass-after shown; the PR itself does not yet record this, so this comment is the record.
- PR is a draft from an external contributor.
Blocks ready
- CI green on this SHA (Unit Tests, Lint Code, Integration Tests, Security Scanning still pending).
- Mark the PR ready for review (currently draft).
Member
|
Gate record for 6d20289 (origin/main CLAUDE.md "Review gate"):
Marking ready (owner-authorized) and merging at 6d20289 with --match-head-commit. |
cristim
marked this pull request as ready for review
October 16, 2026 02:50
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.
Root cause
ConfirmPasswordReset(internal/auth/service_password.go) returnedErrAccountDeactivatedfor a live, unexpired reset token of an admin-deactivated user, andresetPassword(internal/api/handler_auth.go) mapped it to 403{"error":"account is deactivated"}. Unknown and used tokens get 400{"error":"invalid or expired reset token"}. That made POST the only place the deactivated state could be seen, and it disagreed with GET/api/auth/reset-password/status, which since #592 reports the same token asused.Fix
auth.ErrInvalidResetToken("invalid or expired reset token").validateResetTokennow returns it for the three unknown/mismatch cases; the message is unchanged.ConfirmPasswordResetreturns that same sentinel. The check itself is unchanged: the reset is still refused before any password write or reactivation (A03-006).used, the same as for an unknown token, so GET and POST now agree.ErrAccountDeactivatedand the handler's 403 branch. The sentinel goes through the existingisResetPasswordClientErrorpath and comes back as a 400 with a body identical to an unknown token's.Closes #595
Verification
All evidence is from unit, mock-store or testcontainers runs. No real cloud, email or deploy was involved.
Regression test:
TestHandleRequest_ConfirmPasswordReset_DeactivatedUserLooksLikeUnknownTokenininternal/server/reset_token_status_deactivated_test.go. It goes throughHandler.HandleRequestinto the realauth.Servicewith only the store mocked. It replaces the old test that asserted 403. It sends POST with a deactivated user's live token and with an unknown token, then checks that status, headers and JSON body are equal (400), and that the body does not contain "deactivated". The mock expectations check that the token is consumed and that nothing else is written to the store, so there is no password write.Fail-before (fix stashed, new test kept):
Pass-after:
ok internal/server.Updated tests:
internal/auth/service_password_test.go: the deactivated-account case now expectsErrInvalidResetTokenwith the exact unknown-token message. It still checks thatActivestays false, the password hash is unchanged and the token is consumed.internal/api/handler_auth_test.go:TestHandler_resetPassword_AccountDeactivated(403) is replaced byTestHandler_resetPassword_InvalidResetToken, which expects 400 and the exact message.internal/auth/service_credentials_db_test.go(integration): the replay after a concurrent deactivation now expectsErrInvalidResetToken.Commands. The
/dev/nullAWS env vars are there only because this machine's default~/.aws/configprofile is invalid.git-secretsis not installed here, so that hook was not run.Out of scope / notes
ConsumePasswordResetToken) that the unknown-token path does not, so there is a small timing difference. Exploiting it needs a live 256-bit token, so I left it alone.ErrUserChanged(sec(auth): stale whole-row user writes can still erase MFA fields #493). That is a race-only signal and is not changed here; a replay afterwards gets the generic 400.internal/auth/service_password.go(594 lines) andinternal/api/handler_auth.go(664 lines) were already over the 500-line guideline. This PR changes them by +2 and -3 lines.Maintainer: please mirror the issue's triage labels onto this PR:
triaged,priority/p3,severity/low,urgency/eventually,impact/few,effort/xs,type/bug.🤖 Generated with Claude Code