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
+6
View File
@@ -204,6 +204,12 @@ func StartCommands(
CryptoMFA: otpEncryption,
Issuer: defaults.Multifactors.OTP.Issuer,
},
RecoveryCodes: domain.RecoveryCodesConfig{
MaxCount: defaults.Multifactors.RecoveryCodes.MaxCount,
Format: domain.RecoveryCodeFormat(defaults.Multifactors.RecoveryCodes.Format),
Length: defaults.Multifactors.RecoveryCodes.Length,
WithHyphen: defaults.Multifactors.RecoveryCodes.WithHyphen,
},
},
GenerateDomain: domain.NewGeneratedInstanceDomain,
caches: caches,
+4
View File
@@ -219,6 +219,10 @@ func (s *SessionCommands) OTPEmailChecked(ctx context.Context, checkedAt time.Ti
s.eventCommands = append(s.eventCommands, session.NewOTPEmailCheckedEvent(ctx, s.sessionWriteModel.aggregate, checkedAt))
}
func (s *SessionCommands) RecoveryCodeChecked(ctx context.Context, checkedAt time.Time) {
s.eventCommands = append(s.eventCommands, session.NewRecoveryCodeCheckedEvent(ctx, s.sessionWriteModel.aggregate, checkedAt))
}
func (s *SessionCommands) SetToken(ctx context.Context, tokenID string) {
// trigger activity log for session for user
activity.Trigger(ctx, s.sessionWriteModel.UserResourceOwner, s.sessionWriteModel.UserID, activity.SessionAPI, s.eventstore.FilterToQueryReducer)
+28 -18
View File
@@ -41,28 +41,28 @@ func (p *WebAuthNChallengeModel) WebAuthNLogin(human *domain.Human, credentialAs
type SessionWriteModel struct {
eventstore.WriteModel
TokenID string
UserID string
UserResourceOwner string
PreferredLanguage *language.Tag
UserCheckedAt time.Time
PasswordCheckedAt time.Time
IntentCheckedAt time.Time
WebAuthNCheckedAt time.Time
TOTPCheckedAt time.Time
OTPSMSCheckedAt time.Time
OTPEmailCheckedAt time.Time
WebAuthNUserVerified bool
Metadata map[string][]byte
State domain.SessionState
UserAgent *domain.UserAgent
Expiration time.Time
TokenID string
UserID string
UserResourceOwner string
PreferredLanguage *language.Tag
UserCheckedAt time.Time
PasswordCheckedAt time.Time
IntentCheckedAt time.Time
WebAuthNCheckedAt time.Time
TOTPCheckedAt time.Time
OTPSMSCheckedAt time.Time
OTPEmailCheckedAt time.Time
RecoveryCodeCheckedAt time.Time
WebAuthNUserVerified bool
Metadata map[string][]byte
State domain.SessionState
UserAgent *domain.UserAgent
Expiration time.Time
WebAuthNChallenge *WebAuthNChallengeModel
OTPSMSCodeChallenge *OTPCode
OTPEmailCodeChallenge *OTPCode
aggregate *eventstore.Aggregate
aggregate *eventstore.Aggregate
}
func NewSessionWriteModel(sessionID string, instanceID string) *SessionWriteModel {
@@ -108,6 +108,8 @@ func (wm *SessionWriteModel) Reduce() error {
wm.reduceLifetimeSet(e)
case *session.TerminateEvent:
wm.reduceTerminate()
case *session.RecoveryCodeCheckedEvent:
wm.reduceRecoveryCodeChecked(e)
}
}
return wm.WriteModel.Reduce()
@@ -131,6 +133,7 @@ func (wm *SessionWriteModel) Query() *eventstore.SearchQueryBuilder {
session.OTPSMSCheckedType,
session.OTPEmailChallengedType,
session.OTPEmailCheckedType,
session.RecoveryCodeCheckedType,
session.TokenSetType,
session.MetadataSetType,
session.LifetimeSetType,
@@ -227,6 +230,10 @@ func (wm *SessionWriteModel) reduceTerminate() {
wm.State = domain.SessionStateTerminated
}
func (wm *SessionWriteModel) reduceRecoveryCodeChecked(e *session.RecoveryCodeCheckedEvent) {
wm.RecoveryCodeCheckedAt = e.CheckedAt
}
// AuthenticationTime returns the time the user authenticated using the latest time of all checks
func (wm *SessionWriteModel) AuthenticationTime() time.Time {
var authTime time.Time
@@ -270,6 +277,9 @@ func (wm *SessionWriteModel) AuthMethodTypes() []domain.UserAuthMethodType {
if !wm.OTPEmailCheckedAt.IsZero() {
types = append(types, domain.UserAuthMethodTypeOTPEmail)
}
if !wm.RecoveryCodeCheckedAt.IsZero() {
types = append(types, domain.UserAuthMethodTypeRecoveryCode)
}
return types
}
+52 -8
View File
@@ -11,10 +11,14 @@ import (
func TestSessionWriteModel_AuthMethodTypes(t *testing.T) {
type fields struct {
PasswordCheckedAt time.Time
IntentCheckedAt time.Time
WebAuthNCheckedAt time.Time
WebAuthNUserVerified bool
PasswordCheckedAt time.Time
IntentCheckedAt time.Time
WebAuthNCheckedAt time.Time
WebAuthNUserVerified bool
TOTPCheckedAt time.Time
OTPSMSCheckedAt time.Time
OTPEmailCheckedAt time.Time
RecoveryCodeCheckedAt time.Time
}
tests := []struct {
name string
@@ -59,14 +63,54 @@ func TestSessionWriteModel_AuthMethodTypes(t *testing.T) {
domain.UserAuthMethodTypeIDP,
},
},
{
name: "totp",
fields: fields{
TOTPCheckedAt: testNow,
},
want: []domain.UserAuthMethodType{
domain.UserAuthMethodTypeTOTP,
},
},
{
name: "otp sms",
fields: fields{
OTPSMSCheckedAt: testNow,
},
want: []domain.UserAuthMethodType{
domain.UserAuthMethodTypeOTPSMS,
},
},
{
name: "otp email",
fields: fields{
OTPEmailCheckedAt: testNow,
},
want: []domain.UserAuthMethodType{
domain.UserAuthMethodTypeOTPEmail,
},
},
{
name: "recovery code",
fields: fields{
RecoveryCodeCheckedAt: testNow,
},
want: []domain.UserAuthMethodType{
domain.UserAuthMethodTypeRecoveryCode,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
wm := &SessionWriteModel{
PasswordCheckedAt: tt.fields.PasswordCheckedAt,
IntentCheckedAt: tt.fields.IntentCheckedAt,
WebAuthNCheckedAt: tt.fields.WebAuthNCheckedAt,
WebAuthNUserVerified: tt.fields.WebAuthNUserVerified,
PasswordCheckedAt: tt.fields.PasswordCheckedAt,
IntentCheckedAt: tt.fields.IntentCheckedAt,
WebAuthNCheckedAt: tt.fields.WebAuthNCheckedAt,
WebAuthNUserVerified: tt.fields.WebAuthNUserVerified,
TOTPCheckedAt: tt.fields.TOTPCheckedAt,
OTPSMSCheckedAt: tt.fields.OTPSMSCheckedAt,
OTPEmailCheckedAt: tt.fields.OTPEmailCheckedAt,
RecoveryCodeCheckedAt: tt.fields.RecoveryCodeCheckedAt,
}
got := wm.AuthMethodTypes()
assert.Equal(t, got, tt.want)
@@ -0,0 +1,28 @@
package command
import (
"context"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/eventstore"
)
func CheckRecoveryCode(code string) SessionCommand {
return func(ctx context.Context, cmd *SessionCommands) ([]eventstore.Command, error) {
commands, err := checkRecoveryCode(ctx, cmd.sessionWriteModel.UserID, code, cmd.sessionWriteModel.UserResourceOwner, nil, cmd.eventstore.FilterToQueryReducer, cmd.hasher)
if err != nil {
return commands, err
}
cmd.eventCommands = append(cmd.eventCommands, commands...)
cmd.RecoveryCodeChecked(ctx, cmd.now())
return nil, nil
}
}
func toHumanRecoveryCode(recoveryCodeWriteModel *HumanRecoveryCodeWriteModel) *domain.HumanRecoveryCodes {
return &domain.HumanRecoveryCodes{
ObjectDetails: writeModelToObjectDetails(&recoveryCodeWriteModel.WriteModel),
Codes: recoveryCodeWriteModel.Codes(),
}
}
+189
View File
@@ -1283,6 +1283,195 @@ func TestCheckTOTP(t *testing.T) {
}
}
func TestCheckRecoveryCode(t *testing.T) {
ctx := authz.NewMockContext("instance1", "org1", "user1")
sessAgg := &session.NewAggregate("session1", "instance1").Aggregate
userAgg := &user.NewAggregate("user1", "org1").Aggregate
orgAgg := &org.NewAggregate("org1").Aggregate
hasher := mockPasswordHasher("x")
hashedCodes, rawCodes, err := domain.GenerateRecoveryCodes(ctx, 1, domain.RecoveryCodesConfig{
Format: domain.RecoveryCodeFormatUUID,
MaxCount: 10,
Length: 10,
}, hasher)
require.NoError(t, err)
validCode := rawCodes[0]
invalidCode := "invalid-code"
type fields struct {
sessionWriteModel *SessionWriteModel
eventstore func(*testing.T) *eventstore.Eventstore
hasher *crypto.Hasher
}
tests := []struct {
name string
code string
fields fields
wantEventCommands []eventstore.Command
wantErrorCommands []eventstore.Command
wantErr error
}{
{
name: "missing userID",
code: validCode,
fields: fields{
sessionWriteModel: &SessionWriteModel{
aggregate: sessAgg,
},
eventstore: expectEventstore(),
hasher: hasher,
},
wantErr: zerrors.ThrowInvalidArgument(nil, "COMMAND-4m9s2", "Errors.User.UserIDMissing"),
},
{
name: "filter error",
code: validCode,
fields: fields{
sessionWriteModel: &SessionWriteModel{
UserID: "user1",
aggregate: sessAgg,
},
eventstore: expectEventstore(
expectFilterError(io.ErrClosedPipe),
),
hasher: hasher,
},
wantErr: io.ErrClosedPipe,
},
{
name: "recovery codes not ready",
code: validCode,
fields: fields{
sessionWriteModel: &SessionWriteModel{
UserID: "user1",
aggregate: sessAgg,
},
eventstore: expectEventstore(
expectFilter(), // No codes added
),
hasher: hasher,
},
wantErr: zerrors.ThrowPreconditionFailed(nil, "COMMAND-84rgg", "Errors.User.MFA.RecoveryCodes.NotReady"),
},
{
name: "invalid code",
code: invalidCode,
fields: fields{
sessionWriteModel: &SessionWriteModel{
UserID: "user1",
aggregate: sessAgg,
},
eventstore: expectEventstore(
expectFilter(
eventFromEventPusher(
user.NewHumanRecoveryCodesAddedEvent(ctx, userAgg, hashedCodes, nil),
),
),
expectFilter(), // additional lock check
expectFilter(
eventFromEventPusher(org.NewLockoutPolicyAddedEvent(ctx, orgAgg, 0, 0, false)),
),
),
hasher: hasher,
},
wantErrorCommands: []eventstore.Command{
user.NewHumanRecoveryCodeCheckFailedEvent(ctx, userAgg, nil),
},
wantErr: zerrors.ThrowInvalidArgument(nil, "DOMAIN-6uvh0", "Errors.User.MFA.RecoveryCodes.InvalidCode"),
},
{
name: "invalid code, locked",
code: invalidCode,
fields: fields{
sessionWriteModel: &SessionWriteModel{
UserID: "user1",
aggregate: sessAgg,
},
eventstore: expectEventstore(
expectFilter(
eventFromEventPusher(
user.NewHumanRecoveryCodesAddedEvent(ctx, userAgg, hashedCodes, nil),
),
),
expectFilter(), // additional lock check
expectFilter(
eventFromEventPusher(org.NewLockoutPolicyAddedEvent(ctx, orgAgg, 1, 1, false)),
),
),
hasher: hasher,
},
wantErrorCommands: []eventstore.Command{
user.NewHumanRecoveryCodeCheckFailedEvent(ctx, userAgg, nil),
user.NewUserLockedEvent(ctx, userAgg),
},
wantErr: zerrors.ThrowInvalidArgument(nil, "DOMAIN-6uvh0", "Errors.User.MFA.RecoveryCodes.InvalidCode"),
},
{
name: "user locked",
code: validCode,
fields: fields{
sessionWriteModel: &SessionWriteModel{
UserID: "user1",
aggregate: sessAgg,
},
eventstore: expectEventstore(
expectFilter(
eventFromEventPusher(
user.NewHumanRecoveryCodesAddedEvent(ctx, userAgg, hashedCodes, nil),
),
eventFromEventPusher(
user.NewUserLockedEvent(ctx, userAgg),
),
),
),
hasher: hasher,
},
wantErr: zerrors.ThrowPreconditionFailed(nil, "COMMAND-2w6oa", "Errors.User.Locked"),
},
{
name: "ok",
code: validCode,
fields: fields{
sessionWriteModel: &SessionWriteModel{
UserID: "user1",
aggregate: sessAgg,
},
eventstore: expectEventstore(
expectFilter(
eventFromEventPusher(
user.NewHumanRecoveryCodesAddedEvent(ctx, userAgg, hashedCodes, nil),
),
),
expectFilter(), // additional lock check
),
hasher: hasher,
},
wantEventCommands: []eventstore.Command{
user.NewHumanRecoveryCodeCheckSucceededEvent(ctx, userAgg, "$plain$x$"+validCode, nil),
session.NewRecoveryCodeCheckedEvent(ctx, sessAgg, testNow),
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd := &SessionCommands{
sessionWriteModel: tt.fields.sessionWriteModel,
eventstore: tt.fields.eventstore(t),
hasher: tt.fields.hasher,
now: func() time.Time { return testNow },
}
gotCmds, err := CheckRecoveryCode(tt.code)(ctx, cmd)
require.ErrorIs(t, err, tt.wantErr)
assert.Equal(t, tt.wantErrorCommands, gotCmds)
assert.Equal(t, tt.wantEventCommands, cmd.eventCommands)
})
}
}
func TestCommands_TerminateSession(t *testing.T) {
type fields struct {
eventstore func(t *testing.T) *eventstore.Eventstore
@@ -0,0 +1,90 @@
package command
import (
"slices"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/repository/user"
)
type HumanRecoveryCodeWriteModel struct {
eventstore.WriteModel
State domain.MFAState
FailedAttempts uint64
codes []string
userLocked bool
}
func (wm *HumanRecoveryCodeWriteModel) Codes() []string {
if wm.codes == nil {
return make([]string, 0)
}
return wm.codes
}
func (wm *HumanRecoveryCodeWriteModel) UserLocked() bool {
return wm.userLocked
}
func NewHumanRecoveryCodeWriteModel(userID, resourceOwner string) *HumanRecoveryCodeWriteModel {
return &HumanRecoveryCodeWriteModel{
WriteModel: eventstore.WriteModel{
AggregateID: userID,
ResourceOwner: resourceOwner,
},
}
}
func (wm *HumanRecoveryCodeWriteModel) Reduce() error {
for _, event := range wm.Events {
switch e := event.(type) {
case *user.HumanRecoveryCodesAddedEvent:
wm.codes = append(wm.Codes(), e.Codes...)
wm.State = domain.MFAStateReady
case *user.HumanRecoveryCodeCheckSucceededEvent:
wm.FailedAttempts = 0
codeCheckedIndex := slices.Index(wm.codes, e.CodeChecked)
if codeCheckedIndex == -1 {
// NB: this should typically never happen, but a race-condition could
// possibly lead to inconsistent state and duplicate code checks
continue
}
wm.codes = slices.Delete(wm.codes, codeCheckedIndex, codeCheckedIndex+1)
case *user.HumanRecoveryCodeCheckFailedEvent:
wm.FailedAttempts += 1
case *user.HumanRecoveryCodesRemovedEvent:
wm.codes = make([]string, 0)
wm.State = domain.MFAStateRemoved
case *user.UserLockedEvent:
wm.userLocked = true
case *user.UserUnlockedEvent:
wm.userLocked = false
wm.FailedAttempts = 0
case *user.UserRemovedEvent:
wm.State = domain.MFAStateRemoved
}
}
return wm.WriteModel.Reduce()
}
func (wm *HumanRecoveryCodeWriteModel) Query() *eventstore.SearchQueryBuilder {
query := eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent).
AddQuery().
AggregateTypes(user.AggregateType).
AggregateIDs(wm.AggregateID).
EventTypes(user.HumanRecoveryCodesAddedType,
user.HumanRecoveryCodesRemovedType,
user.HumanRecoveryCodeCheckSucceededType,
user.HumanRecoveryCodeCheckFailedType,
user.UserLockedType,
user.UserUnlockedType,
user.UserRemovedType).
Builder()
if wm.ResourceOwner != "" {
query.ResourceOwner(wm.ResourceOwner)
}
return query
}
@@ -0,0 +1,383 @@
package command
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/repository/user"
)
func TestHumanRecoveryCodeWriteModel_Reduce(t *testing.T) {
userAgg := user.NewAggregate("user1", "org1")
ctx := context.Background()
tests := []struct {
name string
events []eventstore.Event
want *HumanRecoveryCodeWriteModel
}{
{
name: "no events",
events: []eventstore.Event{},
want: &HumanRecoveryCodeWriteModel{
WriteModel: eventstore.WriteModel{
AggregateID: "user1",
ResourceOwner: "org1",
},
},
},
{
name: "recovery codes added",
events: []eventstore.Event{
user.NewHumanRecoveryCodesAddedEvent(
ctx,
&userAgg.Aggregate,
[]string{"code1", "code2", "code3"},
nil,
),
},
want: &HumanRecoveryCodeWriteModel{
WriteModel: eventstore.WriteModel{
AggregateID: "user1",
ResourceOwner: "org1",
},
State: domain.MFAStateReady,
FailedAttempts: 0,
codes: []string{"code1", "code2", "code3"},
userLocked: false,
},
},
{
name: "recovery codes added multiple times",
events: []eventstore.Event{
user.NewHumanRecoveryCodesAddedEvent(
ctx,
&userAgg.Aggregate,
[]string{"code1", "code2"},
nil,
),
user.NewHumanRecoveryCodesAddedEvent(
ctx,
&userAgg.Aggregate,
[]string{"code3", "code4"},
nil,
),
},
want: &HumanRecoveryCodeWriteModel{
WriteModel: eventstore.WriteModel{
AggregateID: "user1",
ResourceOwner: "org1",
},
State: domain.MFAStateReady,
FailedAttempts: 0,
codes: []string{"code1", "code2", "code3", "code4"},
userLocked: false,
},
},
{
name: "recovery code check succeeded",
events: []eventstore.Event{
user.NewHumanRecoveryCodesAddedEvent(
ctx,
&userAgg.Aggregate,
[]string{"code1", "code2", "code3"},
nil,
),
user.NewHumanRecoveryCodeCheckSucceededEvent(
ctx,
&userAgg.Aggregate,
"code2",
nil,
),
},
want: &HumanRecoveryCodeWriteModel{
WriteModel: eventstore.WriteModel{
AggregateID: "user1",
ResourceOwner: "org1",
},
State: domain.MFAStateReady,
FailedAttempts: 0,
codes: []string{"code1", "code3"},
userLocked: false,
},
},
{
name: "recovery code check succeeded with code that no longer exists",
events: []eventstore.Event{
user.NewHumanRecoveryCodesAddedEvent(
ctx,
&userAgg.Aggregate,
[]string{"code1", "code2", "code3"},
nil,
),
user.NewHumanRecoveryCodeCheckSucceededEvent(
ctx,
&userAgg.Aggregate,
"nonexistent",
nil,
),
},
want: &HumanRecoveryCodeWriteModel{
WriteModel: eventstore.WriteModel{
AggregateID: "user1",
ResourceOwner: "org1",
},
State: domain.MFAStateReady,
FailedAttempts: 0,
codes: []string{"code1", "code2", "code3"},
userLocked: false,
},
},
{
name: "recovery code check failed",
events: []eventstore.Event{
user.NewHumanRecoveryCodesAddedEvent(
ctx,
&userAgg.Aggregate,
[]string{"code1", "code2", "code3"},
nil,
),
user.NewHumanRecoveryCodeCheckFailedEvent(
ctx,
&userAgg.Aggregate,
nil,
),
},
want: &HumanRecoveryCodeWriteModel{
WriteModel: eventstore.WriteModel{
AggregateID: "user1",
ResourceOwner: "org1",
},
State: domain.MFAStateReady,
FailedAttempts: 1,
codes: []string{"code1", "code2", "code3"},
userLocked: false,
},
},
{
name: "multiple recovery code check failures",
events: []eventstore.Event{
user.NewHumanRecoveryCodesAddedEvent(
ctx,
&userAgg.Aggregate,
[]string{"code1", "code2", "code3"},
nil,
),
user.NewHumanRecoveryCodeCheckFailedEvent(
ctx,
&userAgg.Aggregate,
nil,
),
user.NewHumanRecoveryCodeCheckFailedEvent(
ctx,
&userAgg.Aggregate,
nil,
),
user.NewHumanRecoveryCodeCheckFailedEvent(
ctx,
&userAgg.Aggregate,
nil,
),
},
want: &HumanRecoveryCodeWriteModel{
WriteModel: eventstore.WriteModel{
AggregateID: "user1",
ResourceOwner: "org1",
},
State: domain.MFAStateReady,
FailedAttempts: 3,
codes: []string{"code1", "code2", "code3"},
userLocked: false,
},
},
{
name: "recovery codes removed",
events: []eventstore.Event{
user.NewHumanRecoveryCodesAddedEvent(
ctx,
&userAgg.Aggregate,
[]string{"code1", "code2", "code3"},
nil,
),
user.NewHumanRecoveryCodeRemovedEvent(
ctx,
&userAgg.Aggregate,
nil,
),
},
want: &HumanRecoveryCodeWriteModel{
WriteModel: eventstore.WriteModel{
AggregateID: "user1",
ResourceOwner: "org1",
},
State: domain.MFAStateRemoved,
FailedAttempts: 0,
codes: []string{},
userLocked: false,
},
},
{
name: "user locked",
events: []eventstore.Event{
user.NewHumanRecoveryCodesAddedEvent(
ctx,
&userAgg.Aggregate,
[]string{"code1", "code2", "code3"},
nil,
),
user.NewHumanRecoveryCodeCheckFailedEvent(
ctx,
&userAgg.Aggregate,
nil,
),
user.NewUserLockedEvent(
ctx,
&userAgg.Aggregate,
),
},
want: &HumanRecoveryCodeWriteModel{
WriteModel: eventstore.WriteModel{
AggregateID: "user1",
ResourceOwner: "org1",
},
State: domain.MFAStateReady,
FailedAttempts: 1,
codes: []string{"code1", "code2", "code3"},
userLocked: true,
},
},
{
name: "user unlocked",
events: []eventstore.Event{
user.NewHumanRecoveryCodesAddedEvent(
ctx,
&userAgg.Aggregate,
[]string{"code1", "code2", "code3"},
nil,
),
user.NewHumanRecoveryCodeCheckFailedEvent(
ctx,
&userAgg.Aggregate,
nil,
),
user.NewUserLockedEvent(
ctx,
&userAgg.Aggregate,
),
user.NewUserUnlockedEvent(
ctx,
&userAgg.Aggregate,
),
},
want: &HumanRecoveryCodeWriteModel{
WriteModel: eventstore.WriteModel{
AggregateID: "user1",
ResourceOwner: "org1",
},
State: domain.MFAStateReady,
FailedAttempts: 0,
codes: []string{"code1", "code2", "code3"},
userLocked: false,
},
},
{
name: "user removed",
events: []eventstore.Event{
user.NewHumanRecoveryCodesAddedEvent(
ctx,
&userAgg.Aggregate,
[]string{"code1", "code2", "code3"},
nil,
),
user.NewUserRemovedEvent(
ctx,
&userAgg.Aggregate,
"username",
nil,
false,
),
},
want: &HumanRecoveryCodeWriteModel{
WriteModel: eventstore.WriteModel{
AggregateID: "user1",
ResourceOwner: "org1",
},
State: domain.MFAStateRemoved,
FailedAttempts: 0,
codes: []string{"code1", "code2", "code3"},
userLocked: false,
},
},
{
name: "complex flow with multiple events",
events: []eventstore.Event{
user.NewHumanRecoveryCodesAddedEvent(
ctx,
&userAgg.Aggregate,
[]string{"code1", "code2", "code3", "code4"},
nil,
),
user.NewHumanRecoveryCodeCheckFailedEvent(
ctx,
&userAgg.Aggregate,
nil,
),
user.NewHumanRecoveryCodeCheckSucceededEvent(
ctx,
&userAgg.Aggregate,
"code2",
nil,
),
user.NewHumanRecoveryCodeCheckFailedEvent(
ctx,
&userAgg.Aggregate,
nil,
),
user.NewUserLockedEvent(
ctx,
&userAgg.Aggregate,
),
user.NewUserUnlockedEvent(
ctx,
&userAgg.Aggregate,
),
user.NewHumanRecoveryCodeCheckSucceededEvent(
ctx,
&userAgg.Aggregate,
"code4",
nil,
),
},
want: &HumanRecoveryCodeWriteModel{
WriteModel: eventstore.WriteModel{
AggregateID: "user1",
ResourceOwner: "org1",
},
State: domain.MFAStateReady,
FailedAttempts: 0,
codes: []string{"code1", "code3"},
userLocked: false,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
wm := NewHumanRecoveryCodeWriteModel("user1", "org1")
wm.Events = tt.events
err := wm.Reduce()
require.NoError(t, err)
assert.Equal(t, tt.want.State, wm.State)
assert.Equal(t, tt.want.FailedAttempts, wm.FailedAttempts)
assert.Equal(t, tt.want.codes, wm.codes)
assert.Equal(t, tt.want.userLocked, wm.userLocked)
})
}
}
@@ -0,0 +1,201 @@
package command
import (
"context"
"github.com/zitadel/logging"
"github.com/zitadel/zitadel/internal/crypto"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/repository/user"
"github.com/zitadel/zitadel/internal/telemetry/tracing"
"github.com/zitadel/zitadel/internal/zerrors"
)
func (c *Commands) ImportHumanRecoveryCodes(ctx context.Context, userID, resourceOwner string, codes []domain.ImportHumanRecoveryCode) (err error) {
ctx, span := tracing.NewSpan(ctx)
defer func() { span.EndWithError(err) }()
if len(codes) == 0 {
return zerrors.ThrowInvalidArgument(nil, "COMMAND-vee93", "Errors.User.MFA.RecoveryCodes.CountInvalid")
}
if _, err = c.checkUserExists(ctx, userID, resourceOwner); err != nil {
return err
}
recoveryCodeWriteModel := NewHumanRecoveryCodeWriteModel(userID, resourceOwner)
if err := c.eventstore.FilterToQueryReducer(ctx, recoveryCodeWriteModel); err != nil {
return err
}
if len(recoveryCodeWriteModel.Codes())+len(codes) > c.multifactors.RecoveryCodes.MaxCount {
return zerrors.ThrowPreconditionFailed(nil, "COMMAND-53cjw", "Errors.User.MFA.RecoveryCodes.MaxCountExceeded")
}
hashedCodes, err := domain.HashRecoveryCodesIfNeeded(ctx, codes, c.userPasswordHasher)
if err != nil {
return err
}
userAgg := UserAggregateFromWriteModelCtx(ctx, &recoveryCodeWriteModel.WriteModel)
_, err = c.eventstore.Push(ctx,
user.NewHumanRecoveryCodesAddedEvent(ctx, userAgg, hashedCodes, nil),
)
return err
}
type RecoveryCodesDetails struct {
*domain.ObjectDetails
RawCodes []string
}
func (c *Commands) GenerateRecoveryCodes(ctx context.Context, userID string, count int, resourceOwner string, authRequest *domain.AuthRequest) (*RecoveryCodesDetails, error) {
if userID == "" {
return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-4kje7", "Errors.User.UserIDMissing")
}
if count <= 0 {
return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-7c0nx", "Errors.User.RecoveryCodes.CountInvalid")
}
resourceOwner, err := c.checkUserExists(ctx, userID, resourceOwner)
if err != nil {
return nil, err
}
if err := c.checkPermissionUpdateUserCredentials(ctx, resourceOwner, userID); err != nil {
return nil, err
}
recoveryCodeWriteModel := NewHumanRecoveryCodeWriteModel(userID, resourceOwner)
if err := c.eventstore.FilterToQueryReducer(ctx, recoveryCodeWriteModel); err != nil {
return nil, err
}
if len(recoveryCodeWriteModel.Codes())+count > c.multifactors.RecoveryCodes.MaxCount {
return nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-8f2k9", "Errors.User.MFA.RecoveryCodes.MaxCountExceeded")
}
hashedCodes, rawCodes, err := domain.GenerateRecoveryCodes(ctx, count, c.multifactors.RecoveryCodes, c.userPasswordHasher)
if err != nil {
return nil, err
}
userAgg := UserAggregateFromWriteModelCtx(ctx, &recoveryCodeWriteModel.WriteModel)
_, err = c.eventstore.Push(ctx,
user.NewHumanRecoveryCodesAddedEvent(ctx, userAgg, hashedCodes, authRequestDomainToAuthRequestInfo(authRequest)),
)
if err != nil {
return nil, err
}
return &RecoveryCodesDetails{
ObjectDetails: writeModelToObjectDetails(&recoveryCodeWriteModel.WriteModel),
RawCodes: rawCodes,
}, nil
}
func (c *Commands) RemoveRecoveryCodes(ctx context.Context, userID, resourceOwner string, authRequest *domain.AuthRequest) (*domain.ObjectDetails, error) {
if userID == "" {
return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-l2n9r", "Errors.User.UserIDMissing")
}
writeModel := NewHumanRecoveryCodeWriteModel(userID, resourceOwner)
if err := c.eventstore.FilterToQueryReducer(ctx, writeModel); err != nil {
return nil, err
}
if err := c.checkPermissionUpdateUserCredentials(ctx, writeModel.ResourceOwner, userID); err != nil {
return nil, err
}
if writeModel.UserLocked() {
return nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-d9u8q", "Errors.User.Locked")
}
// if there aren't any recovery codes, we don't need to do anything
if writeModel.State != domain.MFAStateReady {
return writeModelToObjectDetails(&writeModel.WriteModel), nil
}
userAgg := UserAggregateFromWriteModelCtx(ctx, &writeModel.WriteModel)
_, err := c.eventstore.Push(ctx, user.NewHumanRecoveryCodeRemovedEvent(ctx, userAgg, authRequestDomainToAuthRequestInfo(authRequest)))
if err != nil {
return nil, err
}
return writeModelToObjectDetails(&writeModel.WriteModel), nil
}
func (c *Commands) HumanCheckRecoveryCode(ctx context.Context, userID, code, resourceOwner string, authRequest *domain.AuthRequest) error {
commands, err := checkRecoveryCode(ctx, userID, code, resourceOwner, authRequest, c.eventstore.FilterToQueryReducer, c.userPasswordHasher)
if len(commands) > 0 {
_, err = c.eventstore.Push(ctx, commands...)
logging.OnError(err).Error("failed to push recovery code check events")
}
return err
}
func checkRecoveryCode(
ctx context.Context,
userID, code, resourceOwner string,
authRequest *domain.AuthRequest,
queryReducer func(ctx context.Context, r eventstore.QueryReducer) error,
secretHasher *crypto.Hasher,
) ([]eventstore.Command, error) {
if code == "" {
return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-u0b6c", "Errors.User.UserIDMissing")
}
if userID == "" {
return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-4m9s2", "Errors.User.UserIDMissing")
}
recoveryCodeWm := NewHumanRecoveryCodeWriteModel(userID, resourceOwner)
err := queryReducer(ctx, recoveryCodeWm)
if err != nil {
return nil, err
}
if recoveryCodeWm.UserLocked() {
return nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-2w6oa", "Errors.User.Locked")
}
if recoveryCodeWm.State != domain.MFAStateReady {
return nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-84rgg", "Errors.User.MFA.RecoveryCodes.NotReady")
}
hashedCode, err := domain.ValidateRecoveryCode(ctx, code, toHumanRecoveryCode(recoveryCodeWm), secretHasher)
// recheck for additional events (failed password checks or locks)
recheckErr := queryReducer(ctx, recoveryCodeWm)
if recheckErr != nil {
return nil, recheckErr
}
if recoveryCodeWm.UserLocked() {
return nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-ASV12", "Errors.User.Locked")
}
userAgg := UserAggregateFromWriteModelCtx(ctx, &recoveryCodeWm.WriteModel)
commands := make([]eventstore.Command, 0, 2)
authRequestInfo := authRequestDomainToAuthRequestInfo(authRequest)
if err == nil {
return append(commands, user.NewHumanRecoveryCodeCheckSucceededEvent(ctx, userAgg, hashedCode, authRequestInfo)), nil
}
commands = append(commands, user.NewHumanRecoveryCodeCheckFailedEvent(ctx, userAgg, authRequestInfo))
lockoutPolicy, lockoutErr := getLockoutPolicy(ctx, recoveryCodeWm.ResourceOwner, queryReducer)
logging.OnError(lockoutErr).Error("failed to get lockout policy")
if lockoutPolicy != nil && lockoutPolicy.MaxOTPAttempts > 0 && recoveryCodeWm.FailedAttempts+1 >= lockoutPolicy.MaxOTPAttempts {
commands = append(commands, user.NewUserLockedEvent(ctx, userAgg))
}
return commands, err
}
File diff suppressed because it is too large Load Diff