Files
zitadel/backend/v3/domain/session_check_totp_test.go
T
6128aeaa89 fix(security): prevent TOTP reuse (#12616)
# Which Problems Are Solved

TOTP codes could be reused, violating [RFC 6238, section
5.2](https://datatracker.ietf.org/doc/html/rfc6238#section-5.2).

# How the Problems Are Solved

Transmitted codes are now recorded in each success event. As the codes
amount to secrets, they are not stored verbatim but as a randomly salted
HMAC-SHA256 (`crypto.HMACValue`). Each value gets a fresh random salt,
so an equal code always produces a different stored value and two
entries can never be recognized as being derived from the same code.

This is deliberately not a replacement for proper secret storage: with
the salt stored alongside the hash and a 6 digit payload, anyone with
event access can brute-force the code in milliseconds. That is
acceptable here because a code is worthless once its window has passed.
The `HMACValue` doc comment states the same.

TOTP codes are low-entropy (6 digits), pseudo random codes. Therefore it
is possible for the same code to be generated more than once over the
lifetime of a user. In order to prevent the (unlikely) case where users
login with large time intervalls and a generated code may be equal to
the one last used a long time ago, we only check for codes used within
the recent period window.

A simpler implementation would be simply storing the latest consumed
step, but the TOTP library does not return this value. Because of the
allowed skew there are always 3 steps within the validation window, so
we can't calculate the current step based on time alone. The retained
history spans one period more than the validation window: validity is
decided by the app server's clock, while the stored events carry the
database clock, and the extra period absorbs the drift between them.

**Note:** a reused code pushes a distinct
`user.human.mfa.otp.check.reused` event, not a failed check event. A
replay therefore stays observable through the Events API without
counting towards the lockout policy. Otherwise anyone who captured a
code could replay it to lock the user out.

# Additional Changes

- Read only the latest events in recheck query

# Additional Context

- Closes https://github.com/zitadel/zitadel/issues/6452

---------

Co-authored-by: Livio Spring <[email protected]>
Co-authored-by: Copilot Autofix powered by AI <[email protected]>
Co-authored-by: copilot-swe-agent[bot] <[email protected]>
Co-authored-by: muhlemmer <[email protected]>
2026-08-27 07:17:39 +02:00

944 lines
36 KiB
Go

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, 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)
}
}
})
}
}