feat(mfa): add recovery codes unenroll guards - #2787
Conversation
| var terr error | ||
|
|
||
| // Recovery codes can never be a user's only second factor. | ||
| if factor.IsVerified() { |
There was a problem hiding this comment.
⚪ Severity: LOW
An authenticated caller can submit DELETE /factors/{id} for an unverified factor while another request verifies it. Because this check runs before the transaction, the deletion skips the recovery-code invariant and tx.Destroy(factor) can remove the newly verified last non-recovery factor, leaving recovery codes as the sole factor.
Helpful? Add 👍 / 👎
💡 Fix Suggestion
Suggestion: Inside UnenrollFactor, reload the factor row from the database with a FOR UPDATE lock at the start of the transaction body, before the if factor.IsVerified() guard. This replaces the stale in-memory factor struct (loaded before the transaction) with the current committed state, and holds the row lock until the transaction completes. Any concurrent VerifyFactor call that tries to flip the status will block on the same row lock, preventing the race. Replace lines 1059–1062 with code that issues a raw SELECT ... FOR UPDATE query (following the same pattern as FindRecoveryCodeSetForUpdate / FindFlowStateByIDForUpdate) and then re-evaluates factor.IsVerified() on the freshly-locked row.
⚠️ Experimental Feature: This code suggestion is automatically generated. Please review carefully.
| var terr error | |
| // Recovery codes can never be a user's only second factor. | |
| if factor.IsVerified() { | |
| var terr error | |
| // Reload the factor inside the transaction with a row-level lock to prevent | |
| // a TOCTOU race: a concurrent VerifyFactor request could change the factor | |
| // status between the pre-transaction IsVerified() check and tx.Destroy. | |
| if terr = tx.RawQuery( | |
| "SELECT * FROM \"mfa_factors\" WHERE id = ? LIMIT 1 FOR UPDATE", | |
| factor.ID, | |
| ).First(factor); terr != nil { | |
| if models.IsNotFoundError(terr) { | |
| return apierrors.NewNotFoundError(apierrors.ErrorCodeMFAFactorNotFound, "MFA factor not found") | |
| } | |
| return apierrors.NewInternalServerError("Database error locking factor").WithInternalError(terr) | |
| } | |
| // Recovery codes can never be a user's only second factor. | |
| if factor.IsVerified() { |
| } | ||
|
|
||
| if factor.IsRecoveryCodeFactor() { | ||
| return apierrors.NewUnprocessableEntityError(apierrors.ErrorCodeValidationFailed, "Recovery codes cannot be unenrolled with this endpoint, use DELETE /factors/recovery-codes") |
There was a problem hiding this comment.
praise: I like the helpful error message.
| } | ||
|
|
||
| ts.Run("AdminGetFactors", func() { | ||
| w := ts.serveRequest(http.MethodGet, fmt.Sprintf("http://localhost/admin/users/%s/factors/", ts.TestUser.ID), ts.adminToken(), nil) |
There was a problem hiding this comment.
praise: I like that we're using localhost here.
thought: Another safe option is to use example.com. I noticed that we make live network calls in some of our unit tests. (e.g. login.microsoftonline.com).
Adds guards to:
DELETE /factors/{id}endpoint for unenrolling recovery codes