mirror of
https://github.com/zitadel/zitadel.git
synced 2026-08-19 01:14:48 -05:00
feat(session): TOTP Check API with relation tables (#11886)
# Which Problems Are Solved As part of #11035 , this PR implements the TOTP check logic for session validation # How the Problems Are Solved - Implement TOTP check logic and tests - The tarpit function has been moved to a common file so that it can be used by both TOTP and password checks - Manual transaction management to avoid stalling the DB while verifier function is executed - Update `database.Change` `Matches()` function to allow comparison of `time.Time` values - A converter package from GRPC to Domain model has been added # Additional Context This is a cherry-picked PR + changes, coming from https://github.com/zitadel/zitadel/pull/11164 - Relates to #11035
This commit is contained in:
@@ -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(),
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user