Repository navigation
fix(auth): refuse MFA setup and enable while MFA is enabled - #508
Merged
Merged
Conversation
A live session plus the password could replace an enabled authenticator: MFASetup never checked MFAEnabled and MFAEnable overwrote the active secret and every recovery code, so the re-enrol path skipped the current factor that MFADisable demands. Setup and enable now return ErrMFAAlreadyEnabled (409) while MFA is on; replacing a factor goes through MFADisable with a current TOTP or recovery code first. The setup, enable and password-only cancel writes become column-scoped compare-and-swap writes (mfa_enabled = false, and for enable the exact pending secret that was verified), returning ErrUserChanged (409) when a concurrent enrollment won. Rejecting an expired enrollment no longer writes the stale row back. Refs #227
Contributor
|
Warning Review limit reached
This review includes 11 billable files and costs up to $2.75.
Reviews can continue after your included limit without a manual trigger. An admin must approve usage-based billing. Or wait 12 minutes for your next included review. View limit detailsLimit details: You’ve used all 2 included reviews currently available. Your 50 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 (11)
Comment |
Add a read-barrier subtest where MFA is turned on with the victim's secret between the service read and the write while the verified pending secret is still stored. Only the mfa_enabled = false predicate refuses that write; removing it now fails the suite. Refs #227
This was referenced Oct 5, 2026
Closed
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
A live session plus the password could replace an enabled authenticator.
MFASetupnever checkedMFAEnabled, andMFAEnableoverwrote the active secret and every recovery code. Disable, by contrast, demands a current TOTP or recovery code.MFASetupandMFAEnablereturnErrMFAAlreadyEnabled(HTTP 409) while MFA is on. Setup checks the password first, so a wrong password still gets the existing 400.disableMFAAlreadyOff) are now column-scoped compare-and-swap writes instore_postgres_credentials.go, following theUpdateUserCredentials/ErrUserChangedpattern:SetPendingMFASecretwrites only whilemfa_enabled = false.EnableMFAwrites only whilemfa_enabled = false AND mfa_pending_secret = <the secret whose TOTP was verified>.ClearPendingMFASecretwrites only whilemfa_enabled = false.A lost race returns
ErrUserChanged(409, mapped inmapMFAServiceError). Storage errors still surface as 500s.Policy and where it comes from
Option A: no setup or enable while MFA is enabled. To replace a factor, the user disables MFA first with the password plus a current TOTP or recovery code (unchanged), then enrolls again. A user who has lost their authenticator uses a recovery code to disable. No admin MFA-reset path exists, and this PR adds none. This is the shape in the reviewed #227 lifecycle plan that the owner's issue comment refers to. It is also the issue's second suggested fix. The UI already hides "Set up" when
mfa_enabledis true. A stale tab displays the server's 409 message through the existing flow error element, so the frontend needs no change.Verification
Commands:
GOTOOLCHAIN=go1.26.6 GOWORK=off GOFLAGS='-p=2 -count=1' AWS_EC2_METADATA_DISABLED=true, run on macOS with testcontainers PG16.internal/auth/service_mfa_db_test.go(integration tag, real Postgres, uses the existingcredentialReadBarrierread-barrier fixture):Expected error with "MFA is already enabled; ..." in chain but got nil. In the expired case the stored row ends up withMFAEnabled:false, MFASecret:"", so the victim's factor is erased. The control passes.internal/authintegration suite: 913 PASS, 0 FAIL, 0 SKIP.mfa_enabled = falsefromSetPendingMFASecretfailsstale-setup-...EnableMFAfailsstale-enable-after-concurrent-setupMFASetupfails the error-class assertions; the CAS still keeps the row safego build ./...,go vet(with and without-tags integration), and golangci-lint (with and without the integration tag) on auth/api/server/mocks: clean. Unit tests for auth, api, server and mocks: pass. Handler testTestHandler_mfaSetupAndEnable_ConflictsAre409covers the HTTP mapping.TestIntegration_LoginLockoutExpirysometimes fails locally on a sub-millisecond Go-vs-container clock skew. It reproduces on unmodified main (-count=5) and this PR does not touch it.Deliberately left out
mfa_enabled = falseCAS. Reviewers can push back on this.service_mfa.goMFADisableenabled branch), recovery-code regeneration, and adminUpdateUser: these stay with sec(auth): stale whole-row user writes can still erase MFA fields #493.Refs #227rather than closing it, because sec(auth): stale whole-row user writes can still erase MFA fields #493's residual stale writers can still undo an enrollment.Refs #227