feat: Add recovery code MFA support (#9954)

<!--
Please inform yourself about the contribution guidelines on submitting a
PR here:
https://github.com/zitadel/zitadel/blob/main/CONTRIBUTING.md#submit-a-pull-request-pr.
Take note of how PR/commit titles should be written and replace the
template texts in the sections below. Don't remove any of the sections.
It is important that the commit history clearly shows what is changed
and why.
Important: By submitting a contribution you agree to the terms from our
Licensing Policy as described here:
https://github.com/zitadel/zitadel/blob/main/LICENSING.md#community-contributions.
-->

One-time recovery codes are a common multi-factor authentication (MFA)
backup method, letting users access their account if they lose other MFA
devices. Support for recovery codes can also reduce support burden for
users locked out of their accounts and provide a more secure and
reliable form of verification than security questions.

# Which Problems Are Solved

Zitadel currently lacks support for recovery codes.

# How the Problems Are Solved

This PR partially addresses recovery code support in Zitadel.
Importantly, it adds recovery codes as a new 2FA `Factor` and an
additional `Check` type for the Session API.

```
Example recovery code flow:

1. User generates N new recovery codes using `POST /v2/users/{user_id}/recovery_codes`
2. Zitadel hashes and stores these codes and returns the un-hashed codes in the response
3. User creates new session with an additional check: `recoveryCode`
4. Code is checked against hash and, if valid, cannot be used again
5. User attempts to adds N more codes using the same endpoint 
6. If `remaining_codes + N <= RecoveryCodes->MaxCount` config value, then recovery codes are added in addition to original codes
7. User can remove all recovery codes using `DEL /v2/users/:userId/recovery_codes` 
```
This PR adds: 
- [x] Session recovery_code check support on `POST+PATCH /v2/sessions`
endpoints
- [x] Adds `mfa_recovery_code_checked_at` column (default null) to
`projections.sessions8` table
- [x] Support for `SECOND_FACTOR_TYPE_RECOVERY_CODES` as available 2FA
method on login policy
- [x] Support for importing recovery codes in /import code

Missing, will _not_ implement in this PR:
- [ ] Admin console support for displaying Recovery Code settings for
user(s)
- [ ] Zitadel Typescript login support for recovery codes 

TODO: 
- [x] Additional unit and integration tests
- [x] Error translations

# Additional Changes

None

# Additional Context

- Closes #6898

---------

Co-authored-by: Livio Spring <[email protected]>
This commit is contained in:
Zach Hirschtritt
2025-11-24 06:44:48 +00:00
committed by GitHub
co-authored by Livio Spring
parent afa92e55f7
commit 5beeb5738a
101 changed files with 3397 additions and 169 deletions
+19
View File
@@ -504,6 +504,25 @@ func importHumanUsers(ctx context.Context, s *Server, errors *[]*admin_pb.Import
logging.Debugf("successful user otp: %s", user.GetUserId())
}
}
if len(user.User.RecoveryCodes) > 0 {
logging.Debugf("import user recovery codes: %s", user.GetUserId())
recoveryCodes := make([]domain.ImportHumanRecoveryCode, len(user.User.RecoveryCodes))
for i, code := range user.User.RecoveryCodes {
recoveryCodes[i] = domain.ImportHumanRecoveryCode{
RawCode: code.GetRaw(),
HashedCode: code.GetHash(),
}
}
if err := s.command.ImportHumanRecoveryCodes(ctx, user.UserId, org.GetOrgId(), recoveryCodes); err != nil {
*errors = append(*errors, &admin_pb.ImportDataError{Type: "human_user_recovery_codes", Id: user.GetUserId(), Message: err.Error()})
if isCtxTimeout(ctx) {
return err
}
} else {
logging.Debugf("successful user recovery codes: %s", user.GetUserId())
}
}
}
return nil
}
@@ -491,6 +491,180 @@ func TestServer_ImportData(t *testing.T) {
},
},
},
{
name: "success with recovery codes",
req: &admin.ImportDataRequest{
Data: &admin.ImportDataRequest_DataOrgs{
DataOrgs: &admin.ImportDataOrg{
Orgs: []*admin.DataOrg{
{
OrgId: orgIDs[7],
Org: &management.AddOrgRequest{
Name: integration.OrganizationName(),
},
HumanUsers: []*v1.DataHumanUser{
{
UserId: userIDs[3],
User: &management.ImportHumanUserRequest{
UserName: integration.Username(),
Profile: &management.ImportHumanUserRequest_Profile{
FirstName: integration.FirstName(),
LastName: integration.LastName(),
DisplayName: integration.Username(),
PreferredLanguage: integration.Language(),
},
Email: &management.ImportHumanUserRequest_Email{
Email: integration.Email(),
IsEmailVerified: true,
},
RecoveryCodes: []*management.ImportHumanUserRequest_RecoveryCode{
{CodeType: &management.ImportHumanUserRequest_RecoveryCode_Raw{Raw: "code-001"}},
{CodeType: &management.ImportHumanUserRequest_RecoveryCode_Raw{Raw: "code-002"}},
{CodeType: &management.ImportHumanUserRequest_RecoveryCode_Raw{Raw: "code-003"}},
},
},
},
{
UserId: userIDs[4],
User: &management.ImportHumanUserRequest{
UserName: integration.Username(),
Profile: &management.ImportHumanUserRequest_Profile{
FirstName: integration.FirstName(),
LastName: integration.LastName(),
DisplayName: integration.Username(),
PreferredLanguage: integration.Language(),
},
Email: &management.ImportHumanUserRequest_Email{
Email: integration.Email(),
IsEmailVerified: true,
},
RecoveryCodes: []*management.ImportHumanUserRequest_RecoveryCode{
{CodeType: &management.ImportHumanUserRequest_RecoveryCode_Raw{Raw: "code-101"}},
{CodeType: &management.ImportHumanUserRequest_RecoveryCode_Hash{Hash: "$2a$12$3UWLT4aUQsgO/Jn/rydRmuqF39JTXox6G4dwea/UOvbstI/Qba20y"}},
},
},
},
},
},
},
},
},
Timeout: (5 * time.Minute).String(),
},
want: &admin.ImportDataResponse{
Success: &admin.ImportDataSuccess{
Orgs: []*admin.ImportDataSuccessOrg{
{
OrgId: orgIDs[7],
HumanUserIds: userIDs[3:5],
},
},
},
},
},
{
name: "empty recovery codes",
req: &admin.ImportDataRequest{
Data: &admin.ImportDataRequest_DataOrgs{
DataOrgs: &admin.ImportDataOrg{
Orgs: []*admin.DataOrg{
{
OrgId: orgIDs[8],
Org: &management.AddOrgRequest{
Name: integration.OrganizationName(),
},
HumanUsers: []*v1.DataHumanUser{
{
UserId: userIDs[5],
User: &management.ImportHumanUserRequest{
UserName: integration.Username(),
Profile: &management.ImportHumanUserRequest_Profile{
FirstName: integration.FirstName(),
LastName: integration.LastName(),
DisplayName: integration.Username(),
PreferredLanguage: integration.Language(),
},
Email: &management.ImportHumanUserRequest_Email{
Email: integration.Email(),
IsEmailVerified: true,
},
RecoveryCodes: []*management.ImportHumanUserRequest_RecoveryCode{},
},
},
},
},
},
},
},
Timeout: time.Minute.String(),
},
want: &admin.ImportDataResponse{
Success: &admin.ImportDataSuccess{
Orgs: []*admin.ImportDataSuccessOrg{
{
OrgId: orgIDs[8],
HumanUserIds: userIDs[5:6],
},
},
},
},
},
{
name: "invalid recovery code hash",
req: &admin.ImportDataRequest{
Data: &admin.ImportDataRequest_DataOrgs{
DataOrgs: &admin.ImportDataOrg{
Orgs: []*admin.DataOrg{
{
OrgId: orgIDs[9],
Org: &management.AddOrgRequest{
Name: integration.OrganizationName(),
},
HumanUsers: []*v1.DataHumanUser{
{
UserId: userIDs[6],
User: &management.ImportHumanUserRequest{
UserName: integration.Username(),
Profile: &management.ImportHumanUserRequest_Profile{
FirstName: integration.FirstName(),
LastName: integration.LastName(),
DisplayName: integration.Username(),
PreferredLanguage: integration.Language(),
},
Email: &management.ImportHumanUserRequest_Email{
Email: integration.Email(),
IsEmailVerified: true,
},
RecoveryCodes: []*management.ImportHumanUserRequest_RecoveryCode{
{CodeType: &management.ImportHumanUserRequest_RecoveryCode_Hash{Hash: "invalid-hash"}},
},
},
},
},
},
},
},
},
Timeout: time.Minute.String(),
},
want: &admin.ImportDataResponse{
Success: &admin.ImportDataSuccess{
Orgs: []*admin.ImportDataSuccessOrg{
{
OrgId: orgIDs[9],
HumanUserIds: userIDs[6:7],
},
},
},
Errors: []*admin.ImportDataError{
{
Type: "human_user_recovery_codes",
Id: userIDs[6],
Message: "ID=DOMAIN-JDk4t Message=Errors.Hash.NotSupported",
},
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
+4
View File
@@ -23,6 +23,8 @@ func SecondFactorTypeToDomain(secondFactorType policy_pb.SecondFactorType) domai
return domain.SecondFactorTypeOTPEmail
case policy_pb.SecondFactorType_SECOND_FACTOR_TYPE_OTP_SMS:
return domain.SecondFactorTypeOTPSMS
case policy_pb.SecondFactorType_SECOND_FACTOR_TYPE_RECOVERY_CODES:
return domain.SecondFactorTypeRecoveryCodes
default:
return domain.SecondFactorTypeUnspecified
}
@@ -46,6 +48,8 @@ func ModelSecondFactorTypeToPb(secondFactorType domain.SecondFactorType) policy_
return policy_pb.SecondFactorType_SECOND_FACTOR_TYPE_OTP_EMAIL
case domain.SecondFactorTypeOTPSMS:
return policy_pb.SecondFactorType_SECOND_FACTOR_TYPE_OTP_SMS
case domain.SecondFactorTypeRecoveryCodes:
return policy_pb.SecondFactorType_SECOND_FACTOR_TYPE_RECOVERY_CODES
default:
return policy_pb.SecondFactorType_SECOND_FACTOR_TYPE_UNSPECIFIED
}
@@ -80,6 +80,7 @@ const (
wantIntentFactor
wantOTPSMSFactor
wantOTPEmailFactor
wantRecoveryCodeFactor
)
func verifyFactors(t assert.TestingT, factors *session.Factors, creationDate, changeDate *timestamppb.Timestamp, userID string, want []wantFactor) {
@@ -122,6 +123,10 @@ func verifyFactors(t assert.TestingT, factors *session.Factors, creationDate, ch
pf := factors.GetOtpEmail()
assert.NotNil(t, pf)
assert.WithinRange(t, pf.GetVerifiedAt().AsTime(), creationDateWithSkew, changeDateWithSkew)
case wantRecoveryCodeFactor:
pf := factors.GetRecoveryCode()
assert.NotNil(t, pf)
assert.WithinRange(t, pf.GetVerifiedAt().AsTime(), creationDateWithSkew, changeDateWithSkew)
}
}
}
@@ -555,6 +560,19 @@ func registerOTPEmail(ctx context.Context, t *testing.T, userID string) {
require.NoError(t, err)
}
func registerRecoveryCode(ctx context.Context, t *testing.T, userID string) []string {
_, err := Instance.Client.UserV2.RemoveRecoveryCodes(ctx, &user.RemoveRecoveryCodesRequest{
UserId: userID,
})
require.NoError(t, err)
recoveryCodes, err := Instance.Client.UserV2.GenerateRecoveryCodes(ctx, &user.GenerateRecoveryCodesRequest{
UserId: userID,
Count: 5,
})
require.NoError(t, err)
return recoveryCodes.GetRecoveryCodes()
}
func TestServer_SetSession_flow_totp(t *testing.T) {
userExisting := createFullUser(CTX)
@@ -728,6 +746,8 @@ func TestServer_SetSession_flow(t *testing.T) {
totpSecret := registerTOTP(userAuthCtx, t, User.GetUserId())
registerOTPSMS(userAuthCtx, t, User.GetUserId())
registerOTPEmail(userAuthCtx, t, User.GetUserId())
recoveryCodes := registerRecoveryCode(userAuthCtx, t, User.GetUserId())
recoveryCode := recoveryCodes[0]
t.Run("check webauthn, user not verified (U2F)", func(t *testing.T) {
@@ -838,6 +858,20 @@ func TestServer_SetSession_flow(t *testing.T) {
sessionToken = resp.GetSessionToken()
verifyCurrentSession(t, createResp.GetSessionId(), sessionToken, resp.GetDetails().GetSequence(), createResp.GetDetails().GetChangeDate(), resp.GetDetails().GetChangeDate(), nil, nil, 0, User.GetUserId(), wantUserFactor, wantWebAuthNFactor, wantOTPEmailFactor)
})
t.Run("check Recovery Code", func(t *testing.T) {
resp, err := Client.SetSession(LoginCTX, &session.SetSessionRequest{
SessionId: createResp.GetSessionId(),
Checks: &session.Checks{
RecoveryCode: &session.CheckRecoveryCode{
Code: recoveryCode,
},
},
})
require.NoError(t, err)
sessionToken = resp.GetSessionToken()
verifyCurrentSession(t, createResp.GetSessionId(), sessionToken, resp.GetDetails().GetSequence(), createResp.GetDetails().GetChangeDate(), resp.GetDetails().GetChangeDate(), nil, nil, 0, User.GetUserId(), wantUserFactor, wantWebAuthNFactor, wantRecoveryCodeFactor)
})
}
func TestServer_SetSession_expired(t *testing.T) {
+17 -7
View File
@@ -219,13 +219,14 @@ func factorsToPb(s *query.Session) *session.Factors {
return nil
}
return &session.Factors{
User: user,
Password: passwordFactorToPb(s.PasswordFactor),
WebAuthN: webAuthNFactorToPb(s.WebAuthNFactor),
Intent: intentFactorToPb(s.IntentFactor),
Totp: totpFactorToPb(s.TOTPFactor),
OtpSms: otpFactorToPb(s.OTPSMSFactor),
OtpEmail: otpFactorToPb(s.OTPEmailFactor),
User: user,
Password: passwordFactorToPb(s.PasswordFactor),
WebAuthN: webAuthNFactorToPb(s.WebAuthNFactor),
Intent: intentFactorToPb(s.IntentFactor),
Totp: totpFactorToPb(s.TOTPFactor),
OtpSms: otpFactorToPb(s.OTPSMSFactor),
OtpEmail: otpFactorToPb(s.OTPEmailFactor),
RecoveryCode: recoveryCodeFactorToPb(s.RecoveryCodeFactor),
}
}
@@ -275,6 +276,15 @@ func otpFactorToPb(factor query.SessionOTPFactor) *session.OTPFactor {
}
}
func recoveryCodeFactorToPb(factor query.SessionRecoveryCodeFactor) *session.RecoveryCodeFactor {
if factor.RecoveryCodeCheckedAt.IsZero() {
return nil
}
return &session.RecoveryCodeFactor{
VerifiedAt: timestamppb.New(factor.RecoveryCodeCheckedAt),
}
}
func userFactorToPb(factor query.SessionUserFactor) *session.UserFactor {
if factor.UserID == "" || factor.UserCheckedAt.IsZero() {
return nil
+3
View File
@@ -145,6 +145,9 @@ func (s *Server) checksToCommand(ctx context.Context, checks *session.Checks) ([
if otp := checks.GetOtpEmail(); otp != nil {
sessionChecks = append(sessionChecks, command.CheckOTPEmail(otp.GetCode()))
}
if recoveryCode := checks.GetRecoveryCode(); recoveryCode != nil {
sessionChecks = append(sessionChecks, command.CheckRecoveryCode(recoveryCode.GetCode()))
}
return sessionChecks, nil
}
@@ -127,6 +127,26 @@ func Test_sessionsToPb(t *testing.T) {
},
Metadata: map[string][]byte{"hello": []byte("world")},
},
{ // recovery code factor
ID: "999",
CreationDate: now,
ChangeDate: now,
Sequence: 123,
State: domain.SessionStateActive,
ResourceOwner: "me",
Creator: "he",
UserFactor: query.SessionUserFactor{
UserID: "345",
UserCheckedAt: past,
LoginName: "donald",
DisplayName: "donald duck",
ResourceOwner: "org1",
},
RecoveryCodeFactor: query.SessionRecoveryCodeFactor{
RecoveryCodeCheckedAt: past,
},
Metadata: map[string][]byte{"hello": []byte("world")},
},
}
want := []*session.Session{
@@ -221,6 +241,25 @@ func Test_sessionsToPb(t *testing.T) {
},
Metadata: map[string][]byte{"hello": []byte("world")},
},
{ // recovery code factor
Id: "999",
CreationDate: timestamppb.New(now),
ChangeDate: timestamppb.New(now),
Sequence: 123,
Factors: &session.Factors{
User: &session.UserFactor{
VerifiedAt: timestamppb.New(past),
Id: "345",
LoginName: "donald",
DisplayName: "donald duck",
OrganizationId: "org1",
},
RecoveryCode: &session.RecoveryCodeFactor{
VerifiedAt: timestamppb.New(past),
},
},
Metadata: map[string][]byte{"hello": []byte("world")},
},
}
out := sessionsToPb(sessions)
@@ -76,6 +76,8 @@ func secondFactorTypeToPb(secondFactorType domain.SecondFactorType) settings.Sec
return settings.SecondFactorType_SECOND_FACTOR_TYPE_OTP_EMAIL
case domain.SecondFactorTypeOTPSMS:
return settings.SecondFactorType_SECOND_FACTOR_TYPE_OTP_SMS
case domain.SecondFactorTypeRecoveryCodes:
return settings.SecondFactorType_SECOND_FACTOR_TYPE_RECOVERY_CODES
case domain.SecondFactorTypeUnspecified:
return settings.SecondFactorType_SECOND_FACTOR_TYPE_UNSPECIFIED
default:
@@ -46,6 +46,7 @@ func Test_loginSettingsToPb(t *testing.T) {
domain.SecondFactorTypeU2F,
domain.SecondFactorTypeOTPEmail,
domain.SecondFactorTypeOTPSMS,
domain.SecondFactorTypeRecoveryCodes,
},
MultiFactors: []domain.MultiFactorType{
domain.MultiFactorTypeU2FWithPIN,
@@ -77,6 +78,7 @@ func Test_loginSettingsToPb(t *testing.T) {
settings.SecondFactorType_SECOND_FACTOR_TYPE_U2F,
settings.SecondFactorType_SECOND_FACTOR_TYPE_OTP_EMAIL,
settings.SecondFactorType_SECOND_FACTOR_TYPE_OTP_SMS,
settings.SecondFactorType_SECOND_FACTOR_TYPE_RECOVERY_CODES,
},
MultiFactors: []settings.MultiFactorType{
settings.MultiFactorType_MULTI_FACTOR_TYPE_U2F_WITH_VERIFICATION,
@@ -182,6 +184,10 @@ func Test_secondFactorTypeToPb(t *testing.T) {
args: args{99},
want: settings.SecondFactorType_SECOND_FACTOR_TYPE_UNSPECIFIED,
},
{
args: args{domain.SecondFactorTypeRecoveryCodes},
want: settings.SecondFactorType_SECOND_FACTOR_TYPE_RECOVERY_CODES,
},
}
for _, tt := range tests {
t.Run(tt.want.String(), func(t *testing.T) {
@@ -0,0 +1,29 @@
package user
import (
"context"
"connectrpc.com/connect"
"github.com/zitadel/zitadel/internal/api/grpc/object/v2"
user "github.com/zitadel/zitadel/pkg/grpc/user/v2"
)
func (s *Server) GenerateRecoveryCodes(ctx context.Context, req *connect.Request[user.GenerateRecoveryCodesRequest]) (*connect.Response[user.GenerateRecoveryCodesResponse], error) {
details, err := s.command.GenerateRecoveryCodes(ctx, req.Msg.GetUserId(), int(req.Msg.GetCount()), "", nil)
if err != nil {
return nil, err
}
return connect.NewResponse(&user.GenerateRecoveryCodesResponse{
Details: object.DomainToDetailsPb(details.ObjectDetails),
RecoveryCodes: details.RawCodes,
}), nil
}
func (s *Server) RemoveRecoveryCodes(ctx context.Context, req *connect.Request[user.RemoveRecoveryCodesRequest]) (*connect.Response[user.RemoveRecoveryCodesResponse], error) {
objectDetails, err := s.command.RemoveRecoveryCodes(ctx, req.Msg.GetUserId(), "", nil)
if err != nil {
return nil, err
}
return connect.NewResponse(&user.RemoveRecoveryCodesResponse{Details: object.DomainToDetailsPb(objectDetails)}), nil
}
+2
View File
@@ -343,6 +343,8 @@ func authMethodTypeToPb(methodType domain.UserAuthMethodType) user.Authenticatio
return user.AuthenticationMethodType_AUTHENTICATION_METHOD_TYPE_OTP_EMAIL
case domain.UserAuthMethodTypeUnspecified:
return user.AuthenticationMethodType_AUTHENTICATION_METHOD_TYPE_UNSPECIFIED
case domain.UserAuthMethodTypeRecoveryCode:
return user.AuthenticationMethodType_AUTHENTICATION_METHOD_TYPE_RECOVERY_CODE
default:
return user.AuthenticationMethodType_AUTHENTICATION_METHOD_TYPE_UNSPECIFIED
}
+2 -1
View File
@@ -44,7 +44,8 @@ func AuthMethodTypesToAMR(methodTypes []domain.UserAuthMethodType) []string {
case domain.UserAuthMethodTypeOTP,
domain.UserAuthMethodTypeTOTP,
domain.UserAuthMethodTypeOTPSMS,
domain.UserAuthMethodTypeOTPEmail:
domain.UserAuthMethodTypeOTPEmail,
domain.UserAuthMethodTypeRecoveryCode:
// a user could use multiple (t)otp, which is a factor, but still will be returned as a single `otp` entry
otp++
factors++
+1
View File
@@ -169,6 +169,7 @@ const (
authMethodOTP authMethod = "OTP"
authMethodOTPSMS authMethod = "OTP SMS"
authMethodOTPEmail authMethod = "OTP Email"
authMethodRecoveryCode authMethod = "recovery code"
authMethodU2F authMethod = "U2F"
authMethodPasswordless authMethod = "passwordless"
)
@@ -103,7 +103,9 @@ func (l *Login) handleOTPVerificationCheck(w http.ResponseWriter, r *http.Reques
case domain.MFATypeOTPEmail:
actionType = authMethodOTPEmail
verifyCode = l.authRepo.VerifyMFAOTPEmail
// another type should never be passed, but just making sure
case domain.MFATypeRecoveryCode:
actionType = authMethodRecoveryCode
verifyCode = l.authRepo.VerifyMFARecoveryCode
case domain.MFATypeU2F,
domain.MFATypeTOTP,
domain.MFATypeU2FUserVerification: