diff --git a/backend/v3/api/session/v2/convert/session_check_totp.go b/backend/v3/api/session/v2/convert/session_check_totp.go new file mode 100644 index 000000000..5ceffb467 --- /dev/null +++ b/backend/v3/api/session/v2/convert/session_check_totp.go @@ -0,0 +1,16 @@ +package convert + +import ( + "github.com/zitadel/zitadel/backend/v3/domain" + session_grpc "github.com/zitadel/zitadel/pkg/grpc/session/v2" +) + +func CheckTOTPGRPCToDomain(checkTOTP *session_grpc.CheckTOTP) *domain.CheckTOTPType { + if checkTOTP == nil { + return nil + } + + return &domain.CheckTOTPType{ + Code: checkTOTP.GetCode(), + } +} diff --git a/backend/v3/api/session/v2/convert/session_check_totp_test.go b/backend/v3/api/session/v2/convert/session_check_totp_test.go new file mode 100644 index 000000000..519a17e6f --- /dev/null +++ b/backend/v3/api/session/v2/convert/session_check_totp_test.go @@ -0,0 +1,36 @@ +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 TestCheckTOTPGRPCToDomain(t *testing.T) { + t.Parallel() + + tt := []struct { + name string + input *session_grpc.CheckTOTP + expected *domain.CheckTOTPType + }{ + {name: "nil input returns nil"}, + { + name: "code is mapped", + input: &session_grpc.CheckTOTP{Code: "123456"}, + expected: &domain.CheckTOTPType{Code: "123456"}, + }, + } + + for _, tc := range tt { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got := CheckTOTPGRPCToDomain(tc.input) + assert.Equal(t, tc.expected, got) + }) + } +} diff --git a/backend/v3/domain/defaults.go b/backend/v3/domain/defaults.go index eccae7984..930407982 100644 --- a/backend/v3/domain/defaults.go +++ b/backend/v3/domain/defaults.go @@ -49,6 +49,10 @@ func SetOTPSMSSecretGeneratorConfig(cfg *crypto.GeneratorConfig) { otpSMSSecretGeneratorConfig = cfg } +func SetWebAuthNConfig(cfg *webauthn.Config) { + webauthnConfig = cfg +} + func SetOTPEmailSecretGeneratorConfig(cfg *crypto.GeneratorConfig) { otpEmailSecretGeneratorConfig = cfg } @@ -56,7 +60,3 @@ func SetOTPEmailSecretGeneratorConfig(cfg *crypto.GeneratorConfig) { func SetMFAEncryptionAlgorithm(mfaEncryptionAlg crypto.EncryptionAlgorithm) { mfaEncryptionAlgo = mfaEncryptionAlg } - -func SetWebAuthNConfig(cfg *webauthn.Config) { - webauthnConfig = cfg -} diff --git a/backend/v3/domain/session_check_password.go b/backend/v3/domain/session_check_password.go index d217ab7f9..a64da3381 100644 --- a/backend/v3/domain/session_check_password.go +++ b/backend/v3/domain/session_check_password.go @@ -15,11 +15,6 @@ import ( "github.com/zitadel/zitadel/internal/zerrors" ) -// tarpitFn represents a tarpit function -// -// The input is the number of failed attempts after which the tarpit is started -type tarpitFn func(failedAttempts uint64) - type CheckPasswordType struct { Password string } @@ -55,7 +50,7 @@ type PasswordCheckCommand struct { // and an input password to verify. It returns an updated hash and an error. // It defaults to [passwap.Swapper.Verify] // -// The command does not implement [Transactional] due verifyFn that might take a long time to execute. +// The command does not implement [Transactional] due to verifyFn that might take a long time to execute. // So the DB transaction will be started only after verifyFn has been run. // // Moreover, the command may return a functional error so manual management of the transaction is needed @@ -200,7 +195,7 @@ func (p *PasswordCheckCommand) GetPasswordCheckChanges(ctx context.Context, opts } case *VerificationTypeFailed: dbUpdates[0] = humanRepo.IncrementPasswordFailedAttempts() - lockoutPolicy, err := GetLockoutPolicy(ctx, opts, p.InstanceID, p.FetchedUser.OrganizationID) + lockoutPolicy, err := GetLockoutPolicy(ctx, opts.DB(), opts.lockoutSettingRepo, p.InstanceID, p.FetchedUser.OrganizationID) if err != nil { return nil, err } diff --git a/backend/v3/domain/session_check_recovery_code.go b/backend/v3/domain/session_check_recovery_code.go index ab1a4834b..c27d8ed91 100644 --- a/backend/v3/domain/session_check_recovery_code.go +++ b/backend/v3/domain/session_check_recovery_code.go @@ -193,7 +193,7 @@ func (rc *RecoveryCodeCheckCommand) String() string { func (rc *RecoveryCodeCheckCommand) handleRecoveryCodeCheckFailed(ctx context.Context, opts *InvokeOpts) error { checkTime := time.Now() - lockoutPolicy, err := GetLockoutPolicy(ctx, opts, rc.InstanceID, rc.user.OrganizationID) + lockoutPolicy, err := GetLockoutPolicy(ctx, opts.DB(), opts.lockoutSettingRepo, rc.InstanceID, rc.user.OrganizationID) logging.OnError(ctx, err).Error("failed to get lockout policy") // update user state and recovery_code_failed_attempts diff --git a/backend/v3/domain/session_check_totp.go b/backend/v3/domain/session_check_totp.go new file mode 100644 index 000000000..15beaa61e --- /dev/null +++ b/backend/v3/domain/session_check_totp.go @@ -0,0 +1,255 @@ +package domain + +import ( + "context" + "time" + + "github.com/pquerna/otp/totp" + + "github.com/zitadel/zitadel/backend/v3/storage/database" + "github.com/zitadel/zitadel/internal/crypto" + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/repository/session" + "github.com/zitadel/zitadel/internal/repository/user" + "github.com/zitadel/zitadel/internal/zerrors" +) + +type CheckTOTPType struct { + Code string +} + +type TOTPCheckCommand struct { + CheckTOTP *CheckTOTPType + TarpitFunc tarpitFn + ValidateFunc totpValidateFn + EncryptionAlgorithm crypto.EncryptionAlgorithm + SessionID string + InstanceID string + + FetchedUser User + + // For Events() + IsCheckSuccessful bool + IsUserLocked bool + CheckedAt time.Time +} + +// NewTOTPCheckCommand initializes a new [TOTPCheckCommand] +// +// If tarpitFunc is nil, the default tarpit will be used. +// +// totpValidator is a function that takes as input a target TOTP to verify +// and an input ciphered secret. +// The secret is deciphered first using the input encryptionAlgo, +// then it is used to verify the TOTP. It returns true if the TOTP is validated successfully. +// +// - totpValidator defaults to [totp.Validate] +// - encryptionAlgo defaults to [crypto.NewAESCrypto] using the config specified in defaults.yaml +func NewTOTPCheckCommand(sessionID, instanceID string, tarpitFunc tarpitFn, totpValidator totpValidateFn, encryptionAlgo crypto.EncryptionAlgorithm, request *CheckTOTPType) (*TOTPCheckCommand, error) { + if sysConfig.Tarpit.Tarpit() == nil && tarpitFunc == nil { + return nil, zerrors.ThrowInternal(nil, "DOM-o46bLe", "no tarpit function set") + } + + cmd := &TOTPCheckCommand{ + CheckTOTP: request, + TarpitFunc: sysConfig.Tarpit.Tarpit(), + EncryptionAlgorithm: mfaEncryptionAlgo, + SessionID: sessionID, + InstanceID: instanceID, + ValidateFunc: totp.Validate, + } + if tarpitFunc != nil { + cmd.TarpitFunc = tarpitFunc + } + + if encryptionAlgo != nil { + cmd.EncryptionAlgorithm = encryptionAlgo + } + + if totpValidator != nil { + cmd.ValidateFunc = totpValidator + } + + return cmd, nil +} + +// RequiresTransaction implements [Transactional]. +func (t *TOTPCheckCommand) RequiresTransaction() {} + +// Events implements [Commander]. +func (t *TOTPCheckCommand) Events(ctx context.Context, opts *InvokeOpts) ([]eventstore.Command, error) { + if t.CheckTOTP == nil { + return nil, nil + } + + events := make([]eventstore.Command, 1, 2) + userAgg := &user.NewAggregate(t.FetchedUser.ID, t.FetchedUser.OrganizationID).Aggregate + if t.IsCheckSuccessful { + events[0] = user.NewHumanOTPCheckSucceededEvent(ctx, userAgg, nil) + return append(events, session.NewTOTPCheckedEvent(ctx, &session.NewAggregate(t.SessionID, t.InstanceID).Aggregate, t.CheckedAt)), nil + } + events[0] = user.NewHumanOTPCheckFailedEvent(ctx, userAgg, nil) + + if t.IsUserLocked { + events = append(events, user.NewUserLockedEvent(ctx, userAgg)) + } + + return events, nil +} + +// Execute implements [Commander]. +func (t *TOTPCheckCommand) Execute(ctx context.Context, opts *InvokeOpts) (err error) { + if t.CheckTOTP == nil { + return nil + } + + sessionRepo := opts.sessionRepo + humanRepo := opts.userRepo.Human() + + verifyErr := t.verifyTOTP(t.FetchedUser.Human.TOTP.Secret) + + t.CheckedAt = time.Now() + if verifyErr == nil { + rowCount, err := humanRepo.Update(ctx, opts.DB(), + humanRepo.PrimaryKeyCondition(t.InstanceID, t.FetchedUser.ID), + humanRepo.SetLastSuccessfulTOTPCheck(t.CheckedAt), + ) + if err := handleUpdateError(err, 1, rowCount, "DOM-aoMAzO", "user"); err != nil { + return err + } + + rowCount, err = sessionRepo.Update(ctx, opts.DB(), + sessionRepo.PrimaryKeyCondition(t.InstanceID, t.SessionID), + sessionRepo.SetFactor(&SessionFactorTOTP{LastVerifiedAt: t.CheckedAt}), + ) + if err := handleUpdateError(err, 1, rowCount, "DOM-ymhCTD", "session"); err != nil { + return err + } + + t.IsCheckSuccessful = true + return nil + } + + changes := make(database.Changes, 1, 2) + changes[0] = humanRepo.IncrementTOTPFailedAttempts() + + policy, err := GetLockoutPolicy(ctx, opts.DB(), opts.lockoutSettingRepo, t.InstanceID, t.FetchedUser.OrganizationID) + if err != nil { + return err + } + + if policy != nil && + policy.MaxOTPAttempts != nil && *policy.MaxOTPAttempts > 0 && + uint64(t.FetchedUser.Human.TOTP.FailedAttempts)+1 >= *policy.MaxOTPAttempts { + changes = append(changes, humanRepo.SetState(UserStateLocked)) + t.IsUserLocked = true + } + + rowCount, err := humanRepo.Update(ctx, opts.DB(), humanRepo.PrimaryKeyCondition(t.InstanceID, t.FetchedUser.ID), changes) + if err := handleUpdateError(err, 1, rowCount, "DOM-lQLpIa", "user"); err != nil { + return err + } + + rowCount, err = sessionRepo.Update(ctx, opts.DB(), + sessionRepo.PrimaryKeyCondition(t.InstanceID, t.SessionID), + sessionRepo.SetFactor(&SessionFactorTOTP{LastFailedAt: t.CheckedAt}), + ) + if err := handleUpdateError(err, 1, rowCount, "DOM-rSa1yU", "session"); err != nil { + return err + } + + t.TarpitFunc(uint64(t.FetchedUser.Human.TOTP.FailedAttempts) + 1) + + // TODO(IAM-Marco): This error is a functional error and needs to be returned BUT + // the transaction needs to NOT be rollbacked. + // + // As of now, this check doesn't work because the error will rollback the transaction that is + // managed automatically by implementing the [Transactional] interface. + // + // Not implementing the [Transactional] interface and managing it manually is not possible either + // because emitting the events (in [TOTPCheckCommand.Events]) need to happen in the same transaction. + // + // As of now, we do not have a solution for this. + return verifyErr +} + +// String implements [Commander]. +func (t *TOTPCheckCommand) String() string { + return "TOTPCheckCommand" +} + +// Validate implements [Commander]. +func (t *TOTPCheckCommand) Validate(ctx context.Context, opts *InvokeOpts) (err error) { + if t.CheckTOTP == nil { + return nil + } + + if t.SessionID == "" { + return zerrors.ThrowPreconditionFailed(nil, "DOM-ZNWO80", "Errors.Missing.SessionID") + } + if t.InstanceID == "" { + return zerrors.ThrowPreconditionFailed(nil, "DOM-47G8S3", "Errors.Missing.InstanceID") + } + + sessionRepo := opts.sessionRepo + userRepo := opts.userRepo + + session, err := sessionRepo.Get(ctx, opts.DB(), database.WithCondition(sessionRepo.PrimaryKeyCondition(t.InstanceID, t.SessionID))) + if err := handleGetError(err, "DOM-e4OuhO", "session"); err != nil { + return err + } + if session.UserID == "" { + return zerrors.ThrowPreconditionFailed(nil, "DOM-hord0Z", "Errors.User.UserIDMissing") + } + + user, err := userRepo.Get(ctx, opts.DB(), + database.WithCondition( + userRepo.PrimaryKeyCondition(t.InstanceID, session.UserID), + ), + // TODO(IAM-Marco): This might not work if we do manual transaction management. See https://github.com/zitadel/zitadel/pull/11886#discussion_r3014948862 + database.WithResultLock(), + ) + if err := handleGetError(err, "DOM-PZvWq0", "user"); err != nil { + return err + } + if user.Human == nil { + return zerrors.ThrowPreconditionFailed(nil, "DOM-zzv1MO", "Errors.User.NotHuman") + } + + if user.Human.TOTP == nil { + return zerrors.ThrowPreconditionFailed(nil, "DOM-V6Av2a", "Errors.User.NoTOTP") + } + + if user.Human.TOTP.Secret == nil { + return zerrors.ThrowPreconditionFailed(nil, "DOM-b44CWR", "Errors.User.NoTOTPSecret") + } + + if user.Human.TOTP.VerifiedAt.IsZero() { + return zerrors.ThrowPreconditionFailed(nil, "DOM-0g4ZAU", "Errors.User.MFA.OTP.NotReady") + } + + if user.State == UserStateLocked { + return zerrors.ThrowPreconditionFailed(nil, "DOM-gM4SUh", "Errors.User.Locked") + } + + t.FetchedUser = *user + + return nil +} + +func (t *TOTPCheckCommand) verifyTOTP(existingTOTPSecret *crypto.CryptoValue) error { + decryptedSecret, err := crypto.DecryptString(existingTOTPSecret, t.EncryptionAlgorithm) + if err != nil { + return zerrors.ThrowInternal(err, "DOM-Yqhggx", "Errors.TOTP.FailedToDecryptSecret") + } + + isValid := t.ValidateFunc(t.CheckTOTP.Code, decryptedSecret) + if !isValid { + return zerrors.ThrowInvalidArgument(nil, "DOM-o5cVir", "Errors.User.MFA.OTP.InvalidCode") + } + + return nil +} + +var _ Commander = (*TOTPCheckCommand)(nil) +var _ Transactional = (*TOTPCheckCommand)(nil) diff --git a/backend/v3/domain/session_check_totp_test.go b/backend/v3/domain/session_check_totp_test.go new file mode 100644 index 000000000..ad19aa5e9 --- /dev/null +++ b/backend/v3/domain/session_check_totp_test.go @@ -0,0 +1,943 @@ +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" + noopdb "github.com/zitadel/zitadel/backend/v3/storage/database/dialect/noop" + "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/repository/user" + "github.com/zitadel/zitadel/internal/zerrors" +) + +func TestTOTPCheckCommand_Validate(t *testing.T) { + t.Parallel() + sessionGetErr := errors.New("session get error") + userGetErr := errors.New("user get error") + notFoundErr := database.NewNoRowFoundError(nil) + now := time.Now() + + tt := []struct { + testName string + sessionRepo func(ctrl *gomock.Controller) domain.SessionRepository + userRepo func(ctrl *gomock.Controller) domain.UserRepository + cmd *domain.TOTPCheckCommand + + expectedError error + expectedUser domain.User + }{ + { + testName: "when checkTOTP is nil should return no error", + cmd: &domain.TOTPCheckCommand{}, + expectedError: nil, + }, + { + testName: "when session ID is not set should return error", + cmd: &domain.TOTPCheckCommand{CheckTOTP: &domain.CheckTOTPType{}}, + expectedError: zerrors.ThrowPreconditionFailed(nil, "DOM-ZNWO80", "Errors.Missing.SessionID"), + }, + { + testName: "when instance ID is not set should return error", + cmd: &domain.TOTPCheckCommand{SessionID: "session-1", CheckTOTP: &domain.CheckTOTPType{}}, + expectedError: zerrors.ThrowPreconditionFailed(nil, "DOM-47G8S3", "Errors.Missing.InstanceID"), + }, + { + testName: "when retrieving session fails should return error", + cmd: &domain.TOTPCheckCommand{SessionID: "session-1", InstanceID: "instance-1", CheckTOTP: &domain.CheckTOTPType{Code: "123456"}}, + sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository { + repo := domainmock.NewSessionRepo(ctrl) + idCondition := repo.PrimaryKeyCondition("instance-1", "session-1") + repo.EXPECT(). + Get(gomock.Any(), gomock.Any(), + dbmock.QueryOptions(database.WithCondition(idCondition))). + Return(nil, sessionGetErr) + return repo + }, + expectedError: zerrors.ThrowInternal(sessionGetErr, "DOM-e4OuhO", "failed fetching session"), + }, + { + testName: "when session not found should return not found error", + cmd: &domain.TOTPCheckCommand{SessionID: "session-1", InstanceID: "instance-1", CheckTOTP: &domain.CheckTOTPType{Code: "123456"}}, + sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository { + repo := domainmock.NewSessionRepo(ctrl) + idCondition := repo.PrimaryKeyCondition("instance-1", "session-1") + repo.EXPECT(). + Get(gomock.Any(), gomock.Any(), + dbmock.QueryOptions(database.WithCondition(idCondition))). + Return(nil, notFoundErr) + return repo + }, + expectedError: zerrors.ThrowNotFound(notFoundErr, "DOM-e4OuhO", "session not found"), + }, + { + testName: "when session userID is empty should return precondition failed error", + cmd: &domain.TOTPCheckCommand{SessionID: "session-1", InstanceID: "instance-1", CheckTOTP: &domain.CheckTOTPType{Code: "123456"}}, + sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository { + repo := domainmock.NewSessionRepo(ctrl) + idCondition := repo.PrimaryKeyCondition("instance-1", "session-1") + repo.EXPECT(). + Get(gomock.Any(), gomock.Any(), + dbmock.QueryOptions(database.WithCondition(idCondition))). + Return(&domain.Session{}, nil) + return repo + }, + expectedError: zerrors.ThrowPreconditionFailed(nil, "DOM-hord0Z", "Errors.User.UserIDMissing"), + }, + { + testName: "when retrieving user fails should return error", + cmd: &domain.TOTPCheckCommand{SessionID: "session-1", InstanceID: "instance-1", CheckTOTP: &domain.CheckTOTPType{Code: "123456"}}, + sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository { + repo := domainmock.NewSessionRepo(ctrl) + idCondition := repo.PrimaryKeyCondition("instance-1", "session-1") + repo.EXPECT(). + Get(gomock.Any(), gomock.Any(), + dbmock.QueryOptions(database.WithCondition(idCondition))). + Return(&domain.Session{UserID: "user-1"}, nil) + return repo + }, + userRepo: func(ctrl *gomock.Controller) domain.UserRepository { + repo := domainmock.NewUserRepo(ctrl) + idCondition := repo.PrimaryKeyCondition("instance-1", "user-1") + repo.EXPECT(). + Get(gomock.Any(), gomock.Any(), + dbmock.QueryOptions(database.WithCondition(idCondition))). + Return(nil, userGetErr) + return repo + }, + expectedError: zerrors.ThrowInternal(userGetErr, "DOM-PZvWq0", "failed fetching user"), + }, + { + testName: "when user not found should return not found error", + cmd: &domain.TOTPCheckCommand{SessionID: "session-1", InstanceID: "instance-1", CheckTOTP: &domain.CheckTOTPType{Code: "123456"}}, + sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository { + repo := domainmock.NewSessionRepo(ctrl) + idCondition := repo.PrimaryKeyCondition("instance-1", "session-1") + repo.EXPECT(). + Get(gomock.Any(), gomock.Any(), + dbmock.QueryOptions(database.WithCondition(idCondition))). + Return(&domain.Session{UserID: "user-1"}, nil) + return repo + }, + userRepo: func(ctrl *gomock.Controller) domain.UserRepository { + repo := domainmock.NewUserRepo(ctrl) + idCondition := repo.PrimaryKeyCondition("instance-1", "user-1") + repo.EXPECT(). + Get(gomock.Any(), gomock.Any(), + dbmock.QueryOptions(database.WithCondition(idCondition))). + Return(nil, notFoundErr) + return repo + }, + expectedError: zerrors.ThrowNotFound(notFoundErr, "DOM-PZvWq0", "user not found"), + }, + { + testName: "when user is not human should return precondition failed error", + cmd: &domain.TOTPCheckCommand{SessionID: "session-1", InstanceID: "instance-1", CheckTOTP: &domain.CheckTOTPType{Code: "123456"}}, + sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository { + repo := domainmock.NewSessionRepo(ctrl) + idCondition := repo.PrimaryKeyCondition("instance-1", "session-1") + repo.EXPECT(). + Get(gomock.Any(), gomock.Any(), + dbmock.QueryOptions(database.WithCondition(idCondition))). + Return(&domain.Session{UserID: "user-1"}, nil) + return repo + }, + userRepo: func(ctrl *gomock.Controller) domain.UserRepository { + repo := domainmock.NewUserRepo(ctrl) + idCondition := repo.PrimaryKeyCondition("instance-1", "user-1") + repo.EXPECT(). + Get(gomock.Any(), gomock.Any(), + dbmock.QueryOptions(database.WithCondition(idCondition))). + Return(&domain.User{}, nil) + return repo + }, + expectedError: zerrors.ThrowPreconditionFailed(nil, "DOM-zzv1MO", "Errors.User.NotHuman"), + }, + { + testName: "when user has no TOTP should return precondition failed error", + cmd: &domain.TOTPCheckCommand{SessionID: "session-1", InstanceID: "instance-1", CheckTOTP: &domain.CheckTOTPType{Code: "123456"}}, + sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository { + repo := domainmock.NewSessionRepo(ctrl) + idCondition := repo.PrimaryKeyCondition("instance-1", "session-1") + repo.EXPECT(). + Get(gomock.Any(), gomock.Any(), + dbmock.QueryOptions(database.WithCondition(idCondition))). + Return(&domain.Session{UserID: "user-1"}, nil) + return repo + }, + userRepo: func(ctrl *gomock.Controller) domain.UserRepository { + repo := domainmock.NewUserRepo(ctrl) + idCondition := repo.PrimaryKeyCondition("instance-1", "user-1") + repo.EXPECT(). + Get(gomock.Any(), gomock.Any(), + dbmock.QueryOptions(database.WithCondition(idCondition))). + Return(&domain.User{ + State: domain.UserStateLocked, + Human: &domain.HumanUser{}, + }, nil) + return repo + }, + expectedError: zerrors.ThrowPreconditionFailed(nil, "DOM-V6Av2a", "Errors.User.NoTOTP"), + }, + { + testName: "when user TOTP has no secret set should return precondition failed error", + cmd: &domain.TOTPCheckCommand{SessionID: "session-1", InstanceID: "instance-1", CheckTOTP: &domain.CheckTOTPType{Code: "123456"}}, + sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository { + repo := domainmock.NewSessionRepo(ctrl) + idCondition := repo.PrimaryKeyCondition("instance-1", "session-1") + repo.EXPECT(). + Get(gomock.Any(), gomock.Any(), + dbmock.QueryOptions(database.WithCondition(idCondition))). + Return(&domain.Session{UserID: "user-1"}, nil) + return repo + }, + userRepo: func(ctrl *gomock.Controller) domain.UserRepository { + repo := domainmock.NewUserRepo(ctrl) + idCondition := repo.PrimaryKeyCondition("instance-1", "user-1") + repo.EXPECT(). + Get(gomock.Any(), gomock.Any(), + dbmock.QueryOptions(database.WithCondition(idCondition))). + Return(&domain.User{ + State: domain.UserStateLocked, + Human: &domain.HumanUser{TOTP: &domain.HumanTOTP{}}, + }, nil) + return repo + }, + expectedError: zerrors.ThrowPreconditionFailed(nil, "DOM-b44CWR", "Errors.User.NoTOTPSecret"), + }, + { + testName: "when TOTP is not successfully checked should return precondition error", + cmd: &domain.TOTPCheckCommand{SessionID: "session-1", InstanceID: "instance-1", CheckTOTP: &domain.CheckTOTPType{Code: "123456"}}, + sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository { + repo := domainmock.NewSessionRepo(ctrl) + idCondition := repo.PrimaryKeyCondition("instance-1", "session-1") + repo.EXPECT(). + Get(gomock.Any(), gomock.Any(), + dbmock.QueryOptions(database.WithCondition(idCondition))). + Return(&domain.Session{UserID: "user-1"}, nil) + return repo + }, + userRepo: func(ctrl *gomock.Controller) domain.UserRepository { + repo := domainmock.NewUserRepo(ctrl) + idCondition := repo.PrimaryKeyCondition("instance-1", "user-1") + repo.EXPECT(). + Get(gomock.Any(), gomock.Any(), + dbmock.QueryOptions(database.WithCondition(idCondition))). + Return(&domain.User{ + State: domain.UserStateLocked, + Human: &domain.HumanUser{TOTP: &domain.HumanTOTP{Secret: &crypto.CryptoValue{}}}, + }, nil) + return repo + }, + expectedError: zerrors.ThrowPreconditionFailed(nil, "DOM-0g4ZAU", "Errors.User.MFA.OTP.NotReady"), + }, + { + testName: "when user is locked should return precondition failed error", + cmd: &domain.TOTPCheckCommand{SessionID: "session-1", InstanceID: "instance-1", CheckTOTP: &domain.CheckTOTPType{Code: "123456"}}, + sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository { + repo := domainmock.NewSessionRepo(ctrl) + idCondition := repo.PrimaryKeyCondition("instance-1", "session-1") + repo.EXPECT(). + Get(gomock.Any(), gomock.Any(), + dbmock.QueryOptions(database.WithCondition(idCondition))). + Return(&domain.Session{UserID: "user-1"}, nil) + return repo + }, + userRepo: func(ctrl *gomock.Controller) domain.UserRepository { + repo := domainmock.NewUserRepo(ctrl) + idCondition := repo.PrimaryKeyCondition("instance-1", "user-1") + repo.EXPECT(). + Get(gomock.Any(), gomock.Any(), + dbmock.QueryOptions(database.WithCondition(idCondition))). + Return(&domain.User{ + State: domain.UserStateLocked, + Human: &domain.HumanUser{TOTP: &domain.HumanTOTP{Secret: &crypto.CryptoValue{}, VerifiedAt: now}}, + }, nil) + return repo + }, + expectedError: zerrors.ThrowPreconditionFailed(nil, "DOM-gM4SUh", "Errors.User.Locked"), + }, + { + testName: "when all validations pass should return no error and set user", + cmd: &domain.TOTPCheckCommand{SessionID: "session-1", InstanceID: "instance-1", CheckTOTP: &domain.CheckTOTPType{Code: "123456"}}, + sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository { + repo := domainmock.NewSessionRepo(ctrl) + idCondition := repo.PrimaryKeyCondition("instance-1", "session-1") + repo.EXPECT(). + Get(gomock.Any(), gomock.Any(), + dbmock.QueryOptions(database.WithCondition(idCondition))). + Return(&domain.Session{UserID: "user-1"}, nil) + return repo + }, + userRepo: func(ctrl *gomock.Controller) domain.UserRepository { + repo := domainmock.NewUserRepo(ctrl) + idCondition := repo.PrimaryKeyCondition("instance-1", "user-1") + repo.EXPECT(). + Get(gomock.Any(), gomock.Any(), + dbmock.QueryOptions(database.WithCondition(idCondition))). + Return(&domain.User{ + ID: "user-1", + State: domain.UserStateActive, + Human: &domain.HumanUser{ + TOTP: &domain.HumanTOTP{ + VerifiedAt: now, + Secret: &crypto.CryptoValue{Crypted: []byte("123456")}, + FailedAttempts: 0, + }, + }, + }, nil) + return repo + }, + expectedUser: domain.User{ + ID: "user-1", + State: domain.UserStateActive, + Human: &domain.HumanUser{ + TOTP: &domain.HumanTOTP{ + VerifiedAt: now, + Secret: &crypto.CryptoValue{Crypted: []byte("123456")}, + FailedAttempts: 0, + }, + }, + }, + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + + opts := &domain.InvokeOpts{ + Invoker: domain.NewTransactionInvoker(nil), + } + domain.WithQueryExecutor(new(noopdb.Pool))(opts) + + if tc.sessionRepo != nil { + domain.WithSessionRepo(tc.sessionRepo(ctrl))(opts) + } + if tc.userRepo != nil { + domain.WithUserRepo(tc.userRepo(ctrl))(opts) + } + + err := tc.cmd.Validate(t.Context(), opts) + assert.ErrorIs(t, err, tc.expectedError) + + if tc.expectedError == nil { + assert.Equal(t, tc.expectedUser, tc.cmd.FetchedUser) + } + }) + } +} + +func TestTOTPCheckCommand_Execute(t *testing.T) { + t.Parallel() + + userUpdateErr := errors.New("user update error") + sessionUpdateErr := errors.New("session update error") + listErr := errors.New("list error") + decryptErr := errors.New("decrypt error") + + tt := []struct { + testName string + cmd *domain.TOTPCheckCommand + encryptionAlgo func(ctrl *gomock.Controller) crypto.EncryptionAlgorithm + userRepo func(ctrl *gomock.Controller) domain.HumanUserRepository + sessionRepo func(ctrl *gomock.Controller) domain.SessionRepository + lockoutSettingRepo func(ctrl *gomock.Controller) domain.LockoutSettingsRepository + expectedError error + expectedSuccess bool + expectedLocked bool + }{ + { + testName: "when checkTOTP is nil should return no error", + cmd: &domain.TOTPCheckCommand{}, + expectedError: nil, + expectedSuccess: false, + }, + { + testName: "when TOTP verification succeeds should update user and session", + cmd: &domain.TOTPCheckCommand{ + CheckTOTP: &domain.CheckTOTPType{Code: "123456"}, + InstanceID: "instance-1", + SessionID: "session-1", + FetchedUser: domain.User{ + ID: "user-1", + OrganizationID: "org-1", + Human: &domain.HumanUser{ + TOTP: &domain.HumanTOTP{ + Secret: &crypto.CryptoValue{Crypted: []byte("encrypted-secret")}, FailedAttempts: 0}, + }, + }, + ValidateFunc: func(_, _ string) bool { return true }, + }, + encryptionAlgo: func(ctrl *gomock.Controller) crypto.EncryptionAlgorithm { + mock := crypto.NewMockEncryptionAlgorithm(ctrl) + mock.EXPECT().Algorithm().AnyTimes().Return("") + mock.EXPECT().DecryptionKeyIDs().AnyTimes().Return([]string{""}) + mock.EXPECT().DecryptString(gomock.Any(), gomock.Any()).AnyTimes().Return("clear txt", nil) + return mock + }, + userRepo: func(ctrl *gomock.Controller) domain.HumanUserRepository { + repo := domainmock.NewHumanRepo(ctrl) + idCondition := repo.PrimaryKeyCondition("instance-1", "user-1") + repo.EXPECT(). + Update(gomock.Any(), gomock.Any(), idCondition, repo.SetLastSuccessfulTOTPCheck(time.Now())). + Times(1). + Return(int64(1), nil) + return repo + }, + sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository { + repo := domainmock.NewSessionRepo(ctrl) + idCondition := repo.PrimaryKeyCondition("instance-1", "session-1") + repo.EXPECT(). + Update(gomock.Any(), gomock.Any(), idCondition, repo.SetFactor(&domain.SessionFactorTOTP{LastVerifiedAt: time.Now()})). + Times(1). + Return(int64(1), nil) + return repo + }, + expectedSuccess: true, + }, + { + testName: "when user update fails should return error", + cmd: &domain.TOTPCheckCommand{ + CheckTOTP: &domain.CheckTOTPType{Code: "123456"}, + InstanceID: "instance-1", + SessionID: "session-1", + FetchedUser: domain.User{ + ID: "user-1", + OrganizationID: "org-1", + Human: &domain.HumanUser{ + TOTP: &domain.HumanTOTP{ + Secret: &crypto.CryptoValue{Crypted: []byte("encrypted-secret")}, FailedAttempts: 0}, + }, + }, + ValidateFunc: func(_, _ string) bool { return true }, + }, + userRepo: func(ctrl *gomock.Controller) domain.HumanUserRepository { + repo := domainmock.NewHumanRepo(ctrl) + idCondition := repo.PrimaryKeyCondition("instance-1", "user-1") + repo.EXPECT(). + Update(gomock.Any(), gomock.Any(), idCondition, repo.SetLastSuccessfulTOTPCheck(time.Now())). + Times(1). + Return(int64(0), userUpdateErr) + return repo + }, + encryptionAlgo: func(ctrl *gomock.Controller) crypto.EncryptionAlgorithm { + mock := crypto.NewMockEncryptionAlgorithm(ctrl) + mock.EXPECT().Algorithm().AnyTimes().Return("") + mock.EXPECT().DecryptionKeyIDs().AnyTimes().Return([]string{""}) + mock.EXPECT().DecryptString(gomock.Any(), gomock.Any()).AnyTimes().Return("clear txt", nil) + return mock + }, + expectedError: zerrors.ThrowInternal(userUpdateErr, "DOM-aoMAzO", "failed updating user"), + }, + { + testName: "when session update fails after successful TOTP should return error", + cmd: &domain.TOTPCheckCommand{ + CheckTOTP: &domain.CheckTOTPType{Code: "123456"}, + InstanceID: "instance-1", + SessionID: "session-1", + FetchedUser: domain.User{ + ID: "user-1", + OrganizationID: "org-1", + Human: &domain.HumanUser{ + TOTP: &domain.HumanTOTP{ + Secret: &crypto.CryptoValue{Crypted: []byte("encrypted-secret")}, FailedAttempts: 0}, + }, + }, + ValidateFunc: func(_, _ string) bool { return true }, + }, + userRepo: func(ctrl *gomock.Controller) domain.HumanUserRepository { + repo := domainmock.NewHumanRepo(ctrl) + idCondition := repo.PrimaryKeyCondition("instance-1", "user-1") + repo.EXPECT(). + Update(gomock.Any(), gomock.Any(), idCondition, repo.SetLastSuccessfulTOTPCheck(time.Now())). + Times(1). + Return(int64(1), nil) + return repo + }, + sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository { + repo := domainmock.NewSessionRepo(ctrl) + idCondition := repo.PrimaryKeyCondition("instance-1", "session-1") + repo.EXPECT(). + Update(gomock.Any(), gomock.Any(), idCondition, repo.SetFactor(&domain.SessionFactorTOTP{LastVerifiedAt: time.Now()})). + Times(1). + Return(int64(0), sessionUpdateErr) + return repo + }, + encryptionAlgo: func(ctrl *gomock.Controller) crypto.EncryptionAlgorithm { + mock := crypto.NewMockEncryptionAlgorithm(ctrl) + mock.EXPECT().Algorithm().AnyTimes().Return("") + mock.EXPECT().DecryptionKeyIDs().AnyTimes().Return([]string{""}) + mock.EXPECT().DecryptString(gomock.Any(), gomock.Any()).AnyTimes().Return("clear txt", nil) + return mock + }, + expectedError: zerrors.ThrowInternal(sessionUpdateErr, "DOM-ymhCTD", "failed updating session"), + }, + { + testName: "when lockout policy fetch fails should return error", + cmd: &domain.TOTPCheckCommand{ + CheckTOTP: &domain.CheckTOTPType{Code: "wrong-code"}, + InstanceID: "instance-1", + SessionID: "session-1", + FetchedUser: domain.User{ + ID: "user-1", + OrganizationID: "org-1", + Human: &domain.HumanUser{ + TOTP: &domain.HumanTOTP{ + Secret: &crypto.CryptoValue{Crypted: []byte("encrypted-secret")}, FailedAttempts: 0}, + }, + }, + ValidateFunc: func(_, _ string) bool { return false }, + }, + userRepo: func(ctrl *gomock.Controller) domain.HumanUserRepository { + repo := domainmock.NewHumanRepo(ctrl) + return repo + }, + lockoutSettingRepo: func(ctrl *gomock.Controller) domain.LockoutSettingsRepository { + repo := domainmock.NewLockoutSettingsRepo(ctrl) + instanceAndOrg := database.And(repo.InstanceIDCondition("instance-1"), repo.OrganizationIDCondition(gu.Ptr("org-1"))) + orgNullOrEmpty := database.Or(repo.OrganizationIDCondition(nil), repo.OrganizationIDCondition(gu.Ptr(""))) + onlyInstance := database.And(repo.InstanceIDCondition("instance-1"), orgNullOrEmpty) + conds := database.WithCondition(database.Or(instanceAndOrg, onlyInstance)) + + repo.EXPECT(). + List(gomock.Any(), gomock.Any(), + dbmock.QueryOptions(conds), + dbmock.QueryOptions(database.WithOrderByAscending(repo.OrganizationIDColumn(), repo.InstanceIDColumn())), + dbmock.QueryOptions(database.WithLimit(1)), + ).Times(1). + Return(nil, listErr) + return repo + }, + encryptionAlgo: func(ctrl *gomock.Controller) crypto.EncryptionAlgorithm { + mock := crypto.NewMockEncryptionAlgorithm(ctrl) + mock.EXPECT().Algorithm().AnyTimes().Return("") + mock.EXPECT().DecryptionKeyIDs().AnyTimes().Return([]string{""}) + mock.EXPECT().DecryptString(gomock.Any(), gomock.Any()).AnyTimes().Return("clear txt", nil) + return mock + }, + expectedError: zerrors.ThrowInternal(listErr, "DOM-3B8Z6s", "failed fetching lockout settings"), + }, + { + testName: "when TOTP verification fails should update user and fail", + cmd: &domain.TOTPCheckCommand{ + CheckTOTP: &domain.CheckTOTPType{Code: "wrong-code"}, + InstanceID: "instance-1", + SessionID: "session-1", + FetchedUser: domain.User{ + ID: "user-1", + OrganizationID: "org-1", + Human: &domain.HumanUser{ + TOTP: &domain.HumanTOTP{ + Secret: &crypto.CryptoValue{Crypted: []byte("encrypted-secret")}, FailedAttempts: 0}, + }, + }, + ValidateFunc: func(_, _ string) bool { return false }, + }, + encryptionAlgo: func(ctrl *gomock.Controller) crypto.EncryptionAlgorithm { + mock := crypto.NewMockEncryptionAlgorithm(ctrl) + mock.EXPECT().Algorithm().AnyTimes().Return("") + mock.EXPECT().DecryptionKeyIDs().AnyTimes().Return([]string{""}) + mock.EXPECT().DecryptString(gomock.Any(), gomock.Any()).AnyTimes().Return("", decryptErr) + return mock + }, + lockoutSettingRepo: func(ctrl *gomock.Controller) domain.LockoutSettingsRepository { + repo := domainmock.NewLockoutSettingsRepo(ctrl) + instanceAndOrg := database.And(repo.InstanceIDCondition("instance-1"), repo.OrganizationIDCondition(gu.Ptr("org-1"))) + orgNullOrEmpty := database.Or(repo.OrganizationIDCondition(nil), repo.OrganizationIDCondition(gu.Ptr(""))) + onlyInstance := database.And(repo.InstanceIDCondition("instance-1"), orgNullOrEmpty) + conds := database.WithCondition(database.Or(instanceAndOrg, onlyInstance)) + + repo.EXPECT(). + List(gomock.Any(), gomock.Any(), + dbmock.QueryOptions(conds), + dbmock.QueryOptions(database.WithOrderByAscending(repo.OrganizationIDColumn(), repo.InstanceIDColumn())), + dbmock.QueryOptions(database.WithLimit(1)), + ).Times(1). + Return([]*domain.LockoutSettings{ + {LockoutSettingsAttributes: domain.LockoutSettingsAttributes{MaxOTPAttempts: gu.Ptr(uint64(5))}}, + }, nil) + return repo + }, + userRepo: func(ctrl *gomock.Controller) domain.HumanUserRepository { + repo := domainmock.NewHumanRepo(ctrl) + idCondition := repo.PrimaryKeyCondition("instance-1", "user-1") + changes := database.Changes{ + repo.IncrementTOTPFailedAttempts(), + } + repo.EXPECT(). + Update(gomock.Any(), gomock.Any(), + idCondition, + changes, + ). + Times(1). + Return(int64(0), userUpdateErr) + return repo + }, + expectedError: zerrors.ThrowInternal(userUpdateErr, "DOM-lQLpIa", "failed updating user"), + }, + { + testName: "when TOTP verification fails should update user with failed check and fail on session update", + cmd: &domain.TOTPCheckCommand{ + CheckTOTP: &domain.CheckTOTPType{Code: "wrong-code"}, + InstanceID: "instance-1", + SessionID: "session-1", + FetchedUser: domain.User{ + ID: "user-1", + OrganizationID: "org-1", + Human: &domain.HumanUser{ + TOTP: &domain.HumanTOTP{ + Secret: &crypto.CryptoValue{Crypted: []byte("encrypted-secret")}, FailedAttempts: 0}, + }, + }, + ValidateFunc: func(_, _ string) bool { return false }, + }, + encryptionAlgo: func(ctrl *gomock.Controller) crypto.EncryptionAlgorithm { + mock := crypto.NewMockEncryptionAlgorithm(ctrl) + mock.EXPECT().Algorithm().AnyTimes().Return("") + mock.EXPECT().DecryptionKeyIDs().AnyTimes().Return([]string{""}) + mock.EXPECT().DecryptString(gomock.Any(), gomock.Any()).AnyTimes().Return("", decryptErr) + return mock + }, + lockoutSettingRepo: func(ctrl *gomock.Controller) domain.LockoutSettingsRepository { + repo := domainmock.NewLockoutSettingsRepo(ctrl) + instanceAndOrg := database.And(repo.InstanceIDCondition("instance-1"), repo.OrganizationIDCondition(gu.Ptr("org-1"))) + orgNullOrEmpty := database.Or(repo.OrganizationIDCondition(nil), repo.OrganizationIDCondition(gu.Ptr(""))) + onlyInstance := database.And(repo.InstanceIDCondition("instance-1"), orgNullOrEmpty) + conds := database.WithCondition(database.Or(instanceAndOrg, onlyInstance)) + + repo.EXPECT(). + List(gomock.Any(), gomock.Any(), + dbmock.QueryOptions(conds), + dbmock.QueryOptions(database.WithOrderByAscending(repo.OrganizationIDColumn(), repo.InstanceIDColumn())), + dbmock.QueryOptions(database.WithLimit(1)), + ).Times(1). + Return([]*domain.LockoutSettings{ + { + Settings: domain.Settings{}, + LockoutSettingsAttributes: domain.LockoutSettingsAttributes{ + MaxOTPAttempts: gu.Ptr(uint64(5)), + }, + }, + }, nil) + return repo + }, + userRepo: func(ctrl *gomock.Controller) domain.HumanUserRepository { + repo := domainmock.NewHumanRepo(ctrl) + idCondition := repo.PrimaryKeyCondition("instance-1", "user-1") + changes := database.Changes{ + repo.IncrementTOTPFailedAttempts(), + } + repo.EXPECT(). + Update(gomock.Any(), gomock.Any(), + idCondition, + changes, + ). + Times(1). + Return(int64(1), nil) + return repo + }, + sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository { + repo := domainmock.NewSessionRepo(ctrl) + idCondition := repo.PrimaryKeyCondition("instance-1", "session-1") + repo.EXPECT(). + Update(gomock.Any(), gomock.Any(), idCondition, repo.SetFactor(&domain.SessionFactorTOTP{LastVerifiedAt: time.Now()})). + Times(1). + Return(int64(0), sessionUpdateErr) + return repo + }, + expectedError: zerrors.ThrowInternal(sessionUpdateErr, "DOM-rSa1yU", "failed updating session"), + }, + { + testName: "when TOTP verification fails should update user with failed check", + cmd: &domain.TOTPCheckCommand{ + CheckTOTP: &domain.CheckTOTPType{Code: "wrong-code"}, + InstanceID: "instance-1", + SessionID: "session-1", + FetchedUser: domain.User{ + ID: "user-1", + OrganizationID: "org-1", + Human: &domain.HumanUser{ + TOTP: &domain.HumanTOTP{ + Secret: &crypto.CryptoValue{Crypted: []byte("encrypted-secret")}, FailedAttempts: 0}, + }, + }, + TarpitFunc: func(_ uint64) {}, + ValidateFunc: func(_, _ string) bool { return false }, + }, + userRepo: func(ctrl *gomock.Controller) domain.HumanUserRepository { + repo := domainmock.NewHumanRepo(ctrl) + idCondition := repo.PrimaryKeyCondition("instance-1", "user-1") + changes := database.Changes{ + repo.IncrementTOTPFailedAttempts(), + } + repo.EXPECT(). + Update(gomock.Any(), gomock.Any(), + idCondition, + changes, + ). + Times(1). + Return(int64(1), nil) + return repo + }, + sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository { + repo := domainmock.NewSessionRepo(ctrl) + idCondition := repo.PrimaryKeyCondition("instance-1", "session-1") + repo.EXPECT(). + Update(gomock.Any(), gomock.Any(), idCondition, repo.SetFactor(&domain.SessionFactorTOTP{LastVerifiedAt: time.Now()})). + Times(1). + Return(int64(1), nil) + return repo + }, + lockoutSettingRepo: func(ctrl *gomock.Controller) domain.LockoutSettingsRepository { + repo := domainmock.NewLockoutSettingsRepo(ctrl) + instanceAndOrg := database.And(repo.InstanceIDCondition("instance-1"), repo.OrganizationIDCondition(gu.Ptr("org-1"))) + orgNullOrEmpty := database.Or(repo.OrganizationIDCondition(nil), repo.OrganizationIDCondition(gu.Ptr(""))) + onlyInstance := database.And(repo.InstanceIDCondition("instance-1"), orgNullOrEmpty) + conds := database.WithCondition(database.Or(instanceAndOrg, onlyInstance)) + + repo.EXPECT(). + List(gomock.Any(), gomock.Any(), + dbmock.QueryOptions(conds), + dbmock.QueryOptions(database.WithOrderByAscending(repo.OrganizationIDColumn(), repo.InstanceIDColumn())), + dbmock.QueryOptions(database.WithLimit(1)), + ).Times(1). + Return([]*domain.LockoutSettings{ + { + Settings: domain.Settings{}, + LockoutSettingsAttributes: domain.LockoutSettingsAttributes{ + MaxOTPAttempts: gu.Ptr(uint64(5)), + }, + }, + }, nil) + return repo + }, + encryptionAlgo: func(ctrl *gomock.Controller) crypto.EncryptionAlgorithm { + mock := crypto.NewMockEncryptionAlgorithm(ctrl) + mock.EXPECT().Algorithm().AnyTimes().Return("") + mock.EXPECT().DecryptionKeyIDs().AnyTimes().Return([]string{""}) + mock.EXPECT().DecryptString(gomock.Any(), gomock.Any()).AnyTimes().Return("", decryptErr) + return mock + }, + expectedError: zerrors.ThrowInternal(decryptErr, "DOM-Yqhggx", "Errors.TOTP.FailedToDecryptSecret"), + }, + { + testName: "when TOTP verification fails and user exceeds max attempts should lock user", + cmd: &domain.TOTPCheckCommand{ + CheckTOTP: &domain.CheckTOTPType{Code: "wrong-code"}, + InstanceID: "instance-1", + SessionID: "session-1", + FetchedUser: domain.User{ + ID: "user-1", + OrganizationID: "org-1", + Human: &domain.HumanUser{ + TOTP: &domain.HumanTOTP{ + Secret: &crypto.CryptoValue{Crypted: []byte("encrypted-secret")}, FailedAttempts: 4}, + }, + }, + TarpitFunc: func(_ uint64) {}, + ValidateFunc: func(_, _ string) bool { return false }, + }, + userRepo: func(ctrl *gomock.Controller) domain.HumanUserRepository { + repo := domainmock.NewHumanRepo(ctrl) + idCondition := repo.PrimaryKeyCondition("instance-1", "user-1") + changes := database.Changes{ + repo.IncrementTOTPFailedAttempts(), + repo.SetState(domain.UserStateLocked), + } + repo.EXPECT(). + Update(gomock.Any(), gomock.Any(), + idCondition, + changes, + ). + Times(1). + Return(int64(1), nil) + return repo + }, + sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository { + repo := domainmock.NewSessionRepo(ctrl) + idCondition := repo.PrimaryKeyCondition("instance-1", "session-1") + repo.EXPECT(). + Update(gomock.Any(), gomock.Any(), idCondition, repo.SetFactor(&domain.SessionFactorTOTP{LastVerifiedAt: time.Now()})). + Times(1). + Return(int64(1), nil) + return repo + }, + lockoutSettingRepo: func(ctrl *gomock.Controller) domain.LockoutSettingsRepository { + repo := domainmock.NewLockoutSettingsRepo(ctrl) + instanceAndOrg := database.And(repo.InstanceIDCondition("instance-1"), repo.OrganizationIDCondition(gu.Ptr("org-1"))) + orgNullOrEmpty := database.Or(repo.OrganizationIDCondition(nil), repo.OrganizationIDCondition(gu.Ptr(""))) + onlyInstance := database.And(repo.InstanceIDCondition("instance-1"), orgNullOrEmpty) + conds := database.WithCondition(database.Or(instanceAndOrg, onlyInstance)) + + repo.EXPECT(). + List(gomock.Any(), gomock.Any(), + dbmock.QueryOptions(conds), + dbmock.QueryOptions(database.WithOrderByAscending(repo.OrganizationIDColumn(), repo.InstanceIDColumn())), + dbmock.QueryOptions(database.WithLimit(1)), + ).Times(1). + Return([]*domain.LockoutSettings{ + { + Settings: domain.Settings{}, + LockoutSettingsAttributes: domain.LockoutSettingsAttributes{ + MaxOTPAttempts: gu.Ptr(uint64(5)), + }, + }, + }, nil) + return repo + }, + encryptionAlgo: func(ctrl *gomock.Controller) crypto.EncryptionAlgorithm { + mock := crypto.NewMockEncryptionAlgorithm(ctrl) + mock.EXPECT().Algorithm().AnyTimes().Return("") + mock.EXPECT().DecryptionKeyIDs().AnyTimes().Return([]string{""}) + mock.EXPECT().DecryptString(gomock.Any(), gomock.Any()).AnyTimes().Return("clear txt", nil) + return mock + }, + expectedError: zerrors.ThrowInvalidArgument(nil, "DOM-o5cVir", "Errors.User.MFA.OTP.InvalidCode"), + expectedLocked: true, + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + + var encAlgo crypto.EncryptionAlgorithm + if tc.encryptionAlgo != nil { + encAlgo = tc.encryptionAlgo(ctrl) + tc.cmd.EncryptionAlgorithm = encAlgo + } + + opts := &domain.InvokeOpts{ + Invoker: domain.NewTransactionInvoker(nil), + } + domain.WithQueryExecutor(new(noopdb.Pool))(opts) + + if tc.userRepo != nil { + userRepo := domainmock.NewUserRepo(ctrl) + humanRepo := tc.userRepo(ctrl) + userRepo.EXPECT().Human().Times(1).Return(humanRepo) + domain.WithUserRepo(userRepo)(opts) + } + if tc.sessionRepo != nil { + domain.WithSessionRepo(tc.sessionRepo(ctrl))(opts) + } + if tc.lockoutSettingRepo != nil { + domain.WithLockoutSettingsRepo(tc.lockoutSettingRepo(ctrl))(opts) + } + + err := tc.cmd.Execute(t.Context(), opts) + + assert.ErrorIs(t, err, tc.expectedError) + assert.Equal(t, tc.expectedSuccess, tc.cmd.IsCheckSuccessful) + assert.Equal(t, tc.expectedLocked, tc.cmd.IsUserLocked) + }) + } +} + +func TestTOTPCheckCommand_Events(t *testing.T) { + t.Parallel() + + sessionAgg := session.NewAggregate("session-1", "instance-1").Aggregate + userAgg := user.NewAggregate("user-1", "org-1").Aggregate + + tt := []struct { + testName string + cmd *domain.TOTPCheckCommand + expectedEvents []eventstore.Command + }{ + { + testName: "when checkTOTP is nil should return no events", + cmd: &domain.TOTPCheckCommand{}, + expectedEvents: []eventstore.Command{}, + }, + { + testName: "when check is successful should emit user succeeded and session totp checked events", + cmd: &domain.TOTPCheckCommand{ + CheckTOTP: &domain.CheckTOTPType{}, + SessionID: "session-1", + InstanceID: "instance-1", + FetchedUser: domain.User{ID: "user-1", OrganizationID: "org-1"}, + IsCheckSuccessful: true, + CheckedAt: time.Now(), + }, + + expectedEvents: []eventstore.Command{ + user.NewHumanOTPCheckSucceededEvent(t.Context(), &userAgg, nil), + session.NewTOTPCheckedEvent(t.Context(), &sessionAgg, time.Now()), + }, + }, + { + testName: "when check is unsuccessful should emit user failed event", + cmd: &domain.TOTPCheckCommand{ + CheckTOTP: &domain.CheckTOTPType{}, + SessionID: "session-1", + InstanceID: "instance-1", + FetchedUser: domain.User{ID: "user-1", OrganizationID: "org-1"}, + CheckedAt: time.Now(), + }, + expectedEvents: []eventstore.Command{ + user.NewHumanOTPCheckFailedEvent(t.Context(), &userAgg, nil), + }, + }, + { + testName: "when check is unsuccessful and user is locked should emit user failed and user locked events", + cmd: &domain.TOTPCheckCommand{ + CheckTOTP: &domain.CheckTOTPType{}, + SessionID: "session-1", + InstanceID: "instance-1", + FetchedUser: domain.User{ID: "user-1", OrganizationID: "org-1"}, + IsUserLocked: true, + CheckedAt: time.Now(), + }, + expectedEvents: []eventstore.Command{ + user.NewHumanOTPCheckFailedEvent(t.Context(), &userAgg, nil), + user.NewUserLockedEvent(t.Context(), &userAgg), + }, + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + // Given + ctx := authz.NewMockContext("instance-1", "", "") + + // Test + events, err := tc.cmd.Events(ctx, &domain.InvokeOpts{}) + + // Verify + assert.NoError(t, err) + assert.Len(t, events, len(tc.expectedEvents)) + for i, expectedType := range tc.expectedEvents { + assert.IsType(t, expectedType, events[i]) + switch expectedType.(type) { + case *session.TOTPCheckedEvent: + actualAssertedType, ok := events[i].(*session.TOTPCheckedEvent) + require.True(t, ok) + assert.Equal(t, tc.cmd.CheckedAt, actualAssertedType.CheckedAt) + case *user.HumanOTPCheckSucceededEvent: + _, ok := events[i].(*user.HumanOTPCheckSucceededEvent) + require.True(t, ok) + case *user.HumanOTPCheckFailedEvent: + _, ok := events[i].(*user.HumanOTPCheckFailedEvent) + require.True(t, ok) + } + } + }) + } +} diff --git a/backend/v3/domain/session_check_util.go b/backend/v3/domain/session_check_util.go index f98098447..c95b04013 100644 --- a/backend/v3/domain/session_check_util.go +++ b/backend/v3/domain/session_check_util.go @@ -11,8 +11,15 @@ import ( type verifierFn func(encoded, password string) (updated string, err error) -func GetLockoutPolicy(ctx context.Context, opts *InvokeOpts, instanceID, orgID string) (*LockoutSettings, error) { - lockoutSettingRepo := opts.lockoutSettingRepo +// tarpitFn represents a tarpit function +// +// The input is the number of failed attempts after which the tarpit is started +type tarpitFn func(failedAttempts uint64) + +// totpValidateFn represents a function to validate TOTP +type totpValidateFn func(toValidate, verifier string) bool + +func GetLockoutPolicy(ctx context.Context, db database.QueryExecutor, lockoutSettingsRepo LockoutSettingsRepository, instanceID, orgID string) (*LockoutSettings, error) { // We need the organization lockout policy first, and if not available, the instance (default) policy. // So we retrieve all records with a matching instance ID and organization ID OR @@ -20,9 +27,9 @@ func GetLockoutPolicy(ctx context.Context, opts *InvokeOpts, instanceID, orgID s // Then we assume NULLs are sorted as largest numbers (that's the case in Postgres), // so we sort ascending by organization ID. // We limit the result to 1 so that we get either the org policy or the instance one. - settings, err := lockoutSettingRepo.List(ctx, opts.DB(), - listLockoutSettingCondition(lockoutSettingRepo, instanceID, orgID), - database.WithOrderByAscending(lockoutSettingRepo.OrganizationIDColumn(), lockoutSettingRepo.InstanceIDColumn()), + settings, err := lockoutSettingsRepo.List(ctx, db, + listLockoutSettingCondition(lockoutSettingsRepo, instanceID, orgID), + database.WithOrderByAscending(lockoutSettingsRepo.OrganizationIDColumn(), lockoutSettingsRepo.InstanceIDColumn()), database.WithLimit(1), ) if err != nil { diff --git a/backend/v3/storage/database/change.go b/backend/v3/storage/database/change.go index 7fe2b4f43..4b84d0d27 100644 --- a/backend/v3/storage/database/change.go +++ b/backend/v3/storage/database/change.go @@ -4,6 +4,7 @@ import ( "fmt" "reflect" "slices" + "time" "go.uber.org/mock/gomock" @@ -40,6 +41,19 @@ func (c *change[V]) Matches(x any) bool { } colMatch := c.column.Equals(toMatch.column) valueMatch := reflect.DeepEqual(c.value, toMatch.value) + if !valueMatch { + // if c.value and toMatch.value are [time.Time] values, we want to compare them within a range. + if t1, ok1 := any(c.value).(time.Time); ok1 { + if t2, ok2 := any(toMatch.value).(time.Time); ok2 { + diff := t1.Sub(t2) + if diff < 0 { + diff = -diff + } + // We may want to make [time.Second] configurable in the future + valueMatch = diff <= time.Second + } + } + } return colMatch && valueMatch }