Skip to content

Commit 435b90e

Browse files
committed
feat(mfa): add recovery code factor model support
1 parent 5b0af70 commit 435b90e

4 files changed

Lines changed: 107 additions & 5 deletions

File tree

internal/models/amr.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ func (AMRClaim) TableName() string {
2222
}
2323

2424
func (cl *AMRClaim) IsAAL2Claim() bool {
25-
return *cl.AuthenticationMethod == TOTPSignIn.String() || *cl.AuthenticationMethod == MFAPhone.String() || *cl.AuthenticationMethod == MFAWebAuthn.String()
25+
return *cl.AuthenticationMethod == TOTPSignIn.String() || *cl.AuthenticationMethod == MFAPhone.String() || *cl.AuthenticationMethod == MFAWebAuthn.String() || *cl.AuthenticationMethod == MFARecoveryCode.String()
2626
}
2727

2828
func AddClaimToSession(tx *storage.Connection, sessionId uuid.UUID, authenticationMethod AuthenticationMethod) error {

internal/models/amr_test.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
package models
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/require"
7+
)
8+
9+
// TestAMRClaimIsAAL2 pins which authentication methods upgrade a session to AAL2.
10+
func TestAMRClaimIsAAL2(t *testing.T) {
11+
for method, want := range map[AuthenticationMethod]bool{
12+
TOTPSignIn: true,
13+
MFAPhone: true,
14+
MFAWebAuthn: true,
15+
MFARecoveryCode: true,
16+
PasswordGrant: false,
17+
OTP: false,
18+
} {
19+
methodString := method.String()
20+
claim := AMRClaim{AuthenticationMethod: &methodString}
21+
require.Equal(t, want, claim.IsAAL2Claim(), "method %q", methodString)
22+
}
23+
}

internal/models/factor.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,9 @@ func (factorState FactorState) String() string {
3636
const TOTP = "totp"
3737
const Phone = "phone"
3838
const WebAuthn = "webauthn"
39+
const RecoveryCode = "recovery_code"
40+
41+
const DefaultRecoveryCodeFriendlyName = "Recovery codes"
3942

4043
type AuthenticationMethod int
4144

@@ -57,6 +60,7 @@ const (
5760
Web3
5861
OAuthProviderAuthorizationCode
5962
PasskeyLogin
63+
MFARecoveryCode
6064
)
6165

6266
func (authMethod AuthenticationMethod) IsRecovery() bool {
@@ -98,6 +102,8 @@ func (authMethod AuthenticationMethod) String() string {
98102
return "mfa/phone"
99103
case MFAWebAuthn:
100104
return "mfa/webauthn"
105+
case MFARecoveryCode:
106+
return "mfa/recovery_code"
101107
case Web3:
102108
return "web3"
103109
case OAuthProviderAuthorizationCode:
@@ -139,6 +145,8 @@ func ParseAuthenticationMethod(authMethod string) (AuthenticationMethod, error)
139145
return MFAPhone, nil
140146
case "mfa/webauthn":
141147
return MFAWebAuthn, nil
148+
case "mfa/recovery_code":
149+
return MFARecoveryCode, nil
142150
case "web3":
143151
return Web3, nil
144152
case "oauth_provider/authorization_code":
@@ -268,6 +276,16 @@ func NewWebAuthnFactor(user *User, friendlyName string) *Factor {
268276
return factor
269277
}
270278

279+
// NewRecoveryCodeFactor creates a recovery-code factor directly in the verified
280+
// state: there is no challenge/verify enrollment round-trip, the codes are handed
281+
// to an already-AAL2 user.
282+
func NewRecoveryCodeFactor(user *User, friendlyName string) *Factor {
283+
if strings.TrimSpace(friendlyName) == "" {
284+
friendlyName = DefaultRecoveryCodeFriendlyName
285+
}
286+
return NewFactor(user, friendlyName, RecoveryCode, FactorStateVerified)
287+
}
288+
271289
func (f *Factor) SetSecret(secret string, encrypt bool, encryptionKeyID, encryptionKey string) error {
272290
f.Secret = secret
273291
if encrypt {
@@ -339,6 +357,20 @@ func FindFactorByFactorID(conn *storage.Connection, factorID uuid.UUID) (*Factor
339357
return &factor, nil
340358
}
341359

360+
// FindRecoveryCodeFactorByUser returns the user's recovery-code factor (only one should exist per user).
361+
func FindRecoveryCodeFactorByUser(conn *storage.Connection, userID uuid.UUID) (*Factor, error) {
362+
var factor Factor
363+
364+
err := conn.Q().Where("user_id = ? AND factor_type = ?", userID, RecoveryCode).First(&factor)
365+
if err != nil && errors.Cause(err) == sql.ErrNoRows {
366+
return nil, FactorNotFoundError{}
367+
} else if err != nil {
368+
return nil, err
369+
}
370+
371+
return &factor, nil
372+
}
373+
342374
func DeleteUnverifiedFactors(tx *storage.Connection, user *User, factorType string) error {
343375
if err := tx.RawQuery("DELETE FROM "+(&pop.Model{Value: Factor{}}).TableName()+" WHERE user_id = ? and status = ? and factor_type = ?", user.ID, FactorStateUnverified.String(), factorType).Exec(); err != nil {
344376
return err
@@ -407,6 +439,8 @@ func amrMethodForFactorType(factorType string) (string, error) {
407439
return MFAPhone.String(), nil
408440
case WebAuthn:
409441
return MFAWebAuthn.String(), nil
442+
case RecoveryCode:
443+
return MFARecoveryCode.String(), nil
410444
default:
411445
return "", fmt.Errorf("no AMR authentication method mapped for factor type %q", factorType)
412446
}
@@ -441,6 +475,10 @@ func (f *Factor) IsPhoneFactor() bool {
441475
return f.FactorType == Phone
442476
}
443477

478+
func (f *Factor) IsRecoveryCodeFactor() bool {
479+
return f.FactorType == RecoveryCode
480+
}
481+
444482
func (f *Factor) FindChallengeByID(conn *storage.Connection, challengeID uuid.UUID) (*Challenge, error) {
445483
var challenge Challenge
446484
err := conn.Q().Where("id = ? and factor_id = ?", challengeID, f.ID).First(&challenge)

internal/models/factor_test.go

Lines changed: 45 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,10 @@ func TestFactor(t *testing.T) {
3535
// TestAMRMethodForFactorType pins the factor-type -> AMR method mapping.
3636
func TestAMRMethodForFactorType(t *testing.T) {
3737
for factorType, want := range map[string]AuthenticationMethod{
38-
TOTP: TOTPSignIn,
39-
Phone: MFAPhone,
40-
WebAuthn: MFAWebAuthn,
38+
TOTP: TOTPSignIn,
39+
Phone: MFAPhone,
40+
WebAuthn: MFAWebAuthn,
41+
RecoveryCode: MFARecoveryCode,
4142
} {
4243
got, err := amrMethodForFactorType(factorType)
4344
require.NoError(t, err, "factor type %q must map", factorType)
@@ -50,7 +51,7 @@ func TestAMRMethodForFactorType(t *testing.T) {
5051

5152
// TestAuthenticationMethodRoundTrip guards the String() <-> ParseAuthenticationMethod symmetry.
5253
func TestAuthenticationMethodRoundTrip(t *testing.T) {
53-
for _, m := range []AuthenticationMethod{TOTPSignIn, MFAPhone, MFAWebAuthn} {
54+
for _, m := range []AuthenticationMethod{TOTPSignIn, MFAPhone, MFAWebAuthn, MFARecoveryCode} {
5455
parsed, err := ParseAuthenticationMethod(m.String())
5556
require.NoError(t, err, "method %q must round-trip", m.String())
5657
require.Equal(t, m, parsed)
@@ -78,6 +79,41 @@ func (ts *FactorTestSuite) TestFindFactorByFactorID() {
7879
require.EqualError(ts.T(), err, FactorNotFoundError{}.Error())
7980
}
8081

82+
func (ts *FactorTestSuite) TestNewRecoveryCodeFactor() {
83+
user, err := NewUser("", "recoveryfactor@example.com", "secret", "test", nil)
84+
require.NoError(ts.T(), err)
85+
86+
factor := NewRecoveryCodeFactor(user, "my recovery codes")
87+
require.Equal(ts.T(), RecoveryCode, factor.FactorType)
88+
require.Equal(ts.T(), FactorStateVerified.String(), factor.Status)
89+
require.True(ts.T(), factor.IsVerified())
90+
require.Equal(ts.T(), "my recovery codes", factor.FriendlyName)
91+
require.True(ts.T(), factor.IsRecoveryCodeFactor())
92+
require.False(ts.T(), ts.TestFactor.IsRecoveryCodeFactor())
93+
94+
// blank names fall back to the default
95+
require.Equal(ts.T(), DefaultRecoveryCodeFriendlyName, NewRecoveryCodeFactor(user, "").FriendlyName)
96+
require.Equal(ts.T(), DefaultRecoveryCodeFriendlyName, NewRecoveryCodeFactor(user, " ").FriendlyName)
97+
}
98+
99+
func (ts *FactorTestSuite) TestFindRecoveryCodeFactorByUser() {
100+
// the fixture user has only a TOTP factor
101+
_, err := FindRecoveryCodeFactorByUser(ts.db, ts.TestFactor.UserID)
102+
require.EqualError(ts.T(), err, FactorNotFoundError{}.Error())
103+
104+
user, err := NewUser("", "findrecovery@example.com", "secret", "test", nil)
105+
require.NoError(ts.T(), err)
106+
require.NoError(ts.T(), ts.db.Create(user))
107+
108+
factor := NewRecoveryCodeFactor(user, "")
109+
require.NoError(ts.T(), ts.db.Create(factor))
110+
111+
found, err := FindRecoveryCodeFactorByUser(ts.db, user.ID)
112+
require.NoError(ts.T(), err)
113+
require.Equal(ts.T(), factor.ID, found.ID)
114+
require.True(ts.T(), found.IsRecoveryCodeFactor())
115+
}
116+
81117
func (ts *FactorTestSuite) TestUpdateStatus() {
82118
newFactorStatus := FactorStateVerified
83119
require.NoError(ts.T(), ts.TestFactor.UpdateStatus(ts.db, newFactorStatus))
@@ -122,6 +158,11 @@ func (ts *FactorTestSuite) TestDowngradeSessionsToAAL1RemovesAMRClaim() {
122158
newFactor: func(u *User) *Factor { return NewTOTPFactor(u, "totpfactor") },
123159
authMethod: TOTPSignIn,
124160
},
161+
{
162+
desc: "recovery_code",
163+
newFactor: func(u *User) *Factor { return NewRecoveryCodeFactor(u, "") },
164+
authMethod: MFARecoveryCode,
165+
},
125166
}
126167

127168
for i, c := range cases {

0 commit comments

Comments
 (0)