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:
Gayathri Vijayan
2026-03-26 11:33:44 +00:00
committed by GitHub
parent f94e4065c2
commit f99142bc4a
12 changed files with 2008 additions and 71 deletions
+16 -6
View File
@@ -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
}
+8 -1
View File
@@ -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) {
+1
View File
@@ -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 {
+2
View File
@@ -210,6 +210,8 @@ func startZitadel(ctx context.Context, config *Config, masterKey string, server
new_domain.SetLegacyEventstore(eventstoreClient)
new_domain.SetSystemConfig(config.SystemDefaults)
new_domain.SetIDPEncryptionAlgorithm(keys.IDPConfig)
new_domain.SetMFAEncryptionAlgorithm(keys.OTP)
new_domain.SetOTPSMSSecretGeneratorConfig(config.DefaultInstance.SecretGenerators.OTPSMS)
sessionTokenVerifier := internal_authz.SessionTokenVerifier(keys.OIDC)
sessionTokenDecryptor := internal_authz.SessionTokenDecryptor(keys.OIDC)