Skip to content

fix(auth): compare-and-swap writes for reset confirm and recovery-code login - #496

Merged
cristim merged 3 commits into
mainfrom
fix/password-reset-confirm-narrow-write
Oct 5, 2026
Merged

cristim merged 3 commits into
mainfrom
fix/password-reset-confirm-narrow-write

Conversation

@cristim

@cristim cristim commented Oct 5, 2026 •

Copy link
Copy Markdown
Member

What

Two of the whole-row user writers listed in #493 now use compare-and-swap column writes, following the UpdateUserCredentials pattern from #487:

  • ConfirmPasswordReset (internal/auth/service_password.go). New CompletePasswordReset writes only password_hash, salt, password_history, active and the reset-token columns, and only while the row still holds the token and password hash that were read and deactivated_at IS NULL. The token-only consume on the deactivated and rejected-password paths goes through new ConsumePasswordResetToken, which is guarded by the same token. A lost race returns ErrUserChanged, and POST /api/auth/reset-password maps it to 409.
  • Recovery-code use at login (internal/auth/service.go). New ConsumeMFARecoveryCode writes only mfa_recovery_codes, and only while the row still holds the codes that were read. A lost race fails the login with ErrInvalidMFACode, which is what a failed persist already returns.

Why

On main, each of these races replays a stale snapshot over a concurrent change:

Race (concurrent action lands between read and write) Result on main
MFA enrollment during reset confirm MFA secret and recovery codes erased; the reset-token holder sets a password they know, and the account has no second factor
MFA enrollment during a rejected-password confirm (token-only consume) MFA erased
Same reset token confirmed twice Both succeed, last writer wins
Token consumed by a rejected attempt, then a valid confirm Valid confirm still sets the password
Concurrent password change during reset confirm Overwritten, password history stale
Admin deactivation during reset confirm Deactivation undone (active and deactivated_at restored from the snapshot, the A03-006 bypass)
Same recovery code used by two logins Both logins get a session
Password change during recovery-code login Old password hash restored

One-time token semantics: a successful confirm clears the token in the same statement that sets the password. A lost race does not touch the row, so the token stays usable and a retry re-reads the row: it succeeds, returns "invalid or expired" if the token is gone, or returns ErrAccountDeactivated (which consumes the token) if the account was deactivated.

How verified

Integration tests against Postgres (testcontainers) replay each race with the existing deterministic read barrier (credentialReadBarrier, which now also wraps GetUserByResetToken): TestIntegration_ResetConfirmPreservesConcurrentMFA, TestIntegration_ResetConfirmRejectsStaleRead and TestIntegration_RecoveryCodeLoginRejectsStaleRead.

Before the fix (service changes reverted, tests and store methods kept), all 9 subtests fail:

--- FAIL: TestIntegration_ResetConfirmPreservesConcurrentMFA/confirm
--- FAIL: TestIntegration_ResetConfirmPreservesConcurrentMFA/rejected-password-consumes-token
    Messages: stale write erased the concurrent MFA enrollment
--- FAIL: TestIntegration_ResetConfirmRejectsStaleRead/after-concurrent-confirm
    Messages: a reset token must be spent once
--- FAIL: TestIntegration_ResetConfirmRejectsStaleRead/after-concurrent-rejected-confirm
    Messages: a token consumed by a rejected attempt must not set a password
--- FAIL: TestIntegration_ResetConfirmRejectsStaleRead/after-concurrent-password-change
--- FAIL: TestIntegration_ResetConfirmRejectsStaleRead/after-concurrent-deactivation
--- FAIL: TestIntegration_RecoveryCodeLoginRejectsStaleRead/after-concurrent-use-of-same-code
    Messages: a recovery code must be spent once
--- FAIL: TestIntegration_RecoveryCodeLoginRejectsStaleRead/after-concurrent-password-change
FAIL	github.com/LeanerCloud/cloud-commitments-platform/internal/auth

After the fix, all pass. Mutation check: replacing the password_reset_token = $2 predicate in CompletePasswordReset and the mfa_recovery_codes = $2 predicate in ConsumeMFARecoveryCode with always-true conditions fails after-concurrent-rejected-confirm and after-concurrent-use-of-same-code. The predicates were restored afterwards.

Run with GOTOOLCHAIN=go1.26.6 GOWORK=off GOFLAGS='-p=2 -count=1' AWS_EC2_METADATA_DISABLED=true:

  • go build ./..., go vet ./..., go vet -tags integration ./internal/...: clean
  • go test -tags integration ./internal/auth/ (full suite): ok
  • go test ./...: no failures
  • golangci-lint run on auth/api/server/mocks, with and without --build-tags integration: no issues

This evidence comes from Postgres integration tests run against the service layer. The HTTP 409 mapping is covered by the handler unit test TestHandler_resetPassword_UserChanged, which uses a mocked service. No HTTP-level race test was added.

Left for follow-up (#493 stays open)

  • Admin UpdateUser (internal/auth/service_user.go) is still a whole-row write.
  • MFA lifecycle writes (internal/auth/service_mfa.go) are still whole-row writes. These overlap sec(auth): MFA can be replaced with a session and password alone #227, which first needs a decision on whether MFA setup should be allowed while MFA is already enabled.
  • Login checks the password against the hash it read, so a login that races a password change can still succeed with the old password. The same is true of TOTP and password-only logins, and this PR does not change it. What this PR changes is that the recovery-code write no longer restores the old hash.
  • Two different recovery codes used at the same moment: the CAS loser gets ErrInvalidMFACode and has to retry. Its code was not consumed.
  • An HTTP-level race test, as named in sec(auth): stale whole-row user writes can still erase MFA fields #493's acceptance criteria.
  • Reset-token expiry is still checked only in Go (validateResetToken), not in the CompletePasswordReset WHERE clause. That leaves a window of milliseconds, the same as on main.

Review: an independent Opus review of the two commits found no blockers. Its two nits (a stale comment, and a test comment noting the stale-password login gap) are folded into the commits. The new tests were also confirmed to fail on main's service code in a separate clone.

Refs #493

Summary by CodeRabbit

  • Bug Fixes
    • Password-reset confirmations now return a conflict response when account details change during confirmation, preventing outdated requests from being applied.
    • Concurrent password resets and recovery-code sign-ins are handled more reliably: reset tokens and recovery codes cannot be consumed based on outdated account information, and MFA remains enforced.

ConfirmPasswordReset read the user by reset token, mutated the struct and
saved the whole row with UpdateUser. A write that raced a concurrent
change replayed the stale snapshot over it:

- an MFA enrollment committed after the read was erased, so a reset-token
  holder ended with a password they chose and no second factor;
- the same token could be confirmed twice, last writer winning;
- a concurrent admin deactivation was undone (active and deactivated_at
  restored from the snapshot);
- a concurrent password change was overwritten with stale history.

CompletePasswordReset now writes only the password, salt, history,
active flag and token columns, and only while the row still holds the
token and password hash that were read and the account is not
deactivated. The token-only consume on the deactivated and
rejected-password paths goes through ConsumePasswordResetToken, guarded
by the same token. A lost race returns ErrUserChanged, which the reset
handler maps to 409; the token is left in place, so a retry re-reads the
row.

Integration tests replay each race with the read barrier against
Postgres; all fail on main.

Refs #493
Recovery-code login removed the matched hash from the user struct and
saved the whole row with UpdateUser. Two logins with the same code that
both read the row before either wrote each got a session, and a password
change committed between the read and the write was reverted to the old
hash.

ConsumeMFARecoveryCode writes only mfa_recovery_codes, and only while
the row still holds the codes that were read. A lost race fails the
login with ErrInvalidMFACode, the same answer as a failed persist today.

Refs #493
@coderabbitai

coderabbitai Bot commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

Warning

Review limit reached

  • Run on-demand review

This review includes 1 billable file and costs up to $0.25.

  • Ask an admin to make reviews automatic

Open in CodeRabbit

Reviews can continue after your included limit without a manual trigger. An admin must approve usage-based billing.

Or wait 17 minutes for your next included review.

Check out review usage here.

View limit details

Limit details: You’ve used all 2 included reviews currently available. Your 57 included PR review attempts over the past 7 days set your current allowance at 2 reviews per hour.

Learn how review limits work.

Review configuration:

⚙️ Run configuration
  • Configuration used: Repository: LeanerCloud/cloud-commitments-platform/.coderabbit.yaml
  • Review profile: CHILL
  • Plan: Essentials
  • Run ID: e45f6fb8-0a07-4c5a-aca8-d95756d1bcb3
📥 Commits

Reviewing files that changed from the base of the PR and between 06f1ade and 0e3246c.

📒 Files selected for processing (1)
  • internal/auth/service_credentials_db_test.go

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Repository: LeanerCloud/cloud-commitments-platform/.coderabbit.yaml
  • Review profile: CHILL
  • Plan: Essentials
  • Run ID: 7afa195b-783e-452c-b688-47b1c1c67bc2
📥 Commits

Reviewing files that changed from the base of the PR and between a442002 and 06f1ade.

📒 Files selected for processing (14)
  • internal/api/handler_auth.go
  • internal/api/handler_auth_test.go
  • internal/auth/interfaces.go
  • internal/auth/service.go
  • internal/auth/service_credentials_db_test.go
  • internal/auth/service_mfa_test.go
  • internal/auth/service_password.go
  • internal/auth/service_password_callback_test.go
  • internal/auth/service_password_test.go
  • internal/auth/service_test.go
  • internal/auth/store_postgres_credentials.go
  • internal/auth/test_helpers.go
  • internal/mocks/stores.go
  • internal/server/health_test.go

Included review availability: This review used your included allowance. 0 included reviews remain after this review. Your included PR review attempts over the past 7 days set your current allowance at 2 reviews per hour.


📝 Walkthrough

Walkthrough

The auth store adds conditional operations for password-reset completion, reset-token consumption, and MFA recovery-code consumption. Password-reset and recovery-code flows use these operations. The reset API returns HTTP 409 when a reset fails because the user record changed.

Changes

Credential update flows

Layer / File(s) Summary
Conditional credential store operations
internal/auth/interfaces.go, internal/auth/store_postgres_credentials.go, internal/auth/test_helpers.go, internal/mocks/stores.go, internal/server/health_test.go
The store interface and PostgreSQL store add conditional operations for reset completion, reset-token consumption, and recovery-code consumption. Test doubles implement the new methods.
Password-reset persistence and response
internal/auth/service_password.go, internal/auth/service_credentials_db_test.go, internal/auth/service_password_callback_test.go, internal/auth/service_password_test.go, internal/auth/service_test.go, internal/api/handler_auth.go, internal/api/handler_auth_test.go
Password-reset confirmation uses conditional store operations for successful resets and token consumption. The API maps auth.ErrUserChanged to HTTP 409. Tests cover reset outcomes and competing updates.
Recovery-code consumption
internal/auth/service.go, internal/auth/service_credentials_db_test.go, internal/auth/service_mfa_test.go
Recovery-code login persists the submitted and remaining codes through ConsumeMFARecoveryCode. Tests cover persistence failures and concurrent code use.

Priority: ➖ Normal

Estimated code review effort: 3 (Moderate) | ~25 minutes

Change: Bug fix

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant resetPassword
  participant ConfirmPasswordReset
  participant PostgresStore
  Client->>resetPassword: Submit reset request
  resetPassword->>ConfirmPasswordReset: Confirm reset
  ConfirmPasswordReset->>PostgresStore: CompletePasswordReset with read token and password hash
  PostgresStore-->>ConfirmPasswordReset: ErrUserChanged when no row matches
  ConfirmPasswordReset-->>resetPassword: Return ErrUserChanged
  resetPassword-->>Client: Return HTTP 409
Loading

Merge Risk: ⚪ Minimal · up to 06f1a

Password-reset confirmation and recovery-code login now reject stale-snapshot overwrites. No concrete merge-blocking risk was found; the author lists the remaining follow-ups.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 27.27% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 11 functions across 14 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: compare-and-swap writes for password-reset confirmation and recovery-code login.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Comment @coderabbitai help to get the list of available commands.

@cristim cristim added priority/p1 Next up; this sprint severity/high Significant harm urgency/this-sprint Within the current sprint impact/few Limited audience effort/m Days type/security Security finding triaged Item has been triaged labels Oct 5, 2026
Add a read-barrier subtest where a rejected-password confirm reads token T,
a fresh reset is issued (T2) before its clear runs, and the stored token must
stay T2. Mutating the ConsumePasswordResetToken predicate to
(password_reset_token = $2 OR true) now fails it.

Refs #493
@cristim
cristim merged commit 330619c into main Oct 5, 2026
25 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/m Days impact/few Limited audience priority/p1 Next up; this sprint severity/high Significant harm triaged Item has been triaged type/security Security finding urgency/this-sprint Within the current sprint

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant