diff --git a/backend/v3/api/session/v2/convert/session_check_recovery_code.go b/backend/v3/api/session/v2/convert/session_check_recovery_code.go new file mode 100644 index 000000000..fbac37414 --- /dev/null +++ b/backend/v3/api/session/v2/convert/session_check_recovery_code.go @@ -0,0 +1,15 @@ +package convert + +import ( + "github.com/zitadel/zitadel/backend/v3/domain" + session_grpc "github.com/zitadel/zitadel/pkg/grpc/session/v2" +) + +func CheckRecoveryCodeGRPCToDomain(checkRecoveryCode *session_grpc.CheckRecoveryCode) *domain.CheckTypeRecoveryCode { + if checkRecoveryCode == nil { + return nil + } + return &domain.CheckTypeRecoveryCode{ + RecoveryCode: checkRecoveryCode.GetCode(), + } +} diff --git a/backend/v3/api/session/v2/convert/session_check_recovery_code_test.go b/backend/v3/api/session/v2/convert/session_check_recovery_code_test.go new file mode 100644 index 000000000..31b414740 --- /dev/null +++ b/backend/v3/api/session/v2/convert/session_check_recovery_code_test.go @@ -0,0 +1,39 @@ +package convert + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/zitadel/zitadel/backend/v3/domain" + session_grpc "github.com/zitadel/zitadel/pkg/grpc/session/v2" +) + +func TestCheckRecoveryCodeGRPCToDomain(t *testing.T) { + t.Parallel() + tests := []struct { + name string + check *session_grpc.CheckRecoveryCode + want *domain.CheckTypeRecoveryCode + }{ + { + name: "nil recovery code check", + }, + { + name: "recovery code check", + check: &session_grpc.CheckRecoveryCode{ + Code: "code1", + }, + want: &domain.CheckTypeRecoveryCode{ + RecoveryCode: "code1", + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := CheckRecoveryCodeGRPCToDomain(tt.check) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/backend/v3/domain/session_check_password.go b/backend/v3/domain/session_check_password.go index 089455fda..b4c74c5b1 100644 --- a/backend/v3/domain/session_check_password.go +++ b/backend/v3/domain/session_check_password.go @@ -6,7 +6,6 @@ import ( "strings" "time" - "github.com/muhlemmer/gu" "github.com/zitadel/passwap" "github.com/zitadel/zitadel/backend/v3/storage/database" @@ -31,7 +30,7 @@ type PasswordCheckCommand struct { SessionID string InstanceID string TarpitFunc tarpitFn - VerifierFn func(encoded, password string) (updated string, err error) + VerifierFn verifierFn FetchedUser User UpdatedHashedPsw string @@ -194,39 +193,6 @@ func (p *PasswordCheckCommand) Execute(ctx context.Context, opts *InvokeOpts) (e return err } -func (p *PasswordCheckCommand) getLockoutPolicy(ctx context.Context, opts *InvokeOpts, orgID string) (*LockoutSettings, error) { - lockoutSettingRepo := opts.lockoutSettingRepo - - // We need the organization lockout policy first, and if not available, the instance (default) policy. - // So we retrieve all records with a matching instance ID and organization ID OR - // all records with a matching instance ID and NULL (or empty) organization ID. - // Then we assume NULLs are sorted as largest numbers (that's the case in Postgres), - // so we sort ascending by organization ID. - // We limit the result to 1 so that we get either the org policy or the instance one. - settings, err := lockoutSettingRepo.List(ctx, opts.DB(), - p.listLockoutSettingCondition(lockoutSettingRepo, orgID), - database.WithOrderByAscending(lockoutSettingRepo.OrganizationIDColumn(), lockoutSettingRepo.InstanceIDColumn()), - database.WithLimit(1), - ) - if err != nil { - return nil, zerrors.ThrowInternal(err, "DOM-3B8Z6s", "failed fetching lockout settings") - } - - if rowsReturned := len(settings); rowsReturned != 1 { - return nil, zerrors.ThrowInternal(NewRowsReturnedMismatchError(1, int64(rowsReturned)), "DOM-mmsrCt", "unexpected number of rows returned") - } - - return settings[0], nil -} - -func (p *PasswordCheckCommand) listLockoutSettingCondition(repo LockoutSettingsRepository, orgID string) database.QueryOption { - instanceAndOrg := database.And(repo.InstanceIDCondition(p.InstanceID), repo.OrganizationIDCondition(&orgID)) - orgNullOrEmpty := database.Or(repo.OrganizationIDCondition(nil), repo.OrganizationIDCondition(gu.Ptr(""))) - onlyInstance := database.And(repo.InstanceIDCondition(p.InstanceID), orgNullOrEmpty) - - return database.WithCondition(database.Or(instanceAndOrg, onlyInstance)) -} - func (p *PasswordCheckCommand) GetPasswordCheckChanges(ctx context.Context, opts *InvokeOpts, humanRepo HumanUserRepository, updatedHash string, checkType VerificationType) (database.Changes, error) { dbUpdates := make(database.Changes, 1) switch ct := checkType.(type) { @@ -238,7 +204,7 @@ func (p *PasswordCheckCommand) GetPasswordCheckChanges(ctx context.Context, opts } case *VerificationTypeFailed: dbUpdates[0] = humanRepo.IncrementPasswordFailedAttempts() - lockoutPolicy, err := p.getLockoutPolicy(ctx, opts, p.FetchedUser.OrganizationID) + lockoutPolicy, err := GetLockoutPolicy(ctx, opts, p.InstanceID, p.FetchedUser.OrganizationID) if err != nil { return nil, err } diff --git a/backend/v3/domain/session_check_recovery_code.go b/backend/v3/domain/session_check_recovery_code.go new file mode 100644 index 000000000..cf89c2412 --- /dev/null +++ b/backend/v3/domain/session_check_recovery_code.go @@ -0,0 +1,348 @@ +package domain + +import ( + "context" + "errors" + "time" + + "github.com/zitadel/zitadel/backend/v3/instrumentation/logging" + "github.com/zitadel/zitadel/backend/v3/storage/database" + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/repository/session" + "github.com/zitadel/zitadel/internal/repository/user" + "github.com/zitadel/zitadel/internal/zerrors" +) + +var _ Commander = &RecoveryCodeCheckCommand{} +var _ Transactional = &RecoveryCodeCheckCommand{} + +type CheckTypeRecoveryCode struct { + RecoveryCode string +} + +type RecoveryCodeCheckCommand struct { + CheckRecoveryCode *CheckTypeRecoveryCode + + SessionID string + InstanceID string + + verify verifierFn + session *Session + user *User + + checkSucceeded bool + userLocked bool + checkedAt time.Time + hashedRecoveryCode string +} + +// NewRecoveryCodeCheckCommand returns a check Commander validating the input recovery code +// +// It assumes that a [Session] already exists: this check should be part of the +// batch call to create/set a session. +// +// The check will update the existing session or return an error if the session +// is not found or validation fails. +func NewRecoveryCodeCheckCommand(sessionID, instanceID string, check *CheckTypeRecoveryCode, verifier verifierFn) *RecoveryCodeCheckCommand { + if verifier == nil { + verifier = passwordHasher.Verify + } + + return &RecoveryCodeCheckCommand{ + SessionID: sessionID, + InstanceID: instanceID, + CheckRecoveryCode: check, + verify: verifier, + } +} + +// Validate implements [Commander]. +func (rc *RecoveryCodeCheckCommand) Validate(ctx context.Context, opts *InvokeOpts) (err error) { + // no recovery code check to be done + if rc.CheckRecoveryCode == nil { + return nil + } + + // precondition checks + if rc.CheckRecoveryCode.RecoveryCode == "" { + return zerrors.ThrowInvalidArgument(nil, "DOM-cEKxoG", "Errors.User.MFA.RecoveryCodes.Empty") + } + if rc.SessionID == "" { + return zerrors.ThrowPreconditionFailed(nil, "DOM-hsbVyd", "Errors.Session.IDMissing") + } + if rc.InstanceID == "" { + return zerrors.ThrowPreconditionFailed(nil, "DOM-lGIe1v", "Errors.Instance.IDMissing") + } + + // check if the password hash verifier is set + if rc.verify == nil { + return zerrors.ThrowInternal(nil, "DOM-rhMvn5", "Errors.Internal") + } + + // (@grvijayan) todo: review permission check + + // get session + sessionRepo := opts.sessionRepo + retrievedSession, err := sessionRepo.Get( + ctx, + opts.DB(), + database.WithCondition(sessionRepo.PrimaryKeyCondition(rc.InstanceID, rc.SessionID)), + ) + if err != nil { + if errors.Is(err, &database.NoRowFoundError{}) { + return zerrors.ThrowNotFound(err, "DOM-Ot3qO6", "Errors.Session.NotFound") + } + return zerrors.ThrowInternal(err, "DOM-2sF2kF", "Errors.Internal") + } + + if retrievedSession.UserID == "" { + return zerrors.ThrowInvalidArgument(nil, "DOM-EaLqwq", "Errors.User.UserIDMissing") + } + + // get the user from the session + userRepo := opts.userRepo + retrievedUser, err := userRepo.Get( + ctx, + opts.DB(), + database.WithCondition(userRepo.IDCondition(retrievedSession.UserID)), + database.WithResultLock(), + ) + if err != nil { + if errors.Is(err, &database.NoRowFoundError{}) { + return zerrors.ThrowNotFound(err, "DOM-Ot3qO6", "Errors.User.NotFound") + } + return zerrors.ThrowInternal(err, "DOM-7sWTNf", "Errors.Internal") + } + + // check user state + if retrievedUser.State == UserStateLocked { + return zerrors.ThrowPreconditionFailed(nil, "DOM-47H1Ii", "Errors.User.Locked") + } + + // check user MFA state + if retrievedUser.Human == nil || retrievedUser.Human.RecoveryCodes == nil || len(retrievedUser.Human.RecoveryCodes.Codes) == 0 { + return zerrors.ThrowPreconditionFailed(nil, "DOM-tzN2a1", "Errors.User.MFA.RecoveryCodes.NotReady") + } + + rc.session = retrievedSession + rc.user = retrievedUser + + return nil +} + +// Execute implements [Commander]. +func (rc *RecoveryCodeCheckCommand) Execute(ctx context.Context, opts *InvokeOpts) (err error) { + // no recovery code check to be done + if rc.CheckRecoveryCode == nil { + return nil + } + + hashedRecoveryCode, checkErr := validateRecoveryCode(rc.CheckRecoveryCode.RecoveryCode, rc.user.Human.RecoveryCodes.Codes, rc.verify) + if checkErr != nil { + err = rc.handleRecoveryCodeCheckFailed(ctx, opts) + // logging the update error and returning the original error related to recovery code verification + logging.OnError(ctx, err).Error("failed to update user and session after recovery code check failure") + return checkErr + } + + return rc.handleRecoveryCodeCheckSucceeded(ctx, opts, hashedRecoveryCode) +} + +func (rc *RecoveryCodeCheckCommand) Events(ctx context.Context, _ *InvokeOpts) ([]eventstore.Command, error) { + // no recovery code check to be done + if rc.CheckRecoveryCode == nil { + return nil, nil + } + + if !rc.checkSucceeded { + events := make([]eventstore.Command, 0, 2) + events = append(events, + user.NewHumanRecoveryCodeCheckFailedEvent( + ctx, + &user.NewAggregate(rc.user.ID, rc.user.OrganizationID).Aggregate, + nil, + ), + ) + if rc.userLocked { + events = append(events, + user.NewUserLockedEvent( + ctx, + &user.NewAggregate(rc.user.ID, rc.user.OrganizationID).Aggregate, + ), + ) + } + return events, nil + } + + return []eventstore.Command{ + user.NewHumanRecoveryCodeCheckSucceededEvent( + ctx, + &user.NewAggregate(rc.user.ID, rc.user.OrganizationID).Aggregate, + rc.hashedRecoveryCode, + nil, + ), + session.NewRecoveryCodeCheckedEvent( + ctx, + &session.NewAggregate(rc.SessionID, rc.InstanceID).Aggregate, + rc.checkedAt, + ), + }, nil +} + +// RequiresTransaction implements [Transactional]. +func (rc *RecoveryCodeCheckCommand) RequiresTransaction() {} + +// String implements [Commander]. +func (rc *RecoveryCodeCheckCommand) String() string { + return "RecoveryCodeCheckCommand" +} + +func (rc *RecoveryCodeCheckCommand) handleRecoveryCodeCheckFailed(ctx context.Context, opts *InvokeOpts) error { + checkTime := time.Now() + + lockoutPolicy, err := GetLockoutPolicy(ctx, opts, rc.InstanceID, rc.user.OrganizationID) + logging.OnError(ctx, err).Error("failed to get lockout policy") + + // update user state and recovery_code_failed_attempts + humanRepo := opts.userRepo.Human() + userUpdates := make([]database.Change, 0, 2) + + // update recovery_code_failed_attempts for the user + userUpdates = append(userUpdates, humanRepo.IncrementRecoveryCodeFailedAttempts()) + + // update the user's state to locked if the failed recovery code check attempts exceed the configured max value + if lockoutPolicy != nil && + lockoutPolicy.MaxOTPAttempts != nil && + *lockoutPolicy.MaxOTPAttempts > 0 && + (uint64(rc.user.Human.RecoveryCodes.FailedAttempts)+1 >= *lockoutPolicy.MaxOTPAttempts) { + userUpdates = append(userUpdates, humanRepo.SetState(UserStateLocked)) + rc.userLocked = true + } + + err = rc.updateUser( + ctx, + opts, + humanRepo, + userUpdates..., + ) + if err != nil { + return err + } + + // update the session's recovery code check factor + recoveryCodeCheckFactor := &SessionFactorRecoveryCode{ + LastFailedAt: checkTime, + } + sessionRepo := opts.sessionRepo + err = rc.updateSession( + ctx, + opts, + sessionRepo.SetFactor(recoveryCodeCheckFactor), + ) + if err != nil { + return err + } + + rc.checkedAt = checkTime + return nil +} + +func (rc *RecoveryCodeCheckCommand) handleRecoveryCodeCheckSucceeded(ctx context.Context, opts *InvokeOpts, hashedRecoveryCode string) error { + checkTime := time.Now() + + // update the recovery_code_last_successful_check timestamp for the user + // and remove the used recovery code from the user's list of recovery codes + humanRepo := opts.userRepo.Human() + err := rc.updateUser( + ctx, + opts, + humanRepo, + humanRepo.SetLastSuccessfulRecoveryCodeCheck(checkTime), + humanRepo.RemoveRecoveryCode(hashedRecoveryCode), + ) + if err != nil { + return err + } + + // update the session's recovery code check factor + recoveryCodeCheckFactor := &SessionFactorRecoveryCode{ + LastVerifiedAt: checkTime, + } + sessionRepo := opts.sessionRepo + err = rc.updateSession( + ctx, + opts, + sessionRepo.SetFactor(recoveryCodeCheckFactor), + ) + if err != nil { + return err + } + + rc.checkSucceeded = true + rc.hashedRecoveryCode = hashedRecoveryCode + rc.checkedAt = checkTime + + return nil +} + +func (rc *RecoveryCodeCheckCommand) updateSession(ctx context.Context, opts *InvokeOpts, changes ...database.Change) error { + sessionRepo := opts.sessionRepo + updateCount, err := sessionRepo.Update( + ctx, + opts.DB(), + sessionRepo.PrimaryKeyCondition(rc.InstanceID, rc.SessionID), + changes..., + ) + if err != nil { + return zerrors.ThrowInternal(err, "DOM-fhR4N3", "session update failed") // (@grvijayan) todo: review error message format + } + if updateCount == 0 { + return zerrors.ThrowNotFound(nil, "DOM-Frnwxt", "Errors.Session.NotFound") + } + if updateCount > 1 { + return zerrors.ThrowInternal(NewMultipleObjectsUpdatedError(1, updateCount), "DOM-gYp8tG", "unexpected number of rows updateCount") + } + return nil +} + +func (rc *RecoveryCodeCheckCommand) updateUser( + ctx context.Context, + opts *InvokeOpts, + humanRepo HumanUserRepository, + changes ...database.Change, +) error { + updateCount, err := humanRepo.Update( + ctx, + opts.DB(), + humanRepo.PrimaryKeyCondition(rc.InstanceID, rc.user.ID), + changes..., + ) + if err != nil { + return zerrors.ThrowInternal(err, "DOM-XGf3Tk", "user update failed") + } + if updateCount == 0 { + return zerrors.ThrowNotFound(nil, "DOM-hQu5ns", "Errors.User.NotFound") + } + if updateCount > 1 { + return zerrors.ThrowInternal(NewMultipleObjectsUpdatedError(1, updateCount), "DOM-EWjTOH", "Errors.Internal") + } + return nil + +} + +func validateRecoveryCode(reqRecoveryCode string, recoveryCodes []string, verify verifierFn) (string, error) { + if reqRecoveryCode == "" { + return "", zerrors.ThrowInvalidArgument(nil, "DOM-dk1MaX", "Errors.User.MFA.RecoveryCodes.InvalidCode") + } + var matchedCode string + for _, recoveryCode := range recoveryCodes { + _, err := verify(recoveryCode, reqRecoveryCode) + if err == nil && matchedCode == "" { + matchedCode = recoveryCode + } + } + if matchedCode != "" { + return matchedCode, nil + } + return "", zerrors.ThrowInvalidArgument(nil, "DOM-845kaq", "Errors.User.MFA.RecoveryCodes.InvalidCode") +} diff --git a/backend/v3/domain/session_check_recovery_code_test.go b/backend/v3/domain/session_check_recovery_code_test.go new file mode 100644 index 000000000..c1a2616e3 --- /dev/null +++ b/backend/v3/domain/session_check_recovery_code_test.go @@ -0,0 +1,1418 @@ +package domain_test + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/muhlemmer/gu" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + "github.com/zitadel/zitadel/backend/v3/domain" + domainmock "github.com/zitadel/zitadel/backend/v3/domain/mock" + "github.com/zitadel/zitadel/backend/v3/storage/database" + "github.com/zitadel/zitadel/backend/v3/storage/database/dbmock" + noopdb "github.com/zitadel/zitadel/backend/v3/storage/database/dialect/noop" + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/repository/session" + "github.com/zitadel/zitadel/internal/repository/user" + "github.com/zitadel/zitadel/internal/zerrors" +) + +func TestRecoveryCodeCheckCommand_Validate(t *testing.T) { + t.Parallel() + + getError := errors.New("get error") + + tests := []struct { + name string + sessionID string + instanceID string + check *domain.CheckTypeRecoveryCode + sessionRepo func(ctrl *gomock.Controller) domain.SessionRepository + userRepo func(ctrl *gomock.Controller) domain.UserRepository + wantErr error + }{ + { + name: "no recovery code check to be done", + }, + { + name: "empty recovery code", + check: &domain.CheckTypeRecoveryCode{}, + wantErr: zerrors.ThrowInvalidArgument(nil, "DOM-cEKxoG", "Errors.User.MFA.RecoveryCodes.Empty"), + }, + { + name: "no session id", + check: &domain.CheckTypeRecoveryCode{RecoveryCode: "test-code"}, + wantErr: zerrors.ThrowPreconditionFailed(nil, "DOM-hsbVyd", "Errors.Session.IDMissing"), + }, + { + name: "no instance id", + sessionID: "session-1", + check: &domain.CheckTypeRecoveryCode{RecoveryCode: "test-code"}, + wantErr: zerrors.ThrowPreconditionFailed(nil, "DOM-lGIe1v", "Errors.Instance.IDMissing"), + }, + { + name: "failed to get session - session not found", + sessionID: "session-1", + instanceID: "instance-1", + check: &domain.CheckTypeRecoveryCode{RecoveryCode: "test-code"}, + sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository { + sessionRepo := domainmock.NewSessionRepo(ctrl) + primaryKeyCondition := sessionRepo.PrimaryKeyCondition("instance-1", "session-1") + sessionRepo.EXPECT(). + Get(gomock.Any(), gomock.Any(), dbmock.QueryOptions(database.WithCondition(primaryKeyCondition))). + Times(1). + Return(nil, &database.NoRowFoundError{}) + return sessionRepo + }, + wantErr: zerrors.ThrowNotFound(&database.NoRowFoundError{}, "DOM-Ot3qO6", "Errors.Session.NotFound"), + }, + { + name: "failed to get session - database error", + sessionID: "session-1", + instanceID: "instance-1", + check: &domain.CheckTypeRecoveryCode{RecoveryCode: "test-code"}, + sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository { + sessionRepo := domainmock.NewSessionRepo(ctrl) + primaryKeyCondition := sessionRepo.PrimaryKeyCondition("instance-1", "session-1") + sessionRepo.EXPECT(). + Get(gomock.Any(), gomock.Any(), dbmock.QueryOptions(database.WithCondition(primaryKeyCondition))). + Times(1). + Return(nil, getError) + return sessionRepo + }, + wantErr: zerrors.ThrowInternal(getError, "DOM-2sF2kF", "Errors.Internal"), + }, + { + name: "missing user id in session", + sessionID: "session-1", + instanceID: "instance-1", + check: &domain.CheckTypeRecoveryCode{RecoveryCode: "test-code"}, + sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository { + sessionRepo := domainmock.NewSessionRepo(ctrl) + primaryKeyCondition := sessionRepo.PrimaryKeyCondition("instance-1", "session-1") + sessionRepo.EXPECT(). + Get(gomock.Any(), gomock.Any(), dbmock.QueryOptions(database.WithCondition(primaryKeyCondition))). + Times(1). + Return(&domain.Session{}, nil) + return sessionRepo + }, + wantErr: zerrors.ThrowInvalidArgument(nil, "DOM-EaLqwq", "Errors.User.UserIDMissing"), + }, + { + name: "failed to get user - user not found", + sessionID: "session-1", + instanceID: "instance-1", + check: &domain.CheckTypeRecoveryCode{RecoveryCode: "test-code"}, + sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository { + sessionRepo := domainmock.NewSessionRepo(ctrl) + primaryKeyCondition := sessionRepo.PrimaryKeyCondition("instance-1", "session-1") + sessionRepo.EXPECT(). + Get(gomock.Any(), gomock.Any(), dbmock.QueryOptions(database.WithCondition(primaryKeyCondition))). + Times(1). + Return(&domain.Session{UserID: "user-1"}, nil) + return sessionRepo + }, + userRepo: func(ctrl *gomock.Controller) domain.UserRepository { + userRepo := domainmock.NewUserRepo(ctrl) + userIDCondition := userRepo.IDCondition("user-1") + userRepo.EXPECT(). + Get(gomock.Any(), gomock.Any(), dbmock.QueryOptions(database.WithCondition(userIDCondition))). + Times(1). + Return(nil, &database.NoRowFoundError{}) + return userRepo + }, + wantErr: zerrors.ThrowNotFound(&database.NoRowFoundError{}, "DOM-Ot3qO6", "Errors.User.NotFound"), + }, + { + name: "failed to get user - database error", + sessionID: "session-1", + instanceID: "instance-1", + check: &domain.CheckTypeRecoveryCode{RecoveryCode: "test-code"}, + sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository { + sessionRepo := domainmock.NewSessionRepo(ctrl) + primaryKeyCondition := sessionRepo.PrimaryKeyCondition("instance-1", "session-1") + sessionRepo.EXPECT(). + Get(gomock.Any(), gomock.Any(), dbmock.QueryOptions(database.WithCondition(primaryKeyCondition))). + Times(1). + Return(&domain.Session{UserID: "user-1"}, nil) + return sessionRepo + }, + userRepo: func(ctrl *gomock.Controller) domain.UserRepository { + userRepo := domainmock.NewUserRepo(ctrl) + userIDCondition := userRepo.IDCondition("user-1") + userRepo.EXPECT(). + Get(gomock.Any(), gomock.Any(), dbmock.QueryOptions(database.WithCondition(userIDCondition))). + Times(1). + Return(nil, getError) + return userRepo + }, + wantErr: zerrors.ThrowInternal(getError, "DOM-7sWTNf", "Errors.Internal"), + }, + { + name: "user is locked", + sessionID: "session-1", + instanceID: "instance-1", + check: &domain.CheckTypeRecoveryCode{RecoveryCode: "test-code"}, + sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository { + sessionRepo := domainmock.NewSessionRepo(ctrl) + primaryKeyCondition := sessionRepo.PrimaryKeyCondition("instance-1", "session-1") + sessionRepo.EXPECT(). + Get(gomock.Any(), gomock.Any(), dbmock.QueryOptions(database.WithCondition(primaryKeyCondition))). + Times(1). + Return(&domain.Session{UserID: "user-1"}, nil) + return sessionRepo + }, + userRepo: func(ctrl *gomock.Controller) domain.UserRepository { + userRepo := domainmock.NewUserRepo(ctrl) + userIDCondition := userRepo.IDCondition("user-1") + userRepo.EXPECT(). + Get(gomock.Any(), gomock.Any(), dbmock.QueryOptions(database.WithCondition(userIDCondition))). + Times(1). + Return(&domain.User{State: domain.UserStateLocked}, nil) + return userRepo + }, + wantErr: zerrors.ThrowPreconditionFailed(nil, "DOM-47H1Ii", "Errors.User.Locked"), + }, + { + name: "user does not have recovery codes - not a human user", + sessionID: "session-1", + instanceID: "instance-1", + check: &domain.CheckTypeRecoveryCode{RecoveryCode: "test-code"}, + sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository { + sessionRepo := domainmock.NewSessionRepo(ctrl) + primaryKeyCondition := sessionRepo.PrimaryKeyCondition("instance-1", "session-1") + sessionRepo.EXPECT(). + Get(gomock.Any(), gomock.Any(), dbmock.QueryOptions(database.WithCondition(primaryKeyCondition))). + Times(1). + Return(&domain.Session{UserID: "user-1"}, nil) + return sessionRepo + }, + userRepo: func(ctrl *gomock.Controller) domain.UserRepository { + userRepo := domainmock.NewUserRepo(ctrl) + userIDCondition := userRepo.IDCondition("user-1") + userRepo.EXPECT(). + Get(gomock.Any(), gomock.Any(), dbmock.QueryOptions(database.WithCondition(userIDCondition))). + Times(1). + Return( + &domain.User{ + InstanceID: "instance-1", + OrganizationID: "org-1", + State: domain.UserStateActive, + }, nil) + return userRepo + }, + wantErr: zerrors.ThrowPreconditionFailed(nil, "DOM-tzN2a1", "Errors.User.MFA.RecoveryCodes.NotReady"), + }, + { + name: "user does not have recovery codes", + sessionID: "session-1", + instanceID: "instance-1", + check: &domain.CheckTypeRecoveryCode{RecoveryCode: "test-code"}, + sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository { + sessionRepo := domainmock.NewSessionRepo(ctrl) + primaryKeyCondition := sessionRepo.PrimaryKeyCondition("instance-1", "session-1") + sessionRepo.EXPECT(). + Get(gomock.Any(), gomock.Any(), dbmock.QueryOptions(database.WithCondition(primaryKeyCondition))). + Times(1). + Return(&domain.Session{UserID: "user-1"}, nil) + return sessionRepo + }, + userRepo: func(ctrl *gomock.Controller) domain.UserRepository { + userRepo := domainmock.NewUserRepo(ctrl) + userIDCondition := userRepo.IDCondition("user-1") + userRepo.EXPECT(). + Get(gomock.Any(), gomock.Any(), dbmock.QueryOptions(database.WithCondition(userIDCondition))). + Times(1). + Return( + &domain.User{ + InstanceID: "instance-1", + OrganizationID: "org-1", + State: domain.UserStateActive, + Human: &domain.HumanUser{}, + }, nil) + return userRepo + }, + wantErr: zerrors.ThrowPreconditionFailed(nil, "DOM-tzN2a1", "Errors.User.MFA.RecoveryCodes.NotReady"), + }, + { + name: "valid request", + sessionID: "session-1", + instanceID: "instance-1", + check: &domain.CheckTypeRecoveryCode{RecoveryCode: "test-code"}, + sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository { + sessionRepo := domainmock.NewSessionRepo(ctrl) + primaryKeyCondition := sessionRepo.PrimaryKeyCondition("instance-1", "session-1") + sessionRepo.EXPECT(). + Get(gomock.Any(), gomock.Any(), dbmock.QueryOptions(database.WithCondition(primaryKeyCondition))). + Times(1). + Return(&domain.Session{UserID: "user-1"}, nil) + return sessionRepo + }, + userRepo: func(ctrl *gomock.Controller) domain.UserRepository { + userRepo := domainmock.NewUserRepo(ctrl) + userIDCondition := userRepo.IDCondition("user-1") + userRepo.EXPECT(). + Get(gomock.Any(), gomock.Any(), dbmock.QueryOptions(database.WithCondition(userIDCondition))). + Times(1). + Return( + &domain.User{ + InstanceID: "instance-1", + OrganizationID: "org-1", + State: domain.UserStateActive, + Human: &domain.HumanUser{ + RecoveryCodes: &domain.HumanRecoveryCodes{ + Codes: []string{"code1", "code2", "code3"}, + }, + }, + }, nil) + return userRepo + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + + opts := &domain.InvokeOpts{ + Invoker: domain.NewTransactionInvoker(nil), + } + domain.WithQueryExecutor(new(noopdb.Pool))(opts) + if tt.userRepo != nil { + domain.WithUserRepo(tt.userRepo(ctrl))(opts) + } + if tt.sessionRepo != nil { + domain.WithSessionRepo(tt.sessionRepo(ctrl))(opts) + } + + cmd := domain.NewRecoveryCodeCheckCommand(tt.sessionID, tt.instanceID, tt.check, nil) + got := cmd.Validate(context.Background(), opts) + assert.ErrorIs(t, got, tt.wantErr) + }) + } +} + +func TestRecoveryCodeCheckCommand_Execute(t *testing.T) { + t.Parallel() + dbError := errors.New("db error") + + tests := []struct { + name string + sessionID string + instanceID string + check *domain.CheckTypeRecoveryCode + verify func(encoded, password string) (updated string, err error) + sessionRepo func(ctrl *gomock.Controller) domain.SessionRepository + userRepo func(ctrl *gomock.Controller) domain.UserRepository + humanRepo func(ctrl *gomock.Controller) domain.HumanUserRepository + lockoutRepo func(ctrl *gomock.Controller) domain.LockoutSettingsRepository + wantErr error + }{ + { + name: "no recovery code check to be done", + }, + { + name: "invalid recovery code - failed to update user", + sessionID: "session-1", + instanceID: "instance-1", + check: &domain.CheckTypeRecoveryCode{ + RecoveryCode: "test-code", + }, + verify: func(encoded, password string) (updated string, err error) { + return "", errors.New("verify error") + }, + sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository { + sessionRepo := domainmock.NewSessionRepo(ctrl) + // set up expectation to get session in Validate() + getSessionSucceededExpectation(sessionRepo) + return sessionRepo + }, + userRepo: func(ctrl *gomock.Controller) domain.UserRepository { + userRepo := domainmock.NewUserRepo(ctrl) + // set up expectation to get recovery codes in Validate() + getUserSucceededExpectation(userRepo, 2) + + // set up expectation to update user in Execute() + updateHumanUserFailedExpectation(ctrl, userRepo, true, dbError, 0) + + return userRepo + }, + lockoutRepo: func(ctrl *gomock.Controller) domain.LockoutSettingsRepository { + lockoutRepo := domainmock.NewLockoutSettingsRepo(ctrl) + // set up expectation to get lockout settings in Execute() + getLockoutSettingsSucceededExpectation("instance-1", "org-1", lockoutRepo) + return lockoutRepo + }, + wantErr: zerrors.ThrowInvalidArgument(nil, "DOM-845kaq", "Errors.User.MFA.RecoveryCodes.InvalidCode"), + }, + { + name: "invalid recovery code - user not found", + sessionID: "session-1", + instanceID: "instance-1", + check: &domain.CheckTypeRecoveryCode{ + RecoveryCode: "test-code", + }, + verify: func(encoded, password string) (updated string, err error) { + return "", errors.New("verify error") + }, + sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository { + sessionRepo := domainmock.NewSessionRepo(ctrl) + // set up expectation to get session in Validate() + getSessionSucceededExpectation(sessionRepo) + return sessionRepo + }, + userRepo: func(ctrl *gomock.Controller) domain.UserRepository { + userRepo := domainmock.NewUserRepo(ctrl) + // set up expectation to get recovery codes in Validate() + getUserSucceededExpectation(userRepo, 2) + + // set up expectation to update user in Execute() + updateHumanUserFailedExpectation(ctrl, userRepo, true, nil, 0) + + return userRepo + }, + lockoutRepo: func(ctrl *gomock.Controller) domain.LockoutSettingsRepository { + lockoutRepo := domainmock.NewLockoutSettingsRepo(ctrl) + // set up expectation to get lockout settings in Execute() + getLockoutSettingsSucceededExpectation("instance-1", "org-1", lockoutRepo) + return lockoutRepo + }, + wantErr: zerrors.ThrowInvalidArgument(nil, "DOM-845kaq", "Errors.User.MFA.RecoveryCodes.InvalidCode"), + }, + { + name: "invalid recovery code - multiple user rows updated", + sessionID: "session-1", + instanceID: "instance-1", + check: &domain.CheckTypeRecoveryCode{ + RecoveryCode: "test-code", + }, + verify: func(encoded, password string) (updated string, err error) { + return "", errors.New("verify error") + }, + sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository { + sessionRepo := domainmock.NewSessionRepo(ctrl) + // set up expectation to get session in Validate() + getSessionSucceededExpectation(sessionRepo) + return sessionRepo + }, + userRepo: func(ctrl *gomock.Controller) domain.UserRepository { + userRepo := domainmock.NewUserRepo(ctrl) + // set up expectation to get recovery codes in Validate() + getUserSucceededExpectation(userRepo, 2) + + // set up expectation to update user in Execute() + updateHumanUserFailedExpectation(ctrl, userRepo, true, nil, 2) + + return userRepo + }, + lockoutRepo: func(ctrl *gomock.Controller) domain.LockoutSettingsRepository { + lockoutRepo := domainmock.NewLockoutSettingsRepo(ctrl) + // set up expectation to get lockout settings in Execute() + getLockoutSettingsSucceededExpectation("instance-1", "org-1", lockoutRepo) + return lockoutRepo + }, + wantErr: zerrors.ThrowInvalidArgument(nil, "DOM-845kaq", "Errors.User.MFA.RecoveryCodes.InvalidCode"), + }, + { + name: "invalid recovery code - failed to update session", + sessionID: "session-1", + instanceID: "instance-1", + check: &domain.CheckTypeRecoveryCode{ + RecoveryCode: "test-code", + }, + verify: func(encoded, password string) (updated string, err error) { + return "", errors.New("verify error") + }, + sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository { + sessionRepo := domainmock.NewSessionRepo(ctrl) + // set up expectation to get session in Validate() + getSessionSucceededExpectation(sessionRepo) + + // set up expectation to update session in Execute() + updateSessionFailedExpectation(sessionRepo, dbError, 0) + return sessionRepo + }, + userRepo: func(ctrl *gomock.Controller) domain.UserRepository { + userRepo := domainmock.NewUserRepo(ctrl) + // set up expectation to get recovery codes in Validate() + getUserSucceededExpectation(userRepo, 0) + + // set up expectation to update user in Execute() + humanRepo := domainmock.NewHumanRepo(ctrl) + updateHumanUserSucceededExpectation(userRepo, humanRepo, humanRepo.IncrementRecoveryCodeFailedAttempts()) + + return userRepo + }, + lockoutRepo: func(ctrl *gomock.Controller) domain.LockoutSettingsRepository { + lockoutRepo := domainmock.NewLockoutSettingsRepo(ctrl) + // set up expectation to get lockout settings in Execute() + getLockoutSettingsSucceededExpectation("instance-1", "org-1", lockoutRepo) + return lockoutRepo + }, + wantErr: zerrors.ThrowInvalidArgument(nil, "DOM-845kaq", "Errors.User.MFA.RecoveryCodes.InvalidCode"), + }, + { + name: "invalid recovery code - session not found", + sessionID: "session-1", + instanceID: "instance-1", + check: &domain.CheckTypeRecoveryCode{ + RecoveryCode: "test-code", + }, + verify: func(encoded, password string) (updated string, err error) { + return "", errors.New("verify error") + }, + sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository { + sessionRepo := domainmock.NewSessionRepo(ctrl) + // set up expectation to get session in Validate() + getSessionSucceededExpectation(sessionRepo) + + // set up expectation to update session in Execute() + updateSessionFailedExpectation(sessionRepo, nil, 0) + return sessionRepo + }, + userRepo: func(ctrl *gomock.Controller) domain.UserRepository { + userRepo := domainmock.NewUserRepo(ctrl) + // set up expectation to get recovery codes in Validate() + getUserSucceededExpectation(userRepo, 0) + + // set up expectation to update user in Execute() + humanRepo := domainmock.NewHumanRepo(ctrl) + updateHumanUserSucceededExpectation(userRepo, humanRepo, humanRepo.IncrementRecoveryCodeFailedAttempts()) + + return userRepo + }, + lockoutRepo: func(ctrl *gomock.Controller) domain.LockoutSettingsRepository { + lockoutRepo := domainmock.NewLockoutSettingsRepo(ctrl) + // set up expectation to get lockout settings in Execute() + getLockoutSettingsSucceededExpectation("instance-1", "org-1", lockoutRepo) + return lockoutRepo + }, + wantErr: zerrors.ThrowInvalidArgument(nil, "DOM-845kaq", "Errors.User.MFA.RecoveryCodes.InvalidCode"), + }, + { + name: "invalid recovery code - multiple session rows updated", + sessionID: "session-1", + instanceID: "instance-1", + check: &domain.CheckTypeRecoveryCode{ + RecoveryCode: "test-code", + }, + verify: func(encoded, password string) (updated string, err error) { + return "", errors.New("verify error") + }, + sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository { + sessionRepo := domainmock.NewSessionRepo(ctrl) + // set up expectation to get session in Validate() + getSessionSucceededExpectation(sessionRepo) + + // set up expectation to update session in Execute() + updateSessionFailedExpectation(sessionRepo, nil, 2) + return sessionRepo + }, + userRepo: func(ctrl *gomock.Controller) domain.UserRepository { + userRepo := domainmock.NewUserRepo(ctrl) + // set up expectation to get recovery codes in Validate() + getUserSucceededExpectation(userRepo, 0) + + // set up expectation to update user in Execute() + humanRepo := domainmock.NewHumanRepo(ctrl) + updateHumanUserSucceededExpectation(userRepo, humanRepo, humanRepo.IncrementRecoveryCodeFailedAttempts()) + + return userRepo + }, + lockoutRepo: func(ctrl *gomock.Controller) domain.LockoutSettingsRepository { + lockoutRepo := domainmock.NewLockoutSettingsRepo(ctrl) + // set up expectation to get lockout settings in Execute() + getLockoutSettingsSucceededExpectation("instance-1", "org-1", lockoutRepo) + return lockoutRepo + }, + wantErr: zerrors.ThrowInvalidArgument(nil, "DOM-845kaq", "Errors.User.MFA.RecoveryCodes.InvalidCode"), + }, + { + name: "invalid recovery code - update user and session with failed status and return error", + sessionID: "session-1", + instanceID: "instance-1", + check: &domain.CheckTypeRecoveryCode{ + RecoveryCode: "test-code", + }, + verify: func(encoded, password string) (updated string, err error) { + return "", errors.New("verify error") + }, + sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository { + sessionRepo := domainmock.NewSessionRepo(ctrl) + // set up expectation to get session in Validate() + getSessionSucceededExpectation(sessionRepo) + + // set up expectation to update session in Execute() + factor := sessionRepo.SetFactor(&domain.SessionFactorRecoveryCode{ + LastFailedAt: time.Now(), + }) + updateSessionSucceededExpectation(sessionRepo, factor) + return sessionRepo + }, + userRepo: func(ctrl *gomock.Controller) domain.UserRepository { + userRepo := domainmock.NewUserRepo(ctrl) + // set up expectation to get recovery codes in Validate() + getUserSucceededExpectation(userRepo, 0) + + // set up expectation to update user in Execute() + humanRepo := domainmock.NewHumanRepo(ctrl) + updateHumanUserSucceededExpectation(userRepo, humanRepo, humanRepo.IncrementRecoveryCodeFailedAttempts()) + + return userRepo + }, + lockoutRepo: func(ctrl *gomock.Controller) domain.LockoutSettingsRepository { + lockoutRepo := domainmock.NewLockoutSettingsRepo(ctrl) + // set up expectation to get lockout settings in Execute() + getLockoutSettingsSucceededExpectation("instance-1", "org-1", lockoutRepo) + return lockoutRepo + }, + wantErr: zerrors.ThrowInvalidArgument(nil, "DOM-845kaq", "Errors.User.MFA.RecoveryCodes.InvalidCode"), + }, + { + name: "invalid recovery code - set user as locked, update user and session with failed status, and return error", + sessionID: "session-1", + instanceID: "instance-1", + check: &domain.CheckTypeRecoveryCode{ + RecoveryCode: "test-code", + }, + verify: func(encoded, password string) (updated string, err error) { + return "", errors.New("verify error") + }, + sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository { + sessionRepo := domainmock.NewSessionRepo(ctrl) + // set up expectation to get session in Validate() + getSessionSucceededExpectation(sessionRepo) + + // set up expectation to update session in Execute() + factor := sessionRepo.SetFactor(&domain.SessionFactorRecoveryCode{ + LastFailedAt: time.Now(), + }) + updateSessionSucceededExpectation(sessionRepo, factor) + return sessionRepo + }, + userRepo: func(ctrl *gomock.Controller) domain.UserRepository { + userRepo := domainmock.NewUserRepo(ctrl) + // set up expectation to get recovery codes in Validate() + getUserSucceededExpectation(userRepo, 2) + + // set up expectation to update user in Execute() + humanRepo := domainmock.NewHumanRepo(ctrl) + updateHumanUserSucceededExpectation(userRepo, humanRepo, humanRepo.IncrementRecoveryCodeFailedAttempts(), + humanRepo.SetState(domain.UserStateLocked)) + + return userRepo + }, + lockoutRepo: func(ctrl *gomock.Controller) domain.LockoutSettingsRepository { + lockoutRepo := domainmock.NewLockoutSettingsRepo(ctrl) + // set up expectation to get lockout settings in Execute() + getLockoutSettingsSucceededExpectation("instance-1", "org-1", lockoutRepo) + return lockoutRepo + }, + wantErr: zerrors.ThrowInvalidArgument(nil, "DOM-845kaq", "Errors.User.MFA.RecoveryCodes.InvalidCode"), + }, + { + name: "recovery code check succeeded - user update failed, return error", + sessionID: "session-1", + instanceID: "instance-1", + check: &domain.CheckTypeRecoveryCode{ + RecoveryCode: "test-code", + }, + verify: func(encoded, password string) (updated string, err error) { + return "hashed-code1", nil + }, + sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository { + sessionRepo := domainmock.NewSessionRepo(ctrl) + // set up expectation to get session in Validate() + getSessionSucceededExpectation(sessionRepo) + + return sessionRepo + }, + userRepo: func(ctrl *gomock.Controller) domain.UserRepository { + userRepo := domainmock.NewUserRepo(ctrl) + // set up expectation to get recovery codes in Validate() + getUserSucceededExpectation(userRepo, 2) + + // set up expectation to update user in Execute() + humanRepo := domainmock.NewHumanRepo(ctrl) + primaryKeyCondition := humanRepo.PrimaryKeyCondition("instance-1", "user-1") + humanRepo.EXPECT(). + Update(gomock.Any(), + gomock.Any(), + primaryKeyCondition, + gomock.Any(), + humanRepo.RemoveRecoveryCode("hashed-code1"), + ).Times(1). + Return(int64(0), dbError) + userRepo.EXPECT().Human().Times(1).Return(humanRepo) + return userRepo + }, + wantErr: zerrors.ThrowInternal(dbError, "DOM-XGf3Tk", "user update failed"), + }, + { + name: "recovery code check succeeded - user not found, return error", + sessionID: "session-1", + instanceID: "instance-1", + check: &domain.CheckTypeRecoveryCode{ + RecoveryCode: "test-code", + }, + verify: func(encoded, password string) (updated string, err error) { + return "hashed-code1", nil + }, + sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository { + sessionRepo := domainmock.NewSessionRepo(ctrl) + // set up expectation to get session in Validate() + getSessionSucceededExpectation(sessionRepo) + + return sessionRepo + }, + userRepo: func(ctrl *gomock.Controller) domain.UserRepository { + userRepo := domainmock.NewUserRepo(ctrl) + // set up expectation to get recovery codes in Validate() + getUserSucceededExpectation(userRepo, 2) + + // set up expectation to update user in Execute() + humanRepo := domainmock.NewHumanRepo(ctrl) + primaryKeyCondition := humanRepo.PrimaryKeyCondition("instance-1", "user-1") + humanRepo.EXPECT(). + Update(gomock.Any(), + gomock.Any(), + primaryKeyCondition, + gomock.Any(), + humanRepo.RemoveRecoveryCode("hashed-code1"), + ).Times(1). + Return(int64(0), nil) + userRepo.EXPECT().Human().Times(1).Return(humanRepo) + return userRepo + }, + wantErr: zerrors.ThrowNotFound(nil, "DOM-hQu5ns", "Errors.User.NotFound"), + }, + { + name: "recovery code check succeeded - multiple user rows updated, return error", + sessionID: "session-1", + instanceID: "instance-1", + check: &domain.CheckTypeRecoveryCode{ + RecoveryCode: "test-code", + }, + verify: func(encoded, password string) (updated string, err error) { + return "hashed-code1", nil + }, + sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository { + sessionRepo := domainmock.NewSessionRepo(ctrl) + // set up expectation to get session in Validate() + getSessionSucceededExpectation(sessionRepo) + + return sessionRepo + }, + userRepo: func(ctrl *gomock.Controller) domain.UserRepository { + userRepo := domainmock.NewUserRepo(ctrl) + // set up expectation to get recovery codes in Validate() + getUserSucceededExpectation(userRepo, 2) + + // set up expectation to update user in Execute() + humanRepo := domainmock.NewHumanRepo(ctrl) + primaryKeyCondition := humanRepo.PrimaryKeyCondition("instance-1", "user-1") + humanRepo.EXPECT(). + Update(gomock.Any(), + gomock.Any(), + primaryKeyCondition, + gomock.Any(), + humanRepo.RemoveRecoveryCode("hashed-code1"), + ).Times(1). + Return(int64(2), nil) + userRepo.EXPECT().Human().Times(1).Return(humanRepo) + return userRepo + }, + wantErr: zerrors.ThrowInternal(domain.NewMultipleObjectsUpdatedError(1, 2), "DOM-EWjTOH", "Errors.Internal"), + }, + { + name: "recovery code check succeeded - session update failed, return error", + sessionID: "session-1", + instanceID: "instance-1", + check: &domain.CheckTypeRecoveryCode{ + RecoveryCode: "test-code", + }, + verify: func(encoded, password string) (updated string, err error) { + return "hashed-code1", nil + }, + sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository { + sessionRepo := domainmock.NewSessionRepo(ctrl) + // set up expectation to get session in Validate() + getSessionSucceededExpectation(sessionRepo) + + // set up expectation to update session in Execute() + updateSessionFailedExpectation(sessionRepo, dbError, 0) + return sessionRepo + }, + userRepo: func(ctrl *gomock.Controller) domain.UserRepository { + userRepo := domainmock.NewUserRepo(ctrl) + // set up expectation to get recovery codes in Validate() + getUserSucceededExpectation(userRepo, 2) + + // set up expectation to update user in Execute() + humanRepo := domainmock.NewHumanRepo(ctrl) + primaryKeyCondition := humanRepo.PrimaryKeyCondition("instance-1", "user-1") + humanRepo.EXPECT(). + Update(gomock.Any(), + gomock.Any(), + primaryKeyCondition, + gomock.Any(), + humanRepo.RemoveRecoveryCode("hashed-code1"), + ).Times(1). + Return(int64(1), nil) + userRepo.EXPECT().Human().Times(1).Return(humanRepo) + + return userRepo + }, + wantErr: zerrors.ThrowInternal(dbError, "DOM-fhR4N3", "session update failed"), + }, + { + name: "recovery code check succeeded - session not found, return error", + sessionID: "session-1", + instanceID: "instance-1", + check: &domain.CheckTypeRecoveryCode{ + RecoveryCode: "test-code", + }, + verify: func(encoded, password string) (updated string, err error) { + return "hashed-code1", nil + }, + sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository { + sessionRepo := domainmock.NewSessionRepo(ctrl) + // set up expectation to get session in Validate() + getSessionSucceededExpectation(sessionRepo) + + // set up expectation to update session in Execute() + updateSessionFailedExpectation(sessionRepo, nil, 0) + return sessionRepo + }, + userRepo: func(ctrl *gomock.Controller) domain.UserRepository { + userRepo := domainmock.NewUserRepo(ctrl) + // set up expectation to get recovery codes in Validate() + getUserSucceededExpectation(userRepo, 2) + + // set up expectation to update user in Execute() + humanRepo := domainmock.NewHumanRepo(ctrl) + primaryKeyCondition := humanRepo.PrimaryKeyCondition("instance-1", "user-1") + humanRepo.EXPECT(). + Update(gomock.Any(), + gomock.Any(), + primaryKeyCondition, + gomock.Any(), + humanRepo.RemoveRecoveryCode("hashed-code1"), + ).Times(1). + Return(int64(1), nil) + userRepo.EXPECT().Human().Times(1).Return(humanRepo) + + return userRepo + }, + wantErr: zerrors.ThrowNotFound(nil, "DOM-Frnwxt", "Errors.Session.NotFound"), + }, + { + name: "recovery code check succeeded - multiple session rows updated, return error", + sessionID: "session-1", + instanceID: "instance-1", + check: &domain.CheckTypeRecoveryCode{ + RecoveryCode: "test-code", + }, + verify: func(encoded, password string) (updated string, err error) { + return "hashed-code1", nil + }, + sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository { + sessionRepo := domainmock.NewSessionRepo(ctrl) + // set up expectation to get session in Validate() + getSessionSucceededExpectation(sessionRepo) + + // set up expectation to update session in Execute() + updateSessionFailedExpectation(sessionRepo, nil, 2) + return sessionRepo + }, + userRepo: func(ctrl *gomock.Controller) domain.UserRepository { + userRepo := domainmock.NewUserRepo(ctrl) + // set up expectation to get recovery codes in Validate() + getUserSucceededExpectation(userRepo, 0) + + // set up expectation to update user in Execute() + humanRepo := domainmock.NewHumanRepo(ctrl) + primaryKeyCondition := humanRepo.PrimaryKeyCondition("instance-1", "user-1") + humanRepo.EXPECT(). + Update(gomock.Any(), + gomock.Any(), + primaryKeyCondition, + gomock.Any(), + humanRepo.RemoveRecoveryCode("hashed-code1"), + ).Times(1). + Return(int64(1), nil) + userRepo.EXPECT().Human().Times(1).Return(humanRepo) + + return userRepo + }, + wantErr: zerrors.ThrowInternal(domain.NewMultipleObjectsUpdatedError(1, 2), "DOM-gYp8tG", "unexpected number of rows updateCount"), + }, + { + name: "recovery code check succeeded - user and session updated successfully", + sessionID: "session-1", + instanceID: "instance-1", + check: &domain.CheckTypeRecoveryCode{ + RecoveryCode: "test-code", + }, + verify: func(encoded, password string) (updated string, err error) { + return "hashed-code1", nil + }, + sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository { + sessionRepo := domainmock.NewSessionRepo(ctrl) + // set up expectation to get session in Validate() + getSessionSucceededExpectation(sessionRepo) + + // set up expectation to update session in Execute() + factor := sessionRepo.SetFactor(&domain.SessionFactorRecoveryCode{ + LastVerifiedAt: time.Now(), + }) + updateSessionSucceededExpectation(sessionRepo, factor) + return sessionRepo + }, + userRepo: func(ctrl *gomock.Controller) domain.UserRepository { + userRepo := domainmock.NewUserRepo(ctrl) + // set up expectation to get recovery codes in Validate() + getUserSucceededExpectation(userRepo, 2) + + // set up expectation to update user in Execute() + humanRepo := domainmock.NewHumanRepo(ctrl) + primaryKeyCondition := humanRepo.PrimaryKeyCondition("instance-1", "user-1") + humanRepo.EXPECT(). + Update(gomock.Any(), + gomock.Any(), + primaryKeyCondition, + gomock.Any(), + humanRepo.RemoveRecoveryCode("hashed-code1"), + ).Times(1). + Return(int64(1), nil) + userRepo.EXPECT().Human().Times(1).Return(humanRepo) + + return userRepo + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + + opts := &domain.InvokeOpts{ + Invoker: domain.NewTransactionInvoker(nil), + } + domain.WithQueryExecutor(new(noopdb.Pool))(opts) + if tt.userRepo != nil { + domain.WithUserRepo(tt.userRepo(ctrl))(opts) + } + if tt.sessionRepo != nil { + domain.WithSessionRepo(tt.sessionRepo(ctrl))(opts) + } + if tt.lockoutRepo != nil { + domain.WithLockoutSettingsRepo(tt.lockoutRepo(ctrl))(opts) + } + + ctx := context.Background() + cmd := domain.NewRecoveryCodeCheckCommand(tt.sessionID, tt.instanceID, tt.check, tt.verify) + + // to fetch/validate session and user before calling Execute + err := cmd.Validate(ctx, opts) + assert.NoError(t, err) + + got := cmd.Execute(ctx, opts) + assert.ErrorIs(t, got, tt.wantErr) + }) + } +} + +func TestRecoveryCodeCheckCommand_Events(t *testing.T) { + t.Parallel() + updateErr := errors.New("update error") + + tests := []struct { + name string + sessionID string + instanceID string + check *domain.CheckTypeRecoveryCode + verify func(encoded, password string) (updated string, err error) + sessionRepo func(ctrl *gomock.Controller) domain.SessionRepository + userRepo func(ctrl *gomock.Controller) domain.UserRepository + humanRepo func(ctrl *gomock.Controller) domain.HumanUserRepository + lockoutRepo func(ctrl *gomock.Controller) domain.LockoutSettingsRepository + wantErr error + wantEvents []eventstore.Command + }{ + { + name: "no check needed", + }, + { + // relevant events: HumanRecoveryCodeCheckSucceededEvent, NewRecoveryCodeCheckedEvent + name: "recovery code check succeeded - return relevant events", + sessionID: "session-1", + instanceID: "instance-1", + check: &domain.CheckTypeRecoveryCode{ + RecoveryCode: "test-code", + }, + verify: func(encoded, password string) (updated string, err error) { + return "hashed-code1", nil + }, + sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository { + sessionRepo := domainmock.NewSessionRepo(ctrl) + // set up expectation to get session in Validate() + getSessionSucceededExpectation(sessionRepo) + + // set up expectation to update session in Execute() + factor := sessionRepo.SetFactor(&domain.SessionFactorRecoveryCode{ + LastVerifiedAt: time.Now(), + }) + updateSessionSucceededExpectation(sessionRepo, factor) + return sessionRepo + }, + userRepo: func(ctrl *gomock.Controller) domain.UserRepository { + userRepo := domainmock.NewUserRepo(ctrl) + // set up expectation to get recovery codes in Validate() + getUserSucceededExpectation(userRepo, 0) + + // set up expectation to update user in Execute() + humanRepo := domainmock.NewHumanRepo(ctrl) + primaryKeyCondition := humanRepo.PrimaryKeyCondition("instance-1", "user-1") + humanRepo.EXPECT(). + Update(gomock.Any(), + gomock.Any(), + primaryKeyCondition, + gomock.Any(), + humanRepo.RemoveRecoveryCode("hashed-code1"), + ).Times(1). + Return(int64(1), nil) + userRepo.EXPECT().Human().Times(1).Return(humanRepo) + + return userRepo + }, + wantEvents: []eventstore.Command{ + user.NewHumanRecoveryCodeCheckSucceededEvent( + t.Context(), + &user.NewAggregate("user-1", "org-1").Aggregate, + "hashed-code1", + nil, + ), + session.NewRecoveryCodeCheckedEvent( + t.Context(), + &session.NewAggregate("session-1", "instance-1").Aggregate, + time.Now(), + ), + }, + }, + { + // relevant events: HumanRecoveryCodeCheckFailedEvent; + // the user isn't locked despite exceeding failed attempts as the recovery code is valid + name: "recovery code check succeeded but update failed - return error and relevant events", + sessionID: "session-1", + instanceID: "instance-1", + check: &domain.CheckTypeRecoveryCode{ + RecoveryCode: "test-code", + }, + verify: func(encoded, password string) (updated string, err error) { + return "hashed-code1", nil + }, + sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository { + sessionRepo := domainmock.NewSessionRepo(ctrl) + // set up expectation to get session in Validate() + getSessionSucceededExpectation(sessionRepo) + return sessionRepo + }, + userRepo: func(ctrl *gomock.Controller) domain.UserRepository { + userRepo := domainmock.NewUserRepo(ctrl) + // set up expectation to get recovery codes in Validate() + getUserSucceededExpectation(userRepo, 2) + + // set up expectation to update user in Execute() + humanRepo := domainmock.NewHumanRepo(ctrl) + primaryKeyCondition := humanRepo.PrimaryKeyCondition("instance-1", "user-1") + humanRepo.EXPECT(). + Update(gomock.Any(), + gomock.Any(), + primaryKeyCondition, + gomock.Any(), + humanRepo.RemoveRecoveryCode("hashed-code1"), + ).Times(1). + Return(int64(0), updateErr) + userRepo.EXPECT().Human().Times(1).Return(humanRepo) + + return userRepo + }, + wantEvents: []eventstore.Command{ + user.NewHumanRecoveryCodeCheckFailedEvent( + t.Context(), + &user.NewAggregate("user-1", "org-1").Aggregate, + nil, + ), + }, + wantErr: zerrors.ThrowInternal(updateErr, "DOM-XGf3Tk", "user update failed"), + }, + { + // relevant events: HumanRecoveryCodeCheckFailedEvent and UserLockedEvent + name: "invalid recovery code and max OTP attempts reached - return relevant events and error", + sessionID: "session-1", + instanceID: "instance-1", + check: &domain.CheckTypeRecoveryCode{ + RecoveryCode: "test-code", + }, + verify: func(encoded, password string) (updated string, err error) { + return "", errors.New("verify error") + }, + sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository { + sessionRepo := domainmock.NewSessionRepo(ctrl) + // set up expectation to get session in Validate() + getSessionSucceededExpectation(sessionRepo) + + // set up expectation to update session in Execute() + factor := sessionRepo.SetFactor(&domain.SessionFactorRecoveryCode{ + LastFailedAt: time.Now(), + }) + updateSessionSucceededExpectation(sessionRepo, factor) + return sessionRepo + }, + userRepo: func(ctrl *gomock.Controller) domain.UserRepository { + userRepo := domainmock.NewUserRepo(ctrl) + // set up expectation to get recovery codes in Validate() + getUserSucceededExpectation(userRepo, 2) // 2 failed attempts, with max set to 3 + + // set up expectation to update user in Execute() + humanRepo := domainmock.NewHumanRepo(ctrl) + updateHumanUserSucceededExpectation( + userRepo, + humanRepo, + humanRepo.IncrementRecoveryCodeFailedAttempts(), + humanRepo.SetState(domain.UserStateLocked), + ) + + return userRepo + }, + lockoutRepo: func(ctrl *gomock.Controller) domain.LockoutSettingsRepository { + lockoutRepo := domainmock.NewLockoutSettingsRepo(ctrl) + // set up expectation to get lockout settings in Execute() + getLockoutSettingsSucceededExpectation("instance-1", "org-1", lockoutRepo) + return lockoutRepo + }, + wantEvents: []eventstore.Command{ + user.NewHumanRecoveryCodeCheckFailedEvent( + t.Context(), + &user.NewAggregate("user-1", "org-1").Aggregate, + nil, + ), + user.NewUserLockedEvent( + t.Context(), + &user.NewAggregate("user-1", "org-1").Aggregate, + ), + }, + wantErr: zerrors.ThrowInvalidArgument(nil, "DOM-845kaq", "Errors.User.MFA.RecoveryCodes.InvalidCode"), + }, + { + // relevant events: HumanRecoveryCodeCheckFailedEvent + name: "invalid recovery code - return relevant events and error", + sessionID: "session-1", + instanceID: "instance-1", + check: &domain.CheckTypeRecoveryCode{ + RecoveryCode: "test-code", + }, + verify: func(encoded, password string) (updated string, err error) { + return "", errors.New("verify error") + }, + sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository { + sessionRepo := domainmock.NewSessionRepo(ctrl) + // set up expectation to get session in Validate() + getSessionSucceededExpectation(sessionRepo) + + // set up expectation to update session in Execute() + factor := sessionRepo.SetFactor(&domain.SessionFactorRecoveryCode{ + LastFailedAt: time.Now(), + }) + updateSessionSucceededExpectation(sessionRepo, factor) + return sessionRepo + }, + userRepo: func(ctrl *gomock.Controller) domain.UserRepository { + userRepo := domainmock.NewUserRepo(ctrl) + // set up expectation to get recovery codes in Validate() + getUserSucceededExpectation(userRepo, 1) + + // set up expectation to update user in Execute() + humanRepo := domainmock.NewHumanRepo(ctrl) + updateHumanUserSucceededExpectation( + userRepo, + humanRepo, humanRepo.IncrementRecoveryCodeFailedAttempts(), + ) + + return userRepo + }, + lockoutRepo: func(ctrl *gomock.Controller) domain.LockoutSettingsRepository { + lockoutRepo := domainmock.NewLockoutSettingsRepo(ctrl) + // set up expectation to get lockout settings in Execute() + getLockoutSettingsSucceededExpectation("instance-1", "org-1", lockoutRepo) + return lockoutRepo + }, + wantEvents: []eventstore.Command{ + user.NewHumanRecoveryCodeCheckFailedEvent( + t.Context(), + &user.NewAggregate("user-1", "org-1").Aggregate, + nil, + ), + }, + wantErr: zerrors.ThrowInvalidArgument(nil, "DOM-845kaq", "Errors.User.MFA.RecoveryCodes.InvalidCode"), + }, + { + // relevant events: HumanRecoveryCodeCheckFailedEvent + name: "invalid recovery code and update failed - return relevant events and error", + sessionID: "session-1", + instanceID: "instance-1", + check: &domain.CheckTypeRecoveryCode{ + RecoveryCode: "test-code", + }, + verify: func(encoded, password string) (updated string, err error) { + return "", errors.New("verify error") + }, + sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository { + sessionRepo := domainmock.NewSessionRepo(ctrl) + // set up expectation to get session in Validate() + getSessionSucceededExpectation(sessionRepo) + return sessionRepo + }, + userRepo: func(ctrl *gomock.Controller) domain.UserRepository { + userRepo := domainmock.NewUserRepo(ctrl) + // set up expectation to get recovery codes in Validate() + getUserSucceededExpectation(userRepo, 1) + + // set up expectation to update user in Execute() + updateHumanUserFailedExpectation( + ctrl, + userRepo, + false, + updateErr, + 0, + ) + + return userRepo + }, + lockoutRepo: func(ctrl *gomock.Controller) domain.LockoutSettingsRepository { + lockoutRepo := domainmock.NewLockoutSettingsRepo(ctrl) + // set up expectation to get lockout settings in Execute() + getLockoutSettingsSucceededExpectation("instance-1", "org-1", lockoutRepo) + return lockoutRepo + }, + wantEvents: []eventstore.Command{ + user.NewHumanRecoveryCodeCheckFailedEvent( + t.Context(), + &user.NewAggregate("user-1", "org-1").Aggregate, + nil, + ), + }, + wantErr: zerrors.ThrowInvalidArgument(nil, "DOM-845kaq", "Errors.User.MFA.RecoveryCodes.InvalidCode"), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + + opts := &domain.InvokeOpts{ + Invoker: domain.NewTransactionInvoker(nil), + } + domain.WithQueryExecutor(new(noopdb.Pool))(opts) + if tt.userRepo != nil { + domain.WithUserRepo(tt.userRepo(ctrl))(opts) + } + if tt.sessionRepo != nil { + domain.WithSessionRepo(tt.sessionRepo(ctrl))(opts) + } + if tt.lockoutRepo != nil { + domain.WithLockoutSettingsRepo(tt.lockoutRepo(ctrl))(opts) + } + + ctx := context.Background() + cmd := domain.NewRecoveryCodeCheckCommand(tt.sessionID, tt.instanceID, tt.check, tt.verify) + + // to fetch/validate session and user before calling Execute + err := cmd.Validate(ctx, opts) + assert.NoError(t, err) + + // to update user/session before calling Events + err = cmd.Execute(ctx, opts) + if tt.wantErr != nil { + assert.ErrorIs(t, err, tt.wantErr) + } else { + assert.NoError(t, err) + } + + got, gotErr := cmd.Events(ctx, opts) + assert.NoError(t, gotErr) + assertEvents(t, got, tt.wantEvents) + }) + } +} + +func assertEvents(t *testing.T, got, want []eventstore.Command) { + require.Len(t, got, len(want)) + for i, event := range want { + assert.IsType(t, event, got[i]) + switch expectedEvent := event.(type) { + case *user.HumanRecoveryCodeCheckSucceededEvent: + gotEvent, ok := got[i].(*user.HumanRecoveryCodeCheckSucceededEvent) + require.True(t, ok) + assert.Equal(t, expectedEvent, gotEvent) + case *session.RecoveryCodeCheckedEvent: + gotEvent, ok := got[i].(*session.RecoveryCodeCheckedEvent) + require.True(t, ok) + assert.NotZero(t, gotEvent.CheckedAt) + assert.Equal(t, expectedEvent.Aggregate().ID, gotEvent.Aggregate().ID) + assert.Equal(t, expectedEvent.Aggregate().ResourceOwner, gotEvent.Aggregate().ResourceOwner) + case *user.HumanRecoveryCodeCheckFailedEvent: + gotEvent, ok := got[i].(*user.HumanRecoveryCodeCheckFailedEvent) + require.True(t, ok) + assert.Equal(t, expectedEvent, gotEvent) + case *user.UserLockedEvent: + gotEvent, ok := got[i].(*user.UserLockedEvent) + require.True(t, ok) + assert.Equal(t, expectedEvent, gotEvent) + } + } + +} + +func updateHumanUserSucceededExpectation(userRepo *domainmock.UserRepo, humanRepo *domainmock.HumanRepo, userUpdates ...database.Change) { + primaryKeyCondition := humanRepo.PrimaryKeyCondition("instance-1", "user-1") + humanRepo.EXPECT(). + Update(gomock.Any(), gomock.Any(), primaryKeyCondition, userUpdates). + Times(1). + Return(int64(1), nil) + userRepo.EXPECT().Human().Times(1).Return(humanRepo) +} + +func updateHumanUserFailedExpectation(ctrl *gomock.Controller, userRepo *domainmock.UserRepo, lock bool, err error, updateCount int64) { + humanRepo := domainmock.NewHumanRepo(ctrl) + primaryKeyCondition := humanRepo.PrimaryKeyCondition("instance-1", "user-1") + + userUpdates := make([]database.Change, 0, 2) + userUpdates = append(userUpdates, humanRepo.IncrementRecoveryCodeFailedAttempts()) + + if lock { + userUpdates = append(userUpdates, humanRepo.SetState(domain.UserStateLocked)) + } + userRepo.EXPECT().Human().Times(1).Return(humanRepo) + if err != nil { + humanRepo.EXPECT(). + Update(gomock.Any(), gomock.Any(), primaryKeyCondition, userUpdates). + Times(1). + Return(int64(0), err) + return + } + switch updateCount { + case 0: + humanRepo.EXPECT(). + Update(gomock.Any(), gomock.Any(), primaryKeyCondition, userUpdates). + Times(1). + Return(int64(0), nil) + case 1: + humanRepo.EXPECT(). + Update(gomock.Any(), gomock.Any(), primaryKeyCondition, userUpdates). + Times(1). + Return(int64(1), nil) + default: + humanRepo.EXPECT(). + Update(gomock.Any(), gomock.Any(), primaryKeyCondition, userUpdates). + Times(1). + Return(updateCount, nil) + } +} + +func getUserSucceededExpectation(userRepo *domainmock.UserRepo, failedAttempts uint8) { + userIDCondition := userRepo.IDCondition("user-1") + userRepo.EXPECT(). + Get(gomock.Any(), gomock.Any(), dbmock.QueryOptions(database.WithCondition(userIDCondition))). + Times(1). + Return( + &domain.User{ + ID: "user-1", + InstanceID: "instance-1", + OrganizationID: "org-1", + State: domain.UserStateActive, + Human: &domain.HumanUser{ + RecoveryCodes: &domain.HumanRecoveryCodes{ + Codes: []string{"hashed-code1", "hashed-code2", "hashed-code3"}, + FailedAttempts: failedAttempts, + }, + }, + }, nil) +} + +func updateSessionSucceededExpectation(sessionRepo *domainmock.SessionRepo, change database.Change) { + primaryKeyCondition := sessionRepo.PrimaryKeyCondition("instance-1", "session-1") + sessionRepo.EXPECT(). + Update(gomock.Any(), gomock.Any(), primaryKeyCondition, change). + Times(1). + Return(int64(1), nil) +} + +func updateSessionFailedExpectation(sessionRepo *domainmock.SessionRepo, err error, updateCount int64) { + primaryKeyCondition := sessionRepo.PrimaryKeyCondition("instance-1", "session-1") + factor := sessionRepo.SetFactor(&domain.SessionFactorRecoveryCode{ + LastFailedAt: time.Now(), + }) + + if err != nil { + sessionRepo.EXPECT(). + Update(gomock.Any(), gomock.Any(), primaryKeyCondition, factor). + Times(1). + Return(int64(0), err) + return + } + + switch updateCount { + case 0: + sessionRepo.EXPECT(). + Update(gomock.Any(), gomock.Any(), primaryKeyCondition, factor). + Times(1). + Return(int64(0), nil) + case 1: + sessionRepo.EXPECT(). + Update(gomock.Any(), gomock.Any(), primaryKeyCondition, factor). + Times(1). + Return(int64(1), nil) + default: + sessionRepo.EXPECT(). + Update(gomock.Any(), gomock.Any(), primaryKeyCondition, factor). + Times(1). + Return(updateCount, nil) + } +} + +func getSessionSucceededExpectation(sessionRepo *domainmock.SessionRepo) { + primaryKeyCondition := sessionRepo.PrimaryKeyCondition("instance-1", "session-1") + sessionRepo.EXPECT(). + Get(gomock.Any(), gomock.Any(), dbmock.QueryOptions(database.WithCondition(primaryKeyCondition))). + Times(1). + Return(&domain.Session{UserID: "user-1"}, nil) +} + +func getLockoutSettingsSucceededExpectation(instanceID, orgID string, lockoutRepo *domainmock.LockoutSettingsRepo) { + instanceAndOrg := database.And(lockoutRepo.InstanceIDCondition(instanceID), lockoutRepo.OrganizationIDCondition(gu.Ptr(orgID))) + orgNullOrEmpty := database.Or(lockoutRepo.OrganizationIDCondition(nil), lockoutRepo.OrganizationIDCondition(gu.Ptr(""))) + onlyInstance := database.And(lockoutRepo.InstanceIDCondition(instanceID), orgNullOrEmpty) + orCondition := database.Or(instanceAndOrg, onlyInstance) + lockoutRepo.EXPECT(). + List(gomock.Any(), gomock.Any(), + dbmock.QueryOptions(database.WithCondition(orCondition)), + dbmock.QueryOptions(database.WithOrderByAscending(lockoutRepo.OrganizationIDColumn(), lockoutRepo.InstanceIDColumn())), + dbmock.QueryOptions(database.WithLimit(1)), + ).Times(1).Return( + []*domain.LockoutSettings{ + { + LockoutSettingsAttributes: domain.LockoutSettingsAttributes{ + MaxOTPAttempts: gu.Ptr(uint64(3)), + }, + }, + }, nil) +} diff --git a/backend/v3/domain/session_check_util.go b/backend/v3/domain/session_check_util.go new file mode 100644 index 000000000..f98098447 --- /dev/null +++ b/backend/v3/domain/session_check_util.go @@ -0,0 +1,45 @@ +package domain + +import ( + "context" + + "github.com/muhlemmer/gu" + + "github.com/zitadel/zitadel/backend/v3/storage/database" + "github.com/zitadel/zitadel/internal/zerrors" +) + +type verifierFn func(encoded, password string) (updated string, err error) + +func GetLockoutPolicy(ctx context.Context, opts *InvokeOpts, instanceID, orgID string) (*LockoutSettings, error) { + lockoutSettingRepo := opts.lockoutSettingRepo + + // We need the organization lockout policy first, and if not available, the instance (default) policy. + // So we retrieve all records with a matching instance ID and organization ID OR + // all records with a matching instance ID and NULL (or empty) organization ID. + // Then we assume NULLs are sorted as largest numbers (that's the case in Postgres), + // so we sort ascending by organization ID. + // We limit the result to 1 so that we get either the org policy or the instance one. + settings, err := lockoutSettingRepo.List(ctx, opts.DB(), + listLockoutSettingCondition(lockoutSettingRepo, instanceID, orgID), + database.WithOrderByAscending(lockoutSettingRepo.OrganizationIDColumn(), lockoutSettingRepo.InstanceIDColumn()), + database.WithLimit(1), + ) + if err != nil { + return nil, zerrors.ThrowInternal(err, "DOM-3B8Z6s", "failed fetching lockout settings") + } + + if rowsReturned := len(settings); rowsReturned != 1 { + return nil, zerrors.ThrowInternal(NewRowsReturnedMismatchError(1, int64(rowsReturned)), "DOM-mmsrCt", "unexpected number of rows returned") + } + + return settings[0], nil +} + +func listLockoutSettingCondition(repo LockoutSettingsRepository, instanceID, orgID string) database.QueryOption { + instanceAndOrg := database.And(repo.InstanceIDCondition(instanceID), repo.OrganizationIDCondition(&orgID)) + orgNullOrEmpty := database.Or(repo.OrganizationIDCondition(nil), repo.OrganizationIDCondition(gu.Ptr(""))) + onlyInstance := database.And(repo.InstanceIDCondition(instanceID), orgNullOrEmpty) + + return database.WithCondition(database.Or(instanceAndOrg, onlyInstance)) +} diff --git a/internal/domain/human_recovery_codes.go b/internal/domain/human_recovery_codes.go index 78cab5a27..47e5a5a3d 100644 --- a/internal/domain/human_recovery_codes.go +++ b/internal/domain/human_recovery_codes.go @@ -114,6 +114,9 @@ func generateAlphanumericCode(length int, withHyphen bool) (string, error) { return code, nil } +// ValidateRecoveryCode validates the input code against the saved recovery codes of a user. +// The validation logic is also implemented in backend/v3/domain/session_check_recovery_code.go. +// Keep both implementations in sync until this duplication is removed. func ValidateRecoveryCode(ctx context.Context, code string, recoveryCodes *HumanRecoveryCodes, hasher *crypto.Hasher) (_ string, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() diff --git a/internal/static/i18n/en.yaml b/internal/static/i18n/en.yaml index fd4986a4d..699248ea0 100644 --- a/internal/static/i18n/en.yaml +++ b/internal/static/i18n/en.yaml @@ -5,6 +5,7 @@ Errors: IDMissing: "ID missing" ResourceOwnerMissing: "Resource Owner Organisation missing" RemoveFailed: "Could not be removed" + PermissionDenied: "Permission denied" ProjectionName: Invalid: "Invalid projection name" Assets: @@ -513,6 +514,8 @@ Errors: StorageFailed: "Storing action execution log to database failed" ScanFailed: "Querying usage for action execution seconds failed" Session: + IDMissing: "Session ID missing" + NotFound: "Session not found" NotExisting: "Session does not exist" Terminated: "Session already terminated" Expired: "Session has expired"