mirror of
https://github.com/andwati/zitadel.git
synced 2026-10-18 05:52:51 +00:00
feat: implement otp sms session challenge (#11919)
# Which Problems Are Solved This PR adds the OTP SMS Challenge needed for CreateSession API https://github.com/zitadel/zitadel/issues/11035 # How the Problems Are Solved - Introduce `OTPSMSChallengeCommand` (validate/execute/events) for session creation checks - Introduce `GetOTPCryptoGeneratorConfigWithDefault` to fetch per-instance secret generator settings - Add unit tests # Additional Changes Refactor tests in `session_check_recovery_code_test.go` to make the helper functions reusable in `session_challenge_otp_sms_test.go` # Additional Context Related to https://github.com/zitadel/zitadel/issues/11035
This commit is contained in:
@@ -8,12 +8,14 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
pool database.Pool
|
||||
legacyEventstore eventstore.LegacyEventstore
|
||||
sysConfig systemdefaults.SystemDefaults
|
||||
passwordHasher *crypto.Hasher
|
||||
idpEncryptionAlgo crypto.EncryptionAlgorithm
|
||||
sessionTokenDecryptor SessionTokenDecryptor
|
||||
pool database.Pool
|
||||
legacyEventstore eventstore.LegacyEventstore
|
||||
sysConfig systemdefaults.SystemDefaults
|
||||
passwordHasher *crypto.Hasher
|
||||
idpEncryptionAlgo crypto.EncryptionAlgorithm
|
||||
sessionTokenDecryptor SessionTokenDecryptor
|
||||
mfaEncryptionAlgo crypto.EncryptionAlgorithm
|
||||
otpSMSSecretGeneratorConfig *crypto.GeneratorConfig
|
||||
)
|
||||
|
||||
func SetPool(p database.Pool) {
|
||||
@@ -39,3 +41,11 @@ func SetIDPEncryptionAlgorithm(idpEncryptionAlg crypto.EncryptionAlgorithm) {
|
||||
func SetSessionTokenDecryptor(decryptor SessionTokenDecryptor) {
|
||||
sessionTokenDecryptor = decryptor
|
||||
}
|
||||
|
||||
func SetOTPSMSSecretGeneratorConfig(cfg *crypto.GeneratorConfig) {
|
||||
otpSMSSecretGeneratorConfig = cfg
|
||||
}
|
||||
|
||||
func SetMFAEncryptionAlgorithm(mfaEncryptionAlg crypto.EncryptionAlgorithm) {
|
||||
mfaEncryptionAlgo = mfaEncryptionAlg
|
||||
}
|
||||
|
||||
@@ -57,6 +57,12 @@ func WithLockoutSettingsRepo(repo LockoutSettingsRepository) InvokeOpt {
|
||||
}
|
||||
}
|
||||
|
||||
func WithSecretGeneratorSettingsRepo(repo SecretGeneratorSettingsRepository) InvokeOpt {
|
||||
return func(opts *InvokeOpts) {
|
||||
opts.secretGeneratorSettingsRepo = repo
|
||||
}
|
||||
}
|
||||
|
||||
func WithPermissionChecker(checker PermissionChecker) InvokeOpt {
|
||||
return func(opts *InvokeOpts) {
|
||||
opts.Permissions = checker
|
||||
@@ -118,7 +124,8 @@ type InvokeOpts struct {
|
||||
idpIntentRepo IDPIntentRepository
|
||||
|
||||
// Settings repos
|
||||
lockoutSettingRepo LockoutSettingsRepository
|
||||
lockoutSettingRepo LockoutSettingsRepository
|
||||
secretGeneratorSettingsRepo SecretGeneratorSettingsRepository
|
||||
}
|
||||
|
||||
func (o *InvokeOpts) DB() database.QueryExecutor {
|
||||
|
||||
@@ -1083,6 +1083,44 @@ func (c *MockSecretGeneratorSettingsRepositoryTypeColumnCall) DoAndReturn(f func
|
||||
return c
|
||||
}
|
||||
|
||||
// TypeCondition mocks base method.
|
||||
func (m *MockSecretGeneratorSettingsRepository) TypeCondition(typ domain.SettingType) database.Condition {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "TypeCondition", typ)
|
||||
ret0, _ := ret[0].(database.Condition)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// TypeCondition indicates an expected call of TypeCondition.
|
||||
func (mr *MockSecretGeneratorSettingsRepositoryMockRecorder) TypeCondition(typ any) *MockSecretGeneratorSettingsRepositoryTypeConditionCall {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "TypeCondition", reflect.TypeOf((*MockSecretGeneratorSettingsRepository)(nil).TypeCondition), typ)
|
||||
return &MockSecretGeneratorSettingsRepositoryTypeConditionCall{Call: call}
|
||||
}
|
||||
|
||||
// MockSecretGeneratorSettingsRepositoryTypeConditionCall wrap *gomock.Call
|
||||
type MockSecretGeneratorSettingsRepositoryTypeConditionCall struct {
|
||||
*gomock.Call
|
||||
}
|
||||
|
||||
// Return rewrite *gomock.Call.Return
|
||||
func (c *MockSecretGeneratorSettingsRepositoryTypeConditionCall) Return(arg0 database.Condition) *MockSecretGeneratorSettingsRepositoryTypeConditionCall {
|
||||
c.Call = c.Call.Return(arg0)
|
||||
return c
|
||||
}
|
||||
|
||||
// Do rewrite *gomock.Call.Do
|
||||
func (c *MockSecretGeneratorSettingsRepositoryTypeConditionCall) Do(f func(domain.SettingType) database.Condition) *MockSecretGeneratorSettingsRepositoryTypeConditionCall {
|
||||
c.Call = c.Call.Do(f)
|
||||
return c
|
||||
}
|
||||
|
||||
// DoAndReturn rewrite *gomock.Call.DoAndReturn
|
||||
func (c *MockSecretGeneratorSettingsRepositoryTypeConditionCall) DoAndReturn(f func(domain.SettingType) database.Condition) *MockSecretGeneratorSettingsRepositoryTypeConditionCall {
|
||||
c.Call = c.Call.DoAndReturn(f)
|
||||
return c
|
||||
}
|
||||
|
||||
// UniqueColumns mocks base method.
|
||||
func (m *MockSecretGeneratorSettingsRepository) UniqueColumns() []database.Column {
|
||||
m.ctrl.T.Helper()
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package domainmock
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"go.uber.org/mock/gomock"
|
||||
|
||||
"github.com/zitadel/zitadel/backend/v3/domain"
|
||||
"github.com/zitadel/zitadel/backend/v3/storage/database"
|
||||
"github.com/zitadel/zitadel/backend/v3/storage/database/repository"
|
||||
)
|
||||
|
||||
type SecretGeneratorSettingsRepo struct {
|
||||
domain.SecretGeneratorSettingsRepository
|
||||
mock *MockSecretGeneratorSettingsRepository
|
||||
}
|
||||
|
||||
func NewSecretGeneratorSettingsRepo(ctrl *gomock.Controller) *SecretGeneratorSettingsRepo {
|
||||
return &SecretGeneratorSettingsRepo{
|
||||
mock: NewMockSecretGeneratorSettingsRepository(ctrl),
|
||||
SecretGeneratorSettingsRepository: repository.SecretGeneratorSettingsRepository(),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *SecretGeneratorSettingsRepo) EXPECT() *MockSecretGeneratorSettingsRepositoryMockRecorder {
|
||||
r.mock.ctrl.T.Helper()
|
||||
return r.mock.EXPECT()
|
||||
}
|
||||
|
||||
func (r *SecretGeneratorSettingsRepo) Get(ctx context.Context, client database.QueryExecutor, opts ...database.QueryOption) (*domain.SecretGeneratorSettings, error) {
|
||||
r.mock.ctrl.T.Helper()
|
||||
return r.mock.Get(ctx, client, opts...)
|
||||
}
|
||||
|
||||
func (r *SecretGeneratorSettingsRepo) List(ctx context.Context, client database.QueryExecutor, opts ...database.QueryOption) ([]*domain.SecretGeneratorSettings, error) {
|
||||
r.mock.ctrl.T.Helper()
|
||||
return r.mock.List(ctx, client, opts...)
|
||||
}
|
||||
|
||||
func (r *SecretGeneratorSettingsRepo) Set(ctx context.Context, client database.QueryExecutor, settings *domain.SecretGeneratorSettings) error {
|
||||
r.mock.ctrl.T.Helper()
|
||||
return r.mock.Set(ctx, client, settings)
|
||||
}
|
||||
|
||||
func (r *SecretGeneratorSettingsRepo) Delete(ctx context.Context, client database.QueryExecutor, condition database.Condition) (int64, error) {
|
||||
r.mock.ctrl.T.Helper()
|
||||
return r.mock.Delete(ctx, client, condition)
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/zitadel/zitadel/backend/v3/storage/database"
|
||||
"github.com/zitadel/zitadel/internal/api/http"
|
||||
"github.com/zitadel/zitadel/internal/crypto"
|
||||
"github.com/zitadel/zitadel/internal/eventstore"
|
||||
"github.com/zitadel/zitadel/internal/repository/session"
|
||||
"github.com/zitadel/zitadel/internal/zerrors"
|
||||
)
|
||||
|
||||
const (
|
||||
expectedUpdatedRows = 1
|
||||
objectTypeSession = "Session"
|
||||
objectTypeUser = "User"
|
||||
)
|
||||
|
||||
var _ Commander = (*OTPSMSChallengeCommand)(nil)
|
||||
var _ Transactional = (*OTPSMSChallengeCommand)(nil)
|
||||
|
||||
type ChallengeTypeOTPSMS struct {
|
||||
ReturnCode bool
|
||||
}
|
||||
|
||||
// (@grvijayan) todo: to be implemented
|
||||
// getActiveSMSProviderFn helps determine whether to generate an internal OTP code or use an external SMS provider.
|
||||
// maybe temporary until we figure out how to integrate SMS providers from v1 API
|
||||
type getActiveSMSProviderFn func(ctx context.Context, instanceID string) (string, error)
|
||||
|
||||
// OTPSMSChallengeCommand creates an OTP SMS challenge for a session.
|
||||
type OTPSMSChallengeCommand struct {
|
||||
ChallengeTypeOTPSMS *ChallengeTypeOTPSMS
|
||||
|
||||
SessionID string
|
||||
InstanceID string
|
||||
|
||||
defaultSecretGeneratorConfig *crypto.GeneratorConfig
|
||||
otpAlgorithm crypto.EncryptionAlgorithm
|
||||
smsProvider getActiveSMSProviderFn
|
||||
newPhoneCode newOTPCodeFunc
|
||||
|
||||
session *Session
|
||||
user *User
|
||||
|
||||
challengeOTPSMS *SessionChallengeOTPSMS // the generated OTP SMS challenge that is stored in the session.
|
||||
otpSMSChallenge *string // challenge to be set in the CreateSessionResponse
|
||||
}
|
||||
|
||||
// NewOTPSMSChallengeCommand creates a command to generate an OTP SMS challenge for a session.
|
||||
func NewOTPSMSChallengeCommand(
|
||||
challengeTypeOTPSMS *ChallengeTypeOTPSMS,
|
||||
sessionID string,
|
||||
instanceID string,
|
||||
secretGeneratorConfig *crypto.GeneratorConfig,
|
||||
otpAlgorithm crypto.EncryptionAlgorithm,
|
||||
smsProvider getActiveSMSProviderFn,
|
||||
newPhoneCodeFn newOTPCodeFunc) *OTPSMSChallengeCommand {
|
||||
|
||||
if secretGeneratorConfig == nil {
|
||||
secretGeneratorConfig = otpSMSSecretGeneratorConfig
|
||||
}
|
||||
if otpAlgorithm == nil {
|
||||
otpAlgorithm = mfaEncryptionAlgo
|
||||
}
|
||||
if newPhoneCodeFn == nil {
|
||||
newPhoneCodeFn = crypto.NewCode
|
||||
}
|
||||
|
||||
return &OTPSMSChallengeCommand{
|
||||
ChallengeTypeOTPSMS: challengeTypeOTPSMS,
|
||||
SessionID: sessionID,
|
||||
InstanceID: instanceID,
|
||||
defaultSecretGeneratorConfig: secretGeneratorConfig,
|
||||
otpAlgorithm: otpAlgorithm,
|
||||
smsProvider: smsProvider,
|
||||
newPhoneCode: newPhoneCodeFn,
|
||||
}
|
||||
}
|
||||
|
||||
// Validate implements [Commander].
|
||||
// It checks if the command has all required fields and fetches necessary data.
|
||||
func (o *OTPSMSChallengeCommand) Validate(ctx context.Context, opts *InvokeOpts) (err error) {
|
||||
if o.ChallengeTypeOTPSMS == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
err = o.validatePreConditions()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// get session
|
||||
sessionRepo := opts.sessionRepo
|
||||
retrievedSession, err := sessionRepo.Get(
|
||||
ctx,
|
||||
opts.DB(),
|
||||
database.WithCondition(sessionRepo.PrimaryKeyCondition(o.InstanceID, o.SessionID)),
|
||||
)
|
||||
if err := handleGetError(err, "DOM-2aGWWE", objectTypeSession); err != nil {
|
||||
return err
|
||||
}
|
||||
if retrievedSession.UserID == "" {
|
||||
return zerrors.ThrowPreconditionFailed(nil, "DOM-Vi16Fs", "Errors.Missing.Session.UserID")
|
||||
}
|
||||
|
||||
// get user
|
||||
userRepo := opts.userRepo
|
||||
retrievedUser, err := userRepo.Get(
|
||||
ctx,
|
||||
opts.DB(),
|
||||
database.WithCondition(userRepo.PrimaryKeyCondition(o.InstanceID, retrievedSession.UserID)),
|
||||
)
|
||||
if err := handleGetError(err, "DOM-3aGHDs", objectTypeUser); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if retrievedUser.ID == "" {
|
||||
return zerrors.ThrowPreconditionFailed(nil, "DOM-1bzvsh", "Errors.User.UserIDMissing")
|
||||
}
|
||||
|
||||
// validate human user and user phone
|
||||
if retrievedUser.Human == nil || retrievedUser.Human.Phone == nil {
|
||||
return zerrors.ThrowPreconditionFailed(nil, "DOM-7hG2w", "Errors.NotFound.User.Human.Phone")
|
||||
}
|
||||
// validate phone OTP is enabled
|
||||
if retrievedUser.Human.Phone.OTP.EnabledAt.IsZero() {
|
||||
return zerrors.ThrowPreconditionFailed(nil, "DOM-9kL4m", "Errors.OTPSMS.NotEnabled")
|
||||
}
|
||||
o.session = retrievedSession
|
||||
o.user = retrievedUser
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Execute implements [Commander].
|
||||
// It generates the OTP SMS challenge and updates the session.
|
||||
func (o *OTPSMSChallengeCommand) Execute(ctx context.Context, opts *InvokeOpts) error {
|
||||
if o.ChallengeTypeOTPSMS == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// generate phone code
|
||||
code, plain, generatorID, expiry, err := o.createPhoneCode(ctx, opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// update the session with the otp sms challenge
|
||||
challengeOTPSMS := &SessionChallengeOTPSMS{
|
||||
LastChallengedAt: time.Now(),
|
||||
Code: code,
|
||||
Expiry: expiry,
|
||||
CodeReturned: o.ChallengeTypeOTPSMS.ReturnCode,
|
||||
GeneratorID: generatorID,
|
||||
TriggeredAtOrigin: http.DomainContext(ctx).Origin(),
|
||||
}
|
||||
sessionRepo := opts.sessionRepo
|
||||
updateCount, err := sessionRepo.Update(
|
||||
ctx,
|
||||
opts.DB(),
|
||||
sessionRepo.PrimaryKeyCondition(o.InstanceID, o.SessionID),
|
||||
sessionRepo.SetChallenge(challengeOTPSMS),
|
||||
)
|
||||
if err != nil {
|
||||
return zerrors.ThrowInternal(err, "DOM-AigB0Z", "session update failed")
|
||||
}
|
||||
if updateCount == 0 {
|
||||
return zerrors.ThrowNotFound(nil, "DOM-QThZH7", "Errors.Session.NotFound")
|
||||
}
|
||||
if updateCount > 1 {
|
||||
return zerrors.ThrowInternal(NewMultipleObjectsUpdatedError(expectedUpdatedRows, updateCount), "DOM-gYp8tG", "unexpected number of rows")
|
||||
}
|
||||
// todo (@grvijayan): uncomment after these changes are available
|
||||
// if err := handleUpdateError(err, expectedUpdatedRows, updated, "DOM-AigB0Z", objectTypeSession); err != nil {
|
||||
// return err
|
||||
// }
|
||||
o.challengeOTPSMS = challengeOTPSMS
|
||||
if o.ChallengeTypeOTPSMS.ReturnCode {
|
||||
o.otpSMSChallenge = &plain
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Events implements [eventstore.Command].
|
||||
// It returns an OTPSMSChallengedEvent.
|
||||
func (o *OTPSMSChallengeCommand) Events(ctx context.Context, _ *InvokeOpts) ([]eventstore.Command, error) {
|
||||
if o.ChallengeTypeOTPSMS == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return []eventstore.Command{
|
||||
session.NewOTPSMSChallengedEvent(
|
||||
ctx,
|
||||
&session.NewAggregate(o.SessionID, o.InstanceID).Aggregate,
|
||||
o.challengeOTPSMS.Code,
|
||||
o.challengeOTPSMS.Expiry,
|
||||
o.challengeOTPSMS.CodeReturned,
|
||||
o.challengeOTPSMS.GeneratorID,
|
||||
),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (o *OTPSMSChallengeCommand) GetOTPSMSChallenge() *string {
|
||||
return o.otpSMSChallenge
|
||||
}
|
||||
|
||||
// String implements [Commander].
|
||||
func (o *OTPSMSChallengeCommand) String() string {
|
||||
return "OTPSMSChallengeCommand"
|
||||
}
|
||||
|
||||
// RequiresTransaction implements [Transactional].
|
||||
func (o *OTPSMSChallengeCommand) RequiresTransaction() {}
|
||||
|
||||
// createPhoneCode generates an OTP code or retrieves the external provider ID if an external SMS provider is active.
|
||||
// In the case of an external provider, the code generation is skipped (e.g., when using Twilio verification API).
|
||||
func (o *OTPSMSChallengeCommand) createPhoneCode(ctx context.Context, opts *InvokeOpts) (code *crypto.CryptoValue, plain string, externalID string, expiry time.Duration, err error) {
|
||||
externalID, err = o.smsProvider(ctx, o.InstanceID)
|
||||
if err != nil {
|
||||
return nil, "", "", expiry, err
|
||||
}
|
||||
if externalID != "" {
|
||||
return nil, "", externalID, expiry, nil
|
||||
}
|
||||
|
||||
config, err := GetOTPCryptoGeneratorConfigWithDefault(ctx, o.InstanceID, opts, o.defaultSecretGeneratorConfig, OTPTypeSMS)
|
||||
if err != nil {
|
||||
return nil, "", "", expiry, err
|
||||
}
|
||||
codeGenerator := crypto.NewEncryptionGenerator(*config, o.otpAlgorithm)
|
||||
crypted, plain, err := o.newPhoneCode(codeGenerator)
|
||||
if err != nil {
|
||||
return nil, "", "", expiry, err
|
||||
}
|
||||
return crypted, plain, "", config.Expiry, nil
|
||||
}
|
||||
|
||||
func (o *OTPSMSChallengeCommand) validatePreConditions() error {
|
||||
// validate required fields
|
||||
if o.SessionID == "" {
|
||||
return zerrors.ThrowPreconditionFailed(nil, "DOM-3XpM6A", "Errors.Missing.SessionID")
|
||||
}
|
||||
if o.InstanceID == "" {
|
||||
return zerrors.ThrowPreconditionFailed(nil, "DOM-jNNJ9f", "Errors.Missing.InstanceID")
|
||||
}
|
||||
|
||||
// validate that sms provider is set
|
||||
if o.smsProvider == nil {
|
||||
return zerrors.ThrowPreconditionFailed(nil, "DOM-1aGWeE", "missing sms provider")
|
||||
}
|
||||
|
||||
// validate that default secret generator config is set
|
||||
if o.defaultSecretGeneratorConfig == nil {
|
||||
return zerrors.ThrowInternal(nil, "DOM-IDcOzP", "missing default secret generator config")
|
||||
}
|
||||
|
||||
// validate that otp algorithm is set
|
||||
if o.otpAlgorithm == nil {
|
||||
return zerrors.ThrowInternal(nil, "DOM-VeBPmV", "missing MFA encryption algorithm")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,76 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/zitadel/zitadel/backend/v3/storage/database"
|
||||
"github.com/zitadel/zitadel/internal/crypto"
|
||||
"github.com/zitadel/zitadel/internal/zerrors"
|
||||
)
|
||||
|
||||
type newOTPCodeFunc func(g crypto.Generator) (*crypto.CryptoValue, string, error)
|
||||
|
||||
type OTPType int64
|
||||
|
||||
const (
|
||||
OTPTypeSMS OTPType = iota
|
||||
OTPTypeEmail
|
||||
)
|
||||
|
||||
func GetOTPCryptoGeneratorConfigWithDefault(ctx context.Context, instanceID string, opts *InvokeOpts, defaultConfig *crypto.GeneratorConfig, otpType OTPType) (*crypto.GeneratorConfig, error) {
|
||||
if defaultConfig == nil {
|
||||
return nil, zerrors.ThrowInternal(nil, "DOM-3AcM0U", "missing default config")
|
||||
}
|
||||
|
||||
if opts == nil || opts.secretGeneratorSettingsRepo == nil {
|
||||
return defaultConfig, nil
|
||||
}
|
||||
|
||||
settingsRepo := opts.secretGeneratorSettingsRepo
|
||||
cfg, err := settingsRepo.Get(
|
||||
ctx,
|
||||
opts.DB(),
|
||||
database.WithCondition(
|
||||
settingsRepo.UniqueCondition(instanceID, nil, SettingTypeSecretGenerator, SettingStateActive),
|
||||
),
|
||||
)
|
||||
if err := handleGetError(err, "DOM-x7Yd3E", "SecretGeneratorSettings"); err != nil {
|
||||
return nil, err // todo: or return defaultConfig?
|
||||
}
|
||||
|
||||
if cfg.State != SettingStateActive {
|
||||
return defaultConfig, nil
|
||||
}
|
||||
|
||||
var attrs SecretGeneratorAttrsWithExpiry
|
||||
switch otpType {
|
||||
case OTPTypeSMS:
|
||||
if cfg.OTPSMS == nil {
|
||||
return defaultConfig, nil
|
||||
}
|
||||
attrs = cfg.OTPSMS.SecretGeneratorAttrsWithExpiry
|
||||
case OTPTypeEmail:
|
||||
if cfg.OTPEmail == nil {
|
||||
return defaultConfig, nil
|
||||
}
|
||||
attrs = cfg.OTPEmail.SecretGeneratorAttrsWithExpiry
|
||||
default:
|
||||
return nil, zerrors.ThrowInternal(nil, "DOM-3AcM0U", "invalid otp type")
|
||||
}
|
||||
return &crypto.GeneratorConfig{
|
||||
Length: getValueOrDefault(attrs.Length, defaultConfig.Length),
|
||||
Expiry: getValueOrDefault(attrs.Expiry, defaultConfig.Expiry),
|
||||
IncludeLowerLetters: getValueOrDefault(attrs.IncludeLowerLetters, defaultConfig.IncludeLowerLetters),
|
||||
IncludeUpperLetters: getValueOrDefault(attrs.IncludeUpperLetters, defaultConfig.IncludeUpperLetters),
|
||||
IncludeDigits: getValueOrDefault(attrs.IncludeDigits, defaultConfig.IncludeDigits),
|
||||
IncludeSymbols: getValueOrDefault(attrs.IncludeSymbols, defaultConfig.IncludeSymbols),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// getValueOrDefault safely dereferences a pointer or returns a default value
|
||||
func getValueOrDefault[T any](ptr *T, defaultVal T) T {
|
||||
if ptr != nil {
|
||||
return *ptr
|
||||
}
|
||||
return defaultVal
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
package domain_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/muhlemmer/gu"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"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"
|
||||
"github.com/zitadel/zitadel/internal/crypto"
|
||||
"github.com/zitadel/zitadel/internal/zerrors"
|
||||
)
|
||||
|
||||
func TestGetOTPCryptoGeneratorConfigWithDefault(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
instanceID string
|
||||
secretGeneratorSettingsRepo func(ctrl *gomock.Controller) domain.SecretGeneratorSettingsRepository
|
||||
defaultConfig *crypto.GeneratorConfig
|
||||
otpType domain.OTPType
|
||||
want *crypto.GeneratorConfig
|
||||
wantErr error
|
||||
}{
|
||||
{
|
||||
name: "no default config",
|
||||
instanceID: "instance-1",
|
||||
wantErr: zerrors.ThrowInternal(nil, "DOM-3AcM0U", "missing default config"),
|
||||
},
|
||||
{
|
||||
name: "failed to get secret generator settings",
|
||||
instanceID: "instance-1",
|
||||
secretGeneratorSettingsRepo: func(ctrl *gomock.Controller) domain.SecretGeneratorSettingsRepository {
|
||||
repo := domainmock.NewSecretGeneratorSettingsRepo(ctrl)
|
||||
repo.EXPECT().
|
||||
Get(gomock.Any(), gomock.Any(),
|
||||
dbmock.QueryOptions(
|
||||
database.WithCondition(
|
||||
repo.UniqueCondition("instance-1", nil, domain.SettingTypeSecretGenerator, domain.SettingStateActive),
|
||||
),
|
||||
),
|
||||
).Times(1).
|
||||
Return(nil, assert.AnError)
|
||||
return repo
|
||||
},
|
||||
defaultConfig: &crypto.GeneratorConfig{
|
||||
Length: 6,
|
||||
},
|
||||
otpType: domain.OTPTypeEmail,
|
||||
wantErr: zerrors.ThrowInternal(assert.AnError, "DOM-x7Yd3E", "failed fetching SecretGeneratorSettings"),
|
||||
},
|
||||
{
|
||||
name: "inactive secret generator settings",
|
||||
instanceID: "instance-1",
|
||||
secretGeneratorSettingsRepo: secretGeneratorSettingsRepo(domain.SettingStatePreview, domain.OTPTypeSMS),
|
||||
defaultConfig: &crypto.GeneratorConfig{
|
||||
Length: 6,
|
||||
},
|
||||
otpType: domain.OTPTypeSMS,
|
||||
want: &crypto.GeneratorConfig{
|
||||
Length: 6,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "active secret generator settings - type sms",
|
||||
instanceID: "instance-1",
|
||||
secretGeneratorSettingsRepo: secretGeneratorSettingsRepo(domain.SettingStateActive, domain.OTPTypeSMS),
|
||||
defaultConfig: &crypto.GeneratorConfig{
|
||||
Length: 6,
|
||||
},
|
||||
otpType: domain.OTPTypeSMS,
|
||||
want: &crypto.GeneratorConfig{
|
||||
Expiry: 30 * time.Minute,
|
||||
Length: 6,
|
||||
IncludeLowerLetters: true,
|
||||
IncludeUpperLetters: false,
|
||||
IncludeDigits: true,
|
||||
IncludeSymbols: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "active secret generator settings - type email",
|
||||
instanceID: "instance-1",
|
||||
secretGeneratorSettingsRepo: secretGeneratorSettingsRepo(domain.SettingStateActive, domain.OTPTypeEmail),
|
||||
defaultConfig: &crypto.GeneratorConfig{
|
||||
Length: 6,
|
||||
},
|
||||
otpType: domain.OTPTypeEmail,
|
||||
want: &crypto.GeneratorConfig{
|
||||
Expiry: 30 * time.Minute,
|
||||
Length: 8,
|
||||
IncludeLowerLetters: true,
|
||||
IncludeUpperLetters: false,
|
||||
IncludeDigits: true,
|
||||
IncludeSymbols: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "active secret generator settings sms type with nil values - return default config",
|
||||
instanceID: "instance-1",
|
||||
secretGeneratorSettingsRepo: func(ctrl *gomock.Controller) domain.SecretGeneratorSettingsRepository {
|
||||
repo := domainmock.NewSecretGeneratorSettingsRepo(ctrl)
|
||||
repo.EXPECT().
|
||||
Get(gomock.Any(), gomock.Any(),
|
||||
dbmock.QueryOptions(
|
||||
database.WithCondition(
|
||||
repo.UniqueCondition("instance-1", nil, domain.SettingTypeSecretGenerator, domain.SettingStateActive),
|
||||
),
|
||||
),
|
||||
).Times(1).
|
||||
Return(&domain.SecretGeneratorSettings{
|
||||
Settings: domain.Settings{
|
||||
State: domain.SettingStateActive,
|
||||
},
|
||||
}, nil)
|
||||
return repo
|
||||
},
|
||||
defaultConfig: &crypto.GeneratorConfig{
|
||||
Length: 6,
|
||||
},
|
||||
otpType: domain.OTPTypeSMS,
|
||||
want: &crypto.GeneratorConfig{
|
||||
Length: 6,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "active secret generator settings email type with nil values - return default config",
|
||||
instanceID: "instance-1",
|
||||
secretGeneratorSettingsRepo: func(ctrl *gomock.Controller) domain.SecretGeneratorSettingsRepository {
|
||||
repo := domainmock.NewSecretGeneratorSettingsRepo(ctrl)
|
||||
repo.EXPECT().
|
||||
Get(gomock.Any(), gomock.Any(),
|
||||
dbmock.QueryOptions(
|
||||
database.WithCondition(
|
||||
repo.UniqueCondition("instance-1", nil, domain.SettingTypeSecretGenerator, domain.SettingStateActive),
|
||||
),
|
||||
),
|
||||
).Times(1).
|
||||
Return(&domain.SecretGeneratorSettings{
|
||||
Settings: domain.Settings{
|
||||
State: domain.SettingStateActive,
|
||||
},
|
||||
}, nil)
|
||||
return repo
|
||||
},
|
||||
defaultConfig: &crypto.GeneratorConfig{
|
||||
Length: 6,
|
||||
},
|
||||
otpType: domain.OTPTypeEmail,
|
||||
want: &crypto.GeneratorConfig{
|
||||
Length: 6,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "active secret generator settings with nil field values - return default config",
|
||||
instanceID: "instance-1",
|
||||
secretGeneratorSettingsRepo: func(ctrl *gomock.Controller) domain.SecretGeneratorSettingsRepository {
|
||||
repo := domainmock.NewSecretGeneratorSettingsRepo(ctrl)
|
||||
repo.EXPECT().
|
||||
Get(gomock.Any(), gomock.Any(),
|
||||
dbmock.QueryOptions(
|
||||
database.WithCondition(
|
||||
repo.UniqueCondition("instance-1", nil, domain.SettingTypeSecretGenerator, domain.SettingStateActive),
|
||||
),
|
||||
),
|
||||
).Times(1).
|
||||
Return(&domain.SecretGeneratorSettings{
|
||||
Settings: domain.Settings{
|
||||
State: domain.SettingStateActive,
|
||||
},
|
||||
SecretGeneratorSettingsAttributes: domain.SecretGeneratorSettingsAttributes{
|
||||
OTPEmail: &domain.OTPEmailAttributes{
|
||||
SecretGeneratorAttrsWithExpiry: domain.SecretGeneratorAttrsWithExpiry{
|
||||
Expiry: gu.Ptr(30 * time.Minute),
|
||||
SecretGeneratorAttrs: domain.SecretGeneratorAttrs{
|
||||
Length: nil, // should use value from the default config
|
||||
IncludeLowerLetters: nil, // should use value from the default config
|
||||
IncludeUpperLetters: gu.Ptr(false),
|
||||
IncludeDigits: gu.Ptr(true),
|
||||
IncludeSymbols: gu.Ptr(false),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}, nil)
|
||||
return repo
|
||||
},
|
||||
defaultConfig: &crypto.GeneratorConfig{
|
||||
Length: 6,
|
||||
IncludeUpperLetters: true,
|
||||
IncludeLowerLetters: true,
|
||||
},
|
||||
otpType: domain.OTPTypeEmail,
|
||||
want: &crypto.GeneratorConfig{
|
||||
Expiry: 30 * time.Minute,
|
||||
Length: 6, // from the default config
|
||||
IncludeLowerLetters: true, // from the default config
|
||||
IncludeUpperLetters: false,
|
||||
IncludeDigits: true,
|
||||
IncludeSymbols: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid otp type",
|
||||
instanceID: "instance-1",
|
||||
otpType: 3,
|
||||
defaultConfig: &crypto.GeneratorConfig{
|
||||
Length: 6,
|
||||
IncludeUpperLetters: true,
|
||||
IncludeLowerLetters: true,
|
||||
},
|
||||
secretGeneratorSettingsRepo: secretGeneratorSettingsRepo(domain.SettingStateActive, domain.OTPTypeEmail),
|
||||
wantErr: zerrors.ThrowInternal(nil, "DOM-3AcM0U", "invalid otp type"),
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
opts := &domain.InvokeOpts{
|
||||
Invoker: domain.NewTransactionInvoker(nil),
|
||||
}
|
||||
if tt.secretGeneratorSettingsRepo != nil {
|
||||
domain.WithSecretGeneratorSettingsRepo(tt.secretGeneratorSettingsRepo(ctrl))(opts)
|
||||
}
|
||||
got, err := domain.GetOTPCryptoGeneratorConfigWithDefault(context.Background(), tt.instanceID, opts, tt.defaultConfig, tt.otpType)
|
||||
assert.ErrorIs(t, err, tt.wantErr)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ package domain
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/zitadel/zitadel/backend/v3/instrumentation/logging"
|
||||
@@ -88,11 +87,8 @@ func (rc *RecoveryCodeCheckCommand) Validate(ctx context.Context, opts *InvokeOp
|
||||
opts.DB(),
|
||||
database.WithCondition(sessionRepo.PrimaryKeyCondition(rc.InstanceID, rc.SessionID)),
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, &database.NoRowFoundError{}) {
|
||||
return zerrors.ThrowNotFound(err, "DOM-Ot3qO6", "Errors.Session.NotFound")
|
||||
}
|
||||
return zerrors.ThrowInternal(err, "DOM-2sF2kF", "Errors.Internal")
|
||||
if err := handleGetError(err, "DOM-2sF2kF", objectTypeSession); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if retrievedSession.UserID == "" {
|
||||
@@ -107,11 +103,8 @@ func (rc *RecoveryCodeCheckCommand) Validate(ctx context.Context, opts *InvokeOp
|
||||
database.WithCondition(userRepo.IDCondition(retrievedSession.UserID)),
|
||||
database.WithResultLock(),
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, &database.NoRowFoundError{}) {
|
||||
return zerrors.ThrowNotFound(err, "DOM-Ot3qO6", "Errors.User.NotFound")
|
||||
}
|
||||
return zerrors.ThrowInternal(err, "DOM-7sWTNf", "Errors.Internal")
|
||||
if err := handleGetError(err, "DOM-Ot3qO6", objectTypeUser); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// check user state
|
||||
|
||||
@@ -69,7 +69,7 @@ func TestRecoveryCodeCheckCommand_Validate(t *testing.T) {
|
||||
Return(nil, &database.NoRowFoundError{})
|
||||
return sessionRepo
|
||||
},
|
||||
wantErr: zerrors.ThrowNotFound(&database.NoRowFoundError{}, "DOM-Ot3qO6", "Errors.Session.NotFound"),
|
||||
wantErr: zerrors.ThrowNotFound(&database.NoRowFoundError{}, "DOM-2sF2kF", "Session not found"),
|
||||
},
|
||||
{
|
||||
name: "failed to get session - database error",
|
||||
@@ -85,7 +85,7 @@ func TestRecoveryCodeCheckCommand_Validate(t *testing.T) {
|
||||
Return(nil, getError)
|
||||
return sessionRepo
|
||||
},
|
||||
wantErr: zerrors.ThrowInternal(getError, "DOM-2sF2kF", "Errors.Internal"),
|
||||
wantErr: zerrors.ThrowInternal(getError, "DOM-2sF2kF", "failed fetching Session"),
|
||||
},
|
||||
{
|
||||
name: "missing user id in session",
|
||||
@@ -126,7 +126,7 @@ func TestRecoveryCodeCheckCommand_Validate(t *testing.T) {
|
||||
Return(nil, &database.NoRowFoundError{})
|
||||
return userRepo
|
||||
},
|
||||
wantErr: zerrors.ThrowNotFound(&database.NoRowFoundError{}, "DOM-Ot3qO6", "Errors.User.NotFound"),
|
||||
wantErr: zerrors.ThrowNotFound(&database.NoRowFoundError{}, "DOM-Ot3qO6", "User not found"),
|
||||
},
|
||||
{
|
||||
name: "failed to get user - database error",
|
||||
@@ -151,7 +151,7 @@ func TestRecoveryCodeCheckCommand_Validate(t *testing.T) {
|
||||
Return(nil, getError)
|
||||
return userRepo
|
||||
},
|
||||
wantErr: zerrors.ThrowInternal(getError, "DOM-7sWTNf", "Errors.Internal"),
|
||||
wantErr: zerrors.ThrowInternal(getError, "DOM-Ot3qO6", "failed fetching User"),
|
||||
},
|
||||
{
|
||||
name: "user is locked",
|
||||
@@ -435,7 +435,10 @@ func TestRecoveryCodeCheckCommand_Execute(t *testing.T) {
|
||||
getSessionSucceededExpectation(sessionRepo)
|
||||
|
||||
// set up expectation to update session in Execute()
|
||||
updateSessionFailedExpectation(sessionRepo, dbError, 0)
|
||||
factor := sessionRepo.SetFactor(&domain.SessionFactorRecoveryCode{
|
||||
LastFailedAt: time.Now(),
|
||||
})
|
||||
updateSessionFailedExpectation(sessionRepo, factor, dbError, 0)
|
||||
return sessionRepo
|
||||
},
|
||||
userRepo: func(ctrl *gomock.Controller) domain.UserRepository {
|
||||
@@ -473,7 +476,10 @@ func TestRecoveryCodeCheckCommand_Execute(t *testing.T) {
|
||||
getSessionSucceededExpectation(sessionRepo)
|
||||
|
||||
// set up expectation to update session in Execute()
|
||||
updateSessionFailedExpectation(sessionRepo, nil, 0)
|
||||
factor := sessionRepo.SetFactor(&domain.SessionFactorRecoveryCode{
|
||||
LastFailedAt: time.Now(),
|
||||
})
|
||||
updateSessionFailedExpectation(sessionRepo, factor, nil, 0)
|
||||
return sessionRepo
|
||||
},
|
||||
userRepo: func(ctrl *gomock.Controller) domain.UserRepository {
|
||||
@@ -511,7 +517,10 @@ func TestRecoveryCodeCheckCommand_Execute(t *testing.T) {
|
||||
getSessionSucceededExpectation(sessionRepo)
|
||||
|
||||
// set up expectation to update session in Execute()
|
||||
updateSessionFailedExpectation(sessionRepo, nil, 2)
|
||||
factor := sessionRepo.SetFactor(&domain.SessionFactorRecoveryCode{
|
||||
LastFailedAt: time.Now(),
|
||||
})
|
||||
updateSessionFailedExpectation(sessionRepo, factor, nil, 2)
|
||||
return sessionRepo
|
||||
},
|
||||
userRepo: func(ctrl *gomock.Controller) domain.UserRepository {
|
||||
@@ -746,7 +755,10 @@ func TestRecoveryCodeCheckCommand_Execute(t *testing.T) {
|
||||
getSessionSucceededExpectation(sessionRepo)
|
||||
|
||||
// set up expectation to update session in Execute()
|
||||
updateSessionFailedExpectation(sessionRepo, dbError, 0)
|
||||
factor := sessionRepo.SetFactor(&domain.SessionFactorRecoveryCode{
|
||||
LastFailedAt: time.Now(),
|
||||
})
|
||||
updateSessionFailedExpectation(sessionRepo, factor, dbError, 0)
|
||||
return sessionRepo
|
||||
},
|
||||
userRepo: func(ctrl *gomock.Controller) domain.UserRepository {
|
||||
@@ -787,7 +799,10 @@ func TestRecoveryCodeCheckCommand_Execute(t *testing.T) {
|
||||
getSessionSucceededExpectation(sessionRepo)
|
||||
|
||||
// set up expectation to update session in Execute()
|
||||
updateSessionFailedExpectation(sessionRepo, nil, 0)
|
||||
factor := sessionRepo.SetFactor(&domain.SessionFactorRecoveryCode{
|
||||
LastFailedAt: time.Now(),
|
||||
})
|
||||
updateSessionFailedExpectation(sessionRepo, factor, nil, 0)
|
||||
return sessionRepo
|
||||
},
|
||||
userRepo: func(ctrl *gomock.Controller) domain.UserRepository {
|
||||
@@ -828,7 +843,10 @@ func TestRecoveryCodeCheckCommand_Execute(t *testing.T) {
|
||||
getSessionSucceededExpectation(sessionRepo)
|
||||
|
||||
// set up expectation to update session in Execute()
|
||||
updateSessionFailedExpectation(sessionRepo, nil, 2)
|
||||
factor := sessionRepo.SetFactor(&domain.SessionFactorRecoveryCode{
|
||||
LastFailedAt: time.Now(),
|
||||
})
|
||||
updateSessionFailedExpectation(sessionRepo, factor, nil, 2)
|
||||
return sessionRepo
|
||||
},
|
||||
userRepo: func(ctrl *gomock.Controller) domain.UserRepository {
|
||||
@@ -1309,23 +1327,10 @@ func updateHumanUserFailedExpectation(ctrl *gomock.Controller, userRepo *domainm
|
||||
Return(int64(0), err)
|
||||
return
|
||||
}
|
||||
switch updateCount {
|
||||
case 0:
|
||||
humanRepo.EXPECT().
|
||||
Update(gomock.Any(), gomock.Any(), primaryKeyCondition, userUpdates).
|
||||
Times(1).
|
||||
Return(int64(0), nil)
|
||||
case 1:
|
||||
humanRepo.EXPECT().
|
||||
Update(gomock.Any(), gomock.Any(), primaryKeyCondition, userUpdates).
|
||||
Times(1).
|
||||
Return(int64(1), nil)
|
||||
default:
|
||||
humanRepo.EXPECT().
|
||||
Update(gomock.Any(), gomock.Any(), primaryKeyCondition, userUpdates).
|
||||
Times(1).
|
||||
Return(updateCount, nil)
|
||||
}
|
||||
humanRepo.EXPECT().
|
||||
Update(gomock.Any(), gomock.Any(), primaryKeyCondition, userUpdates).
|
||||
Times(1).
|
||||
Return(updateCount, nil)
|
||||
}
|
||||
|
||||
func getUserSucceededExpectation(userRepo *domainmock.UserRepo, failedAttempts uint8) {
|
||||
@@ -1356,37 +1361,19 @@ func updateSessionSucceededExpectation(sessionRepo *domainmock.SessionRepo, chan
|
||||
Return(int64(1), nil)
|
||||
}
|
||||
|
||||
func updateSessionFailedExpectation(sessionRepo *domainmock.SessionRepo, err error, updateCount int64) {
|
||||
func updateSessionFailedExpectation(sessionRepo *domainmock.SessionRepo, change database.Change, err error, updateCount int64) {
|
||||
primaryKeyCondition := sessionRepo.PrimaryKeyCondition("instance-1", "session-1")
|
||||
factor := sessionRepo.SetFactor(&domain.SessionFactorRecoveryCode{
|
||||
LastFailedAt: time.Now(),
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
sessionRepo.EXPECT().
|
||||
Update(gomock.Any(), gomock.Any(), primaryKeyCondition, factor).
|
||||
Update(gomock.Any(), gomock.Any(), primaryKeyCondition, change).
|
||||
Times(1).
|
||||
Return(int64(0), err)
|
||||
Return(updateCount, err)
|
||||
return
|
||||
}
|
||||
|
||||
switch updateCount {
|
||||
case 0:
|
||||
sessionRepo.EXPECT().
|
||||
Update(gomock.Any(), gomock.Any(), primaryKeyCondition, factor).
|
||||
Times(1).
|
||||
Return(int64(0), nil)
|
||||
case 1:
|
||||
sessionRepo.EXPECT().
|
||||
Update(gomock.Any(), gomock.Any(), primaryKeyCondition, factor).
|
||||
Times(1).
|
||||
Return(int64(1), nil)
|
||||
default:
|
||||
sessionRepo.EXPECT().
|
||||
Update(gomock.Any(), gomock.Any(), primaryKeyCondition, factor).
|
||||
Times(1).
|
||||
Return(updateCount, nil)
|
||||
}
|
||||
sessionRepo.EXPECT().
|
||||
Update(gomock.Any(), gomock.Any(), primaryKeyCondition, change).
|
||||
Times(1).
|
||||
Return(updateCount, nil)
|
||||
}
|
||||
|
||||
func getSessionSucceededExpectation(sessionRepo *domainmock.SessionRepo) {
|
||||
@@ -1394,7 +1381,11 @@ func getSessionSucceededExpectation(sessionRepo *domainmock.SessionRepo) {
|
||||
sessionRepo.EXPECT().
|
||||
Get(gomock.Any(), gomock.Any(), dbmock.QueryOptions(database.WithCondition(primaryKeyCondition))).
|
||||
Times(1).
|
||||
Return(&domain.Session{UserID: "user-1"}, nil)
|
||||
Return(&domain.Session{
|
||||
UserID: "user-1",
|
||||
InstanceID: "instance-1",
|
||||
ID: "session-1",
|
||||
}, nil)
|
||||
}
|
||||
|
||||
func getLockoutSettingsSucceededExpectation(instanceID, orgID string, lockoutRepo *domainmock.LockoutSettingsRepo) {
|
||||
|
||||
@@ -64,6 +64,7 @@ type settingsConditions interface {
|
||||
IDCondition(id string) database.Condition
|
||||
PrimaryKeyCondition(instanceID, id string) database.Condition
|
||||
UniqueCondition(instanceID string, orgID *string, typ SettingType, state SettingState) database.Condition
|
||||
TypeCondition(typ SettingType) database.Condition
|
||||
}
|
||||
|
||||
type settingsChanges interface {
|
||||
|
||||
Reference in New Issue
Block a user