fix(auth): scope profile, password and reset writes to their own columns - #487
Merged
Merged
Conversation
UpdateUserProfile, ChangePassword and RequestPasswordReset read a whole user row, changed a few fields and wrote the whole row back with UpdateUser. An MFA enrollment that committed between the read and the write was erased, so an attacker holding a session and the password (or, for the reset request, only the email) could put a freshly enrolled account back to MFA-disabled. Profile and password changes now persist through UpdateUserCredentials, which writes only email, password hash, salt and history, and only while the stored email and hash still match what the service read. Reset issuance goes through SetPasswordResetToken, which writes only the token columns and requires the account to still be active under the same email with an unchanged reset expiry. A lost race returns ErrUserChanged (409 at the API) and skips session revocation and the password-change hook; a lost reset race answers like any ineligible account and sends no mail. Closes #474 Refs #227
Contributor
|
Warning Review limit reached
This review includes 16 billable files and costs up to $4.00.
Reviews can continue after your included limit without a manual trigger. An admin must approve usage-based billing. Or wait 55 minutes for your next included review. View limit detailsLimit details: You’ve used the included review currently available. Your 60 included PR review attempts over the past 7 days set your current allowance at 1 review per hour. Review configuration: ⚙️ Run configuration
📒 Files selected for processing (16)
Comment |
Member
Author
|
@coderabbitai full review |
Contributor
|
SetPasswordResetToken only stores a token while the row still has the email the reset request read. Replacing the predicate with $2::text IS NOT NULL left every subtest green. Add a subtest that changes the email from the read barrier and asserts no token is stored or mailed to the old address.
This was referenced Oct 5, 2026
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.
What
UpdateUserProfile,ChangePasswordandRequestPasswordResetread a whole user row, changed a few fields, then wrote the entire row back throughUpdateUser(WHERE idonly). If MFA enrollment committed between the read and the write, the write put backmfa_enabled=falseand the old MFA fields. The three schedules in the issue (profile update, password change, public reset request) are all reachable.How
PostgresStore.UpdateUserCredentials(user, readEmail, readPasswordHash)writes onlyemail,password_hash,salt,password_historyandupdated_at, and onlyWHERE id AND email = readEmail AND password_hash = readPasswordHash. Profile updates (email-only or with a new password) andChangePasswordboth use this one writer. Including the hash in the predicate also covers two concurrent password changes, so the history is never computed from a stale hash.PostgresStore.SetPasswordResetToken(user, readExpiry)writes only the token, its expiry andupdated_at,WHERE id AND email AND deactivated_at IS NULL AND password_reset_expiry IS NOT DISTINCT FROM readExpiry. A concurrent deactivation, email change or second reset issuance therefore wins.auth.ErrUserChanged. The profile and password handlers map it to 409. Sessions are not revoked and the password-change hook does not run. A reset request that loses the race returns the same uniform success and sends no mail.Verification
Tests run against a real PostgreSQL 16 in the repo's testcontainers harness (
setupAuthTestDB), with synthetic accounts and a recording mail sink.internal/auth/service_credentials_db_test.gouses a one-shot read barrier: it pauses each service call after its user read, completes the competing action through a second, realauth.Service, resumes the call, and then reads fresh SQL state.ErrMFARequired, and login with the victim's TOTP succeeds. On the password paths the old password is rejected, the history holds the previous hash and the existing sessions are revoked. A later reissue over an expired token still succeeds, which covers the expiry round-trip.ErrUserChanged, the winner's row is untouched and the winner's session stays valid. A concurrent deactivation or reset makes the losing reset request store nothing and send no mail.Before the fix. These tests ran against the parent service code, which writes the whole row through
UpdateUser. All 8 subtests failed:After the fix:
TestHandler_credentialWritesMapConcurrentChangeToConflictchecks the 409 mapping. It fails when the handler change is reverted.Commands run on macOS (
GOTOOLCHAIN=go1.26.6 GOWORK=off GOFLAGS='-p=2 -count=1' AWS_EC2_METADATA_DISABLED=true):go build ./...,go vet ./...andgo vet -tags integrationon auth, api, server and mocksgo teston./internal/auth/... ./internal/api/... ./internal/server/... ./internal/mocks/...go test -tags integration ./internal/auth/(the full auth integration suite)golangci-lint run --build-tags integrationon the touched packages, plus the pre-commit hooks, including gocycloThe tests drive the service directly. The HTTP handlers forward to these service calls unchanged, and the real
auth.Servicereaches the API only throughinternal/server's adapter, so no API-package test uses it. The handler-level evidence is therefore the mock-based 409 test.Scope notes for reviewers
MFASetup,MFAEnable,MFADisable, regenerate). Onmain,MFASetupon an account that already has MFA enabled is still accepted, so the issue's "attacker setup is denied" assertion depends on sec(auth): MFA can be replaced with a session and password alone #227. These tests do not assert it. sec(auth): MFA can be replaced with a session and password alone #227 should stay open.UpdateUser, outside this issue's three paths:ConfirmPasswordReset(needs a valid reset token), adminUpdateUser(service_user.go), and recovery-code consumption at login (service.go). They may need the same treatment in a follow-up.ChangePassworderrors other thanErrUserChangedstill go to the 500 path, as before.Closes #474