Skip to content

Commit 79ad7b2

Browse files
authored
fix(mfa): ignore verify-disabled factors in recovery codes guard (#2824)
When a user has a second factor enrolled but verification has been disabled at the configuration level, we should not allow the enrollment of recovery codes as that would make the recovery codes the only usable second factor.
1 parent 2e9ce6c commit 79ad7b2

4 files changed

Lines changed: 123 additions & 4 deletions

File tree

‎internal/api/mfa.go‎

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -134,9 +134,30 @@ func validateFactors(db *storage.Connection, user *models.User, newFactorName st
134134
return nil
135135
}
136136

137-
func hasVerifiedNonRecoveryFactor(factors []models.Factor, unenrollingID uuid.UUID) bool {
137+
func isFactorTypeVerifyEnabled(config *conf.GlobalConfiguration, factorType string) bool {
138+
switch factorType {
139+
case models.TOTP:
140+
return config.MFA.TOTP.VerifyEnabled
141+
case models.Phone:
142+
return config.MFA.Phone.VerifyEnabled
143+
case models.WebAuthn:
144+
return config.MFA.WebAuthn.VerifyEnabled
145+
case models.RecoveryCode:
146+
return config.MFA.RecoveryCodes.VerifyEnabled
147+
default:
148+
return false
149+
}
150+
}
151+
152+
// hasUsableNonRecoveryFactor reports whether the user has a second factor,
153+
// other than recovery codes, that can actually be used at sign-in (verified and enabled in config).
154+
func hasUsableNonRecoveryFactor(config *conf.GlobalConfiguration, factors []models.Factor, excludeID uuid.UUID) bool {
138155
for _, f := range factors {
139-
if f.ID != unenrollingID && f.IsVerified() && !f.IsRecoveryCodeFactor() {
156+
if f.ID == excludeID || !f.IsVerified() || f.IsRecoveryCodeFactor() {
157+
continue
158+
}
159+
160+
if isFactorTypeVerifyEnabled(config, f.FactorType) {
140161
return true
141162
}
142163
}
@@ -1066,7 +1087,7 @@ func (a *API) UnenrollFactor(w http.ResponseWriter, r *http.Request) error {
10661087
return apierrors.NewInternalServerError("Database error loading factors").WithInternalError(terr)
10671088
}
10681089

1069-
if !hasVerifiedNonRecoveryFactor(user.Factors, factor.ID) {
1090+
if !hasUsableNonRecoveryFactor(config, user.Factors, factor.ID) {
10701091
return apierrors.NewUnprocessableEntityError(apierrors.ErrorCodeMFARecoveryCodesSoleFactor, "Recovery codes cannot be the only verified factor. Please enroll another factor before unenrolling this one or delete your recovery codes.")
10711092
}
10721093
} else if !models.IsNotFoundError(terr) {

‎internal/api/mfa_test.go‎

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,57 @@ func TestMFA(t *testing.T) {
5454
suite.Run(t, ts)
5555
}
5656

57+
func TestIsFactorTypeVerifyEnabled(t *testing.T) {
58+
config := &conf.GlobalConfiguration{}
59+
config.MFA.TOTP.VerifyEnabled = true
60+
config.MFA.Phone.VerifyEnabled = false
61+
config.MFA.WebAuthn.VerifyEnabled = true
62+
config.MFA.RecoveryCodes.VerifyEnabled = false
63+
64+
cases := map[string]bool{
65+
models.TOTP: true,
66+
models.Phone: false,
67+
models.WebAuthn: true,
68+
models.RecoveryCode: false,
69+
"unknown": false,
70+
}
71+
for factorType, want := range cases {
72+
require.Equal(t, want, isFactorTypeVerifyEnabled(config, factorType), factorType)
73+
}
74+
}
75+
76+
func TestHasUsableNonRecoveryFactor(t *testing.T) {
77+
config := &conf.GlobalConfiguration{}
78+
config.MFA.TOTP.VerifyEnabled = true
79+
config.MFA.Phone.VerifyEnabled = false
80+
config.MFA.RecoveryCodes.VerifyEnabled = true
81+
82+
factor := func(factorType string, state models.FactorState) models.Factor {
83+
return models.Factor{ID: uuid.Must(uuid.NewV4()), FactorType: factorType, Status: state.String()}
84+
}
85+
verifiedTOTP := factor(models.TOTP, models.FactorStateVerified)
86+
87+
cases := []struct {
88+
name string
89+
factors []models.Factor
90+
exclude uuid.UUID
91+
want bool
92+
}{
93+
{name: "no factors", want: false},
94+
{name: "verified factor of a verify-enabled type", factors: []models.Factor{verifiedTOTP}, want: true},
95+
{name: "unverified factor", factors: []models.Factor{factor(models.TOTP, models.FactorStateUnverified)}, want: false},
96+
{name: "verified factor of a verify-disabled type", factors: []models.Factor{factor(models.Phone, models.FactorStateVerified)}, want: false},
97+
{name: "recovery-code factor never counts even when verify is enabled", factors: []models.Factor{factor(models.RecoveryCode, models.FactorStateVerified)}, want: false},
98+
{name: "excluded factor is skipped", factors: []models.Factor{verifiedTOTP}, exclude: verifiedTOTP.ID, want: false},
99+
{name: "another usable factor besides the excluded one", factors: []models.Factor{verifiedTOTP, factor(models.TOTP, models.FactorStateVerified)}, exclude: verifiedTOTP.ID, want: true},
100+
}
101+
for _, tc := range cases {
102+
t.Run(tc.name, func(t *testing.T) {
103+
require.Equal(t, tc.want, hasUsableNonRecoveryFactor(config, tc.factors, tc.exclude))
104+
})
105+
}
106+
}
107+
57108
func (ts *MFATestSuite) SetupTest() {
58109
models.TruncateAll(ts.API.db)
59110

‎internal/api/recovery_codes.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,7 @@ func (a *API) RecoveryCodesGenerate(w http.ResponseWriter, r *http.Request) erro
147147
}
148148

149149
// Recovery codes can never be the user's only second factor.
150-
if !hasVerifiedNonRecoveryFactor(user.Factors, uuid.Nil) {
150+
if !hasUsableNonRecoveryFactor(config, user.Factors, uuid.Nil) {
151151
return apierrors.NewUnprocessableEntityError(apierrors.ErrorCodeMFARecoveryCodesSoleFactor, "At least one other verified factor is required to generate recovery codes")
152152
}
153153

‎internal/api/recovery_codes_test.go‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -320,6 +320,18 @@ func (ts *RecoveryCodesTestSuite) TestRecoveryCodesGenerateSoleFactor() {
320320
ts.requireErrorCode(w, http.StatusUnprocessableEntity, apierrors.ErrorCodeMFARecoveryCodesSoleFactor)
321321
}
322322

323+
func (ts *RecoveryCodesTestSuite) TestRecoveryCodesGenerateVerifyDisabledFactorNotCounted() {
324+
token := ts.aal2Token()
325+
// The verified TOTP factor cannot be used at sign-in once TOTP verification
326+
// is disabled, so recovery codes would become the only usable factor.
327+
prev := ts.Config.MFA.TOTP.VerifyEnabled
328+
defer func() { ts.Config.MFA.TOTP.VerifyEnabled = prev }()
329+
ts.Config.MFA.TOTP.VerifyEnabled = false
330+
331+
w := ts.serveRequest(http.MethodPost, "http://localhost/factors/recovery-codes", token, nil)
332+
ts.requireErrorCode(w, http.StatusUnprocessableEntity, apierrors.ErrorCodeMFARecoveryCodesSoleFactor)
333+
}
334+
323335
func (ts *RecoveryCodesTestSuite) TestRecoveryCodesGenerateAtFactorLimits() {
324336
token := ts.aal2Token()
325337

@@ -1019,6 +1031,15 @@ func (ts *RecoveryCodesTestSuite) createTOTPFactor(friendlyName string, state mo
10191031
return f
10201032
}
10211033

1034+
func (ts *RecoveryCodesTestSuite) createPhoneFactor(friendlyName string, state models.FactorState) *models.Factor {
1035+
f := models.NewPhoneFactor(ts.TestUser, "+15555555555", friendlyName)
1036+
require.NoError(ts.T(), ts.API.db.Create(f))
1037+
if state == models.FactorStateVerified {
1038+
require.NoError(ts.T(), f.UpdateStatus(ts.API.db, models.FactorStateVerified))
1039+
}
1040+
return f
1041+
}
1042+
10221043
// performUnenroll hits the generic DELETE /factors/{factor_id} endpoint.
10231044
func (ts *RecoveryCodesTestSuite) performUnenroll(token string, factorID uuid.UUID) *httptest.ResponseRecorder {
10241045
return ts.serveRequest(http.MethodDelete, fmt.Sprintf("http://localhost/factors/%s", factorID), token, nil)
@@ -1093,6 +1114,32 @@ func (ts *RecoveryCodesTestSuite) TestRecoveryCodesUnenrollOtherSecondFactorAllo
10931114
ts.requireErrorCode(w, http.StatusUnprocessableEntity, apierrors.ErrorCodeMFARecoveryCodesSoleFactor)
10941115
}
10951116

1117+
func (ts *RecoveryCodesTestSuite) TestRecoveryCodesUnenrollVerifyDisabledFactorNotCounted() {
1118+
token := ts.aal2Token()
1119+
ts.performGenerate(token, nil)
1120+
phone := ts.createPhoneFactor("phone_factor", models.FactorStateVerified)
1121+
prev := ts.Config.MFA.Phone.VerifyEnabled
1122+
defer func() { ts.Config.MFA.Phone.VerifyEnabled = prev }()
1123+
1124+
// A verified phone factor does not count while phone verification is disabled,
1125+
// so the TOTP factor is still the last usable second factor.
1126+
ts.Config.MFA.Phone.VerifyEnabled = false
1127+
w := ts.performUnenroll(token, ts.TestFactor.ID)
1128+
ts.requireErrorCode(w, http.StatusUnprocessableEntity, apierrors.ErrorCodeMFARecoveryCodesSoleFactor)
1129+
_, err := models.FindFactorByFactorID(ts.API.db, ts.TestFactor.ID)
1130+
require.NoError(ts.T(), err)
1131+
1132+
// Enabling phone verification makes it a usable second factor and lifts the guard.
1133+
ts.Config.MFA.Phone.VerifyEnabled = true
1134+
w = ts.performUnenroll(token, ts.TestFactor.ID)
1135+
require.Equal(ts.T(), http.StatusOK, w.Code)
1136+
_, err = models.FindFactorByFactorID(ts.API.db, ts.TestFactor.ID)
1137+
require.EqualError(ts.T(), err, models.FactorNotFoundError{}.Error())
1138+
_, err = models.FindFactorByFactorID(ts.API.db, phone.ID)
1139+
require.NoError(ts.T(), err)
1140+
ts.recoveryCodeSetState()
1141+
}
1142+
10961143
func (ts *RecoveryCodesTestSuite) TestRecoveryCodesUnenrollUnverifiedFactorAllowed() {
10971144
token := ts.aal2Token()
10981145
ts.performGenerate(token, nil)

0 commit comments

Comments
 (0)