mirror of
https://github.com/andwati/zitadel.git
synced 2026-10-18 05:52:51 +00:00
# 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]>
944 lines
36 KiB
Go
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)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|