From f99142bc4a30db3f54c652ca61af3189d80610d0 Mon Sep 17 00:00:00 2001 From: Gayathri Vijayan <66356931+grvijayan@users.noreply.github.com> Date: Thu, 26 Mar 2026 12:33:44 +0100 Subject: [PATCH] feat: implement otp sms session challenge (#11919) # Which Problems Are Solved This PR adds the OTP SMS Challenge needed for CreateSession API https://github.com/zitadel/zitadel/issues/11035 # How the Problems Are Solved - Introduce `OTPSMSChallengeCommand` (validate/execute/events) for session creation checks - Introduce `GetOTPCryptoGeneratorConfigWithDefault` to fetch per-instance secret generator settings - Add unit tests # Additional Changes Refactor tests in `session_check_recovery_code_test.go` to make the helper functions reusable in `session_challenge_otp_sms_test.go` # Additional Context Related to https://github.com/zitadel/zitadel/issues/11035 --- backend/v3/domain/defaults.go | 22 +- backend/v3/domain/invoke_option.go | 9 +- .../mock/secret_generator_settings.mock.go | 38 + .../mock/secret_generator_settings_repo.go | 48 + .../v3/domain/session_challenge_otp_sms.go | 266 ++++ .../domain/session_challenge_otp_sms_test.go | 1270 +++++++++++++++++ backend/v3/domain/session_challenge_util.go | 76 + .../v3/domain/session_challenge_util_test.go | 235 +++ .../v3/domain/session_check_recovery_code.go | 15 +- .../session_check_recovery_code_test.go | 97 +- backend/v3/domain/settings.go | 1 + cmd/start/start.go | 2 + 12 files changed, 2008 insertions(+), 71 deletions(-) create mode 100644 backend/v3/domain/mock/secret_generator_settings_repo.go create mode 100644 backend/v3/domain/session_challenge_otp_sms.go create mode 100644 backend/v3/domain/session_challenge_otp_sms_test.go create mode 100644 backend/v3/domain/session_challenge_util.go create mode 100644 backend/v3/domain/session_challenge_util_test.go diff --git a/backend/v3/domain/defaults.go b/backend/v3/domain/defaults.go index 0a7c165d0..a40568e53 100644 --- a/backend/v3/domain/defaults.go +++ b/backend/v3/domain/defaults.go @@ -8,12 +8,14 @@ import ( ) var ( - pool database.Pool - legacyEventstore eventstore.LegacyEventstore - sysConfig systemdefaults.SystemDefaults - passwordHasher *crypto.Hasher - idpEncryptionAlgo crypto.EncryptionAlgorithm - sessionTokenDecryptor SessionTokenDecryptor + pool database.Pool + legacyEventstore eventstore.LegacyEventstore + sysConfig systemdefaults.SystemDefaults + passwordHasher *crypto.Hasher + idpEncryptionAlgo crypto.EncryptionAlgorithm + sessionTokenDecryptor SessionTokenDecryptor + mfaEncryptionAlgo crypto.EncryptionAlgorithm + otpSMSSecretGeneratorConfig *crypto.GeneratorConfig ) func SetPool(p database.Pool) { @@ -39,3 +41,11 @@ func SetIDPEncryptionAlgorithm(idpEncryptionAlg crypto.EncryptionAlgorithm) { func SetSessionTokenDecryptor(decryptor SessionTokenDecryptor) { sessionTokenDecryptor = decryptor } + +func SetOTPSMSSecretGeneratorConfig(cfg *crypto.GeneratorConfig) { + otpSMSSecretGeneratorConfig = cfg +} + +func SetMFAEncryptionAlgorithm(mfaEncryptionAlg crypto.EncryptionAlgorithm) { + mfaEncryptionAlgo = mfaEncryptionAlg +} diff --git a/backend/v3/domain/invoke_option.go b/backend/v3/domain/invoke_option.go index 698fd9349..f57874438 100644 --- a/backend/v3/domain/invoke_option.go +++ b/backend/v3/domain/invoke_option.go @@ -57,6 +57,12 @@ func WithLockoutSettingsRepo(repo LockoutSettingsRepository) InvokeOpt { } } +func WithSecretGeneratorSettingsRepo(repo SecretGeneratorSettingsRepository) InvokeOpt { + return func(opts *InvokeOpts) { + opts.secretGeneratorSettingsRepo = repo + } +} + func WithPermissionChecker(checker PermissionChecker) InvokeOpt { return func(opts *InvokeOpts) { opts.Permissions = checker @@ -118,7 +124,8 @@ type InvokeOpts struct { idpIntentRepo IDPIntentRepository // Settings repos - lockoutSettingRepo LockoutSettingsRepository + lockoutSettingRepo LockoutSettingsRepository + secretGeneratorSettingsRepo SecretGeneratorSettingsRepository } func (o *InvokeOpts) DB() database.QueryExecutor { diff --git a/backend/v3/domain/mock/secret_generator_settings.mock.go b/backend/v3/domain/mock/secret_generator_settings.mock.go index 244f2c895..fa7b036ed 100644 --- a/backend/v3/domain/mock/secret_generator_settings.mock.go +++ b/backend/v3/domain/mock/secret_generator_settings.mock.go @@ -1083,6 +1083,44 @@ func (c *MockSecretGeneratorSettingsRepositoryTypeColumnCall) DoAndReturn(f func return c } +// TypeCondition mocks base method. +func (m *MockSecretGeneratorSettingsRepository) TypeCondition(typ domain.SettingType) database.Condition { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "TypeCondition", typ) + ret0, _ := ret[0].(database.Condition) + return ret0 +} + +// TypeCondition indicates an expected call of TypeCondition. +func (mr *MockSecretGeneratorSettingsRepositoryMockRecorder) TypeCondition(typ any) *MockSecretGeneratorSettingsRepositoryTypeConditionCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "TypeCondition", reflect.TypeOf((*MockSecretGeneratorSettingsRepository)(nil).TypeCondition), typ) + return &MockSecretGeneratorSettingsRepositoryTypeConditionCall{Call: call} +} + +// MockSecretGeneratorSettingsRepositoryTypeConditionCall wrap *gomock.Call +type MockSecretGeneratorSettingsRepositoryTypeConditionCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockSecretGeneratorSettingsRepositoryTypeConditionCall) Return(arg0 database.Condition) *MockSecretGeneratorSettingsRepositoryTypeConditionCall { + c.Call = c.Call.Return(arg0) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockSecretGeneratorSettingsRepositoryTypeConditionCall) Do(f func(domain.SettingType) database.Condition) *MockSecretGeneratorSettingsRepositoryTypeConditionCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockSecretGeneratorSettingsRepositoryTypeConditionCall) DoAndReturn(f func(domain.SettingType) database.Condition) *MockSecretGeneratorSettingsRepositoryTypeConditionCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + // UniqueColumns mocks base method. func (m *MockSecretGeneratorSettingsRepository) UniqueColumns() []database.Column { m.ctrl.T.Helper() diff --git a/backend/v3/domain/mock/secret_generator_settings_repo.go b/backend/v3/domain/mock/secret_generator_settings_repo.go new file mode 100644 index 000000000..967c79352 --- /dev/null +++ b/backend/v3/domain/mock/secret_generator_settings_repo.go @@ -0,0 +1,48 @@ +package domainmock + +import ( + "context" + + "go.uber.org/mock/gomock" + + "github.com/zitadel/zitadel/backend/v3/domain" + "github.com/zitadel/zitadel/backend/v3/storage/database" + "github.com/zitadel/zitadel/backend/v3/storage/database/repository" +) + +type SecretGeneratorSettingsRepo struct { + domain.SecretGeneratorSettingsRepository + mock *MockSecretGeneratorSettingsRepository +} + +func NewSecretGeneratorSettingsRepo(ctrl *gomock.Controller) *SecretGeneratorSettingsRepo { + return &SecretGeneratorSettingsRepo{ + mock: NewMockSecretGeneratorSettingsRepository(ctrl), + SecretGeneratorSettingsRepository: repository.SecretGeneratorSettingsRepository(), + } +} + +func (r *SecretGeneratorSettingsRepo) EXPECT() *MockSecretGeneratorSettingsRepositoryMockRecorder { + r.mock.ctrl.T.Helper() + return r.mock.EXPECT() +} + +func (r *SecretGeneratorSettingsRepo) Get(ctx context.Context, client database.QueryExecutor, opts ...database.QueryOption) (*domain.SecretGeneratorSettings, error) { + r.mock.ctrl.T.Helper() + return r.mock.Get(ctx, client, opts...) +} + +func (r *SecretGeneratorSettingsRepo) List(ctx context.Context, client database.QueryExecutor, opts ...database.QueryOption) ([]*domain.SecretGeneratorSettings, error) { + r.mock.ctrl.T.Helper() + return r.mock.List(ctx, client, opts...) +} + +func (r *SecretGeneratorSettingsRepo) Set(ctx context.Context, client database.QueryExecutor, settings *domain.SecretGeneratorSettings) error { + r.mock.ctrl.T.Helper() + return r.mock.Set(ctx, client, settings) +} + +func (r *SecretGeneratorSettingsRepo) Delete(ctx context.Context, client database.QueryExecutor, condition database.Condition) (int64, error) { + r.mock.ctrl.T.Helper() + return r.mock.Delete(ctx, client, condition) +} diff --git a/backend/v3/domain/session_challenge_otp_sms.go b/backend/v3/domain/session_challenge_otp_sms.go new file mode 100644 index 000000000..60efb70ba --- /dev/null +++ b/backend/v3/domain/session_challenge_otp_sms.go @@ -0,0 +1,266 @@ +package domain + +import ( + "context" + "time" + + "github.com/zitadel/zitadel/backend/v3/storage/database" + "github.com/zitadel/zitadel/internal/api/http" + "github.com/zitadel/zitadel/internal/crypto" + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/repository/session" + "github.com/zitadel/zitadel/internal/zerrors" +) + +const ( + expectedUpdatedRows = 1 + objectTypeSession = "Session" + objectTypeUser = "User" +) + +var _ Commander = (*OTPSMSChallengeCommand)(nil) +var _ Transactional = (*OTPSMSChallengeCommand)(nil) + +type ChallengeTypeOTPSMS struct { + ReturnCode bool +} + +// (@grvijayan) todo: to be implemented +// getActiveSMSProviderFn helps determine whether to generate an internal OTP code or use an external SMS provider. +// maybe temporary until we figure out how to integrate SMS providers from v1 API +type getActiveSMSProviderFn func(ctx context.Context, instanceID string) (string, error) + +// OTPSMSChallengeCommand creates an OTP SMS challenge for a session. +type OTPSMSChallengeCommand struct { + ChallengeTypeOTPSMS *ChallengeTypeOTPSMS + + SessionID string + InstanceID string + + defaultSecretGeneratorConfig *crypto.GeneratorConfig + otpAlgorithm crypto.EncryptionAlgorithm + smsProvider getActiveSMSProviderFn + newPhoneCode newOTPCodeFunc + + session *Session + user *User + + challengeOTPSMS *SessionChallengeOTPSMS // the generated OTP SMS challenge that is stored in the session. + otpSMSChallenge *string // challenge to be set in the CreateSessionResponse +} + +// NewOTPSMSChallengeCommand creates a command to generate an OTP SMS challenge for a session. +func NewOTPSMSChallengeCommand( + challengeTypeOTPSMS *ChallengeTypeOTPSMS, + sessionID string, + instanceID string, + secretGeneratorConfig *crypto.GeneratorConfig, + otpAlgorithm crypto.EncryptionAlgorithm, + smsProvider getActiveSMSProviderFn, + newPhoneCodeFn newOTPCodeFunc) *OTPSMSChallengeCommand { + + if secretGeneratorConfig == nil { + secretGeneratorConfig = otpSMSSecretGeneratorConfig + } + if otpAlgorithm == nil { + otpAlgorithm = mfaEncryptionAlgo + } + if newPhoneCodeFn == nil { + newPhoneCodeFn = crypto.NewCode + } + + return &OTPSMSChallengeCommand{ + ChallengeTypeOTPSMS: challengeTypeOTPSMS, + SessionID: sessionID, + InstanceID: instanceID, + defaultSecretGeneratorConfig: secretGeneratorConfig, + otpAlgorithm: otpAlgorithm, + smsProvider: smsProvider, + newPhoneCode: newPhoneCodeFn, + } +} + +// Validate implements [Commander]. +// It checks if the command has all required fields and fetches necessary data. +func (o *OTPSMSChallengeCommand) Validate(ctx context.Context, opts *InvokeOpts) (err error) { + if o.ChallengeTypeOTPSMS == nil { + return nil + } + + err = o.validatePreConditions() + if err != nil { + return err + } + + // get session + sessionRepo := opts.sessionRepo + retrievedSession, err := sessionRepo.Get( + ctx, + opts.DB(), + database.WithCondition(sessionRepo.PrimaryKeyCondition(o.InstanceID, o.SessionID)), + ) + if err := handleGetError(err, "DOM-2aGWWE", objectTypeSession); err != nil { + return err + } + if retrievedSession.UserID == "" { + return zerrors.ThrowPreconditionFailed(nil, "DOM-Vi16Fs", "Errors.Missing.Session.UserID") + } + + // get user + userRepo := opts.userRepo + retrievedUser, err := userRepo.Get( + ctx, + opts.DB(), + database.WithCondition(userRepo.PrimaryKeyCondition(o.InstanceID, retrievedSession.UserID)), + ) + if err := handleGetError(err, "DOM-3aGHDs", objectTypeUser); err != nil { + return err + } + + if retrievedUser.ID == "" { + return zerrors.ThrowPreconditionFailed(nil, "DOM-1bzvsh", "Errors.User.UserIDMissing") + } + + // validate human user and user phone + if retrievedUser.Human == nil || retrievedUser.Human.Phone == nil { + return zerrors.ThrowPreconditionFailed(nil, "DOM-7hG2w", "Errors.NotFound.User.Human.Phone") + } + // validate phone OTP is enabled + if retrievedUser.Human.Phone.OTP.EnabledAt.IsZero() { + return zerrors.ThrowPreconditionFailed(nil, "DOM-9kL4m", "Errors.OTPSMS.NotEnabled") + } + o.session = retrievedSession + o.user = retrievedUser + + return nil +} + +// Execute implements [Commander]. +// It generates the OTP SMS challenge and updates the session. +func (o *OTPSMSChallengeCommand) Execute(ctx context.Context, opts *InvokeOpts) error { + if o.ChallengeTypeOTPSMS == nil { + return nil + } + + // generate phone code + code, plain, generatorID, expiry, err := o.createPhoneCode(ctx, opts) + if err != nil { + return err + } + + // update the session with the otp sms challenge + challengeOTPSMS := &SessionChallengeOTPSMS{ + LastChallengedAt: time.Now(), + Code: code, + Expiry: expiry, + CodeReturned: o.ChallengeTypeOTPSMS.ReturnCode, + GeneratorID: generatorID, + TriggeredAtOrigin: http.DomainContext(ctx).Origin(), + } + sessionRepo := opts.sessionRepo + updateCount, err := sessionRepo.Update( + ctx, + opts.DB(), + sessionRepo.PrimaryKeyCondition(o.InstanceID, o.SessionID), + sessionRepo.SetChallenge(challengeOTPSMS), + ) + if err != nil { + return zerrors.ThrowInternal(err, "DOM-AigB0Z", "session update failed") + } + if updateCount == 0 { + return zerrors.ThrowNotFound(nil, "DOM-QThZH7", "Errors.Session.NotFound") + } + if updateCount > 1 { + return zerrors.ThrowInternal(NewMultipleObjectsUpdatedError(expectedUpdatedRows, updateCount), "DOM-gYp8tG", "unexpected number of rows") + } + // todo (@grvijayan): uncomment after these changes are available + // if err := handleUpdateError(err, expectedUpdatedRows, updated, "DOM-AigB0Z", objectTypeSession); err != nil { + // return err + // } + o.challengeOTPSMS = challengeOTPSMS + if o.ChallengeTypeOTPSMS.ReturnCode { + o.otpSMSChallenge = &plain + } + + return nil +} + +// Events implements [eventstore.Command]. +// It returns an OTPSMSChallengedEvent. +func (o *OTPSMSChallengeCommand) Events(ctx context.Context, _ *InvokeOpts) ([]eventstore.Command, error) { + if o.ChallengeTypeOTPSMS == nil { + return nil, nil + } + + return []eventstore.Command{ + session.NewOTPSMSChallengedEvent( + ctx, + &session.NewAggregate(o.SessionID, o.InstanceID).Aggregate, + o.challengeOTPSMS.Code, + o.challengeOTPSMS.Expiry, + o.challengeOTPSMS.CodeReturned, + o.challengeOTPSMS.GeneratorID, + ), + }, nil +} + +func (o *OTPSMSChallengeCommand) GetOTPSMSChallenge() *string { + return o.otpSMSChallenge +} + +// String implements [Commander]. +func (o *OTPSMSChallengeCommand) String() string { + return "OTPSMSChallengeCommand" +} + +// RequiresTransaction implements [Transactional]. +func (o *OTPSMSChallengeCommand) RequiresTransaction() {} + +// createPhoneCode generates an OTP code or retrieves the external provider ID if an external SMS provider is active. +// In the case of an external provider, the code generation is skipped (e.g., when using Twilio verification API). +func (o *OTPSMSChallengeCommand) createPhoneCode(ctx context.Context, opts *InvokeOpts) (code *crypto.CryptoValue, plain string, externalID string, expiry time.Duration, err error) { + externalID, err = o.smsProvider(ctx, o.InstanceID) + if err != nil { + return nil, "", "", expiry, err + } + if externalID != "" { + return nil, "", externalID, expiry, nil + } + + config, err := GetOTPCryptoGeneratorConfigWithDefault(ctx, o.InstanceID, opts, o.defaultSecretGeneratorConfig, OTPTypeSMS) + if err != nil { + return nil, "", "", expiry, err + } + codeGenerator := crypto.NewEncryptionGenerator(*config, o.otpAlgorithm) + crypted, plain, err := o.newPhoneCode(codeGenerator) + if err != nil { + return nil, "", "", expiry, err + } + return crypted, plain, "", config.Expiry, nil +} + +func (o *OTPSMSChallengeCommand) validatePreConditions() error { + // validate required fields + if o.SessionID == "" { + return zerrors.ThrowPreconditionFailed(nil, "DOM-3XpM6A", "Errors.Missing.SessionID") + } + if o.InstanceID == "" { + return zerrors.ThrowPreconditionFailed(nil, "DOM-jNNJ9f", "Errors.Missing.InstanceID") + } + + // validate that sms provider is set + if o.smsProvider == nil { + return zerrors.ThrowPreconditionFailed(nil, "DOM-1aGWeE", "missing sms provider") + } + + // validate that default secret generator config is set + if o.defaultSecretGeneratorConfig == nil { + return zerrors.ThrowInternal(nil, "DOM-IDcOzP", "missing default secret generator config") + } + + // validate that otp algorithm is set + if o.otpAlgorithm == nil { + return zerrors.ThrowInternal(nil, "DOM-VeBPmV", "missing MFA encryption algorithm") + } + return nil +} diff --git a/backend/v3/domain/session_challenge_otp_sms_test.go b/backend/v3/domain/session_challenge_otp_sms_test.go new file mode 100644 index 000000000..1757c41ee --- /dev/null +++ b/backend/v3/domain/session_challenge_otp_sms_test.go @@ -0,0 +1,1270 @@ +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" + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/crypto" + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/repository/session" + "github.com/zitadel/zitadel/internal/zerrors" +) + +func TestOTPSMSChallengeCommand_Validate(t *testing.T) { + t.Parallel() + otpEnabledAt := time.Now().Add(-30 * time.Minute) + + tests := []struct { + name string + sessionID string + instanceID string + secretGeneratorConfig *crypto.GeneratorConfig + otpAlgorithm crypto.EncryptionAlgorithm + challengeTypeOTPSMS *domain.ChallengeTypeOTPSMS + userRepo func(ctrl *gomock.Controller) domain.UserRepository + sessionRepo func(ctrl *gomock.Controller) domain.SessionRepository + smsProvider func(ctx context.Context, instanceID string) (string, error) + wantErr error + }{ + { + name: "no request otpsms challenge", + challengeTypeOTPSMS: nil, + wantErr: nil, + }, + { + name: "no session id", + sessionID: "", + instanceID: "instance-1", + challengeTypeOTPSMS: &domain.ChallengeTypeOTPSMS{}, + wantErr: zerrors.ThrowPreconditionFailed(nil, "DOM-3XpM6A", "Errors.Missing.SessionID"), + }, + { + name: "no instance id", + sessionID: "session-1", + instanceID: "", + challengeTypeOTPSMS: &domain.ChallengeTypeOTPSMS{}, + wantErr: zerrors.ThrowPreconditionFailed(nil, "DOM-jNNJ9f", "Errors.Missing.InstanceID"), + }, + { + name: "no sms provider", + sessionID: "session-1", + instanceID: "instance-1", + challengeTypeOTPSMS: &domain.ChallengeTypeOTPSMS{}, + smsProvider: nil, + secretGeneratorConfig: &crypto.GeneratorConfig{ + Expiry: 10 * time.Minute, + }, + wantErr: zerrors.ThrowPreconditionFailed(nil, "DOM-1aGWeE", "missing sms provider"), + }, + { + name: "no default secret generator config", + sessionID: "session-1", + instanceID: "instance-1", + challengeTypeOTPSMS: &domain.ChallengeTypeOTPSMS{}, + smsProvider: func(ctx context.Context, instanceID string) (string, error) { + return "", nil + }, + wantErr: zerrors.ThrowInternal(nil, "DOM-IDcOzP", "missing default secret generator config"), + }, + { + name: "no otp algorithm", + sessionID: "session-1", + instanceID: "instance-1", + challengeTypeOTPSMS: &domain.ChallengeTypeOTPSMS{}, + smsProvider: func(ctx context.Context, instanceID string) (string, error) { + return "", nil + }, + secretGeneratorConfig: &crypto.GeneratorConfig{ + Expiry: 10 * time.Minute, + }, + wantErr: zerrors.ThrowInternal(nil, "DOM-VeBPmV", "missing MFA encryption algorithm"), + }, + { + name: "failed to get session", + sessionID: "session-1", + instanceID: "instance-1", + challengeTypeOTPSMS: &domain.ChallengeTypeOTPSMS{}, + secretGeneratorConfig: &crypto.GeneratorConfig{ + Expiry: 10 * time.Minute, + }, + otpAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + smsProvider: func(ctx context.Context, instanceID string) (string, error) { + return "", nil + }, + sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository { + repo := domainmock.NewSessionRepo(ctrl) + repo.EXPECT(). + Get(gomock.Any(), gomock.Any(), dbmock.QueryOptions( + database.WithCondition( + repo.PrimaryKeyCondition("instance-1", "session-1"), + ), + )). + Times(1). + Return(nil, assert.AnError) + return repo + }, + wantErr: zerrors.ThrowInternal(assert.AnError, "DOM-2aGWWE", "failed fetching Session"), + }, + { + name: "session not found", + sessionID: "session-1", + instanceID: "instance-1", + challengeTypeOTPSMS: &domain.ChallengeTypeOTPSMS{}, + secretGeneratorConfig: &crypto.GeneratorConfig{ + Expiry: 10 * time.Minute, + }, + otpAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + smsProvider: func(ctx context.Context, instanceID string) (string, error) { + return "", nil + }, + sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository { + repo := domainmock.NewSessionRepo(ctrl) + repo.EXPECT(). + Get(gomock.Any(), gomock.Any(), dbmock.QueryOptions( + database.WithCondition( + repo.PrimaryKeyCondition("instance-1", "session-1"), + ), + )). + Times(1). + Return(nil, new(database.NoRowFoundError)) + return repo + }, + wantErr: zerrors.ThrowNotFound(new(database.NoRowFoundError), "DOM-2aGWWE", "Session not found"), + }, + { + name: "missing user id in session", + sessionID: "session-1", + instanceID: "instance-1", + challengeTypeOTPSMS: &domain.ChallengeTypeOTPSMS{}, + secretGeneratorConfig: &crypto.GeneratorConfig{ + Expiry: 10 * time.Minute, + }, + otpAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + smsProvider: func(ctx context.Context, instanceID string) (string, error) { + return "", nil + }, + sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository { + repo := domainmock.NewSessionRepo(ctrl) + repo.EXPECT(). + Get(gomock.Any(), gomock.Any(), dbmock.QueryOptions( + database.WithCondition( + repo.PrimaryKeyCondition("instance-1", "session-1"), + ), + )). + Times(1). + Return(&domain.Session{ + UserID: "", + }, nil) + return repo + }, + wantErr: zerrors.ThrowPreconditionFailed(nil, "DOM-Vi16Fs", "Errors.Missing.Session.UserID"), + }, + { + name: "failed to get user", + sessionID: "session-1", + instanceID: "instance-1", + challengeTypeOTPSMS: &domain.ChallengeTypeOTPSMS{}, + secretGeneratorConfig: &crypto.GeneratorConfig{ + Expiry: 10 * time.Minute, + }, + otpAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + smsProvider: func(ctx context.Context, instanceID string) (string, error) { + return "", nil + }, + sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository { + repo := domainmock.NewSessionRepo(ctrl) + repo.EXPECT(). + Get(gomock.Any(), gomock.Any(), dbmock.QueryOptions( + database.WithCondition( + repo.PrimaryKeyCondition("instance-1", "session-1"), + ), + )). + Times(1). + Return(&domain.Session{ + UserID: "user-1", + }, nil) + return repo + }, + userRepo: func(ctrl *gomock.Controller) domain.UserRepository { + repo := domainmock.NewUserRepo(ctrl) + repo.EXPECT(). + Get(gomock.Any(), gomock.Any(), + dbmock.QueryOptions( + database.WithCondition( + repo.PrimaryKeyCondition("instance-1", "user-1"), + ), + )). + Times(1). + Return(nil, assert.AnError) + return repo + }, + wantErr: zerrors.ThrowInternal(assert.AnError, "DOM-3aGHDs", "failed fetching User"), + }, + { + name: "user not found", + sessionID: "session-1", + instanceID: "instance-1", + challengeTypeOTPSMS: &domain.ChallengeTypeOTPSMS{}, + secretGeneratorConfig: &crypto.GeneratorConfig{ + Expiry: 10 * time.Minute, + }, + otpAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + smsProvider: func(ctx context.Context, instanceID string) (string, error) { + return "", nil + }, + sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository { + repo := domainmock.NewSessionRepo(ctrl) + repo.EXPECT(). + Get(gomock.Any(), gomock.Any(), dbmock.QueryOptions( + database.WithCondition( + repo.PrimaryKeyCondition("instance-1", "session-1"), + ), + )). + Times(1). + Return(&domain.Session{ + UserID: "user-1", + }, nil) + return repo + }, + userRepo: func(ctrl *gomock.Controller) domain.UserRepository { + repo := domainmock.NewUserRepo(ctrl) + repo.EXPECT(). + Get(gomock.Any(), gomock.Any(), + dbmock.QueryOptions( + database.WithCondition( + repo.PrimaryKeyCondition("instance-1", "user-1"), + ), + )). + Times(1). + Return(nil, new(database.NoRowFoundError)) + return repo + }, + wantErr: zerrors.ThrowNotFound(new(database.NoRowFoundError), "DOM-3aGHDs", "User not found"), + }, + { + name: "human user not set", + sessionID: "session-1", + instanceID: "instance-1", + challengeTypeOTPSMS: &domain.ChallengeTypeOTPSMS{}, + secretGeneratorConfig: &crypto.GeneratorConfig{ + Expiry: 10 * time.Minute, + }, + otpAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + smsProvider: func(ctx context.Context, instanceID string) (string, error) { + return "", nil + }, + sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository { + repo := domainmock.NewSessionRepo(ctrl) + repo.EXPECT(). + Get(gomock.Any(), gomock.Any(), dbmock.QueryOptions( + database.WithCondition( + repo.PrimaryKeyCondition("instance-1", "session-1"), + ), + )). + Times(1). + Return(&domain.Session{ + UserID: "user-1", + }, nil) + return repo + }, + userRepo: func(ctrl *gomock.Controller) domain.UserRepository { + repo := domainmock.NewUserRepo(ctrl) + repo.EXPECT(). + Get(gomock.Any(), gomock.Any(), + dbmock.QueryOptions( + database.WithCondition( + repo.PrimaryKeyCondition("instance-1", "user-1"), + ), + )). + Times(1). + Return(&domain.User{ + InstanceID: "instance-1", + OrganizationID: "org-id", + ID: "123", + Username: "username", + Human: nil, + }, nil) + return repo + }, + wantErr: zerrors.ThrowPreconditionFailed(nil, "DOM-7hG2w", "Errors.NotFound.User.Human.Phone"), + }, + { + name: "phone not set", + sessionID: "session-1", + instanceID: "instance-1", + challengeTypeOTPSMS: &domain.ChallengeTypeOTPSMS{}, + secretGeneratorConfig: &crypto.GeneratorConfig{ + Expiry: 10 * time.Minute, + }, + otpAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + smsProvider: func(ctx context.Context, instanceID string) (string, error) { + return "", nil + }, + sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository { + repo := domainmock.NewSessionRepo(ctrl) + repo.EXPECT(). + Get(gomock.Any(), gomock.Any(), dbmock.QueryOptions( + database.WithCondition( + repo.PrimaryKeyCondition("instance-1", "session-1"), + ), + )). + Times(1). + Return(&domain.Session{ + UserID: "user-1", + }, nil) + return repo + }, + userRepo: func(ctrl *gomock.Controller) domain.UserRepository { + repo := domainmock.NewUserRepo(ctrl) + repo.EXPECT(). + Get(gomock.Any(), gomock.Any(), + dbmock.QueryOptions( + database.WithCondition( + repo.PrimaryKeyCondition("instance-1", "user-1"), + ), + )). + Times(1). + Return(&domain.User{ + InstanceID: "instance-1", + OrganizationID: "org-id", + ID: "123", + Username: "username", + Human: &domain.HumanUser{ + FirstName: "first", + LastName: "last", + }, + }, nil) + return repo + }, + wantErr: zerrors.ThrowPreconditionFailed(nil, "DOM-7hG2w", "Errors.NotFound.User.Human.Phone"), + }, + { + name: "phone otp not enabled", + sessionID: "session-1", + instanceID: "instance-1", + challengeTypeOTPSMS: &domain.ChallengeTypeOTPSMS{}, + secretGeneratorConfig: &crypto.GeneratorConfig{ + Expiry: 10 * time.Minute, + }, + otpAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + smsProvider: func(ctx context.Context, instanceID string) (string, error) { + return "", nil + }, + sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository { + repo := domainmock.NewSessionRepo(ctrl) + repo.EXPECT(). + Get(gomock.Any(), gomock.Any(), dbmock.QueryOptions( + database.WithCondition( + repo.PrimaryKeyCondition("instance-1", "session-1"), + ), + )). + Times(1). + Return(&domain.Session{ + UserID: "user-1", + }, nil) + return repo + }, + userRepo: func(ctrl *gomock.Controller) domain.UserRepository { + repo := domainmock.NewUserRepo(ctrl) + repo.EXPECT(). + Get(gomock.Any(), gomock.Any(), + dbmock.QueryOptions( + database.WithCondition( + repo.PrimaryKeyCondition("instance-1", "user-1"), + ), + )). + Times(1). + Return(&domain.User{ + InstanceID: "instance-1", + OrganizationID: "org-id", + ID: "123", + Username: "username", + Human: &domain.HumanUser{ + FirstName: "first", + LastName: "last", + Phone: &domain.HumanPhone{ + Number: "09080706050", + }, + }, + }, nil) + return repo + }, + wantErr: zerrors.ThrowPreconditionFailed(nil, "DOM-9kL4m", "Errors.OTPSMS.NotEnabled"), + }, + { + name: "valid OTP SMS challenge request", + sessionID: "session-1", + instanceID: "instance-1", + challengeTypeOTPSMS: &domain.ChallengeTypeOTPSMS{}, + secretGeneratorConfig: &crypto.GeneratorConfig{ + Expiry: 10 * time.Minute, + }, + otpAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + smsProvider: func(ctx context.Context, instanceID string) (string, error) { + return "", nil + }, + sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository { + repo := domainmock.NewSessionRepo(ctrl) + getSessionSucceededExpectation(repo) + return repo + }, + userRepo: getUser(otpEnabledAt), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + ctx := authz.NewMockContext("instance-1", "", "") + ctrl := gomock.NewController(t) + cmd := domain.NewOTPSMSChallengeCommand( + tt.challengeTypeOTPSMS, + tt.sessionID, + tt.instanceID, + tt.secretGeneratorConfig, + tt.otpAlgorithm, + tt.smsProvider, + nil, + ) + opts := &domain.InvokeOpts{ + Invoker: domain.NewTransactionInvoker(nil), + } + if tt.userRepo != nil { + domain.WithUserRepo(tt.userRepo(ctrl))(opts) + } + if tt.sessionRepo != nil { + domain.WithSessionRepo(tt.sessionRepo(ctrl))(opts) + } + err := cmd.Validate(ctx, opts) + assert.ErrorIs(t, err, tt.wantErr) + }) + } +} + +func TestOTPSMSChallengeCommand_Execute(t *testing.T) { + t.Parallel() + challengedAt := time.Now() + otpEnabledAt := time.Now().Add(-30 * time.Minute) + smsProviderErr := errors.New("failed to get active sms provider") + codeErr := errors.New("failed to create code") + defaultExpiry := 10 * time.Minute + expiry := 30 * time.Minute + + tests := []struct { + name string + challengeTypeOTPSMS *domain.ChallengeTypeOTPSMS + sessionRepo func(ctrl *gomock.Controller) domain.SessionRepository + userRepo func(ctrl *gomock.Controller) domain.UserRepository + secretGeneratorSettingsRepo func(ctrl *gomock.Controller) domain.SecretGeneratorSettingsRepository + secretGeneratorConfig *crypto.GeneratorConfig + otpAlgorithm crypto.EncryptionAlgorithm + smsProviderFn func(ctx context.Context, instanceID string) (string, error) + newPhoneCodeFn func(g crypto.Generator) (*crypto.CryptoValue, string, error) + wantErr error + wantOTPSMSChallenge *string + }{ + { + name: "no otp sms challenge request", + challengeTypeOTPSMS: nil, + wantErr: nil, + }, + { + name: "failed to get active sms provider", + challengeTypeOTPSMS: &domain.ChallengeTypeOTPSMS{ + ReturnCode: false, + }, + secretGeneratorConfig: &crypto.GeneratorConfig{ + Expiry: defaultExpiry, + }, + otpAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository { + repo := domainmock.NewSessionRepo(ctrl) + getSessionSucceededExpectation(repo) + return repo + }, + userRepo: getUser(otpEnabledAt), + smsProviderFn: func(ctx context.Context, instanceID string) (string, error) { + return "", smsProviderErr + }, + wantErr: smsProviderErr, + }, + { + name: "failed to retrieve secret generator config from settings DB", + challengeTypeOTPSMS: &domain.ChallengeTypeOTPSMS{ + ReturnCode: false, + }, + secretGeneratorConfig: &crypto.GeneratorConfig{ + Expiry: defaultExpiry, + }, + otpAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + smsProviderFn: func(ctx context.Context, instanceID string) (string, error) { + return "", nil // no external sms provider + }, + sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository { + repo := domainmock.NewSessionRepo(ctrl) + getSessionSucceededExpectation(repo) + return repo + }, + userRepo: getUser(otpEnabledAt), + secretGeneratorSettingsRepo: func(ctrl *gomock.Controller) domain.SecretGeneratorSettingsRepository { + repo := domainmock.NewSecretGeneratorSettingsRepo(ctrl) + repo.EXPECT(). + Get(gomock.Any(), gomock.Any(), + dbmock.QueryOptions( + database.WithCondition( + repo.UniqueCondition("instance-1", nil, domain.SettingTypeSecretGenerator, domain.SettingStateActive), + ), + ), + ).Times(1). + Return(nil, assert.AnError) + return repo + }, + wantErr: zerrors.ThrowInternal(assert.AnError, "DOM-x7Yd3E", "failed fetching SecretGeneratorSettings"), + }, + { + name: "failed to generate code", + challengeTypeOTPSMS: &domain.ChallengeTypeOTPSMS{}, + smsProviderFn: func(ctx context.Context, instanceID string) (string, error) { + return "", nil // no external sms provider + }, + secretGeneratorConfig: &crypto.GeneratorConfig{ + Expiry: defaultExpiry, + }, + sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository { + repo := domainmock.NewSessionRepo(ctrl) + getSessionSucceededExpectation(repo) + return repo + }, + userRepo: getUser(otpEnabledAt), + secretGeneratorSettingsRepo: secretGeneratorSettingsRepo(domain.SettingStateActive, domain.OTPTypeSMS), + otpAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + newPhoneCodeFn: func(g crypto.Generator) (*crypto.CryptoValue, string, error) { + return nil, "", codeErr + }, + wantErr: codeErr, + }, + { + name: "update successful - external sms provider", + challengeTypeOTPSMS: &domain.ChallengeTypeOTPSMS{}, + smsProviderFn: func(ctx context.Context, instanceID string) (string, error) { + return "external-sms-provider-id", nil // with an external sms provider + }, + secretGeneratorConfig: &crypto.GeneratorConfig{ + Expiry: defaultExpiry, + }, + otpAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + userRepo: getUser(otpEnabledAt), + sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository { + repo := domainmock.NewSessionRepo(ctrl) + getSessionSucceededExpectation(repo) + + challengeOTPSMSChange := repo.SetChallenge(&domain.SessionChallengeOTPSMS{ + Code: nil, + Expiry: 0, + CodeReturned: false, + GeneratorID: "external-sms-provider-id", + LastChallengedAt: challengedAt, + }) + updateSessionSucceededExpectation(repo, challengeOTPSMSChange) + return repo + }, + }, + { + name: "update successful - external sms provider with return code true", + challengeTypeOTPSMS: &domain.ChallengeTypeOTPSMS{ + ReturnCode: true, + }, + smsProviderFn: func(ctx context.Context, instanceID string) (string, error) { + return "external-sms-provider-id", nil // with an external sms provider + }, + otpAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + secretGeneratorConfig: &crypto.GeneratorConfig{ + Expiry: defaultExpiry, + }, + userRepo: getUser(otpEnabledAt), + sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository { + repo := domainmock.NewSessionRepo(ctrl) + getSessionSucceededExpectation(repo) + + challengeOTPSMSChange := repo.SetChallenge(&domain.SessionChallengeOTPSMS{ + Code: nil, + Expiry: 0, + CodeReturned: true, + GeneratorID: "external-sms-provider-id", + LastChallengedAt: challengedAt, + }) + updateSessionSucceededExpectation(repo, challengeOTPSMSChange) + return repo + }, + wantOTPSMSChallenge: gu.Ptr(""), // no code is generated for external sms provider + }, + { + name: "update successful - internal sms provider", + challengeTypeOTPSMS: &domain.ChallengeTypeOTPSMS{ + ReturnCode: true, + }, + smsProviderFn: func(ctx context.Context, instanceID string) (string, error) { + return "", nil + }, + secretGeneratorConfig: &crypto.GeneratorConfig{ + Expiry: defaultExpiry, + }, + otpAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + secretGeneratorSettingsRepo: secretGeneratorSettingsRepo(domain.SettingStateActive, domain.OTPTypeSMS), + newPhoneCodeFn: func(g crypto.Generator) (*crypto.CryptoValue, string, error) { + return &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("code"), + }, "code", nil + }, + userRepo: getUser(otpEnabledAt), + sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository { + repo := domainmock.NewSessionRepo(ctrl) + getSessionSucceededExpectation(repo) + challengeOTPSMSChange := repo.SetChallenge(&domain.SessionChallengeOTPSMS{ + Code: &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("code"), + }, + Expiry: expiry, + CodeReturned: true, + GeneratorID: "", + LastChallengedAt: challengedAt, + }) + updateSessionSucceededExpectation(repo, challengeOTPSMSChange) + return repo + }, + wantOTPSMSChallenge: gu.Ptr("code"), + }, + { + name: "update successful - internal sms provider - with return code false", + challengeTypeOTPSMS: &domain.ChallengeTypeOTPSMS{ + ReturnCode: false, + }, + smsProviderFn: func(ctx context.Context, instanceID string) (string, error) { + return "", nil + }, + secretGeneratorConfig: &crypto.GeneratorConfig{ + Expiry: defaultExpiry, + }, + otpAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + secretGeneratorSettingsRepo: secretGeneratorSettingsRepo(domain.SettingStateActive, domain.OTPTypeSMS), + newPhoneCodeFn: func(g crypto.Generator) (*crypto.CryptoValue, string, error) { + return &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("code"), + }, "code", nil + }, + userRepo: getUser(otpEnabledAt), + sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository { + repo := domainmock.NewSessionRepo(ctrl) + getSessionSucceededExpectation(repo) + challengeOTPSMSChange := repo.SetChallenge(&domain.SessionChallengeOTPSMS{ + Code: &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("code"), + }, + Expiry: expiry, + CodeReturned: false, + GeneratorID: "", + LastChallengedAt: challengedAt, + }) + updateSessionSucceededExpectation(repo, challengeOTPSMSChange) + return repo + }, + wantOTPSMSChallenge: nil, + }, + { + name: "update successful - internal sms provider - with default config", + challengeTypeOTPSMS: &domain.ChallengeTypeOTPSMS{ + ReturnCode: true, + }, + smsProviderFn: func(ctx context.Context, instanceID string) (string, error) { + return "", nil + }, + secretGeneratorConfig: &crypto.GeneratorConfig{ + Expiry: defaultExpiry, + }, + otpAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + secretGeneratorSettingsRepo: secretGeneratorSettingsRepo(domain.SettingStatePreview, domain.OTPTypeSMS), + newPhoneCodeFn: func(g crypto.Generator) (*crypto.CryptoValue, string, error) { + return &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("code"), + }, "code", nil + }, + userRepo: getUser(otpEnabledAt), + sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository { + repo := domainmock.NewSessionRepo(ctrl) + getSessionSucceededExpectation(repo) + + challengeOTPSMSChange := repo.SetChallenge(&domain.SessionChallengeOTPSMS{ + Code: &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("code"), + }, + Expiry: defaultExpiry, + CodeReturned: true, + GeneratorID: "", + LastChallengedAt: challengedAt, + }) + updateSessionSucceededExpectation(repo, challengeOTPSMSChange) + return repo + }, + wantOTPSMSChallenge: gu.Ptr("code"), + }, + { + name: "update successful - internal sms provider - missing SMS OTP config - with default config", + challengeTypeOTPSMS: &domain.ChallengeTypeOTPSMS{ + ReturnCode: true, + }, + smsProviderFn: func(ctx context.Context, instanceID string) (string, error) { + return "", nil + }, + secretGeneratorConfig: &crypto.GeneratorConfig{ + Expiry: defaultExpiry, + }, + otpAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + secretGeneratorSettingsRepo: secretGeneratorSettingsRepo(domain.SettingStateActive, 2), + newPhoneCodeFn: func(g crypto.Generator) (*crypto.CryptoValue, string, error) { + return &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("code"), + }, "code", nil + }, + userRepo: getUser(otpEnabledAt), + sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository { + repo := domainmock.NewSessionRepo(ctrl) + getSessionSucceededExpectation(repo) + challengeOTPSMSChange := repo.SetChallenge(&domain.SessionChallengeOTPSMS{ + Code: &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("code"), + }, + Expiry: defaultExpiry, + CodeReturned: true, + GeneratorID: "", + LastChallengedAt: challengedAt, + }) + updateSessionSucceededExpectation(repo, challengeOTPSMSChange) + return repo + }, + wantOTPSMSChallenge: gu.Ptr("code"), + }, + { + name: "failed to update session", + challengeTypeOTPSMS: &domain.ChallengeTypeOTPSMS{ + ReturnCode: true, + }, + smsProviderFn: func(ctx context.Context, instanceID string) (string, error) { + return "", nil + }, + secretGeneratorConfig: &crypto.GeneratorConfig{ + Expiry: defaultExpiry, + }, + otpAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + secretGeneratorSettingsRepo: secretGeneratorSettingsRepo(domain.SettingStateActive, domain.OTPTypeSMS), + newPhoneCodeFn: func(g crypto.Generator) (*crypto.CryptoValue, string, error) { + return &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("code"), + }, "code", nil + }, + userRepo: getUser(otpEnabledAt), + sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository { + repo := domainmock.NewSessionRepo(ctrl) + getSessionSucceededExpectation(repo) + + challengeOTPSMSChange := repo.SetChallenge(&domain.SessionChallengeOTPSMS{ + Code: &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("code"), + }, + Expiry: expiry, + CodeReturned: true, + GeneratorID: "", + LastChallengedAt: challengedAt, + }) + updateSessionFailedExpectation(repo, challengeOTPSMSChange, assert.AnError, 0) + return repo + }, + wantErr: zerrors.ThrowInternal(assert.AnError, "DOM-AigB0Z", "session update failed"), + }, + { + name: "failed to update session - no rows updated", + challengeTypeOTPSMS: &domain.ChallengeTypeOTPSMS{ + ReturnCode: true, + }, + smsProviderFn: func(ctx context.Context, instanceID string) (string, error) { + return "", nil + }, + secretGeneratorConfig: &crypto.GeneratorConfig{ + Expiry: defaultExpiry, + }, + otpAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + secretGeneratorSettingsRepo: secretGeneratorSettingsRepo(domain.SettingStateActive, domain.OTPTypeSMS), + newPhoneCodeFn: func(g crypto.Generator) (*crypto.CryptoValue, string, error) { + return &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("code"), + }, "code", nil + }, + userRepo: getUser(otpEnabledAt), + sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository { + repo := domainmock.NewSessionRepo(ctrl) + getSessionSucceededExpectation(repo) + + challengeOTPSMSChange := repo.SetChallenge(&domain.SessionChallengeOTPSMS{ + Code: &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("code"), + }, + Expiry: expiry, + CodeReturned: true, + GeneratorID: "", + LastChallengedAt: challengedAt, + }) + updateSessionFailedExpectation(repo, challengeOTPSMSChange, nil, 0) + return repo + }, + wantErr: zerrors.ThrowNotFound(nil, "DOM-QThZH7", "Errors.Session.NotFound"), + }, + { + name: "failed to update session - more than 1 row updated", + challengeTypeOTPSMS: &domain.ChallengeTypeOTPSMS{ + ReturnCode: true, + }, + smsProviderFn: func(ctx context.Context, instanceID string) (string, error) { + return "", nil + }, + secretGeneratorConfig: &crypto.GeneratorConfig{ + Expiry: defaultExpiry, + }, + otpAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + secretGeneratorSettingsRepo: secretGeneratorSettingsRepo(domain.SettingStateActive, domain.OTPTypeSMS), + newPhoneCodeFn: func(g crypto.Generator) (*crypto.CryptoValue, string, error) { + return &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("code"), + }, "code", nil + }, + userRepo: getUser(otpEnabledAt), + sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository { + repo := domainmock.NewSessionRepo(ctrl) + getSessionSucceededExpectation(repo) + challengeOTPSMSChange := repo.SetChallenge(&domain.SessionChallengeOTPSMS{ + Code: &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("code"), + }, + Expiry: expiry, + CodeReturned: true, + GeneratorID: "", + LastChallengedAt: challengedAt, + }) + updateSessionFailedExpectation(repo, challengeOTPSMSChange, nil, 2) + return repo + }, + wantErr: zerrors.ThrowInternal(domain.NewMultipleObjectsUpdatedError(1, 2), "DOM-gYp8tG", "unexpected number of rows"), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + ctx := authz.NewMockContext("instance-1", "", "") + ctrl := gomock.NewController(t) + cmd := domain.NewOTPSMSChallengeCommand(tt.challengeTypeOTPSMS, + "session-1", + "instance-1", + tt.secretGeneratorConfig, + tt.otpAlgorithm, + tt.smsProviderFn, + tt.newPhoneCodeFn, + ) + opts := &domain.InvokeOpts{ + Invoker: domain.NewTransactionInvoker(nil), + } + if tt.sessionRepo != nil { + domain.WithSessionRepo(tt.sessionRepo(ctrl))(opts) + } + if tt.userRepo != nil { + domain.WithUserRepo(tt.userRepo(ctrl))(opts) + } + if tt.secretGeneratorSettingsRepo != nil { + domain.WithSecretGeneratorSettingsRepo(tt.secretGeneratorSettingsRepo(ctrl))(opts) + } + + // to fetch/validate session and user before calling Execute + err := cmd.Validate(ctx, opts) + assert.NoError(t, err) + + err = cmd.Execute(ctx, opts) + assert.ErrorIs(t, err, tt.wantErr) + assert.Equal(t, tt.wantOTPSMSChallenge, cmd.GetOTPSMSChallenge()) + }) + } +} + +func TestOTPSMSChallengeCommand_Events(t *testing.T) { + t.Parallel() + challengedAt := time.Now() + otpEnabledAt := time.Now().Add(-30 * time.Minute) + defaultExpiry := 10 * time.Minute + expiry := 30 * time.Minute + code := &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("code"), + } + + ctx := authz.NewMockContext("instance-1", "", "") + tests := []struct { + name string + challengeTypeOTPSMS *domain.ChallengeTypeOTPSMS + sessionRepo func(ctrl *gomock.Controller) domain.SessionRepository + userRepo func(ctrl *gomock.Controller) domain.UserRepository + secretGeneratorSettingsRepo func(ctrl *gomock.Controller) domain.SecretGeneratorSettingsRepository + secretGeneratorConfig *crypto.GeneratorConfig + otpAlgorithm crypto.EncryptionAlgorithm + smsProviderFn func(ctx context.Context, instanceID string) (string, error) + newPhoneCodeFn func(g crypto.Generator) (*crypto.CryptoValue, string, error) + wantErr error + wantEvent eventstore.Command + }{ + { + name: "no otp sms challenge request", + challengeTypeOTPSMS: nil, + }, + { + name: "valid otp sms challenge request - internal provider - with return code set to false", + challengeTypeOTPSMS: &domain.ChallengeTypeOTPSMS{ + ReturnCode: false, + }, + smsProviderFn: func(ctx context.Context, instanceID string) (string, error) { + return "", nil + }, + secretGeneratorConfig: &crypto.GeneratorConfig{ + Expiry: defaultExpiry, + }, + otpAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + secretGeneratorSettingsRepo: secretGeneratorSettingsRepo(domain.SettingStateActive, domain.OTPTypeSMS), + newPhoneCodeFn: func(g crypto.Generator) (*crypto.CryptoValue, string, error) { + return &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("code"), + }, "code", nil + }, + userRepo: getUser(otpEnabledAt), + sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository { + repo := domainmock.NewSessionRepo(ctrl) + getSessionSucceededExpectation(repo) + challengeOTPSMSChange := repo.SetChallenge(&domain.SessionChallengeOTPSMS{ + Code: &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("code"), + }, + Expiry: expiry, + CodeReturned: false, + GeneratorID: "", + LastChallengedAt: challengedAt, + }) + updateSessionSucceededExpectation(repo, challengeOTPSMSChange) + return repo + }, + wantEvent: session.NewOTPSMSChallengedEvent(ctx, + &session.NewAggregate("session-1", "instance-1").Aggregate, + code, + expiry, + false, + "", + ), + }, + { + name: "valid otp sms challenge request - internal provider - with return code set to true", + challengeTypeOTPSMS: &domain.ChallengeTypeOTPSMS{ + ReturnCode: true, + }, + smsProviderFn: func(ctx context.Context, instanceID string) (string, error) { + return "", nil + }, + secretGeneratorConfig: &crypto.GeneratorConfig{ + Expiry: defaultExpiry, + }, + otpAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + secretGeneratorSettingsRepo: secretGeneratorSettingsRepo(domain.SettingStateActive, domain.OTPTypeSMS), + newPhoneCodeFn: func(g crypto.Generator) (*crypto.CryptoValue, string, error) { + return &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("code"), + }, "code", nil + }, + userRepo: getUser(otpEnabledAt), + sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository { + repo := domainmock.NewSessionRepo(ctrl) + getSessionSucceededExpectation(repo) + challengeOTPSMSChange := repo.SetChallenge(&domain.SessionChallengeOTPSMS{ + Code: &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("code"), + }, + Expiry: expiry, + CodeReturned: true, + GeneratorID: "", + LastChallengedAt: challengedAt, + }) + updateSessionSucceededExpectation(repo, challengeOTPSMSChange) + return repo + }, + wantEvent: session.NewOTPSMSChallengedEvent(ctx, + &session.NewAggregate("session-1", "instance-1").Aggregate, + code, + expiry, + true, + "", + ), + }, + { + name: "update successful - external sms provider - return code false", + challengeTypeOTPSMS: &domain.ChallengeTypeOTPSMS{}, + secretGeneratorConfig: &crypto.GeneratorConfig{ + Expiry: defaultExpiry, + }, + otpAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + smsProviderFn: func(ctx context.Context, instanceID string) (string, error) { + return "external-sms-provider-id", nil // with an external sms provider + }, + userRepo: getUser(otpEnabledAt), + sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository { + repo := domainmock.NewSessionRepo(ctrl) + getSessionSucceededExpectation(repo) + + challengeOTPSMSChange := repo.SetChallenge(&domain.SessionChallengeOTPSMS{ + Code: nil, + Expiry: 0, + CodeReturned: false, + GeneratorID: "external-sms-provider-id", + LastChallengedAt: challengedAt, + }) + updateSessionSucceededExpectation(repo, challengeOTPSMSChange) + return repo + }, + wantEvent: session.NewOTPSMSChallengedEvent(ctx, + &session.NewAggregate("session-1", "instance-1").Aggregate, + nil, + 0, + false, + "external-sms-provider-id", + ), + }, + { + name: "update successful - external sms provider - return code true", + challengeTypeOTPSMS: &domain.ChallengeTypeOTPSMS{ + ReturnCode: true, + }, + secretGeneratorConfig: &crypto.GeneratorConfig{ + Expiry: defaultExpiry, + }, + otpAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + smsProviderFn: func(ctx context.Context, instanceID string) (string, error) { + return "external-sms-provider-id", nil // with an external sms provider + }, + userRepo: getUser(otpEnabledAt), + sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository { + repo := domainmock.NewSessionRepo(ctrl) + getSessionSucceededExpectation(repo) + + challengeOTPSMSChange := repo.SetChallenge(&domain.SessionChallengeOTPSMS{ + Code: nil, + Expiry: 0, + CodeReturned: true, + GeneratorID: "external-sms-provider-id", + LastChallengedAt: challengedAt, + }) + updateSessionSucceededExpectation(repo, challengeOTPSMSChange) + return repo + }, + wantEvent: session.NewOTPSMSChallengedEvent(ctx, + &session.NewAggregate("session-1", "instance-1").Aggregate, + nil, + 0, + true, + "external-sms-provider-id", + ), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + ctx := authz.NewMockContext("instance-1", "", "") + ctrl := gomock.NewController(t) + cmd := domain.NewOTPSMSChallengeCommand(tt.challengeTypeOTPSMS, + "session-1", + "instance-1", + tt.secretGeneratorConfig, + tt.otpAlgorithm, + tt.smsProviderFn, + tt.newPhoneCodeFn, + ) + opts := &domain.InvokeOpts{ + Invoker: domain.NewTransactionInvoker(nil), + } + if tt.sessionRepo != nil { + domain.WithSessionRepo(tt.sessionRepo(ctrl))(opts) + } + if tt.userRepo != nil { + domain.WithUserRepo(tt.userRepo(ctrl))(opts) + } + if tt.secretGeneratorSettingsRepo != nil { + domain.WithSecretGeneratorSettingsRepo(tt.secretGeneratorSettingsRepo(ctrl))(opts) + } + + // to fetch/validate session and user before calling Execute + err := cmd.Validate(ctx, opts) + assert.NoError(t, err) + + // to set the challengeOTPSMS in the command for event generation + err = cmd.Execute(ctx, opts) + assert.NoError(t, err) + + events, err := cmd.Events(ctx, opts) + assert.ErrorIs(t, err, tt.wantErr) + if tt.wantEvent != nil { + require.Len(t, events, 1) + assert.Equal(t, tt.wantEvent, events[0]) + } + }) + } +} + +func secretGeneratorSettingsRepo(state domain.SettingState, otpType domain.OTPType) func(ctrl *gomock.Controller) domain.SecretGeneratorSettingsRepository { + var attrs domain.SecretGeneratorSettingsAttributes + switch otpType { + case domain.OTPTypeEmail: + attrs = domain.SecretGeneratorSettingsAttributes{ + OTPEmail: &domain.OTPEmailAttributes{ + SecretGeneratorAttrsWithExpiry: domain.SecretGeneratorAttrsWithExpiry{ + Expiry: gu.Ptr(30 * time.Minute), + SecretGeneratorAttrs: domain.SecretGeneratorAttrs{ + Length: gu.Ptr(uint(8)), + IncludeLowerLetters: gu.Ptr(true), + IncludeUpperLetters: gu.Ptr(false), + IncludeDigits: gu.Ptr(true), + IncludeSymbols: gu.Ptr(false), + }, + }, + }, + } + case domain.OTPTypeSMS: + attrs = domain.SecretGeneratorSettingsAttributes{ + OTPSMS: &domain.OTPSMSAttributes{ + SecretGeneratorAttrsWithExpiry: domain.SecretGeneratorAttrsWithExpiry{ + Expiry: gu.Ptr(30 * time.Minute), + SecretGeneratorAttrs: domain.SecretGeneratorAttrs{ + Length: gu.Ptr(uint(6)), + IncludeLowerLetters: gu.Ptr(true), + IncludeUpperLetters: gu.Ptr(false), + IncludeDigits: gu.Ptr(true), + IncludeSymbols: gu.Ptr(false), + }, + }, + }, + } + } + return func(ctrl *gomock.Controller) domain.SecretGeneratorSettingsRepository { + repo := domainmock.NewSecretGeneratorSettingsRepo(ctrl) + repo.EXPECT(). + Get(gomock.Any(), gomock.Any(), + dbmock.QueryOptions( + database.WithCondition( + repo.UniqueCondition("instance-1", nil, domain.SettingTypeSecretGenerator, domain.SettingStateActive), + ), + ), + ).Times(1). + Return(&domain.SecretGeneratorSettings{ + Settings: domain.Settings{ + State: state, + }, + SecretGeneratorSettingsAttributes: attrs, + }, nil) + return repo + } +} + +func getUser(otpEnabledAt time.Time) func(ctrl *gomock.Controller) domain.UserRepository { + return func(ctrl *gomock.Controller) domain.UserRepository { + repo := domainmock.NewUserRepo(ctrl) + repo.EXPECT(). + Get(gomock.Any(), gomock.Any(), + dbmock.QueryOptions( + database.WithCondition( + repo.PrimaryKeyCondition("instance-1", "user-1"), + ), + )). + Times(1). + Return(&domain.User{ + InstanceID: "instance-1", + OrganizationID: "org-id", + ID: "123", + Username: "username", + Human: &domain.HumanUser{ + FirstName: "first", + LastName: "last", + Phone: &domain.HumanPhone{ + Number: "09080706050", + OTP: domain.OTP{ + EnabledAt: otpEnabledAt, + }, + }, + }, + }, nil) + return repo + } +} diff --git a/backend/v3/domain/session_challenge_util.go b/backend/v3/domain/session_challenge_util.go new file mode 100644 index 000000000..9fc2f8e36 --- /dev/null +++ b/backend/v3/domain/session_challenge_util.go @@ -0,0 +1,76 @@ +package domain + +import ( + "context" + + "github.com/zitadel/zitadel/backend/v3/storage/database" + "github.com/zitadel/zitadel/internal/crypto" + "github.com/zitadel/zitadel/internal/zerrors" +) + +type newOTPCodeFunc func(g crypto.Generator) (*crypto.CryptoValue, string, error) + +type OTPType int64 + +const ( + OTPTypeSMS OTPType = iota + OTPTypeEmail +) + +func GetOTPCryptoGeneratorConfigWithDefault(ctx context.Context, instanceID string, opts *InvokeOpts, defaultConfig *crypto.GeneratorConfig, otpType OTPType) (*crypto.GeneratorConfig, error) { + if defaultConfig == nil { + return nil, zerrors.ThrowInternal(nil, "DOM-3AcM0U", "missing default config") + } + + if opts == nil || opts.secretGeneratorSettingsRepo == nil { + return defaultConfig, nil + } + + settingsRepo := opts.secretGeneratorSettingsRepo + cfg, err := settingsRepo.Get( + ctx, + opts.DB(), + database.WithCondition( + settingsRepo.UniqueCondition(instanceID, nil, SettingTypeSecretGenerator, SettingStateActive), + ), + ) + if err := handleGetError(err, "DOM-x7Yd3E", "SecretGeneratorSettings"); err != nil { + return nil, err // todo: or return defaultConfig? + } + + if cfg.State != SettingStateActive { + return defaultConfig, nil + } + + var attrs SecretGeneratorAttrsWithExpiry + switch otpType { + case OTPTypeSMS: + if cfg.OTPSMS == nil { + return defaultConfig, nil + } + attrs = cfg.OTPSMS.SecretGeneratorAttrsWithExpiry + case OTPTypeEmail: + if cfg.OTPEmail == nil { + return defaultConfig, nil + } + attrs = cfg.OTPEmail.SecretGeneratorAttrsWithExpiry + default: + return nil, zerrors.ThrowInternal(nil, "DOM-3AcM0U", "invalid otp type") + } + return &crypto.GeneratorConfig{ + Length: getValueOrDefault(attrs.Length, defaultConfig.Length), + Expiry: getValueOrDefault(attrs.Expiry, defaultConfig.Expiry), + IncludeLowerLetters: getValueOrDefault(attrs.IncludeLowerLetters, defaultConfig.IncludeLowerLetters), + IncludeUpperLetters: getValueOrDefault(attrs.IncludeUpperLetters, defaultConfig.IncludeUpperLetters), + IncludeDigits: getValueOrDefault(attrs.IncludeDigits, defaultConfig.IncludeDigits), + IncludeSymbols: getValueOrDefault(attrs.IncludeSymbols, defaultConfig.IncludeSymbols), + }, nil +} + +// getValueOrDefault safely dereferences a pointer or returns a default value +func getValueOrDefault[T any](ptr *T, defaultVal T) T { + if ptr != nil { + return *ptr + } + return defaultVal +} diff --git a/backend/v3/domain/session_challenge_util_test.go b/backend/v3/domain/session_challenge_util_test.go new file mode 100644 index 000000000..cb3db0125 --- /dev/null +++ b/backend/v3/domain/session_challenge_util_test.go @@ -0,0 +1,235 @@ +package domain_test + +import ( + "context" + "testing" + "time" + + "github.com/muhlemmer/gu" + "github.com/stretchr/testify/assert" + "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" + "github.com/zitadel/zitadel/internal/crypto" + "github.com/zitadel/zitadel/internal/zerrors" +) + +func TestGetOTPCryptoGeneratorConfigWithDefault(t *testing.T) { + tests := []struct { + name string + instanceID string + secretGeneratorSettingsRepo func(ctrl *gomock.Controller) domain.SecretGeneratorSettingsRepository + defaultConfig *crypto.GeneratorConfig + otpType domain.OTPType + want *crypto.GeneratorConfig + wantErr error + }{ + { + name: "no default config", + instanceID: "instance-1", + wantErr: zerrors.ThrowInternal(nil, "DOM-3AcM0U", "missing default config"), + }, + { + name: "failed to get secret generator settings", + instanceID: "instance-1", + secretGeneratorSettingsRepo: func(ctrl *gomock.Controller) domain.SecretGeneratorSettingsRepository { + repo := domainmock.NewSecretGeneratorSettingsRepo(ctrl) + repo.EXPECT(). + Get(gomock.Any(), gomock.Any(), + dbmock.QueryOptions( + database.WithCondition( + repo.UniqueCondition("instance-1", nil, domain.SettingTypeSecretGenerator, domain.SettingStateActive), + ), + ), + ).Times(1). + Return(nil, assert.AnError) + return repo + }, + defaultConfig: &crypto.GeneratorConfig{ + Length: 6, + }, + otpType: domain.OTPTypeEmail, + wantErr: zerrors.ThrowInternal(assert.AnError, "DOM-x7Yd3E", "failed fetching SecretGeneratorSettings"), + }, + { + name: "inactive secret generator settings", + instanceID: "instance-1", + secretGeneratorSettingsRepo: secretGeneratorSettingsRepo(domain.SettingStatePreview, domain.OTPTypeSMS), + defaultConfig: &crypto.GeneratorConfig{ + Length: 6, + }, + otpType: domain.OTPTypeSMS, + want: &crypto.GeneratorConfig{ + Length: 6, + }, + }, + { + name: "active secret generator settings - type sms", + instanceID: "instance-1", + secretGeneratorSettingsRepo: secretGeneratorSettingsRepo(domain.SettingStateActive, domain.OTPTypeSMS), + defaultConfig: &crypto.GeneratorConfig{ + Length: 6, + }, + otpType: domain.OTPTypeSMS, + want: &crypto.GeneratorConfig{ + Expiry: 30 * time.Minute, + Length: 6, + IncludeLowerLetters: true, + IncludeUpperLetters: false, + IncludeDigits: true, + IncludeSymbols: false, + }, + }, + { + name: "active secret generator settings - type email", + instanceID: "instance-1", + secretGeneratorSettingsRepo: secretGeneratorSettingsRepo(domain.SettingStateActive, domain.OTPTypeEmail), + defaultConfig: &crypto.GeneratorConfig{ + Length: 6, + }, + otpType: domain.OTPTypeEmail, + want: &crypto.GeneratorConfig{ + Expiry: 30 * time.Minute, + Length: 8, + IncludeLowerLetters: true, + IncludeUpperLetters: false, + IncludeDigits: true, + IncludeSymbols: false, + }, + }, + { + name: "active secret generator settings sms type with nil values - return default config", + instanceID: "instance-1", + secretGeneratorSettingsRepo: func(ctrl *gomock.Controller) domain.SecretGeneratorSettingsRepository { + repo := domainmock.NewSecretGeneratorSettingsRepo(ctrl) + repo.EXPECT(). + Get(gomock.Any(), gomock.Any(), + dbmock.QueryOptions( + database.WithCondition( + repo.UniqueCondition("instance-1", nil, domain.SettingTypeSecretGenerator, domain.SettingStateActive), + ), + ), + ).Times(1). + Return(&domain.SecretGeneratorSettings{ + Settings: domain.Settings{ + State: domain.SettingStateActive, + }, + }, nil) + return repo + }, + defaultConfig: &crypto.GeneratorConfig{ + Length: 6, + }, + otpType: domain.OTPTypeSMS, + want: &crypto.GeneratorConfig{ + Length: 6, + }, + }, + { + name: "active secret generator settings email type with nil values - return default config", + instanceID: "instance-1", + secretGeneratorSettingsRepo: func(ctrl *gomock.Controller) domain.SecretGeneratorSettingsRepository { + repo := domainmock.NewSecretGeneratorSettingsRepo(ctrl) + repo.EXPECT(). + Get(gomock.Any(), gomock.Any(), + dbmock.QueryOptions( + database.WithCondition( + repo.UniqueCondition("instance-1", nil, domain.SettingTypeSecretGenerator, domain.SettingStateActive), + ), + ), + ).Times(1). + Return(&domain.SecretGeneratorSettings{ + Settings: domain.Settings{ + State: domain.SettingStateActive, + }, + }, nil) + return repo + }, + defaultConfig: &crypto.GeneratorConfig{ + Length: 6, + }, + otpType: domain.OTPTypeEmail, + want: &crypto.GeneratorConfig{ + Length: 6, + }, + }, + { + name: "active secret generator settings with nil field values - return default config", + instanceID: "instance-1", + secretGeneratorSettingsRepo: func(ctrl *gomock.Controller) domain.SecretGeneratorSettingsRepository { + repo := domainmock.NewSecretGeneratorSettingsRepo(ctrl) + repo.EXPECT(). + Get(gomock.Any(), gomock.Any(), + dbmock.QueryOptions( + database.WithCondition( + repo.UniqueCondition("instance-1", nil, domain.SettingTypeSecretGenerator, domain.SettingStateActive), + ), + ), + ).Times(1). + Return(&domain.SecretGeneratorSettings{ + Settings: domain.Settings{ + State: domain.SettingStateActive, + }, + SecretGeneratorSettingsAttributes: domain.SecretGeneratorSettingsAttributes{ + OTPEmail: &domain.OTPEmailAttributes{ + SecretGeneratorAttrsWithExpiry: domain.SecretGeneratorAttrsWithExpiry{ + Expiry: gu.Ptr(30 * time.Minute), + SecretGeneratorAttrs: domain.SecretGeneratorAttrs{ + Length: nil, // should use value from the default config + IncludeLowerLetters: nil, // should use value from the default config + IncludeUpperLetters: gu.Ptr(false), + IncludeDigits: gu.Ptr(true), + IncludeSymbols: gu.Ptr(false), + }, + }, + }, + }, + }, nil) + return repo + }, + defaultConfig: &crypto.GeneratorConfig{ + Length: 6, + IncludeUpperLetters: true, + IncludeLowerLetters: true, + }, + otpType: domain.OTPTypeEmail, + want: &crypto.GeneratorConfig{ + Expiry: 30 * time.Minute, + Length: 6, // from the default config + IncludeLowerLetters: true, // from the default config + IncludeUpperLetters: false, + IncludeDigits: true, + IncludeSymbols: false, + }, + }, + { + name: "invalid otp type", + instanceID: "instance-1", + otpType: 3, + defaultConfig: &crypto.GeneratorConfig{ + Length: 6, + IncludeUpperLetters: true, + IncludeLowerLetters: true, + }, + secretGeneratorSettingsRepo: secretGeneratorSettingsRepo(domain.SettingStateActive, domain.OTPTypeEmail), + wantErr: zerrors.ThrowInternal(nil, "DOM-3AcM0U", "invalid otp type"), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + opts := &domain.InvokeOpts{ + Invoker: domain.NewTransactionInvoker(nil), + } + if tt.secretGeneratorSettingsRepo != nil { + domain.WithSecretGeneratorSettingsRepo(tt.secretGeneratorSettingsRepo(ctrl))(opts) + } + got, err := domain.GetOTPCryptoGeneratorConfigWithDefault(context.Background(), tt.instanceID, opts, tt.defaultConfig, tt.otpType) + assert.ErrorIs(t, err, tt.wantErr) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/backend/v3/domain/session_check_recovery_code.go b/backend/v3/domain/session_check_recovery_code.go index cf89c2412..ab1a4834b 100644 --- a/backend/v3/domain/session_check_recovery_code.go +++ b/backend/v3/domain/session_check_recovery_code.go @@ -2,7 +2,6 @@ package domain import ( "context" - "errors" "time" "github.com/zitadel/zitadel/backend/v3/instrumentation/logging" @@ -88,11 +87,8 @@ func (rc *RecoveryCodeCheckCommand) Validate(ctx context.Context, opts *InvokeOp 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 err := handleGetError(err, "DOM-2sF2kF", objectTypeSession); err != nil { + return err } if retrievedSession.UserID == "" { @@ -107,11 +103,8 @@ func (rc *RecoveryCodeCheckCommand) Validate(ctx context.Context, opts *InvokeOp 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") + if err := handleGetError(err, "DOM-Ot3qO6", objectTypeUser); err != nil { + return err } // check user state diff --git a/backend/v3/domain/session_check_recovery_code_test.go b/backend/v3/domain/session_check_recovery_code_test.go index c1a2616e3..0689469a9 100644 --- a/backend/v3/domain/session_check_recovery_code_test.go +++ b/backend/v3/domain/session_check_recovery_code_test.go @@ -69,7 +69,7 @@ func TestRecoveryCodeCheckCommand_Validate(t *testing.T) { Return(nil, &database.NoRowFoundError{}) return sessionRepo }, - wantErr: zerrors.ThrowNotFound(&database.NoRowFoundError{}, "DOM-Ot3qO6", "Errors.Session.NotFound"), + wantErr: zerrors.ThrowNotFound(&database.NoRowFoundError{}, "DOM-2sF2kF", "Session not found"), }, { name: "failed to get session - database error", @@ -85,7 +85,7 @@ func TestRecoveryCodeCheckCommand_Validate(t *testing.T) { Return(nil, getError) return sessionRepo }, - wantErr: zerrors.ThrowInternal(getError, "DOM-2sF2kF", "Errors.Internal"), + wantErr: zerrors.ThrowInternal(getError, "DOM-2sF2kF", "failed fetching Session"), }, { name: "missing user id in session", @@ -126,7 +126,7 @@ func TestRecoveryCodeCheckCommand_Validate(t *testing.T) { Return(nil, &database.NoRowFoundError{}) return userRepo }, - wantErr: zerrors.ThrowNotFound(&database.NoRowFoundError{}, "DOM-Ot3qO6", "Errors.User.NotFound"), + wantErr: zerrors.ThrowNotFound(&database.NoRowFoundError{}, "DOM-Ot3qO6", "User not found"), }, { name: "failed to get user - database error", @@ -151,7 +151,7 @@ func TestRecoveryCodeCheckCommand_Validate(t *testing.T) { Return(nil, getError) return userRepo }, - wantErr: zerrors.ThrowInternal(getError, "DOM-7sWTNf", "Errors.Internal"), + wantErr: zerrors.ThrowInternal(getError, "DOM-Ot3qO6", "failed fetching User"), }, { name: "user is locked", @@ -435,7 +435,10 @@ func TestRecoveryCodeCheckCommand_Execute(t *testing.T) { getSessionSucceededExpectation(sessionRepo) // set up expectation to update session in Execute() - updateSessionFailedExpectation(sessionRepo, dbError, 0) + factor := sessionRepo.SetFactor(&domain.SessionFactorRecoveryCode{ + LastFailedAt: time.Now(), + }) + updateSessionFailedExpectation(sessionRepo, factor, dbError, 0) return sessionRepo }, userRepo: func(ctrl *gomock.Controller) domain.UserRepository { @@ -473,7 +476,10 @@ func TestRecoveryCodeCheckCommand_Execute(t *testing.T) { getSessionSucceededExpectation(sessionRepo) // set up expectation to update session in Execute() - updateSessionFailedExpectation(sessionRepo, nil, 0) + factor := sessionRepo.SetFactor(&domain.SessionFactorRecoveryCode{ + LastFailedAt: time.Now(), + }) + updateSessionFailedExpectation(sessionRepo, factor, nil, 0) return sessionRepo }, userRepo: func(ctrl *gomock.Controller) domain.UserRepository { @@ -511,7 +517,10 @@ func TestRecoveryCodeCheckCommand_Execute(t *testing.T) { getSessionSucceededExpectation(sessionRepo) // set up expectation to update session in Execute() - updateSessionFailedExpectation(sessionRepo, nil, 2) + factor := sessionRepo.SetFactor(&domain.SessionFactorRecoveryCode{ + LastFailedAt: time.Now(), + }) + updateSessionFailedExpectation(sessionRepo, factor, nil, 2) return sessionRepo }, userRepo: func(ctrl *gomock.Controller) domain.UserRepository { @@ -746,7 +755,10 @@ func TestRecoveryCodeCheckCommand_Execute(t *testing.T) { getSessionSucceededExpectation(sessionRepo) // set up expectation to update session in Execute() - updateSessionFailedExpectation(sessionRepo, dbError, 0) + factor := sessionRepo.SetFactor(&domain.SessionFactorRecoveryCode{ + LastFailedAt: time.Now(), + }) + updateSessionFailedExpectation(sessionRepo, factor, dbError, 0) return sessionRepo }, userRepo: func(ctrl *gomock.Controller) domain.UserRepository { @@ -787,7 +799,10 @@ func TestRecoveryCodeCheckCommand_Execute(t *testing.T) { getSessionSucceededExpectation(sessionRepo) // set up expectation to update session in Execute() - updateSessionFailedExpectation(sessionRepo, nil, 0) + factor := sessionRepo.SetFactor(&domain.SessionFactorRecoveryCode{ + LastFailedAt: time.Now(), + }) + updateSessionFailedExpectation(sessionRepo, factor, nil, 0) return sessionRepo }, userRepo: func(ctrl *gomock.Controller) domain.UserRepository { @@ -828,7 +843,10 @@ func TestRecoveryCodeCheckCommand_Execute(t *testing.T) { getSessionSucceededExpectation(sessionRepo) // set up expectation to update session in Execute() - updateSessionFailedExpectation(sessionRepo, nil, 2) + factor := sessionRepo.SetFactor(&domain.SessionFactorRecoveryCode{ + LastFailedAt: time.Now(), + }) + updateSessionFailedExpectation(sessionRepo, factor, nil, 2) return sessionRepo }, userRepo: func(ctrl *gomock.Controller) domain.UserRepository { @@ -1309,23 +1327,10 @@ func updateHumanUserFailedExpectation(ctrl *gomock.Controller, userRepo *domainm 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) - } + humanRepo.EXPECT(). + Update(gomock.Any(), gomock.Any(), primaryKeyCondition, userUpdates). + Times(1). + Return(updateCount, nil) } func getUserSucceededExpectation(userRepo *domainmock.UserRepo, failedAttempts uint8) { @@ -1356,37 +1361,19 @@ func updateSessionSucceededExpectation(sessionRepo *domainmock.SessionRepo, chan Return(int64(1), nil) } -func updateSessionFailedExpectation(sessionRepo *domainmock.SessionRepo, err error, updateCount int64) { +func updateSessionFailedExpectation(sessionRepo *domainmock.SessionRepo, change database.Change, 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). + Update(gomock.Any(), gomock.Any(), primaryKeyCondition, change). Times(1). - Return(int64(0), err) + Return(updateCount, 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) - } + sessionRepo.EXPECT(). + Update(gomock.Any(), gomock.Any(), primaryKeyCondition, change). + Times(1). + Return(updateCount, nil) } func getSessionSucceededExpectation(sessionRepo *domainmock.SessionRepo) { @@ -1394,7 +1381,11 @@ func getSessionSucceededExpectation(sessionRepo *domainmock.SessionRepo) { sessionRepo.EXPECT(). Get(gomock.Any(), gomock.Any(), dbmock.QueryOptions(database.WithCondition(primaryKeyCondition))). Times(1). - Return(&domain.Session{UserID: "user-1"}, nil) + Return(&domain.Session{ + UserID: "user-1", + InstanceID: "instance-1", + ID: "session-1", + }, nil) } func getLockoutSettingsSucceededExpectation(instanceID, orgID string, lockoutRepo *domainmock.LockoutSettingsRepo) { diff --git a/backend/v3/domain/settings.go b/backend/v3/domain/settings.go index f479e919f..b84279232 100644 --- a/backend/v3/domain/settings.go +++ b/backend/v3/domain/settings.go @@ -64,6 +64,7 @@ type settingsConditions interface { IDCondition(id string) database.Condition PrimaryKeyCondition(instanceID, id string) database.Condition UniqueCondition(instanceID string, orgID *string, typ SettingType, state SettingState) database.Condition + TypeCondition(typ SettingType) database.Condition } type settingsChanges interface { diff --git a/cmd/start/start.go b/cmd/start/start.go index 21b45016a..6ad757379 100644 --- a/cmd/start/start.go +++ b/cmd/start/start.go @@ -210,6 +210,8 @@ func startZitadel(ctx context.Context, config *Config, masterKey string, server new_domain.SetLegacyEventstore(eventstoreClient) new_domain.SetSystemConfig(config.SystemDefaults) new_domain.SetIDPEncryptionAlgorithm(keys.IDPConfig) + new_domain.SetMFAEncryptionAlgorithm(keys.OTP) + new_domain.SetOTPSMSSecretGeneratorConfig(config.DefaultInstance.SecretGenerators.OTPSMS) sessionTokenVerifier := internal_authz.SessionTokenVerifier(keys.OIDC) sessionTokenDecryptor := internal_authz.SessionTokenDecryptor(keys.OIDC)