Skip to content

feat(mfa): add recovery codes unenroll guards - #2787

Open
fadymak wants to merge 1 commit into
masterfrom
fm/auth-1535
Open

feat(mfa): add recovery codes unenroll guards#2787
fadymak wants to merge 1 commit into
masterfrom
fm/auth-1535

Conversation

@fadymak

@fadymak fadymak commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Adds guards to:

  • Prevent unenrolling last verified non-recovery factor when a user still has a recovery code factor
  • Disallow the use of DELETE /factors/{id} endpoint for unenrolling recovery codes
    • We may choose to relax this in the future and unify the logic

@fadymak
fadymak requested a review from a team as a code owner September 4, 2026 07:20
Comment thread internal/api/mfa.go
Comment on lines 1059 to +1062
var terr error

// Recovery codes can never be a user's only second factor.
if factor.IsVerified() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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() {

@xlgmokha xlgmokha left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

:shipit:

Comment thread internal/api/mfa.go
}

if factor.IsRecoveryCodeFactor() {
return apierrors.NewUnprocessableEntityError(apierrors.ErrorCodeValidationFailed, "Recovery codes cannot be unenrolled with this endpoint, use DELETE /factors/recovery-codes")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants