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:
Marco A.
2026-04-01 11:34:03 +02:00
committed by GitHub
parent 8c8fc47884
commit 701bca6003
9 changed files with 1283 additions and 17 deletions
@@ -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)
})
}
}
+4 -4
View File
@@ -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
}
+2 -7
View File
@@ -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
+255
View File
@@ -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)
}
}
})
}
}
+12 -5
View File
@@ -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 {
+14
View File
@@ -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
}