From 7c3d26b23b7d72eec9b9950d1cf4acd22dc66c83 Mon Sep 17 00:00:00 2001 From: Gayathri Vijayan <66356931+grvijayan@users.noreply.github.com> Date: Tue, 31 Mar 2026 13:50:53 +0200 Subject: [PATCH] feat: rt sessions OTP email challenge (#11941) # Which Problems Are Solved Add OTP Email challenge needed for Create/Set session in the RT model. # How the Problems Are Solved Introduce `OTPEmailChallengeCommand` (validate/execute/events) for session creation checks Add unit tests # Additional Changes Remove unused fields from `session_challenge_otp_sms.go` # Additional Context - Related to https://github.com/zitadel/zitadel/issues/11035 --------- Co-authored-by: Marco A. --- .../v2/convert/session_challenge_otp_email.go | 36 + .../session_challenge_otp_email_test.go | 82 ++ .../v2/convert/session_challenge_otp_sms.go | 15 + .../convert/session_challenge_otp_sms_test.go | 48 + backend/v3/domain/defaults.go | 23 +- .../v3/domain/session_challenge_otp_email.go | 276 ++++ .../session_challenge_otp_email_test.go | 1113 +++++++++++++++++ .../v3/domain/session_challenge_otp_sms.go | 27 +- .../domain/session_challenge_otp_sms_test.go | 69 +- .../session_check_recovery_code_test.go | 19 +- cmd/start/start.go | 1 + 11 files changed, 1607 insertions(+), 102 deletions(-) create mode 100644 backend/v3/api/session/v2/convert/session_challenge_otp_email.go create mode 100644 backend/v3/api/session/v2/convert/session_challenge_otp_email_test.go create mode 100644 backend/v3/api/session/v2/convert/session_challenge_otp_sms.go create mode 100644 backend/v3/api/session/v2/convert/session_challenge_otp_sms_test.go create mode 100644 backend/v3/domain/session_challenge_otp_email.go create mode 100644 backend/v3/domain/session_challenge_otp_email_test.go diff --git a/backend/v3/api/session/v2/convert/session_challenge_otp_email.go b/backend/v3/api/session/v2/convert/session_challenge_otp_email.go new file mode 100644 index 000000000..260b97851 --- /dev/null +++ b/backend/v3/api/session/v2/convert/session_challenge_otp_email.go @@ -0,0 +1,36 @@ +package convert + +import ( + "github.com/zitadel/zitadel/backend/v3/domain" + "github.com/zitadel/zitadel/internal/zerrors" + session_grpc "github.com/zitadel/zitadel/pkg/grpc/session/v2" +) + +func ChallengeOTPEmailGRPCToDomain(otpEmailChallenge *session_grpc.RequestChallenges_OTPEmail) (*domain.ChallengeTypeOTPEmail, error) { + if otpEmailChallenge == nil { + return nil, nil + } + + switch t := otpEmailChallenge.DeliveryType.(type) { + case *session_grpc.RequestChallenges_OTPEmail_SendCode_: + return &domain.ChallengeTypeOTPEmail{ + DeliveryType: domain.DeliveryType{ + SendCode: &domain.SendCode{ + URLTemplate: t.SendCode.GetUrlTemplate(), + }, + }, + }, nil + case *session_grpc.RequestChallenges_OTPEmail_ReturnCode_: + return &domain.ChallengeTypeOTPEmail{ + DeliveryType: domain.DeliveryType{ + ReturnCode: true, + }, + }, nil + case nil: + return &domain.ChallengeTypeOTPEmail{ + DeliveryType: domain.DeliveryType{}, + }, nil + default: + return nil, zerrors.ThrowUnimplementedf(nil, "SESSION-mfil3D", "delivery_type oneOf %T in OTPEmailChallenge not implemented", t) + } +} diff --git a/backend/v3/api/session/v2/convert/session_challenge_otp_email_test.go b/backend/v3/api/session/v2/convert/session_challenge_otp_email_test.go new file mode 100644 index 000000000..30694dde0 --- /dev/null +++ b/backend/v3/api/session/v2/convert/session_challenge_otp_email_test.go @@ -0,0 +1,82 @@ +package convert + +import ( + "testing" + + "github.com/muhlemmer/gu" + "github.com/stretchr/testify/assert" + + "github.com/zitadel/zitadel/backend/v3/domain" + session_grpc "github.com/zitadel/zitadel/pkg/grpc/session/v2" +) + +func TestChallengeOTPEmailGRPCToDomain(t *testing.T) { + tests := []struct { + name string + otpEmailChallenge *session_grpc.RequestChallenges_OTPEmail + want *domain.ChallengeTypeOTPEmail + wantErr error + }{ + { + name: "nil OTP email challenge", + }, + { + name: "otp email challenge - delivery type send code without url template", + otpEmailChallenge: &session_grpc.RequestChallenges_OTPEmail{ + DeliveryType: &session_grpc.RequestChallenges_OTPEmail_SendCode_{ + SendCode: &session_grpc.RequestChallenges_OTPEmail_SendCode{}, + }, + }, + want: &domain.ChallengeTypeOTPEmail{ + DeliveryType: domain.DeliveryType{ + SendCode: &domain.SendCode{}, + }, + }, + }, + { + name: "otp email challenge - delivery type send code with url template", + otpEmailChallenge: &session_grpc.RequestChallenges_OTPEmail{ + DeliveryType: &session_grpc.RequestChallenges_OTPEmail_SendCode_{ + SendCode: &session_grpc.RequestChallenges_OTPEmail_SendCode{ + UrlTemplate: gu.Ptr("https://example.com/otp"), + }, + }, + }, + want: &domain.ChallengeTypeOTPEmail{ + DeliveryType: domain.DeliveryType{ + SendCode: &domain.SendCode{ + URLTemplate: "https://example.com/otp", + }, + }, + }, + }, + { + name: "otp email challenge - delivery type return code", + otpEmailChallenge: &session_grpc.RequestChallenges_OTPEmail{ + DeliveryType: &session_grpc.RequestChallenges_OTPEmail_ReturnCode_{ + ReturnCode: &session_grpc.RequestChallenges_OTPEmail_ReturnCode{}, + }, + }, + want: &domain.ChallengeTypeOTPEmail{ + DeliveryType: domain.DeliveryType{ + ReturnCode: true, + }, + }, + }, + { + name: "otp email challenge - delivery type not set", + otpEmailChallenge: &session_grpc.RequestChallenges_OTPEmail{}, + want: &domain.ChallengeTypeOTPEmail{ + DeliveryType: domain.DeliveryType{}, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got, err := ChallengeOTPEmailGRPCToDomain(tt.otpEmailChallenge) + assert.Equal(t, tt.want, got) + assert.ErrorIs(t, err, tt.wantErr) + }) + } +} diff --git a/backend/v3/api/session/v2/convert/session_challenge_otp_sms.go b/backend/v3/api/session/v2/convert/session_challenge_otp_sms.go new file mode 100644 index 000000000..c09bd4ffc --- /dev/null +++ b/backend/v3/api/session/v2/convert/session_challenge_otp_sms.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 ChallengeOTPSMSGRPCToDomain(otpSMSChallenge *session_grpc.RequestChallenges_OTPSMS) *domain.ChallengeTypeOTPSMS { + if otpSMSChallenge == nil { + return nil + } + return &domain.ChallengeTypeOTPSMS{ + ReturnCode: otpSMSChallenge.GetReturnCode(), + } +} diff --git a/backend/v3/api/session/v2/convert/session_challenge_otp_sms_test.go b/backend/v3/api/session/v2/convert/session_challenge_otp_sms_test.go new file mode 100644 index 000000000..3bc60dd5e --- /dev/null +++ b/backend/v3/api/session/v2/convert/session_challenge_otp_sms_test.go @@ -0,0 +1,48 @@ +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 TestChallengeOTPSMSGRPCToDomain(t *testing.T) { + t.Parallel() + tests := []struct { + name string + challenge *session_grpc.RequestChallenges_OTPSMS + want *domain.ChallengeTypeOTPSMS + }{ + { + name: "nil OTP SMS challenge", + }, + { + name: "OTP SMS challenge true", + challenge: &session_grpc.RequestChallenges_OTPSMS{ + ReturnCode: true, + }, + want: &domain.ChallengeTypeOTPSMS{ + ReturnCode: true, + }, + }, + { + name: "OTP SMS challenge false", + challenge: &session_grpc.RequestChallenges_OTPSMS{ + ReturnCode: false, + }, + want: &domain.ChallengeTypeOTPSMS{ + ReturnCode: false, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := ChallengeOTPSMSGRPCToDomain(tt.challenge) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/backend/v3/domain/defaults.go b/backend/v3/domain/defaults.go index 33920ee3d..eccae7984 100644 --- a/backend/v3/domain/defaults.go +++ b/backend/v3/domain/defaults.go @@ -9,15 +9,16 @@ import ( ) var ( - pool database.Pool - legacyEventstore eventstore.LegacyEventstore - sysConfig systemdefaults.SystemDefaults - passwordHasher *crypto.Hasher - idpEncryptionAlgo crypto.EncryptionAlgorithm - sessionTokenDecryptor SessionTokenDecryptor - mfaEncryptionAlgo crypto.EncryptionAlgorithm - otpSMSSecretGeneratorConfig *crypto.GeneratorConfig - webauthnConfig *webauthn.Config + pool database.Pool + legacyEventstore eventstore.LegacyEventstore + sysConfig systemdefaults.SystemDefaults + passwordHasher *crypto.Hasher + idpEncryptionAlgo crypto.EncryptionAlgorithm + sessionTokenDecryptor SessionTokenDecryptor + mfaEncryptionAlgo crypto.EncryptionAlgorithm + otpSMSSecretGeneratorConfig *crypto.GeneratorConfig + otpEmailSecretGeneratorConfig *crypto.GeneratorConfig + webauthnConfig *webauthn.Config ) func SetPool(p database.Pool) { @@ -48,6 +49,10 @@ func SetOTPSMSSecretGeneratorConfig(cfg *crypto.GeneratorConfig) { otpSMSSecretGeneratorConfig = cfg } +func SetOTPEmailSecretGeneratorConfig(cfg *crypto.GeneratorConfig) { + otpEmailSecretGeneratorConfig = cfg +} + func SetMFAEncryptionAlgorithm(mfaEncryptionAlg crypto.EncryptionAlgorithm) { mfaEncryptionAlgo = mfaEncryptionAlg } diff --git a/backend/v3/domain/session_challenge_otp_email.go b/backend/v3/domain/session_challenge_otp_email.go new file mode 100644 index 000000000..a12ffbc30 --- /dev/null +++ b/backend/v3/domain/session_challenge_otp_email.go @@ -0,0 +1,276 @@ +package domain + +import ( + "context" + "io" + "text/template" + "time" + + "golang.org/x/text/language" + + "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" +) + +var _ Commander = (*OTPEmailChallengeCommand)(nil) +var _ Transactional = (*OTPEmailChallengeCommand)(nil) + +type SendCode struct { + URLTemplate string +} + +type DeliveryType struct { + SendCode *SendCode + ReturnCode bool +} +type ChallengeTypeOTPEmail struct { + DeliveryType DeliveryType +} + +type OTPEmailChallengeCommand struct { + ChallengeTypeOTPEmail *ChallengeTypeOTPEmail + + SessionID string + InstanceID string + + defaultSecretGeneratorConfig *crypto.GeneratorConfig + otpEncryptionAlgorithm crypto.EncryptionAlgorithm + newEmailCode newOTPCodeFunc + + sessionChallengeOTPEmail *SessionChallengeOTPEmail // the generated OTP Email challenge that is stored in the session. + otpEmailChallenge *string // challenge to be set in the CreateSessionResponse +} + +func NewOTPEmailChallengeCommand( + challengeTypeOTPEmail *ChallengeTypeOTPEmail, + sessionID string, + instanceID string, + secretGeneratorConfig *crypto.GeneratorConfig, + otpAlgorithm crypto.EncryptionAlgorithm, + newEmailCodeFn newOTPCodeFunc) *OTPEmailChallengeCommand { + + if secretGeneratorConfig == nil { + secretGeneratorConfig = otpEmailSecretGeneratorConfig + } + if otpAlgorithm == nil { + otpAlgorithm = mfaEncryptionAlgo + } + if newEmailCodeFn == nil { + newEmailCodeFn = crypto.NewCode + } + + return &OTPEmailChallengeCommand{ + ChallengeTypeOTPEmail: challengeTypeOTPEmail, + SessionID: sessionID, + InstanceID: instanceID, + defaultSecretGeneratorConfig: secretGeneratorConfig, + otpEncryptionAlgorithm: otpAlgorithm, + newEmailCode: newEmailCodeFn, + } +} + +// Validate implements [Commander]. +// It validates that the session and user exist and that the user has email OTP enabled. +func (o *OTPEmailChallengeCommand) Validate(ctx context.Context, opts *InvokeOpts) (err error) { + if o.ChallengeTypeOTPEmail == 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-JArUai", objectTypeSession); err != nil { + return err + } + if retrievedSession.UserID == "" { + return zerrors.ThrowPreconditionFailed(nil, "DOM-wG2XoJ", "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-56MWkg", objectTypeUser); err != nil { + return err + } + + // validate human user and user email + if retrievedUser.Human == nil || retrievedUser.Human.Email.Address == "" { + return zerrors.ThrowPreconditionFailed(nil, "DOM-7hG2d", "Errors.NotFound.User.Human.Email") + } + // validate email OTP is enabled + if retrievedUser.Human.Email.OTP.EnabledAt.IsZero() { + return zerrors.ThrowPreconditionFailed(nil, "DOM-9kL4q", "Errors.User.MFA.OTP.NotReady") + } + + return nil +} + +// Execute implements [Commander]. +// It updates the session with the generated OTP email challenge. +func (o *OTPEmailChallengeCommand) Execute(ctx context.Context, opts *InvokeOpts) error { + if o.ChallengeTypeOTPEmail == nil { + return nil + } + + // prepare the otp email challenge + sessionChallengeOTPEmail, challenge, err := o.prepareOTPEmailChallenge(ctx, opts) + if err != nil { + return err + } + + // update the session with the otp email challenge + sessionRepo := opts.sessionRepo + updated, err := sessionRepo.Update( + ctx, + opts.DB(), + sessionRepo.PrimaryKeyCondition(o.InstanceID, o.SessionID), + sessionRepo.SetChallenge(sessionChallengeOTPEmail), + ) + if err := handleUpdateError(err, expectedUpdatedRows, updated, "DOM-YfQIA3", objectTypeSession); err != nil { + return err + } + o.sessionChallengeOTPEmail = sessionChallengeOTPEmail + if o.ChallengeTypeOTPEmail.DeliveryType.ReturnCode { // only set when the delivery type is ReturnCode + o.otpEmailChallenge = &challenge + } + + return nil +} + +// Events implements [Commander]. +// It creates the OTPEmailChallengedEvent if an OTP email challenge was requested. +func (o *OTPEmailChallengeCommand) Events(ctx context.Context, opts *InvokeOpts) ([]eventstore.Command, error) { + if o.ChallengeTypeOTPEmail == nil { + return nil, nil + } + + return []eventstore.Command{ + session.NewOTPEmailChallengedEvent( + ctx, + &session.NewAggregate(o.SessionID, o.InstanceID).Aggregate, + o.sessionChallengeOTPEmail.Code, + o.sessionChallengeOTPEmail.Expiry, + o.sessionChallengeOTPEmail.CodeReturned, + o.sessionChallengeOTPEmail.URLTemplate, + ), + }, nil +} + +func (o *OTPEmailChallengeCommand) GetOTPEmailChallenge() *string { + return o.otpEmailChallenge +} + +// prepareOTPEmailChallenge generates the OTP email challenge based on the delivery type in the request. +func (o *OTPEmailChallengeCommand) prepareOTPEmailChallenge(ctx context.Context, opts *InvokeOpts) (*SessionChallengeOTPEmail, string, error) { + // generate email code + config, err := GetOTPCryptoGeneratorConfigWithDefault(ctx, o.InstanceID, opts, o.defaultSecretGeneratorConfig, OTPTypeEmail) + if err != nil { + return nil, "", err + } + codeGenerator := crypto.NewEncryptionGenerator(*config, o.otpEncryptionAlgorithm) + crypted, plain, err := o.newEmailCode(codeGenerator) + if err != nil { + return nil, "", err + } + + challengeOTPEmail := &SessionChallengeOTPEmail{ + LastChallengedAt: time.Now(), + Code: crypted, + Expiry: config.Expiry, + TriggeredAtOrigin: http.DomainContext(ctx).Origin(), + } + + var otpEmailChallenge string + switch { + case o.ChallengeTypeOTPEmail.DeliveryType.SendCode != nil: + challengeOTPEmail.URLTemplate = o.ChallengeTypeOTPEmail.DeliveryType.SendCode.URLTemplate + case o.ChallengeTypeOTPEmail.DeliveryType.ReturnCode: + challengeOTPEmail.CodeReturned = true + otpEmailChallenge = plain + default: + // no additional action needed + } + return challengeOTPEmail, otpEmailChallenge, nil +} + +// String implements [Commander]. +func (o *OTPEmailChallengeCommand) String() string { + return "OTPEmailChallengeCommand" +} + +// RequiresTransaction implements [Transactional]. +func (o *OTPEmailChallengeCommand) RequiresTransaction() {} + +// validateURLTemplate renders the given URL template with sample data to validate its correctness. +func validateURLTemplate(w io.Writer, tmpl string) error { + otpEmailURLData := &struct { + Code string + UserID string + LoginName string + DisplayName string + PreferredLanguage language.Tag + SessionID string + }{ + Code: "code", + UserID: "userID", + LoginName: "loginName", + DisplayName: "displayName", + PreferredLanguage: language.English, + SessionID: "SessionID", + } + parsed, err := template.New("").Parse(tmpl) + if err != nil { + return zerrors.ThrowInvalidArgument(err, "DOM-wkDwQM", "Errors.Invalid.URLTemplate") + } + if err = parsed.Execute(w, otpEmailURLData); err != nil { + return zerrors.ThrowInvalidArgument(err, "DOM-F5Yv8l", "Errors.Invalid.URLTemplate") + } + return nil +} + +func (o *OTPEmailChallengeCommand) validatePreConditions() error { + // validate required fields + if o.SessionID == "" { + return zerrors.ThrowPreconditionFailed(nil, "DOM-BQ5UgK", "Errors.Missing.SessionID") + } + if o.InstanceID == "" { + return zerrors.ThrowPreconditionFailed(nil, "DOM-kDnkDn", "Errors.Missing.InstanceID") + } + + // validate that default secret generator config is set + if o.defaultSecretGeneratorConfig == nil { + return zerrors.ThrowInternal(nil, "DOM-nnB9MS", "missing default secret generator config") + } + + // validate that otp encryption algorithm is set + if o.otpEncryptionAlgorithm == nil { + return zerrors.ThrowInternal(nil, "DOM-kuG75Q", "missing MFA encryption algorithm") + } + + // validate the URL template + if sc := o.ChallengeTypeOTPEmail.DeliveryType.SendCode; sc != nil { + if sc.URLTemplate != "" { + if err := validateURLTemplate(io.Discard, sc.URLTemplate); err != nil { + return err + } + } + } + return nil +} diff --git a/backend/v3/domain/session_challenge_otp_email_test.go b/backend/v3/domain/session_challenge_otp_email_test.go new file mode 100644 index 000000000..2005a1c24 --- /dev/null +++ b/backend/v3/domain/session_challenge_otp_email_test.go @@ -0,0 +1,1113 @@ +package domain_test + +import ( + "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 TestOTPEmailChallengeCommand_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 + challengeTypeOTPEmail *domain.ChallengeTypeOTPEmail + userRepo func(ctrl *gomock.Controller) domain.UserRepository + sessionRepo func(ctrl *gomock.Controller) domain.SessionRepository + wantErr error + }{ + { + name: "no request otp email challenge", + challengeTypeOTPEmail: nil, + wantErr: nil, + }, + { + name: "no session id", + sessionID: "", + instanceID: "instance-1", + challengeTypeOTPEmail: &domain.ChallengeTypeOTPEmail{}, + wantErr: zerrors.ThrowPreconditionFailed(nil, "DOM-BQ5UgK", "Errors.Missing.SessionID"), + }, + { + name: "no instance id", + sessionID: "session-1", + instanceID: "", + challengeTypeOTPEmail: &domain.ChallengeTypeOTPEmail{}, + wantErr: zerrors.ThrowPreconditionFailed(nil, "DOM-kDnkDn", "Errors.Missing.InstanceID"), + }, + { + name: "no default secret generator config", + sessionID: "session-1", + instanceID: "instance-1", + challengeTypeOTPEmail: &domain.ChallengeTypeOTPEmail{}, + wantErr: zerrors.ThrowInternal(nil, "DOM-nnB9MS", "missing default secret generator config"), + }, + { + name: "no otp algorithm", + sessionID: "session-1", + instanceID: "instance-1", + challengeTypeOTPEmail: &domain.ChallengeTypeOTPEmail{}, + secretGeneratorConfig: &crypto.GeneratorConfig{ + Expiry: 10 * time.Minute, + }, + wantErr: zerrors.ThrowInternal(nil, "DOM-kuG75Q", "missing MFA encryption algorithm"), + }, + { + name: "failed to render url template", + sessionID: "session-1", + instanceID: "instance-1", + challengeTypeOTPEmail: &domain.ChallengeTypeOTPEmail{ + DeliveryType: domain.DeliveryType{ + SendCode: &domain.SendCode{ + URLTemplate: "http://{{.Invalid", + }, + }, + }, + secretGeneratorConfig: &crypto.GeneratorConfig{}, + otpAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + wantErr: zerrors.ThrowInvalidArgument(nil, "DOM-wkDwQM", "Errors.Invalid.URLTemplate"), + }, + { + name: "failed to get session", + sessionID: "session-1", + instanceID: "instance-1", + challengeTypeOTPEmail: &domain.ChallengeTypeOTPEmail{ + DeliveryType: domain.DeliveryType{ + SendCode: &domain.SendCode{ + URLTemplate: "https://example.com", + }, + }, + }, + otpAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + secretGeneratorConfig: &crypto.GeneratorConfig{ + Expiry: 10 * time.Minute, + }, + 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-JArUai", "failed fetching Session"), + }, + { + name: "session not found", + sessionID: "session-1", + instanceID: "instance-1", + challengeTypeOTPEmail: &domain.ChallengeTypeOTPEmail{}, + otpAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + secretGeneratorConfig: &crypto.GeneratorConfig{ + Expiry: 10 * time.Minute, + }, + 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-JArUai", "Session not found"), + }, + { + name: "missing user id in session", + sessionID: "session-1", + instanceID: "instance-1", + challengeTypeOTPEmail: &domain.ChallengeTypeOTPEmail{}, + otpAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + secretGeneratorConfig: &crypto.GeneratorConfig{ + Expiry: 10 * time.Minute, + }, + 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-wG2XoJ", "Errors.Missing.Session.UserID"), + }, + { + name: "failed to get user", + sessionID: "session-1", + instanceID: "instance-1", + challengeTypeOTPEmail: &domain.ChallengeTypeOTPEmail{}, + otpAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + secretGeneratorConfig: &crypto.GeneratorConfig{ + Expiry: 10 * time.Minute, + }, + sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository { + repo := domainmock.NewSessionRepo(ctrl) + getSessionSucceededExpectation(repo) + 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-56MWkg", "failed fetching User"), + }, + { + name: "user not found", + sessionID: "session-1", + instanceID: "instance-1", + challengeTypeOTPEmail: &domain.ChallengeTypeOTPEmail{}, + otpAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + secretGeneratorConfig: &crypto.GeneratorConfig{ + Expiry: 10 * time.Minute, + }, + sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository { + repo := domainmock.NewSessionRepo(ctrl) + getSessionSucceededExpectation(repo) + 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-56MWkg", "User not found"), + }, + { + name: "human user not set", + sessionID: "session-1", + instanceID: "instance-1", + challengeTypeOTPEmail: &domain.ChallengeTypeOTPEmail{}, + otpAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + secretGeneratorConfig: &crypto.GeneratorConfig{ + Expiry: 10 * time.Minute, + }, + sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository { + repo := domainmock.NewSessionRepo(ctrl) + getSessionSucceededExpectation(repo) + 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-7hG2d", "Errors.NotFound.User.Human.Email"), + }, + { + name: "email not set", + sessionID: "session-1", + instanceID: "instance-1", + challengeTypeOTPEmail: &domain.ChallengeTypeOTPEmail{}, + otpAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + secretGeneratorConfig: &crypto.GeneratorConfig{ + Expiry: 10 * time.Minute, + }, + sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository { + repo := domainmock.NewSessionRepo(ctrl) + getSessionSucceededExpectation(repo) + 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-7hG2d", "Errors.NotFound.User.Human.Email"), + }, + { + name: "email address not set", + sessionID: "session-1", + instanceID: "instance-1", + challengeTypeOTPEmail: &domain.ChallengeTypeOTPEmail{}, + otpAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + secretGeneratorConfig: &crypto.GeneratorConfig{ + Expiry: 10 * time.Minute, + }, + sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository { + repo := domainmock.NewSessionRepo(ctrl) + getSessionSucceededExpectation(repo) + 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", + }, + Email: domain.HumanEmail{ + Address: "", + }, + }, + }, nil) + return repo + }, + wantErr: zerrors.ThrowPreconditionFailed(nil, "DOM-7hG2d", "Errors.NotFound.User.Human.Email"), + }, + { + name: "email otp not enabled", + sessionID: "session-1", + instanceID: "instance-1", + challengeTypeOTPEmail: &domain.ChallengeTypeOTPEmail{}, + otpAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + secretGeneratorConfig: &crypto.GeneratorConfig{ + Expiry: 10 * time.Minute, + }, + sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository { + repo := domainmock.NewSessionRepo(ctrl) + getSessionSucceededExpectation(repo) + 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", + }, + Email: domain.HumanEmail{ + Address: "email@example.com", + OTP: domain.OTP{}, + }, + }, + }, nil) + return repo + }, + wantErr: zerrors.ThrowPreconditionFailed(nil, "DOM-9kL4q", "Errors.User.MFA.OTP.NotReady"), + }, + { + name: "valid OTP Email challenge request", + sessionID: "session-1", + instanceID: "instance-1", + challengeTypeOTPEmail: &domain.ChallengeTypeOTPEmail{}, + otpAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + secretGeneratorConfig: &crypto.GeneratorConfig{ + Expiry: 10 * time.Minute, + }, + sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository { + repo := domainmock.NewSessionRepo(ctrl) + getSessionSucceededExpectation(repo) + 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", + Email: domain.HumanEmail{ + Address: "email@example.com", + OTP: domain.OTP{ + EnabledAt: otpEnabledAt, + }, + }, + }, + }, nil) + return repo + }, + }, + } + 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.NewOTPEmailChallengeCommand( + tt.challengeTypeOTPEmail, + tt.sessionID, + tt.instanceID, + tt.secretGeneratorConfig, + tt.otpAlgorithm, + 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 TestOTPEmailChallengeCommand_Execute(t *testing.T) { + t.Parallel() + codeErr := errors.New("failed to create code") + challengedAt := time.Now() + defaultExpiry := 10 * time.Minute + expiry := 30 * time.Minute + otpEnabledAt := time.Now().Add(-30 * time.Minute) + + tests := []struct { + name string + challengeTypeOTPEmail *domain.ChallengeTypeOTPEmail + 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 + newEmailCodeFn func(g crypto.Generator) (*crypto.CryptoValue, string, error) + wantErr error + wantOTPEmailChallenge *string + }{ + { + name: "no otp email challenge request", + challengeTypeOTPEmail: nil, + wantErr: nil, + }, + { + name: "failed to retrieve secret generator config from settings DB", + challengeTypeOTPEmail: &domain.ChallengeTypeOTPEmail{}, + secretGeneratorConfig: &crypto.GeneratorConfig{}, + otpAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + 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", + challengeTypeOTPEmail: &domain.ChallengeTypeOTPEmail{}, + secretGeneratorConfig: &crypto.GeneratorConfig{}, + sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository { + repo := domainmock.NewSessionRepo(ctrl) + getSessionSucceededExpectation(repo) + return repo + }, + userRepo: getUser(otpEnabledAt), + otpAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + secretGeneratorSettingsRepo: secretGeneratorSettingsRepo(domain.SettingStateActive, domain.OTPTypeEmail), + newEmailCodeFn: func(g crypto.Generator) (*crypto.CryptoValue, string, error) { + return nil, "", codeErr + }, + wantErr: codeErr, + }, + { + name: "failed to update session", + challengeTypeOTPEmail: &domain.ChallengeTypeOTPEmail{ + DeliveryType: domain.DeliveryType{ + ReturnCode: true, + }, + }, + secretGeneratorConfig: &crypto.GeneratorConfig{ + Expiry: expiry, + }, + otpAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + secretGeneratorSettingsRepo: secretGeneratorSettingsRepo(domain.SettingStateActive, domain.OTPTypeEmail), + newEmailCodeFn: func(g crypto.Generator) (*crypto.CryptoValue, string, error) { + return &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("code"), + }, "", nil + }, + userRepo: getUser(otpEnabledAt), + sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository { + repo := domainmock.NewSessionRepo(ctrl) + getSessionSucceededExpectation(repo) + + challengeOTPEmail := repo.SetChallenge(&domain.SessionChallengeOTPEmail{ + Code: &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("code"), + }, + Expiry: expiry, + CodeReturned: true, + LastChallengedAt: challengedAt, + }) + updateSessionFailedExpectation(repo, challengeOTPEmail, assert.AnError, 0) + return repo + }, + wantErr: zerrors.ThrowInternal(assert.AnError, "DOM-YfQIA3", "failed updating Session"), + }, + { + name: "failed to update session - no rows updated", + challengeTypeOTPEmail: &domain.ChallengeTypeOTPEmail{ + DeliveryType: domain.DeliveryType{ + SendCode: &domain.SendCode{ + URLTemplate: "http://example.com", + }, + }, + }, + secretGeneratorConfig: &crypto.GeneratorConfig{ + Expiry: defaultExpiry, + Length: uint(6), + }, + otpAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + secretGeneratorSettingsRepo: secretGeneratorSettingsRepo(domain.SettingStateActive, domain.OTPTypeEmail), + newEmailCodeFn: func(g crypto.Generator) (*crypto.CryptoValue, string, error) { + return &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("code"), + }, "", nil + }, + userRepo: getUser(otpEnabledAt), + sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository { + repo := domainmock.NewSessionRepo(ctrl) + getSessionSucceededExpectation(repo) + + challengeOTPEmail := repo.SetChallenge(&domain.SessionChallengeOTPEmail{ + Code: &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("code"), + }, + Expiry: expiry, + URLTemplate: "http://example.com", + CodeReturned: false, + LastChallengedAt: challengedAt, + }) + updateSessionFailedExpectation(repo, challengeOTPEmail, nil, 0) + return repo + }, + wantErr: zerrors.ThrowNotFound(nil, "DOM-YfQIA3", "Session not found"), + }, + { + name: "failed to update session - more than 1 row updated", + challengeTypeOTPEmail: &domain.ChallengeTypeOTPEmail{ + DeliveryType: domain.DeliveryType{ + ReturnCode: true, + }, + }, + secretGeneratorConfig: &crypto.GeneratorConfig{ + Expiry: defaultExpiry, + }, + otpAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + secretGeneratorSettingsRepo: secretGeneratorSettingsRepo(domain.SettingStateActive, domain.OTPTypeEmail), + newEmailCodeFn: func(g crypto.Generator) (*crypto.CryptoValue, string, error) { + return &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("code"), + }, "", nil + }, + userRepo: getUser(otpEnabledAt), + sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository { + repo := domainmock.NewSessionRepo(ctrl) + getSessionSucceededExpectation(repo) + + challengeOTPEmail := repo.SetChallenge(&domain.SessionChallengeOTPEmail{ + Code: &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("code"), + }, + Expiry: expiry, + CodeReturned: true, + LastChallengedAt: challengedAt, + }) + repo.EXPECT(). + Update( + gomock.Any(), + gomock.Any(), + repo.PrimaryKeyCondition("instance-1", "session-1"), + challengeOTPEmail, + ). + Times(1). + Return(int64(2), nil) + return repo + }, + wantErr: zerrors.ThrowInternal(nil, "DOM-YfQIA3", "unexpected number of rows updated"), + }, + { + name: "update session succeeded - delivery type return code", + challengeTypeOTPEmail: &domain.ChallengeTypeOTPEmail{ + DeliveryType: domain.DeliveryType{ + ReturnCode: true, + }, + }, + secretGeneratorConfig: &crypto.GeneratorConfig{ + Expiry: defaultExpiry, + }, + otpAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + secretGeneratorSettingsRepo: secretGeneratorSettingsRepo(domain.SettingStateActive, domain.OTPTypeEmail), + newEmailCodeFn: 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) + + challengeOTPEmail := repo.SetChallenge(&domain.SessionChallengeOTPEmail{ + Code: &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("code"), + }, + Expiry: expiry, + CodeReturned: true, + LastChallengedAt: challengedAt, + }) + updateSessionSucceededExpectation(repo, challengeOTPEmail) + return repo + }, + wantOTPEmailChallenge: gu.Ptr("code"), // OTPEmailChallenge is only set when the delivery type is return code + }, + { + name: "update session succeeded - delivery type send code", + challengeTypeOTPEmail: &domain.ChallengeTypeOTPEmail{ + DeliveryType: domain.DeliveryType{ + SendCode: &domain.SendCode{ + URLTemplate: "http://example.com", + }, + }, + }, + secretGeneratorConfig: &crypto.GeneratorConfig{ + Expiry: defaultExpiry, + }, + otpAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + secretGeneratorSettingsRepo: secretGeneratorSettingsRepo(domain.SettingStateActive, domain.OTPTypeEmail), + newEmailCodeFn: 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) + challengeOTPEmail := repo.SetChallenge(&domain.SessionChallengeOTPEmail{ + Code: &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("code"), + }, + Expiry: expiry, + CodeReturned: false, + URLTemplate: "http://example.com", + LastChallengedAt: challengedAt, + }) + updateSessionSucceededExpectation(repo, challengeOTPEmail) + return repo + }, + }, + { + name: "update session succeeded - delivery type default", + challengeTypeOTPEmail: &domain.ChallengeTypeOTPEmail{}, + secretGeneratorConfig: &crypto.GeneratorConfig{ + Expiry: defaultExpiry, + }, + otpAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + secretGeneratorSettingsRepo: secretGeneratorSettingsRepo(domain.SettingStateActive, domain.OTPTypeEmail), + newEmailCodeFn: 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) + challengeOTPEmail := repo.SetChallenge(&domain.SessionChallengeOTPEmail{ + Code: &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("code"), + }, + Expiry: expiry, + CodeReturned: false, + URLTemplate: "", + LastChallengedAt: challengedAt, + }) + updateSessionSucceededExpectation(repo, challengeOTPEmail) + return repo + }, + }, + { + name: "update session succeeded - with default config when setting state is not active", + challengeTypeOTPEmail: &domain.ChallengeTypeOTPEmail{}, + secretGeneratorConfig: &crypto.GeneratorConfig{ + Expiry: defaultExpiry, + Length: uint(8), + IncludeLowerLetters: false, + IncludeUpperLetters: true, + IncludeDigits: true, + IncludeSymbols: false, + }, + otpAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + secretGeneratorSettingsRepo: secretGeneratorSettingsRepo(domain.SettingStatePreview, domain.OTPTypeEmail), + newEmailCodeFn: 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) + challengeOTPEmail := repo.SetChallenge(&domain.SessionChallengeOTPEmail{ + Code: &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("code"), + }, + Expiry: defaultExpiry, + CodeReturned: false, + URLTemplate: "", + LastChallengedAt: challengedAt, + }) + updateSessionSucceededExpectation(repo, challengeOTPEmail) + return repo + }, + }, + { + name: "update session succeeded - with default config when OTP email settings not found", + challengeTypeOTPEmail: &domain.ChallengeTypeOTPEmail{}, + secretGeneratorConfig: &crypto.GeneratorConfig{ + Expiry: defaultExpiry, + Length: uint(8), + IncludeLowerLetters: false, + IncludeUpperLetters: true, + IncludeDigits: true, + IncludeSymbols: false, + }, + otpAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + secretGeneratorSettingsRepo: secretGeneratorSettingsRepo(domain.SettingStateActive, 2), + newEmailCodeFn: 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) + challengeOTPEmail := repo.SetChallenge(&domain.SessionChallengeOTPEmail{ + Code: &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("code"), + }, + Expiry: defaultExpiry, + CodeReturned: false, + URLTemplate: "", + LastChallengedAt: challengedAt, + }) + updateSessionSucceededExpectation(repo, challengeOTPEmail) + return repo + }, + }, + } + + 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.NewOTPEmailChallengeCommand( + tt.challengeTypeOTPEmail, + "session-1", + "instance-1", + tt.secretGeneratorConfig, + tt.otpAlgorithm, + tt.newEmailCodeFn, + ) + 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) + require.NoError(t, err) + + err = cmd.Execute(ctx, opts) + assert.ErrorIs(t, err, tt.wantErr) + assert.Equal(t, tt.wantOTPEmailChallenge, cmd.GetOTPEmailChallenge()) + }) + } +} + +func TestOTPEmailChallengeCommand_Events(t *testing.T) { + t.Parallel() + challengedAt := time.Now() + expiry := 30 * time.Minute + defaultExpiry := 10 * time.Minute + otpEnabledAt := time.Now().Add(-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 + challengeTypeOTPEmail *domain.ChallengeTypeOTPEmail + 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 + newEmailCodeFn func(g crypto.Generator) (*crypto.CryptoValue, string, error) + wantErr error + wantEvent eventstore.Command + }{ + { + name: "no request otp email challenge", + challengeTypeOTPEmail: nil, + }, + { + name: "valid OTP email challenge request - no delivery type", + challengeTypeOTPEmail: &domain.ChallengeTypeOTPEmail{}, + secretGeneratorConfig: &crypto.GeneratorConfig{ + Expiry: defaultExpiry, + }, + otpAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + secretGeneratorSettingsRepo: secretGeneratorSettingsRepo(domain.SettingStateActive, domain.OTPTypeEmail), + newEmailCodeFn: 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) + challengeOTPEmail := repo.SetChallenge(&domain.SessionChallengeOTPEmail{ + Code: &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("code"), + }, + Expiry: expiry, + CodeReturned: false, + URLTemplate: "", + LastChallengedAt: challengedAt, + }) + updateSessionSucceededExpectation(repo, challengeOTPEmail) + return repo + }, + wantEvent: session.NewOTPEmailChallengedEvent( + ctx, + &session.NewAggregate("session-1", "instance-1").Aggregate, + code, + expiry, + false, + "", + ), + }, + { + name: "valid OTP email challenge request - delivery type send code", + challengeTypeOTPEmail: &domain.ChallengeTypeOTPEmail{ + DeliveryType: domain.DeliveryType{ + SendCode: &domain.SendCode{ + URLTemplate: "https://example.com", + }, + }, + }, + secretGeneratorConfig: &crypto.GeneratorConfig{ + Expiry: defaultExpiry, + }, + otpAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + secretGeneratorSettingsRepo: secretGeneratorSettingsRepo(domain.SettingStateActive, domain.OTPTypeEmail), + newEmailCodeFn: 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) + challengeOTPEmail := repo.SetChallenge(&domain.SessionChallengeOTPEmail{ + Code: &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("code"), + }, + Expiry: expiry, + CodeReturned: false, + URLTemplate: "https://example.com", + LastChallengedAt: challengedAt, + }) + updateSessionSucceededExpectation(repo, challengeOTPEmail) + return repo + }, + wantEvent: session.NewOTPEmailChallengedEvent( + ctx, + &session.NewAggregate("session-1", "instance-1").Aggregate, + code, + expiry, + false, + "https://example.com", + ), + }, + { + name: "valid OTP email challenge request - delivery type return code", + challengeTypeOTPEmail: &domain.ChallengeTypeOTPEmail{ + DeliveryType: domain.DeliveryType{ + ReturnCode: true, + }, + }, + secretGeneratorConfig: &crypto.GeneratorConfig{ + Expiry: defaultExpiry, + }, + otpAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + secretGeneratorSettingsRepo: secretGeneratorSettingsRepo(domain.SettingStateActive, domain.OTPTypeEmail), + newEmailCodeFn: 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) + challengeOTPEmail := repo.SetChallenge(&domain.SessionChallengeOTPEmail{ + Code: &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("code"), + }, + Expiry: expiry, + CodeReturned: true, + URLTemplate: "", + LastChallengedAt: challengedAt, + }) + updateSessionSucceededExpectation(repo, challengeOTPEmail) + return repo + }, + wantEvent: session.NewOTPEmailChallengedEvent( + ctx, + &session.NewAggregate("session-1", "instance-1").Aggregate, + code, + expiry, + true, + "", + ), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + cmd := domain.NewOTPEmailChallengeCommand( + tt.challengeTypeOTPEmail, + "session-1", + "instance-1", + tt.secretGeneratorConfig, + tt.otpAlgorithm, + tt.newEmailCodeFn, + ) + ctrl := gomock.NewController(t) + 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) + require.NoError(t, err) + + // to set the challengeOTPSMS in the command for event generation + err = cmd.Execute(ctx, opts) + require.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]) + } + }) + } +} diff --git a/backend/v3/domain/session_challenge_otp_sms.go b/backend/v3/domain/session_challenge_otp_sms.go index 60efb70ba..e856b931f 100644 --- a/backend/v3/domain/session_challenge_otp_sms.go +++ b/backend/v3/domain/session_challenge_otp_sms.go @@ -42,9 +42,6 @@ type OTPSMSChallengeCommand struct { 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 } @@ -117,20 +114,14 @@ func (o *OTPSMSChallengeCommand) Validate(ctx context.Context, opts *InvokeOpts) 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 { + if retrievedUser.Human == nil || retrievedUser.Human.Phone == nil || retrievedUser.Human.Phone.Number == "" { 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") + return zerrors.ThrowPreconditionFailed(nil, "DOM-9kL4m", "Errors.User.MFA.OTP.NotReady") } - o.session = retrievedSession - o.user = retrievedUser return nil } @@ -164,19 +155,9 @@ func (o *OTPSMSChallengeCommand) Execute(ctx context.Context, opts *InvokeOpts) sessionRepo.PrimaryKeyCondition(o.InstanceID, o.SessionID), sessionRepo.SetChallenge(challengeOTPSMS), ) - if err != nil { - return zerrors.ThrowInternal(err, "DOM-AigB0Z", "session update failed") + if err := handleUpdateError(err, expectedUpdatedRows, updateCount, "DOM-AigB0Z", objectTypeSession); err != nil { + return err } - 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 diff --git a/backend/v3/domain/session_challenge_otp_sms_test.go b/backend/v3/domain/session_challenge_otp_sms_test.go index 1757c41ee..5615e8145 100644 --- a/backend/v3/domain/session_challenge_otp_sms_test.go +++ b/backend/v3/domain/session_challenge_otp_sms_test.go @@ -185,16 +185,7 @@ func TestOTPSMSChallengeCommand_Validate(t *testing.T) { }, 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) + getSessionSucceededExpectation(repo) return repo }, userRepo: func(ctrl *gomock.Controller) domain.UserRepository { @@ -226,16 +217,7 @@ func TestOTPSMSChallengeCommand_Validate(t *testing.T) { }, 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) + getSessionSucceededExpectation(repo) return repo }, userRepo: func(ctrl *gomock.Controller) domain.UserRepository { @@ -267,16 +249,7 @@ func TestOTPSMSChallengeCommand_Validate(t *testing.T) { }, 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) + getSessionSucceededExpectation(repo) return repo }, userRepo: func(ctrl *gomock.Controller) domain.UserRepository { @@ -314,16 +287,7 @@ func TestOTPSMSChallengeCommand_Validate(t *testing.T) { }, 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) + getSessionSucceededExpectation(repo) return repo }, userRepo: func(ctrl *gomock.Controller) domain.UserRepository { @@ -364,16 +328,7 @@ func TestOTPSMSChallengeCommand_Validate(t *testing.T) { }, 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) + getSessionSucceededExpectation(repo) return repo }, userRepo: func(ctrl *gomock.Controller) domain.UserRepository { @@ -401,7 +356,7 @@ func TestOTPSMSChallengeCommand_Validate(t *testing.T) { }, nil) return repo }, - wantErr: zerrors.ThrowPreconditionFailed(nil, "DOM-9kL4m", "Errors.OTPSMS.NotEnabled"), + wantErr: zerrors.ThrowPreconditionFailed(nil, "DOM-9kL4m", "Errors.User.MFA.OTP.NotReady"), }, { name: "valid OTP SMS challenge request", @@ -819,7 +774,7 @@ func TestOTPSMSChallengeCommand_Execute(t *testing.T) { updateSessionFailedExpectation(repo, challengeOTPSMSChange, assert.AnError, 0) return repo }, - wantErr: zerrors.ThrowInternal(assert.AnError, "DOM-AigB0Z", "session update failed"), + wantErr: zerrors.ThrowInternal(assert.AnError, "DOM-AigB0Z", "failed updating Session"), }, { name: "failed to update session - no rows updated", @@ -862,7 +817,7 @@ func TestOTPSMSChallengeCommand_Execute(t *testing.T) { updateSessionFailedExpectation(repo, challengeOTPSMSChange, nil, 0) return repo }, - wantErr: zerrors.ThrowNotFound(nil, "DOM-QThZH7", "Errors.Session.NotFound"), + wantErr: zerrors.ThrowNotFound(nil, "DOM-AigB0Z", "Session not found"), }, { name: "failed to update session - more than 1 row updated", @@ -904,7 +859,7 @@ func TestOTPSMSChallengeCommand_Execute(t *testing.T) { updateSessionFailedExpectation(repo, challengeOTPSMSChange, nil, 2) return repo }, - wantErr: zerrors.ThrowInternal(domain.NewMultipleObjectsUpdatedError(1, 2), "DOM-gYp8tG", "unexpected number of rows"), + wantErr: zerrors.ThrowInternal(domain.NewMultipleObjectsUpdatedError(1, 2), "DOM-AigB0Z", "unexpected number of rows updated"), }, } for _, tt := range tests { @@ -1263,6 +1218,12 @@ func getUser(otpEnabledAt time.Time) func(ctrl *gomock.Controller) domain.UserRe EnabledAt: otpEnabledAt, }, }, + Email: domain.HumanEmail{ + Address: "testuser@example.com", + OTP: domain.OTP{ + EnabledAt: otpEnabledAt, + }, + }, }, }, nil) return repo diff --git a/backend/v3/domain/session_check_recovery_code_test.go b/backend/v3/domain/session_check_recovery_code_test.go index 0689469a9..d867ba1c9 100644 --- a/backend/v3/domain/session_check_recovery_code_test.go +++ b/backend/v3/domain/session_check_recovery_code_test.go @@ -1320,17 +1320,11 @@ func updateHumanUserFailedExpectation(ctrl *gomock.Controller, userRepo *domainm 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 - } + humanRepo.EXPECT(). Update(gomock.Any(), gomock.Any(), primaryKeyCondition, userUpdates). Times(1). - Return(updateCount, nil) + Return(updateCount, err) } func getUserSucceededExpectation(userRepo *domainmock.UserRepo, failedAttempts uint8) { @@ -1363,17 +1357,10 @@ func updateSessionSucceededExpectation(sessionRepo *domainmock.SessionRepo, chan func updateSessionFailedExpectation(sessionRepo *domainmock.SessionRepo, change database.Change, err error, updateCount int64) { primaryKeyCondition := sessionRepo.PrimaryKeyCondition("instance-1", "session-1") - if err != nil { - sessionRepo.EXPECT(). - Update(gomock.Any(), gomock.Any(), primaryKeyCondition, change). - Times(1). - Return(updateCount, err) - return - } sessionRepo.EXPECT(). Update(gomock.Any(), gomock.Any(), primaryKeyCondition, change). Times(1). - Return(updateCount, nil) + Return(updateCount, err) } func getSessionSucceededExpectation(sessionRepo *domainmock.SessionRepo) { diff --git a/cmd/start/start.go b/cmd/start/start.go index 26aaf2c36..00829f83a 100644 --- a/cmd/start/start.go +++ b/cmd/start/start.go @@ -212,6 +212,7 @@ func startZitadel(ctx context.Context, config *Config, masterKey string, server new_domain.SetIDPEncryptionAlgorithm(keys.IDPConfig) new_domain.SetMFAEncryptionAlgorithm(keys.OTP) new_domain.SetOTPSMSSecretGeneratorConfig(config.DefaultInstance.SecretGenerators.OTPSMS) + new_domain.SetOTPEmailSecretGeneratorConfig(config.DefaultInstance.SecretGenerators.OTPEmail) sessionTokenVerifier := internal_authz.SessionTokenVerifier(keys.OIDC) sessionTokenDecryptor := internal_authz.SessionTokenDecryptor(keys.OIDC)