feat: provide option to limit (T)OTP checks (#7693)

* feat: provide option to limit (T)OTP checks

* fix requests in console

* update errors pkg

* cleanup

* cleanup

* improve naming of existing config
This commit is contained in:
Livio Spring
2024-04-10 11:14:55 +02:00
committed by GitHub
parent e3f10f7e23
commit 153df2e12f
58 changed files with 752 additions and 755 deletions

View File

@@ -101,7 +101,8 @@ type InstanceSetup struct {
ThemeMode domain.LabelPolicyThemeMode
}
LockoutPolicy struct {
MaxAttempts uint64
MaxPasswordAttempts uint64
MaxOTPAttempts uint64
ShouldShowLockoutFailure bool
}
EmailTemplate []byte
@@ -271,7 +272,7 @@ func (c *Commands) SetUpInstance(ctx context.Context, setup *InstanceSetup) (str
prepareAddDefaultPrivacyPolicy(instanceAgg, setup.PrivacyPolicy.TOSLink, setup.PrivacyPolicy.PrivacyLink, setup.PrivacyPolicy.HelpLink, setup.PrivacyPolicy.SupportEmail),
prepareAddDefaultNotificationPolicy(instanceAgg, setup.NotificationPolicy.PasswordChange),
prepareAddDefaultLockoutPolicy(instanceAgg, setup.LockoutPolicy.MaxAttempts, setup.LockoutPolicy.ShouldShowLockoutFailure),
prepareAddDefaultLockoutPolicy(instanceAgg, setup.LockoutPolicy.MaxPasswordAttempts, setup.LockoutPolicy.MaxOTPAttempts, setup.LockoutPolicy.ShouldShowLockoutFailure),
prepareAddDefaultLabelPolicy(
instanceAgg,

View File

@@ -109,6 +109,7 @@ func writeModelToLockoutPolicy(wm *LockoutPolicyWriteModel) *domain.LockoutPolic
return &domain.LockoutPolicy{
ObjectRoot: writeModelToObjectRoot(wm.WriteModel),
MaxPasswordAttempts: wm.MaxPasswordAttempts,
MaxOTPAttempts: wm.MaxOTPAttempts,
ShowLockOutFailures: wm.ShowLockOutFailures,
}
}

View File

@@ -12,9 +12,15 @@ import (
"github.com/zitadel/zitadel/internal/zerrors"
)
func (c *Commands) AddDefaultLockoutPolicy(ctx context.Context, maxAttempts uint64, showLockoutFailure bool) (*domain.ObjectDetails, error) {
func (c *Commands) AddDefaultLockoutPolicy(ctx context.Context, maxPasswordAttempts, maxOTPAttempts uint64, showLockoutFailure bool) (*domain.ObjectDetails, error) {
instanceAgg := instance.NewAggregate(authz.GetInstance(ctx).InstanceID())
cmds, err := preparation.PrepareCommands(ctx, c.eventstore.Filter, prepareAddDefaultLockoutPolicy(instanceAgg, maxAttempts, showLockoutFailure))
//nolint:staticcheck
cmds, err := preparation.PrepareCommands(ctx, c.eventstore.Filter, prepareAddDefaultLockoutPolicy(
instanceAgg,
maxPasswordAttempts,
maxOTPAttempts,
showLockoutFailure,
))
if err != nil {
return nil, err
}
@@ -35,7 +41,13 @@ func (c *Commands) ChangeDefaultLockoutPolicy(ctx context.Context, policy *domai
}
instanceAgg := InstanceAggregateFromWriteModel(&existingPolicy.LockoutPolicyWriteModel.WriteModel)
changedEvent, hasChanged := existingPolicy.NewChangedEvent(ctx, instanceAgg, policy.MaxPasswordAttempts, policy.ShowLockOutFailures)
changedEvent, hasChanged := existingPolicy.NewChangedEvent(
ctx,
instanceAgg,
policy.MaxPasswordAttempts,
policy.MaxOTPAttempts,
policy.ShowLockOutFailures,
)
if !hasChanged {
return nil, zerrors.ThrowPreconditionFailed(nil, "INSTANCE-0psjF", "Errors.IAM.LockoutPolicy.NotChanged")
}
@@ -65,7 +77,8 @@ func (c *Commands) defaultLockoutPolicyWriteModelByID(ctx context.Context) (poli
func prepareAddDefaultLockoutPolicy(
a *instance.Aggregate,
maxAttempts uint64,
maxPasswordAttempts,
maxOTPAttempts uint64,
showLockoutFailure bool,
) preparation.Validation {
return func() (preparation.CreateCommands, error) {
@@ -83,7 +96,7 @@ func prepareAddDefaultLockoutPolicy(
return nil, zerrors.ThrowAlreadyExists(nil, "INSTANCE-0olDf", "Errors.Instance.LockoutPolicy.AlreadyExists")
}
return []eventstore.Command{
instance.NewLockoutPolicyAddedEvent(ctx, &a.Aggregate, maxAttempts, showLockoutFailure),
instance.NewLockoutPolicyAddedEvent(ctx, &a.Aggregate, maxPasswordAttempts, maxOTPAttempts, showLockoutFailure),
}, nil
}, nil
}

View File

@@ -54,11 +54,15 @@ func (wm *InstanceLockoutPolicyWriteModel) Query() *eventstore.SearchQueryBuilde
func (wm *InstanceLockoutPolicyWriteModel) NewChangedEvent(
ctx context.Context,
aggregate *eventstore.Aggregate,
maxAttempts uint64,
maxPasswordAttempts,
maxOTPAttempts uint64,
showLockoutFailure bool) (*instance.LockoutPolicyChangedEvent, bool) {
changes := make([]policy.LockoutPolicyChanges, 0)
if wm.MaxPasswordAttempts != maxAttempts {
changes = append(changes, policy.ChangeMaxAttempts(maxAttempts))
if wm.MaxPasswordAttempts != maxPasswordAttempts {
changes = append(changes, policy.ChangeMaxPasswordAttempts(maxPasswordAttempts))
}
if wm.MaxOTPAttempts != maxOTPAttempts {
changes = append(changes, policy.ChangeMaxOTPAttempts(maxOTPAttempts))
}
if wm.ShowLockOutFailures != showLockoutFailure {
changes = append(changes, policy.ChangeShowLockOutFailures(showLockoutFailure))

View File

@@ -22,6 +22,7 @@ func TestCommandSide_AddDefaultLockoutPolicy(t *testing.T) {
type args struct {
ctx context.Context
maxPasswordAttempts uint64
maxOTPAttempts uint64
showLockOutFailures bool
}
type res struct {
@@ -44,6 +45,7 @@ func TestCommandSide_AddDefaultLockoutPolicy(t *testing.T) {
instance.NewLockoutPolicyAddedEvent(context.Background(),
&instance.NewAggregate("INSTANCE").Aggregate,
10,
10,
true,
),
),
@@ -69,6 +71,7 @@ func TestCommandSide_AddDefaultLockoutPolicy(t *testing.T) {
instance.NewLockoutPolicyAddedEvent(context.Background(),
&instance.NewAggregate("INSTANCE").Aggregate,
10,
10,
true,
),
),
@@ -77,6 +80,7 @@ func TestCommandSide_AddDefaultLockoutPolicy(t *testing.T) {
args: args{
ctx: authz.WithInstanceID(context.Background(), "INSTANCE"),
maxPasswordAttempts: 10,
maxOTPAttempts: 10,
showLockOutFailures: true,
},
res: res{
@@ -91,7 +95,7 @@ func TestCommandSide_AddDefaultLockoutPolicy(t *testing.T) {
r := &Commands{
eventstore: tt.fields.eventstore,
}
got, err := r.AddDefaultLockoutPolicy(tt.args.ctx, tt.args.maxPasswordAttempts, tt.args.showLockOutFailures)
got, err := r.AddDefaultLockoutPolicy(tt.args.ctx, tt.args.maxPasswordAttempts, tt.args.maxOTPAttempts, tt.args.showLockOutFailures)
if tt.res.err == nil {
assert.NoError(t, err)
}
@@ -135,6 +139,7 @@ func TestCommandSide_ChangeDefaultLockoutPolicy(t *testing.T) {
ctx: context.Background(),
policy: &domain.LockoutPolicy{
MaxPasswordAttempts: 10,
MaxOTPAttempts: 10,
ShowLockOutFailures: true,
},
},
@@ -152,6 +157,7 @@ func TestCommandSide_ChangeDefaultLockoutPolicy(t *testing.T) {
instance.NewLockoutPolicyAddedEvent(context.Background(),
&instance.NewAggregate("INSTANCE").Aggregate,
10,
10,
true,
),
),
@@ -162,6 +168,7 @@ func TestCommandSide_ChangeDefaultLockoutPolicy(t *testing.T) {
ctx: context.Background(),
policy: &domain.LockoutPolicy{
MaxPasswordAttempts: 10,
MaxOTPAttempts: 10,
ShowLockOutFailures: true,
},
},
@@ -179,12 +186,13 @@ func TestCommandSide_ChangeDefaultLockoutPolicy(t *testing.T) {
instance.NewLockoutPolicyAddedEvent(context.Background(),
&instance.NewAggregate("INSTANCE").Aggregate,
10,
10,
true,
),
),
),
expectPush(
newDefaultLockoutPolicyChangedEvent(context.Background(), 20, false),
newDefaultLockoutPolicyChangedEvent(context.Background(), 20, 20, false),
),
),
},
@@ -192,6 +200,7 @@ func TestCommandSide_ChangeDefaultLockoutPolicy(t *testing.T) {
ctx: context.Background(),
policy: &domain.LockoutPolicy{
MaxPasswordAttempts: 20,
MaxOTPAttempts: 20,
ShowLockOutFailures: false,
},
},
@@ -203,6 +212,7 @@ func TestCommandSide_ChangeDefaultLockoutPolicy(t *testing.T) {
InstanceID: "INSTANCE",
},
MaxPasswordAttempts: 20,
MaxOTPAttempts: 20,
ShowLockOutFailures: false,
},
},
@@ -227,11 +237,12 @@ func TestCommandSide_ChangeDefaultLockoutPolicy(t *testing.T) {
}
}
func newDefaultLockoutPolicyChangedEvent(ctx context.Context, maxAttempts uint64, showLockoutFailure bool) *instance.LockoutPolicyChangedEvent {
func newDefaultLockoutPolicyChangedEvent(ctx context.Context, maxPasswordAttempts, maxOTPAttempts uint64, showLockoutFailure bool) *instance.LockoutPolicyChangedEvent {
event, _ := instance.NewLockoutPolicyChangedEvent(ctx,
&instance.NewAggregate("INSTANCE").Aggregate,
[]policy.LockoutPolicyChanges{
policy.ChangeMaxAttempts(maxAttempts),
policy.ChangeMaxPasswordAttempts(maxPasswordAttempts),
policy.ChangeMaxOTPAttempts(maxOTPAttempts),
policy.ChangeShowLockOutFailures(showLockoutFailure),
},
)

View File

@@ -21,7 +21,13 @@ func (c *Commands) AddLockoutPolicy(ctx context.Context, resourceOwner string, p
}
orgAgg := OrgAggregateFromWriteModel(&addedPolicy.WriteModel)
pushedEvents, err := c.eventstore.Push(ctx, org.NewLockoutPolicyAddedEvent(ctx, orgAgg, policy.MaxPasswordAttempts, policy.ShowLockOutFailures))
pushedEvents, err := c.eventstore.Push(ctx, org.NewLockoutPolicyAddedEvent(
ctx,
orgAgg,
policy.MaxPasswordAttempts,
policy.MaxOTPAttempts,
policy.ShowLockOutFailures,
))
if err != nil {
return nil, err
}
@@ -45,7 +51,7 @@ func (c *Commands) ChangeLockoutPolicy(ctx context.Context, resourceOwner string
}
orgAgg := OrgAggregateFromWriteModel(&existingPolicy.LockoutPolicyWriteModel.WriteModel)
changedEvent, hasChanged := existingPolicy.NewChangedEvent(ctx, orgAgg, policy.MaxPasswordAttempts, policy.ShowLockOutFailures)
changedEvent, hasChanged := existingPolicy.NewChangedEvent(ctx, orgAgg, policy.MaxPasswordAttempts, policy.MaxOTPAttempts, policy.ShowLockOutFailures)
if !hasChanged {
return nil, zerrors.ThrowPreconditionFailed(nil, "ORG-0JFSr", "Errors.Org.LockoutPolicy.NotChanged")
}
@@ -106,3 +112,20 @@ func (c *Commands) orgLockoutPolicyWriteModelByID(ctx context.Context, orgID str
}
return policy, nil
}
func (c *Commands) getLockoutPolicy(ctx context.Context, orgID string) (*domain.LockoutPolicy, error) {
orgWm, err := c.orgLockoutPolicyWriteModelByID(ctx, orgID)
if err != nil {
return nil, err
}
if orgWm.State == domain.PolicyStateActive {
return writeModelToLockoutPolicy(&orgWm.LockoutPolicyWriteModel), nil
}
instanceWm, err := c.defaultLockoutPolicyWriteModelByID(ctx)
if err != nil {
return nil, err
}
policy := writeModelToLockoutPolicy(&instanceWm.LockoutPolicyWriteModel)
policy.Default = true
return policy, nil
}

View File

@@ -55,11 +55,15 @@ func (wm *OrgLockoutPolicyWriteModel) Query() *eventstore.SearchQueryBuilder {
func (wm *OrgLockoutPolicyWriteModel) NewChangedEvent(
ctx context.Context,
aggregate *eventstore.Aggregate,
maxAttempts uint64,
maxPasswordAttempts,
maxOTPAttempts uint64,
showLockoutFailure bool) (*org.LockoutPolicyChangedEvent, bool) {
changes := make([]policy.LockoutPolicyChanges, 0)
if wm.MaxPasswordAttempts != maxAttempts {
changes = append(changes, policy.ChangeMaxAttempts(maxAttempts))
if wm.MaxPasswordAttempts != maxPasswordAttempts {
changes = append(changes, policy.ChangeMaxPasswordAttempts(maxPasswordAttempts))
}
if wm.MaxOTPAttempts != maxOTPAttempts {
changes = append(changes, policy.ChangeMaxOTPAttempts(maxOTPAttempts))
}
if wm.ShowLockOutFailures != showLockoutFailure {
changes = append(changes, policy.ChangeShowLockOutFailures(showLockoutFailure))

View File

@@ -44,6 +44,7 @@ func TestCommandSide_AddPasswordLockoutPolicy(t *testing.T) {
ctx: context.Background(),
policy: &domain.LockoutPolicy{
MaxPasswordAttempts: 10,
MaxOTPAttempts: 10,
ShowLockOutFailures: true,
},
},
@@ -61,6 +62,7 @@ func TestCommandSide_AddPasswordLockoutPolicy(t *testing.T) {
org.NewLockoutPolicyAddedEvent(context.Background(),
&org.NewAggregate("org1").Aggregate,
10,
10,
true,
),
),
@@ -72,6 +74,7 @@ func TestCommandSide_AddPasswordLockoutPolicy(t *testing.T) {
orgID: "org1",
policy: &domain.LockoutPolicy{
MaxPasswordAttempts: 10,
MaxOTPAttempts: 10,
ShowLockOutFailures: true,
},
},
@@ -89,6 +92,7 @@ func TestCommandSide_AddPasswordLockoutPolicy(t *testing.T) {
org.NewLockoutPolicyAddedEvent(context.Background(),
&org.NewAggregate("org1").Aggregate,
10,
10,
true,
),
),
@@ -99,6 +103,7 @@ func TestCommandSide_AddPasswordLockoutPolicy(t *testing.T) {
orgID: "org1",
policy: &domain.LockoutPolicy{
MaxPasswordAttempts: 10,
MaxOTPAttempts: 10,
ShowLockOutFailures: true,
},
},
@@ -109,6 +114,7 @@ func TestCommandSide_AddPasswordLockoutPolicy(t *testing.T) {
ResourceOwner: "org1",
},
MaxPasswordAttempts: 10,
MaxOTPAttempts: 10,
ShowLockOutFailures: true,
},
},
@@ -163,6 +169,7 @@ func TestCommandSide_ChangePasswordLockoutPolicy(t *testing.T) {
ctx: context.Background(),
policy: &domain.LockoutPolicy{
MaxPasswordAttempts: 10,
MaxOTPAttempts: 10,
ShowLockOutFailures: true,
},
},
@@ -183,6 +190,7 @@ func TestCommandSide_ChangePasswordLockoutPolicy(t *testing.T) {
orgID: "org1",
policy: &domain.LockoutPolicy{
MaxPasswordAttempts: 10,
MaxOTPAttempts: 10,
ShowLockOutFailures: true,
},
},
@@ -200,6 +208,7 @@ func TestCommandSide_ChangePasswordLockoutPolicy(t *testing.T) {
org.NewLockoutPolicyAddedEvent(context.Background(),
&org.NewAggregate("org1").Aggregate,
10,
10,
true,
),
),
@@ -211,6 +220,7 @@ func TestCommandSide_ChangePasswordLockoutPolicy(t *testing.T) {
orgID: "org1",
policy: &domain.LockoutPolicy{
MaxPasswordAttempts: 10,
MaxOTPAttempts: 10,
ShowLockOutFailures: true,
},
},
@@ -228,12 +238,13 @@ func TestCommandSide_ChangePasswordLockoutPolicy(t *testing.T) {
org.NewLockoutPolicyAddedEvent(context.Background(),
&org.NewAggregate("org1").Aggregate,
10,
10,
true,
),
),
),
expectPush(
newPasswordLockoutPolicyChangedEvent(context.Background(), "org1", 5, false),
newPasswordLockoutPolicyChangedEvent(context.Background(), "org1", 5, 5, false),
),
),
},
@@ -242,6 +253,7 @@ func TestCommandSide_ChangePasswordLockoutPolicy(t *testing.T) {
orgID: "org1",
policy: &domain.LockoutPolicy{
MaxPasswordAttempts: 5,
MaxOTPAttempts: 5,
ShowLockOutFailures: false,
},
},
@@ -252,6 +264,7 @@ func TestCommandSide_ChangePasswordLockoutPolicy(t *testing.T) {
ResourceOwner: "org1",
},
MaxPasswordAttempts: 5,
MaxOTPAttempts: 5,
ShowLockOutFailures: false,
},
},
@@ -334,6 +347,7 @@ func TestCommandSide_RemovePasswordLockoutPolicy(t *testing.T) {
org.NewLockoutPolicyAddedEvent(context.Background(),
&org.NewAggregate("org1").Aggregate,
10,
10,
true,
),
),
@@ -371,11 +385,12 @@ func TestCommandSide_RemovePasswordLockoutPolicy(t *testing.T) {
}
}
func newPasswordLockoutPolicyChangedEvent(ctx context.Context, orgID string, maxAttempts uint64, showLockoutFailure bool) *org.LockoutPolicyChangedEvent {
func newPasswordLockoutPolicyChangedEvent(ctx context.Context, orgID string, maxPasswordAttempts, maxOTPAttempts uint64, showLockoutFailure bool) *org.LockoutPolicyChangedEvent {
event, _ := org.NewLockoutPolicyChangedEvent(ctx,
&org.NewAggregate(orgID).Aggregate,
[]policy.LockoutPolicyChanges{
policy.ChangeMaxAttempts(maxAttempts),
policy.ChangeMaxPasswordAttempts(maxPasswordAttempts),
policy.ChangeMaxOTPAttempts(maxOTPAttempts),
policy.ChangeShowLockOutFailures(showLockoutFailure),
},
)

View File

@@ -10,6 +10,7 @@ type LockoutPolicyWriteModel struct {
eventstore.WriteModel
MaxPasswordAttempts uint64
MaxOTPAttempts uint64
ShowLockOutFailures bool
State domain.PolicyState
}
@@ -19,12 +20,16 @@ func (wm *LockoutPolicyWriteModel) Reduce() error {
switch e := event.(type) {
case *policy.LockoutPolicyAddedEvent:
wm.MaxPasswordAttempts = e.MaxPasswordAttempts
wm.MaxOTPAttempts = e.MaxOTPAttempts
wm.ShowLockOutFailures = e.ShowLockOutFailures
wm.State = domain.PolicyStateActive
case *policy.LockoutPolicyChangedEvent:
if e.MaxPasswordAttempts != nil {
wm.MaxPasswordAttempts = *e.MaxPasswordAttempts
}
if e.MaxOTPAttempts != nil {
wm.MaxOTPAttempts = *e.MaxOTPAttempts
}
if e.ShowLockOutFailures != nil {
wm.ShowLockOutFailures = *e.ShowLockOutFailures
}

View File

@@ -158,14 +158,37 @@ func (c *Commands) HumanCheckMFATOTP(ctx context.Context, userID, code, resource
return zerrors.ThrowPreconditionFailed(nil, "COMMAND-3Mif9s", "Errors.User.MFA.OTP.NotReady")
}
userAgg := UserAggregateFromWriteModel(&existingOTP.WriteModel)
err = domain.VerifyTOTP(code, existingOTP.Secret, c.multifactors.OTP.CryptoMFA)
if err == nil {
verifyErr := domain.VerifyTOTP(code, existingOTP.Secret, c.multifactors.OTP.CryptoMFA)
// recheck for additional events (failed OTP checks or locks)
recheckErr := c.eventstore.FilterToQueryReducer(ctx, existingOTP)
if recheckErr != nil {
return recheckErr
}
if existingOTP.UserLocked {
return zerrors.ThrowPreconditionFailed(nil, "COMMAND-SF3fg", "Errors.User.Locked")
}
// the OTP check succeeded and the user was not locked in the meantime
if verifyErr == nil {
_, err = c.eventstore.Push(ctx, user.NewHumanOTPCheckSucceededEvent(ctx, userAgg, authRequestDomainToAuthRequestInfo(authRequest)))
return err
}
_, pushErr := c.eventstore.Push(ctx, user.NewHumanOTPCheckFailedEvent(ctx, userAgg, authRequestDomainToAuthRequestInfo(authRequest)))
// the OTP check failed, therefore check if the limit was reached and the user must additionally be locked
commands := make([]eventstore.Command, 0, 2)
commands = append(commands, user.NewHumanOTPCheckFailedEvent(ctx, userAgg, authRequestDomainToAuthRequestInfo(authRequest)))
lockoutPolicy, err := c.getLockoutPolicy(ctx, resourceOwner)
if err != nil {
return err
}
if lockoutPolicy.MaxOTPAttempts > 0 && existingOTP.CheckFailedCount+1 >= lockoutPolicy.MaxOTPAttempts {
commands = append(commands, user.NewUserLockedEvent(ctx, userAgg))
}
_, pushErr := c.eventstore.Push(ctx, commands...)
logging.OnError(pushErr).Error("error create password check failed event")
return err
return verifyErr
}
func (c *Commands) HumanRemoveTOTP(ctx context.Context, userID, resourceOwner string) (*domain.ObjectDetails, error) {
@@ -515,14 +538,37 @@ func (c *Commands) humanCheckOTP(
return zerrors.ThrowPreconditionFailed(nil, "COMMAND-S34gh", "Errors.User.Code.NotFound")
}
userAgg := &user.NewAggregate(userID, existingOTP.ResourceOwner()).Aggregate
err = crypto.VerifyCode(existingOTP.CodeCreationDate(), existingOTP.CodeExpiry(), existingOTP.Code(), code, c.userEncryption)
if err == nil {
verifyErr := crypto.VerifyCode(existingOTP.CodeCreationDate(), existingOTP.CodeExpiry(), existingOTP.Code(), code, c.userEncryption)
// recheck for additional events (failed OTP checks or locks)
recheckErr := c.eventstore.FilterToQueryReducer(ctx, existingOTP)
if recheckErr != nil {
return recheckErr
}
if existingOTP.UserLocked() {
return zerrors.ThrowPreconditionFailed(nil, "COMMAND-S6h4R", "Errors.User.Locked")
}
// the OTP check succeeded and the user was not locked in the meantime
if verifyErr == nil {
_, err = c.eventstore.Push(ctx, checkSucceededEvent(ctx, userAgg, authRequestDomainToAuthRequestInfo(authRequest)))
return err
}
_, pushErr := c.eventstore.Push(ctx, checkFailedEvent(ctx, userAgg, authRequestDomainToAuthRequestInfo(authRequest)))
// the OTP check failed, therefore check if the limit was reached and the user must additionally be locked
commands := make([]eventstore.Command, 0, 2)
commands = append(commands, checkFailedEvent(ctx, userAgg, authRequestDomainToAuthRequestInfo(authRequest)))
lockoutPolicy, err := c.getLockoutPolicy(ctx, resourceOwner)
if err != nil {
return err
}
if lockoutPolicy.MaxOTPAttempts > 0 && existingOTP.CheckFailedCount()+1 >= lockoutPolicy.MaxOTPAttempts {
commands = append(commands, user.NewUserLockedEvent(ctx, userAgg))
}
_, pushErr := c.eventstore.Push(ctx, commands...)
logging.WithFields("userID", userID).OnError(pushErr).Error("otp failure check push failed")
return err
return verifyErr
}
func (c *Commands) totpWriteModelByID(ctx context.Context, userID, resourceOwner string) (writeModel *HumanTOTPWriteModel, err error) {

View File

@@ -12,8 +12,10 @@ import (
type HumanTOTPWriteModel struct {
eventstore.WriteModel
State domain.MFAState
Secret *crypto.CryptoValue
State domain.MFAState
Secret *crypto.CryptoValue
CheckFailedCount uint64
UserLocked bool
}
func NewHumanTOTPWriteModel(userID, resourceOwner string) *HumanTOTPWriteModel {
@@ -33,6 +35,16 @@ func (wm *HumanTOTPWriteModel) Reduce() error {
wm.State = domain.MFAStateNotReady
case *user.HumanOTPVerifiedEvent:
wm.State = domain.MFAStateReady
wm.CheckFailedCount = 0
case *user.HumanOTPCheckSucceededEvent:
wm.CheckFailedCount = 0
case *user.HumanOTPCheckFailedEvent:
wm.CheckFailedCount++
case *user.UserLockedEvent:
wm.UserLocked = true
case *user.UserUnlockedEvent:
wm.CheckFailedCount = 0
wm.UserLocked = false
case *user.HumanOTPRemovedEvent:
wm.State = domain.MFAStateRemoved
case *user.UserRemovedEvent:
@@ -50,6 +62,10 @@ func (wm *HumanTOTPWriteModel) Query() *eventstore.SearchQueryBuilder {
EventTypes(user.HumanMFAOTPAddedType,
user.HumanMFAOTPVerifiedType,
user.HumanMFAOTPRemovedType,
user.HumanMFAOTPCheckSucceededType,
user.HumanMFAOTPCheckFailedType,
user.UserLockedType,
user.UserUnlockedType,
user.UserRemovedType,
user.UserV1MFAOTPAddedType,
user.UserV1MFAOTPVerifiedType,
@@ -72,6 +88,9 @@ type OTPCodeWriteModel interface {
CodeCreationDate() time.Time
CodeExpiry() time.Duration
Code() *crypto.CryptoValue
CheckFailedCount() uint64
UserLocked() bool
eventstore.QueryReducer
}
type HumanOTPSMSWriteModel struct {
@@ -141,6 +160,9 @@ type HumanOTPSMSCodeWriteModel struct {
code *crypto.CryptoValue
codeCreationDate time.Time
codeExpiry time.Duration
checkFailedCount uint64
userLocked bool
}
func (wm *HumanOTPSMSCodeWriteModel) CodeCreationDate() time.Time {
@@ -155,6 +177,14 @@ func (wm *HumanOTPSMSCodeWriteModel) Code() *crypto.CryptoValue {
return wm.code
}
func (wm *HumanOTPSMSCodeWriteModel) CheckFailedCount() uint64 {
return wm.checkFailedCount
}
func (wm *HumanOTPSMSCodeWriteModel) UserLocked() bool {
return wm.userLocked
}
func NewHumanOTPSMSCodeWriteModel(userID, resourceOwner string) *HumanOTPSMSCodeWriteModel {
return &HumanOTPSMSCodeWriteModel{
HumanOTPSMSWriteModel: NewHumanOTPSMSWriteModel(userID, resourceOwner),
@@ -163,10 +193,20 @@ func NewHumanOTPSMSCodeWriteModel(userID, resourceOwner string) *HumanOTPSMSCode
func (wm *HumanOTPSMSCodeWriteModel) Reduce() error {
for _, event := range wm.Events {
if e, ok := event.(*user.HumanOTPSMSCodeAddedEvent); ok {
switch e := event.(type) {
case *user.HumanOTPSMSCodeAddedEvent:
wm.code = e.Code
wm.codeCreationDate = e.CreationDate()
wm.codeExpiry = e.Expiry
case *user.HumanOTPSMSCheckSucceededEvent:
wm.checkFailedCount = 0
case *user.HumanOTPSMSCheckFailedEvent:
wm.checkFailedCount++
case *user.UserLockedEvent:
wm.userLocked = true
case *user.UserUnlockedEvent:
wm.checkFailedCount = 0
wm.userLocked = false
}
}
return wm.HumanOTPSMSWriteModel.Reduce()
@@ -179,6 +219,10 @@ func (wm *HumanOTPSMSCodeWriteModel) Query() *eventstore.SearchQueryBuilder {
AggregateIDs(wm.AggregateID).
EventTypes(
user.HumanOTPSMSCodeAddedType,
user.HumanOTPSMSCheckSucceededType,
user.HumanOTPSMSCheckFailedType,
user.UserLockedType,
user.UserUnlockedType,
user.HumanPhoneVerifiedType,
user.HumanOTPSMSAddedType,
user.HumanOTPSMSRemovedType,
@@ -259,6 +303,9 @@ type HumanOTPEmailCodeWriteModel struct {
code *crypto.CryptoValue
codeCreationDate time.Time
codeExpiry time.Duration
checkFailedCount uint64
userLocked bool
}
func (wm *HumanOTPEmailCodeWriteModel) CodeCreationDate() time.Time {
@@ -273,6 +320,14 @@ func (wm *HumanOTPEmailCodeWriteModel) Code() *crypto.CryptoValue {
return wm.code
}
func (wm *HumanOTPEmailCodeWriteModel) CheckFailedCount() uint64 {
return wm.checkFailedCount
}
func (wm *HumanOTPEmailCodeWriteModel) UserLocked() bool {
return wm.userLocked
}
func NewHumanOTPEmailCodeWriteModel(userID, resourceOwner string) *HumanOTPEmailCodeWriteModel {
return &HumanOTPEmailCodeWriteModel{
HumanOTPEmailWriteModel: NewHumanOTPEmailWriteModel(userID, resourceOwner),
@@ -281,10 +336,20 @@ func NewHumanOTPEmailCodeWriteModel(userID, resourceOwner string) *HumanOTPEmail
func (wm *HumanOTPEmailCodeWriteModel) Reduce() error {
for _, event := range wm.Events {
if e, ok := event.(*user.HumanOTPEmailCodeAddedEvent); ok {
switch e := event.(type) {
case *user.HumanOTPEmailCodeAddedEvent:
wm.code = e.Code
wm.codeCreationDate = e.CreationDate()
wm.codeExpiry = e.Expiry
case *user.HumanOTPEmailCheckSucceededEvent:
wm.checkFailedCount = 0
case *user.HumanOTPEmailCheckFailedEvent:
wm.checkFailedCount++
case *user.UserLockedEvent:
wm.userLocked = true
case *user.UserUnlockedEvent:
wm.checkFailedCount = 0
wm.userLocked = false
}
}
return wm.HumanOTPEmailWriteModel.Reduce()
@@ -297,6 +362,10 @@ func (wm *HumanOTPEmailCodeWriteModel) Query() *eventstore.SearchQueryBuilder {
AggregateIDs(wm.AggregateID).
EventTypes(
user.HumanOTPEmailCodeAddedType,
user.HumanOTPEmailCheckSucceededType,
user.HumanOTPEmailCheckFailedType,
user.UserLockedType,
user.UserUnlockedType,
user.HumanEmailVerifiedType,
user.HumanOTPEmailAddedType,
user.HumanOTPEmailRemovedType,

View File

@@ -1671,6 +1671,15 @@ func TestCommandSide_HumanCheckOTPSMS(t *testing.T) {
),
),
),
expectFilter(), // recheck
expectFilter(
eventFromEventPusher(
org.NewLockoutPolicyAddedEvent(ctx,
&org.NewAggregate("orgID").Aggregate,
3, 3, true,
),
),
),
expectPush(
user.NewHumanOTPSMSCheckFailedEvent(ctx,
&user.NewAggregate("user1", "org1").Aggregate,
@@ -1707,6 +1716,86 @@ func TestCommandSide_HumanCheckOTPSMS(t *testing.T) {
err: zerrors.ThrowInvalidArgument(nil, "CODE-woT0xc", "Errors.User.Code.Invalid"),
},
},
{
name: "invalid code, max attempts reached, error",
fields: fields{
eventstore: expectEventstore(
expectFilter(
eventFromEventPusher(
user.NewHumanOTPSMSAddedEvent(ctx,
&user.NewAggregate("user1", "org1").Aggregate,
),
),
eventFromEventPusherWithCreationDateNow(
user.NewHumanOTPSMSCodeAddedEvent(ctx,
&user.NewAggregate("user1", "org1").Aggregate,
&crypto.CryptoValue{
CryptoType: crypto.TypeEncryption,
Algorithm: "enc",
KeyID: "id",
Crypted: []byte("other-code"),
},
time.Hour,
&user.AuthRequestInfo{
ID: "authRequestID",
UserAgentID: "userAgentID",
BrowserInfo: &user.BrowserInfo{
UserAgent: "user-agent",
AcceptLanguage: "en",
RemoteIP: net.IP{192, 0, 2, 1},
},
},
),
),
),
expectFilter(), // recheck
expectFilter(
eventFromEventPusher(
org.NewLockoutPolicyAddedEvent(ctx,
&org.NewAggregate("orgID").Aggregate,
1, 1, true,
),
),
),
expectPush(
user.NewHumanOTPSMSCheckFailedEvent(ctx,
&user.NewAggregate("user1", "org1").Aggregate,
&user.AuthRequestInfo{
ID: "authRequestID",
UserAgentID: "userAgentID",
BrowserInfo: &user.BrowserInfo{
UserAgent: "user-agent",
AcceptLanguage: "en",
RemoteIP: net.IP{192, 0, 2, 1},
},
},
),
user.NewUserLockedEvent(ctx,
&user.NewAggregate("user1", "org1").Aggregate,
),
),
),
userEncryption: crypto.CreateMockEncryptionAlg(gomock.NewController(t)),
},
args: args{
ctx: ctx,
userID: "user1",
code: "code",
resourceOwner: "org1",
authRequest: &domain.AuthRequest{
ID: "authRequestID",
AgentID: "userAgentID",
BrowserInfo: &domain.BrowserInfo{
UserAgent: "user-agent",
AcceptLanguage: "en",
RemoteIP: net.IP{192, 0, 2, 1},
},
},
},
res: res{
err: zerrors.ThrowInvalidArgument(nil, "CODE-woT0xc", "Errors.User.Code.Invalid"),
},
},
{
name: "code ok",
fields: fields{
@@ -1739,6 +1828,7 @@ func TestCommandSide_HumanCheckOTPSMS(t *testing.T) {
),
),
),
expectFilter(), // recheck
expectPush(
user.NewHumanOTPSMSCheckSucceededEvent(ctx,
&user.NewAggregate("user1", "org1").Aggregate,
@@ -1777,6 +1867,65 @@ func TestCommandSide_HumanCheckOTPSMS(t *testing.T) {
},
},
},
{
name: "code ok, locked in the meantime",
fields: fields{
eventstore: expectEventstore(
expectFilter(
eventFromEventPusher(
user.NewHumanOTPSMSAddedEvent(ctx,
&user.NewAggregate("user1", "org1").Aggregate,
),
),
eventFromEventPusherWithCreationDateNow(
user.NewHumanOTPSMSCodeAddedEvent(ctx,
&user.NewAggregate("user1", "org1").Aggregate,
&crypto.CryptoValue{
CryptoType: crypto.TypeEncryption,
Algorithm: "enc",
KeyID: "id",
Crypted: []byte("code"),
},
time.Hour,
&user.AuthRequestInfo{
ID: "authRequestID",
UserAgentID: "userAgentID",
BrowserInfo: &user.BrowserInfo{
UserAgent: "user-agent",
AcceptLanguage: "en",
RemoteIP: net.IP{192, 0, 2, 1},
},
},
),
),
),
expectFilter( // recheck
user.NewUserLockedEvent(ctx,
&user.NewAggregate("user1", "org1").Aggregate,
),
),
),
userEncryption: crypto.CreateMockEncryptionAlg(gomock.NewController(t)),
},
args: args{
ctx: ctx,
userID: "user1",
code: "code",
resourceOwner: "org1",
authRequest: &domain.AuthRequest{
ID: "authRequestID",
AgentID: "userAgentID",
BrowserInfo: &domain.BrowserInfo{
UserAgent: "user-agent",
AcceptLanguage: "en",
RemoteIP: net.IP{192, 0, 2, 1},
},
},
},
res: res{
err: zerrors.ThrowPreconditionFailed(nil, "COMMAND-S6h4R", "Errors.User.Locked"),
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@@ -2616,6 +2765,15 @@ func TestCommandSide_HumanCheckOTPEmail(t *testing.T) {
),
),
),
expectFilter(), // recheck
expectFilter(
eventFromEventPusher(
org.NewLockoutPolicyAddedEvent(ctx,
&org.NewAggregate("orgID").Aggregate,
3, 3, true,
),
),
),
expectPush(
user.NewHumanOTPEmailCheckFailedEvent(ctx,
&user.NewAggregate("user1", "org1").Aggregate,
@@ -2652,6 +2810,86 @@ func TestCommandSide_HumanCheckOTPEmail(t *testing.T) {
err: zerrors.ThrowInvalidArgument(nil, "CODE-woT0xc", "Errors.User.Code.Invalid"),
},
},
{
name: "invalid code, max attempts reached, error",
fields: fields{
eventstore: expectEventstore(
expectFilter(
eventFromEventPusher(
user.NewHumanOTPEmailAddedEvent(ctx,
&user.NewAggregate("user1", "org1").Aggregate,
),
),
eventFromEventPusherWithCreationDateNow(
user.NewHumanOTPEmailCodeAddedEvent(ctx,
&user.NewAggregate("user1", "org1").Aggregate,
&crypto.CryptoValue{
CryptoType: crypto.TypeEncryption,
Algorithm: "enc",
KeyID: "id",
Crypted: []byte("other-code"),
},
time.Hour,
&user.AuthRequestInfo{
ID: "authRequestID",
UserAgentID: "userAgentID",
BrowserInfo: &user.BrowserInfo{
UserAgent: "user-agent",
AcceptLanguage: "en",
RemoteIP: net.IP{192, 0, 2, 1},
},
},
),
),
),
expectFilter(), // recheck
expectFilter(
eventFromEventPusher(
org.NewLockoutPolicyAddedEvent(ctx,
&org.NewAggregate("orgID").Aggregate,
1, 1, true,
),
),
),
expectPush(
user.NewHumanOTPEmailCheckFailedEvent(ctx,
&user.NewAggregate("user1", "org1").Aggregate,
&user.AuthRequestInfo{
ID: "authRequestID",
UserAgentID: "userAgentID",
BrowserInfo: &user.BrowserInfo{
UserAgent: "user-agent",
AcceptLanguage: "en",
RemoteIP: net.IP{192, 0, 2, 1},
},
},
),
user.NewUserLockedEvent(ctx,
&user.NewAggregate("user1", "org1").Aggregate,
),
),
),
userEncryption: crypto.CreateMockEncryptionAlg(gomock.NewController(t)),
},
args: args{
ctx: ctx,
userID: "user1",
code: "code",
resourceOwner: "org1",
authRequest: &domain.AuthRequest{
ID: "authRequestID",
AgentID: "userAgentID",
BrowserInfo: &domain.BrowserInfo{
UserAgent: "user-agent",
AcceptLanguage: "en",
RemoteIP: net.IP{192, 0, 2, 1},
},
},
},
res: res{
err: zerrors.ThrowInvalidArgument(nil, "CODE-woT0xc", "Errors.User.Code.Invalid"),
},
},
{
name: "code ok",
fields: fields{
@@ -2684,6 +2922,7 @@ func TestCommandSide_HumanCheckOTPEmail(t *testing.T) {
),
),
),
expectFilter(), // recheck
expectPush(
user.NewHumanOTPEmailCheckSucceededEvent(ctx,
&user.NewAggregate("user1", "org1").Aggregate,
@@ -2722,6 +2961,65 @@ func TestCommandSide_HumanCheckOTPEmail(t *testing.T) {
},
},
},
{
name: "code ok, locked in the meantime",
fields: fields{
eventstore: expectEventstore(
expectFilter(
eventFromEventPusher(
user.NewHumanOTPEmailAddedEvent(ctx,
&user.NewAggregate("user1", "org1").Aggregate,
),
),
eventFromEventPusherWithCreationDateNow(
user.NewHumanOTPEmailCodeAddedEvent(ctx,
&user.NewAggregate("user1", "org1").Aggregate,
&crypto.CryptoValue{
CryptoType: crypto.TypeEncryption,
Algorithm: "enc",
KeyID: "id",
Crypted: []byte("code"),
},
time.Hour,
&user.AuthRequestInfo{
ID: "authRequestID",
UserAgentID: "userAgentID",
BrowserInfo: &user.BrowserInfo{
UserAgent: "user-agent",
AcceptLanguage: "en",
RemoteIP: net.IP{192, 0, 2, 1},
},
},
),
),
),
expectFilter( // recheck
user.NewUserLockedEvent(ctx,
&user.NewAggregate("user1", "org1").Aggregate,
),
),
),
userEncryption: crypto.CreateMockEncryptionAlg(gomock.NewController(t)),
},
args: args{
ctx: ctx,
userID: "user1",
code: "code",
resourceOwner: "org1",
authRequest: &domain.AuthRequest{
ID: "authRequestID",
AgentID: "userAgentID",
BrowserInfo: &domain.BrowserInfo{
UserAgent: "user-agent",
AcceptLanguage: "en",
RemoteIP: net.IP{192, 0, 2, 1},
},
},
},
res: res{
err: zerrors.ThrowPreconditionFailed(nil, "COMMAND-S6h4R", "Errors.User.Locked"),
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {

View File

@@ -1643,6 +1643,7 @@ func TestCommandSide_CheckPassword(t *testing.T) {
},
lockoutPolicy: &domain.LockoutPolicy{
MaxPasswordAttempts: 1,
MaxOTPAttempts: 1,
},
},
res: res{