fix(auth): compare-and-swap writes for reset confirm and recovery-code login - #496
Conversation
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
|
Navigate logical layers of code changes, visualize relationships, and explore their blast radius. Warning Review limit reached
This review includes 1 billable file and costs up to $0.25.
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. View limit detailsLimit 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. Review configuration: ⚙️ Run configuration
📒 Files selected for processing (1)
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configuration
📒 Files selected for processing (14)
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. 📝 WalkthroughWalkthroughThe 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. ChangesCredential update flows
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
Merge Risk: ⚪ Minimal · up to 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)
✅ Passed checks (4 passed)
✨ Finishing Touches 💡 1📝 Generate docstrings 💡
🧪 Generate unit tests (beta)
Comment |
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
What
Two of the whole-row user writers listed in #493 now use compare-and-swap column writes, following the
UpdateUserCredentialspattern from #487:ConfirmPasswordReset(internal/auth/service_password.go). NewCompletePasswordResetwrites onlypassword_hash,salt,password_history,activeand the reset-token columns, and only while the row still holds the token and password hash that were read anddeactivated_at IS NULL. The token-only consume on the deactivated and rejected-password paths goes through newConsumePasswordResetToken, which is guarded by the same token. A lost race returnsErrUserChanged, andPOST /api/auth/reset-passwordmaps it to 409.internal/auth/service.go). NewConsumeMFARecoveryCodewrites onlymfa_recovery_codes, and only while the row still holds the codes that were read. A lost race fails the login withErrInvalidMFACode, which is what a failed persist already returns.Why
On main, each of these races replays a stale snapshot over a concurrent change:
activeanddeactivated_atrestored from the snapshot, the A03-006 bypass)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 wrapsGetUserByResetToken):TestIntegration_ResetConfirmPreservesConcurrentMFA,TestIntegration_ResetConfirmRejectsStaleReadandTestIntegration_RecoveryCodeLoginRejectsStaleRead.Before the fix (service changes reverted, tests and store methods kept), all 9 subtests fail:
After the fix, all pass. Mutation check: replacing the
password_reset_token = $2predicate inCompletePasswordResetand themfa_recovery_codes = $2predicate inConsumeMFARecoveryCodewith always-true conditions failsafter-concurrent-rejected-confirmandafter-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/...: cleango test -tags integration ./internal/auth/(full suite):okgo test ./...: no failuresgolangci-lint runon auth/api/server/mocks, with and without--build-tags integration: no issuesThis 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)
UpdateUser(internal/auth/service_user.go) is still a whole-row write.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.ErrInvalidMFACodeand has to retry. Its code was not consumed.validateResetToken), not in theCompletePasswordResetWHERE 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