diff --git a/cmd/defaults.yaml b/cmd/defaults.yaml index 1f609607c00..abe600ea8e6 100644 --- a/cmd/defaults.yaml +++ b/cmd/defaults.yaml @@ -740,6 +740,15 @@ SystemDefaults: # If this is empty, the issuer is the requested domain # This is helpful in scenarios with multiple ZITADEL environments or virtual instances Issuer: "ZITADEL" # ZITADEL_SYSTEMDEFAULTS_MULTIFACTORS_OTP_ISSUER + Tarpit: + # The amount of failed attempts, the tarpit should start. + MinFailedAttempts: 5 # ZITADEL_SYSTEMDEFAULTS_TARPIT_MINFAILEDATTEMPTS + # The seconds that will be added per step. + StepDuration: 1s # ZITADEL_SYSTEMDEFAULTS_TARPIT_STEPDURATION + # The failed attempts that are needed to increase the tarpit by one step. + StepSize: 5 # ZITADEL_SYSTEMDEFAULTS_TARPIT_STEPSIZE + # The maximum duration the tarpit can reach. + MaxDuration: 10s # ZITADEL_SYSTEMDEFAULTS_TARPIT_MAXDURATION DomainVerification: VerificationGenerator: Length: 32 # ZITADEL_SYSTEMDEFAULTS_DOMAINVERIFICATION_VERIFICATIONGENERATOR_LENGTH diff --git a/internal/command/command.go b/internal/command/command.go index a40038ef802..050ce66791f 100644 --- a/internal/command/command.go +++ b/internal/command/command.go @@ -70,6 +70,7 @@ type Commands struct { defaultRefreshTokenLifetime time.Duration defaultRefreshTokenIdleLifetime time.Duration phoneCodeVerifier func(ctx context.Context, id string) (senders.CodeGenerator, error) + tarpit func(failedAttempts uint64) multifactors domain.MultifactorConfigs webauthnConfig *webauthn_helper.Config @@ -214,6 +215,7 @@ func StartCommands( repo.newHashedSecret = newHashedSecretWithDefault(secretHasher, defaultSecretGenerators.ClientSecret) } repo.phoneCodeVerifier = repo.phoneCodeVerifierFromConfig + repo.tarpit = defaults.Tarpit.Tarpit() return repo, nil } diff --git a/internal/command/session.go b/internal/command/session.go index f93bc3228d8..a503910774c 100644 --- a/internal/command/session.go +++ b/internal/command/session.go @@ -43,6 +43,7 @@ type SessionCommands struct { getCodeVerifier func(ctx context.Context, id string) (senders.CodeGenerator, error) now func() time.Time maxIdPIntentLifetime time.Duration + tarpit func(failedAttempts uint64) } func (c *Commands) NewSessionCommands(cmds []SessionCommand, session *SessionWriteModel) *SessionCommands { @@ -60,6 +61,7 @@ func (c *Commands) NewSessionCommands(cmds []SessionCommand, session *SessionWri getCodeVerifier: c.phoneCodeVerifierFromConfig, now: time.Now, maxIdPIntentLifetime: c.maxIdPIntentLifetime, + tarpit: c.tarpit, } } @@ -76,7 +78,7 @@ func CheckUser(id string, resourceOwner string, preferredLanguage *language.Tag) // CheckPassword defines a password check to be executed for a session update func CheckPassword(password string) SessionCommand { return func(ctx context.Context, cmd *SessionCommands) ([]eventstore.Command, error) { - commands, err := checkPassword(ctx, cmd.sessionWriteModel.UserID, password, cmd.eventstore, cmd.hasher, nil) + commands, err := checkPassword(ctx, cmd.sessionWriteModel.UserID, password, cmd.eventstore, cmd.hasher, nil, cmd.tarpit) if err != nil { return commands, err } @@ -135,6 +137,7 @@ func CheckTOTP(code string) SessionCommand { cmd.eventstore.FilterToQueryReducer, cmd.totpAlg, nil, + cmd.tarpit, ) if err != nil { return commands, err diff --git a/internal/command/session_otp.go b/internal/command/session_otp.go index 6b5f327a2a4..b00eaf49570 100644 --- a/internal/command/session_otp.go +++ b/internal/command/session_otp.go @@ -143,6 +143,7 @@ func CheckOTPSMS(code string) SessionCommand { cmd.getCodeVerifier, succeededEvent, failedEvent, + cmd.tarpit, ) if err != nil { return commands, err @@ -183,6 +184,7 @@ func CheckOTPEmail(code string) SessionCommand { nil, // email currently always uses local code checks succeededEvent, failedEvent, + cmd.tarpit, ) if err != nil { return commands, err diff --git a/internal/command/session_otp_test.go b/internal/command/session_otp_test.go index 30559b9ccaa..8b686bcfcab 100644 --- a/internal/command/session_otp_test.go +++ b/internal/command/session_otp_test.go @@ -1037,6 +1037,9 @@ func TestCheckOTPSMS(t *testing.T) { now: func() time.Time { return testNow }, + tarpit: func(failedAttempts uint64) { + + }, } gotCmds, err := cmd(context.Background(), cmds) @@ -1053,6 +1056,7 @@ func TestCheckOTPEmail(t *testing.T) { userID string otpCodeChallenge *OTPCode otpAlg crypto.EncryptionAlgorithm + tarpit Tarpit } type args struct { code string @@ -1073,6 +1077,7 @@ func TestCheckOTPEmail(t *testing.T) { fields: fields{ eventstore: expectEventstore(), userID: "", + tarpit: expectTarpit(0), }, args: args{ code: "code", @@ -1086,6 +1091,7 @@ func TestCheckOTPEmail(t *testing.T) { fields: fields{ eventstore: expectEventstore(), userID: "userID", + tarpit: expectTarpit(0), }, args: args{}, res: res{ @@ -1099,6 +1105,7 @@ func TestCheckOTPEmail(t *testing.T) { expectFilter(), ), userID: "userID", + tarpit: expectTarpit(0), }, args: args{ code: "code", @@ -1117,6 +1124,7 @@ func TestCheckOTPEmail(t *testing.T) { ), userID: "userID", otpCodeChallenge: nil, + tarpit: expectTarpit(0), }, args: args{ code: "code", @@ -1153,6 +1161,7 @@ func TestCheckOTPEmail(t *testing.T) { CreationDate: testNow.Add(-10 * time.Minute), }, otpAlg: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + tarpit: expectTarpit(1), }, args: args{ code: "code", @@ -1192,6 +1201,7 @@ func TestCheckOTPEmail(t *testing.T) { CreationDate: testNow.Add(-10 * time.Minute), }, otpAlg: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + tarpit: expectTarpit(1), }, args: args{ code: "code", @@ -1225,6 +1235,7 @@ func TestCheckOTPEmail(t *testing.T) { CreationDate: testNow, }, otpAlg: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + tarpit: expectTarpit(0), }, args: args{ code: "code", @@ -1261,6 +1272,7 @@ func TestCheckOTPEmail(t *testing.T) { CreationDate: testNow, }, otpAlg: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + tarpit: expectTarpit(0), }, args: args{ code: "code", @@ -1289,12 +1301,39 @@ func TestCheckOTPEmail(t *testing.T) { now: func() time.Time { return testNow }, + tarpit: tt.fields.tarpit.tarpit, } gotCmds, err := cmd(context.Background(), cmds) assert.ErrorIs(t, err, tt.res.err) assert.Equal(t, tt.res.errorCommands, gotCmds) assert.Equal(t, tt.res.commands, cmds.eventCommands) + tt.fields.tarpit.metExpectedCalls(t) }) } } + +func expectTarpit(requiredFailedAttempts uint64) Tarpit { + return &mockTarpit{ + requiredFailedAttempts: requiredFailedAttempts, + } +} + +type mockTarpit struct { + requiredFailedAttempts uint64 + failedAttempts uint64 +} + +func (m *mockTarpit) tarpit(failedAttempts uint64) { + m.failedAttempts = failedAttempts +} + +func (m *mockTarpit) metExpectedCalls(t *testing.T) bool { + t.Helper() + return assert.Equalf(t, m.requiredFailedAttempts, m.failedAttempts, "tarpit was called with %d failed attempts, but %d were expected", m.failedAttempts, m.requiredFailedAttempts) +} + +type Tarpit interface { + tarpit(failedAttempts uint64) + metExpectedCalls(t *testing.T) bool +} diff --git a/internal/command/session_test.go b/internal/command/session_test.go index 133f2b7373c..2ff7dc7d391 100644 --- a/internal/command/session_test.go +++ b/internal/command/session_test.go @@ -1091,6 +1091,7 @@ func TestCheckTOTP(t *testing.T) { type fields struct { sessionWriteModel *SessionWriteModel eventstore func(*testing.T) *eventstore.Eventstore + tarpit Tarpit } tests := []struct { @@ -1109,6 +1110,7 @@ func TestCheckTOTP(t *testing.T) { aggregate: sessAgg, }, eventstore: expectEventstore(), + tarpit: expectTarpit(0), }, wantErr: zerrors.ThrowInvalidArgument(nil, "COMMAND-8N9ds", "Errors.User.UserIDMissing"), }, @@ -1124,6 +1126,7 @@ func TestCheckTOTP(t *testing.T) { eventstore: expectEventstore( expectFilterError(io.ErrClosedPipe), ), + tarpit: expectTarpit(0), }, wantErr: io.ErrClosedPipe, }, @@ -1143,6 +1146,7 @@ func TestCheckTOTP(t *testing.T) { ), ), ), + tarpit: expectTarpit(0), }, wantErr: zerrors.ThrowPreconditionFailed(nil, "COMMAND-3Mif9s", "Errors.User.MFA.OTP.NotReady"), }, @@ -1169,6 +1173,7 @@ func TestCheckTOTP(t *testing.T) { eventFromEventPusher(org.NewLockoutPolicyAddedEvent(ctx, orgAgg, 0, 0, false)), ), ), + tarpit: expectTarpit(1), }, wantErrorCommands: []eventstore.Command{ user.NewHumanOTPCheckFailedEvent(ctx, userAgg, nil), @@ -1198,6 +1203,7 @@ func TestCheckTOTP(t *testing.T) { eventFromEventPusher(org.NewLockoutPolicyAddedEvent(ctx, orgAgg, 1, 1, false)), ), ), + tarpit: expectTarpit(1), }, wantErrorCommands: []eventstore.Command{ user.NewHumanOTPCheckFailedEvent(ctx, userAgg, nil), @@ -1225,6 +1231,7 @@ func TestCheckTOTP(t *testing.T) { ), expectFilter(), // recheck ), + tarpit: expectTarpit(0), }, wantEventCommands: []eventstore.Command{ user.NewHumanOTPCheckSucceededEvent(ctx, userAgg, nil), @@ -1253,6 +1260,7 @@ func TestCheckTOTP(t *testing.T) { user.NewUserLockedEvent(ctx, userAgg), ), ), + tarpit: expectTarpit(0), }, wantErr: zerrors.ThrowPreconditionFailed(nil, "COMMAND-SF3fg", "Errors.User.Locked"), }, @@ -1264,11 +1272,13 @@ func TestCheckTOTP(t *testing.T) { eventstore: tt.fields.eventstore(t), totpAlg: cryptoAlg, now: func() time.Time { return testNow }, + tarpit: tt.fields.tarpit.tarpit, } gotCmds, err := CheckTOTP(tt.code)(ctx, cmd) require.ErrorIs(t, err, tt.wantErr) assert.Equal(t, tt.wantErrorCommands, gotCmds) assert.Equal(t, tt.wantEventCommands, cmd.eventCommands) + tt.fields.tarpit.metExpectedCalls(t) }) } } diff --git a/internal/command/user_human_otp.go b/internal/command/user_human_otp.go index 7c216b82801..714e917cc62 100644 --- a/internal/command/user_human_otp.go +++ b/internal/command/user_human_otp.go @@ -170,6 +170,7 @@ func (c *Commands) HumanCheckMFATOTP(ctx context.Context, userID, code, resource c.eventstore.FilterToQueryReducer, c.multifactors.OTP.CryptoMFA, authRequestDomainToAuthRequestInfo(authRequest), + c.tarpit, ) _, pushErr := c.eventstore.Push(ctx, commands...) @@ -183,6 +184,7 @@ func checkTOTP( queryReducer func(ctx context.Context, r eventstore.QueryReducer) error, alg crypto.EncryptionAlgorithm, optionalAuthRequestInfo *user.AuthRequestInfo, + tarpit func(failedAttempts uint64), ) ([]eventstore.Command, error) { if userID == "" { return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-8N9ds", "Errors.User.UserIDMissing") @@ -222,6 +224,7 @@ func checkTOTP( if lockoutPolicy.MaxOTPAttempts > 0 && existingOTP.CheckFailedCount+1 >= lockoutPolicy.MaxOTPAttempts { commands = append(commands, user.NewUserLockedEvent(ctx, userAgg)) } + tarpit(existingOTP.CheckFailedCount + 1) return commands, verifyErr } @@ -374,6 +377,7 @@ func (c *Commands) HumanCheckOTPSMS(ctx context.Context, userID, code, resourceO c.phoneCodeVerifier, succeededEvent, failedEvent, + c.tarpit, ) if len(commands) > 0 { _, pushErr := c.eventstore.Push(ctx, commands...) @@ -508,6 +512,7 @@ func (c *Commands) HumanCheckOTPEmail(ctx context.Context, userID, code, resourc nil, // email currently always uses local code checks succeededEvent, failedEvent, + c.tarpit, ) if len(commands) > 0 { _, pushErr := c.eventstore.Push(ctx, commands...) @@ -576,6 +581,7 @@ func checkOTP( alg crypto.EncryptionAlgorithm, getCodeVerifier func(ctx context.Context, id string) (senders.CodeGenerator, error), checkSucceededEvent, checkFailedEvent func(ctx context.Context, aggregate *eventstore.Aggregate, info *user.AuthRequestInfo) eventstore.Command, + tarpit func(failedAttempts uint64), ) ([]eventstore.Command, error) { if userID == "" { return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-S453v", "Errors.User.UserIDMissing") @@ -627,6 +633,7 @@ func checkOTP( if lockoutPolicy != nil && lockoutPolicy.MaxOTPAttempts > 0 && existingOTP.CheckFailedCount()+1 >= lockoutPolicy.MaxOTPAttempts { commands = append(commands, user.NewUserLockedEvent(ctx, userAgg)) } + tarpit(existingOTP.CheckFailedCount() + 1) return commands, verifyErr } diff --git a/internal/command/user_human_otp_test.go b/internal/command/user_human_otp_test.go index 330025cf37e..bf93cebd345 100644 --- a/internal/command/user_human_otp_test.go +++ b/internal/command/user_human_otp_test.go @@ -1940,6 +1940,7 @@ func TestCommandSide_HumanCheckOTPSMS(t *testing.T) { eventstore func(*testing.T) *eventstore.Eventstore userEncryption crypto.EncryptionAlgorithm phoneCodeVerifier func(ctx context.Context, id string) (senders.CodeGenerator, error) + tarpit Tarpit } type ( args struct { @@ -1964,6 +1965,7 @@ func TestCommandSide_HumanCheckOTPSMS(t *testing.T) { name: "userid missing, invalid argument error", fields: fields{ eventstore: expectEventstore(), + tarpit: expectTarpit(0), }, args: args{ ctx: ctx, @@ -1979,6 +1981,7 @@ func TestCommandSide_HumanCheckOTPSMS(t *testing.T) { name: "code missing, invalid argument error", fields: fields{ eventstore: expectEventstore(), + tarpit: expectTarpit(0), }, args: args{ ctx: ctx, @@ -1996,6 +1999,7 @@ func TestCommandSide_HumanCheckOTPSMS(t *testing.T) { eventstore: expectEventstore( expectFilter(), ), + tarpit: expectTarpit(0), }, args: args{ ctx: ctx, @@ -2019,6 +2023,7 @@ func TestCommandSide_HumanCheckOTPSMS(t *testing.T) { ), ), ), + tarpit: expectTarpit(0), }, args: args{ ctx: ctx, @@ -2088,6 +2093,7 @@ func TestCommandSide_HumanCheckOTPSMS(t *testing.T) { ), ), userEncryption: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + tarpit: expectTarpit(1), }, args: args{ ctx: ctx, @@ -2169,6 +2175,7 @@ func TestCommandSide_HumanCheckOTPSMS(t *testing.T) { ), ), userEncryption: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + tarpit: expectTarpit(1), }, args: args{ ctx: ctx, @@ -2239,6 +2246,7 @@ func TestCommandSide_HumanCheckOTPSMS(t *testing.T) { ), ), userEncryption: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + tarpit: expectTarpit(0), }, args: args{ ctx: ctx, @@ -2301,6 +2309,7 @@ func TestCommandSide_HumanCheckOTPSMS(t *testing.T) { ), ), userEncryption: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + tarpit: expectTarpit(0), }, args: args{ ctx: ctx, @@ -2380,6 +2389,7 @@ func TestCommandSide_HumanCheckOTPSMS(t *testing.T) { sender.EXPECT().VerifyCode("verificationID", "code").Return(nil) return sender, nil }, + tarpit: expectTarpit(0), }, args: args{ ctx: ctx, @@ -2409,9 +2419,11 @@ func TestCommandSide_HumanCheckOTPSMS(t *testing.T) { eventstore: tt.fields.eventstore(t), userEncryption: tt.fields.userEncryption, phoneCodeVerifier: tt.fields.phoneCodeVerifier, + tarpit: tt.fields.tarpit.tarpit, } err := r.HumanCheckOTPSMS(tt.args.ctx, tt.args.userID, tt.args.code, tt.args.resourceOwner, tt.args.authRequest) assert.ErrorIs(t, err, tt.res.err) + tt.fields.tarpit.metExpectedCalls(t) }) } } @@ -3115,6 +3127,7 @@ func TestCommandSide_HumanCheckOTPEmail(t *testing.T) { type fields struct { eventstore func(*testing.T) *eventstore.Eventstore userEncryption crypto.EncryptionAlgorithm + tarpit Tarpit } type ( args struct { @@ -3139,6 +3152,7 @@ func TestCommandSide_HumanCheckOTPEmail(t *testing.T) { name: "userid missing, invalid argument error", fields: fields{ eventstore: expectEventstore(), + tarpit: expectTarpit(0), }, args: args{ ctx: ctx, @@ -3154,6 +3168,7 @@ func TestCommandSide_HumanCheckOTPEmail(t *testing.T) { name: "code missing, invalid argument error", fields: fields{ eventstore: expectEventstore(), + tarpit: expectTarpit(0), }, args: args{ ctx: ctx, @@ -3171,6 +3186,7 @@ func TestCommandSide_HumanCheckOTPEmail(t *testing.T) { eventstore: expectEventstore( expectFilter(), ), + tarpit: expectTarpit(0), }, args: args{ ctx: ctx, @@ -3194,6 +3210,7 @@ func TestCommandSide_HumanCheckOTPEmail(t *testing.T) { ), ), ), + tarpit: expectTarpit(0), }, args: args{ ctx: ctx, @@ -3262,6 +3279,7 @@ func TestCommandSide_HumanCheckOTPEmail(t *testing.T) { ), ), userEncryption: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + tarpit: expectTarpit(1), }, args: args{ ctx: ctx, @@ -3342,6 +3360,7 @@ func TestCommandSide_HumanCheckOTPEmail(t *testing.T) { ), ), userEncryption: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + tarpit: expectTarpit(1), }, args: args{ ctx: ctx, @@ -3411,6 +3430,7 @@ func TestCommandSide_HumanCheckOTPEmail(t *testing.T) { ), ), userEncryption: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + tarpit: expectTarpit(0), }, args: args{ ctx: ctx, @@ -3472,6 +3492,7 @@ func TestCommandSide_HumanCheckOTPEmail(t *testing.T) { ), ), userEncryption: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + tarpit: expectTarpit(0), }, args: args{ ctx: ctx, @@ -3498,9 +3519,11 @@ func TestCommandSide_HumanCheckOTPEmail(t *testing.T) { r := &Commands{ eventstore: tt.fields.eventstore(t), userEncryption: tt.fields.userEncryption, + tarpit: tt.fields.tarpit.tarpit, } err := r.HumanCheckOTPEmail(tt.args.ctx, tt.args.userID, tt.args.code, tt.args.resourceOwner, tt.args.authRequest) assert.ErrorIs(t, err, tt.res.err) + tt.fields.tarpit.metExpectedCalls(t) }) } } diff --git a/internal/command/user_human_password.go b/internal/command/user_human_password.go index ad0123f1a01..c4daafd3fed 100644 --- a/internal/command/user_human_password.go +++ b/internal/command/user_human_password.go @@ -103,7 +103,7 @@ func (c *Commands) ChangePassword(ctx context.Context, orgID, userID, oldPasswor "", userAgentID, changeRequired, - c.checkCurrentPassword(newPassword, "", oldPassword, wm.EncodedHash), + c.checkCurrentPassword(newPassword, "", oldPassword, wm, c.tarpit), ) } @@ -140,23 +140,49 @@ func (c *Commands) setPasswordWithVerifyCode( } } +type HumanPasswordCheckWriteModel interface { + GetUserState() domain.UserState + GetPasswordCheckFailedCount() uint64 + GetEncodedHash() string + GetResourceOwner() string + GetWriteModel() *eventstore.WriteModel + eventstore.QueryReducer +} + // checkCurrentPassword returns a password check as [setPasswordVerification] implementation func (c *Commands) checkCurrentPassword( - newPassword, newEncodedPassword, currentPassword, currentEncodePassword string, + newPassword, newEncodedPassword, currentPassword string, + wm HumanPasswordCheckWriteModel, + tarpit func(failedAttempts uint64), ) setPasswordVerification { - // in case the new password is already encoded, we only need to verify the current - if newEncodedPassword != "" { - return func(ctx context.Context) (_ string, err error) { - _, spanPasswap := tracing.NewNamedSpan(ctx, "passwap.Verify") - _, err = c.userPasswordHasher.Verify(currentEncodePassword, currentPassword) - spanPasswap.EndWithError(err) - return "", convertPasswapErr(err) + return func(ctx context.Context) (_ string, err error) { + verify := func(hash, password string) (string, error) { + // in case the new password is already encoded, we only need to verify the current + if newEncodedPassword != "" { + _, spanPasswap := tracing.NewNamedSpan(ctx, "passwap.Verify") + _, err = c.userPasswordHasher.Verify(hash, password) + spanPasswap.EndWithError(err) + return "", convertPasswapErr(err) + } + // otherwise, let's directly verify and return the new generated hash, so we can reuse it in the event + return c.verifyAndUpdatePassword(ctx, hash, password, newPassword) } - } - - // otherwise let's directly verify and return the new generate hash, so we can reuse it in the event - return func(ctx context.Context) (string, error) { - return c.verifyAndUpdatePassword(ctx, currentEncodePassword, currentPassword, newPassword) + commands, updated, err := verifyPasswordWithLockoutPolicy(ctx, wm, currentPassword, c.eventstore, verify, nil, tarpit) + // The verification was successful, and we might have an updated hash. + if err == nil { + return updated, nil + } + // If we get here, the verification failed, either due to a precondition (e.g. user not found or locked) + // or due to a wrong password. + // If the former, we just return the error. + if len(commands) == 0 { + return "", err + } + // If the latter, there's at least a failed password check event to push or additionally a lock event. + // We push these events, but return the original error (which might contain details about the failed attempts). + _, pushErr := c.eventstore.Push(ctx, commands...) + logging.OnError(pushErr).Error("error create password check failed event") + return "", err } } @@ -344,7 +370,11 @@ func (c *Commands) HumanCheckPassword(ctx context.Context, orgID, userID, passwo if !loginPolicy.AllowUsernamePassword { return zerrors.ThrowPreconditionFailed(err, "COMMAND-Dft32", "Errors.Org.LoginPolicy.UsernamePasswordNotAllowed") } - commands, err := checkPassword(ctx, userID, password, c.eventstore, c.userPasswordHasher, authRequestDomainToAuthRequestInfo(authRequest)) + var tarpit func(failedAttempts uint64) + if !loginPolicy.IgnoreUnknownUsernames { + tarpit = c.tarpit + } + commands, err := checkPassword(ctx, userID, password, c.eventstore, c.userPasswordHasher, authRequestDomainToAuthRequestInfo(authRequest), tarpit) if len(commands) == 0 { return err } @@ -353,7 +383,7 @@ func (c *Commands) HumanCheckPassword(ctx context.Context, orgID, userID, passwo return err } -func checkPassword(ctx context.Context, userID, password string, es *eventstore.Eventstore, hasher *crypto.Hasher, optionalAuthRequestInfo *user.AuthRequestInfo) ([]eventstore.Command, error) { +func checkPassword(ctx context.Context, userID, password string, es *eventstore.Eventstore, hasher *crypto.Hasher, optionalAuthRequestInfo *user.AuthRequestInfo, tarpit func(failedAttempts uint64)) ([]eventstore.Command, error) { if userID == "" { return nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-Sfw3f", "Errors.User.UserIDMissing") } @@ -362,36 +392,49 @@ func checkPassword(ctx context.Context, userID, password string, es *eventstore. if err != nil { return nil, err } - if !wm.UserState.Exists() { - return nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-3n77z", "Errors.User.NotFound") + commands, _, err := verifyPasswordWithLockoutPolicy(ctx, wm, password, es, hasher.Verify, optionalAuthRequestInfo, tarpit) + return commands, err +} + +func verifyPasswordWithLockoutPolicy( + ctx context.Context, + wm HumanPasswordCheckWriteModel, + password string, + es *eventstore.Eventstore, + verify func(hash string, password string) (newHash string, err error), + optionalAuthRequestInfo *user.AuthRequestInfo, + tarpit func(failedAttempts uint64), +) ([]eventstore.Command, string, error) { + if !wm.GetUserState().Exists() { + return nil, "", zerrors.ThrowPreconditionFailed(nil, "COMMAND-3n77z", "Errors.User.NotFound") } - if wm.UserState == domain.UserStateLocked { + if wm.GetUserState() == domain.UserStateLocked { wrongPasswordError := &commandErrors.WrongPasswordError{ - FailedAttempts: int32(wm.PasswordCheckFailedCount), + FailedAttempts: int32(wm.GetPasswordCheckFailedCount()), } - return nil, zerrors.ThrowPreconditionFailed(wrongPasswordError, "COMMAND-JLK35", "Errors.User.Locked") + return nil, "", zerrors.ThrowPreconditionFailed(wrongPasswordError, "COMMAND-JLK35", "Errors.User.Locked") } - if wm.EncodedHash == "" { - return nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-3nJ4t", "Errors.User.Password.NotSet") + if wm.GetEncodedHash() == "" { + return nil, "", zerrors.ThrowPreconditionFailed(nil, "COMMAND-3nJ4t", "Errors.User.Password.NotSet") } - userAgg := UserAggregateFromWriteModel(&wm.WriteModel) + userAgg := UserAggregateFromWriteModel(wm.GetWriteModel()) ctx, spanPasswordComparison := tracing.NewNamedSpan(ctx, "passwap.Verify") - updated, err := hasher.Verify(wm.EncodedHash, password) + updated, err := verify(wm.GetEncodedHash(), password) spanPasswordComparison.EndWithError(err) - err = convertLoginPasswapErr(wm.PasswordCheckFailedCount+1, err) + err = convertLoginPasswapErr(wm.GetPasswordCheckFailedCount()+1, err) commands := make([]eventstore.Command, 0, 2) // recheck for additional events (failed password checks or locks) recheckErr := es.FilterToQueryReducer(ctx, wm) if recheckErr != nil { - return nil, recheckErr + return nil, "", recheckErr } - if wm.UserState == domain.UserStateLocked { + if wm.GetUserState() == domain.UserStateLocked { wrongPasswordError := &commandErrors.WrongPasswordError{ - FailedAttempts: int32(wm.PasswordCheckFailedCount), + FailedAttempts: int32(wm.GetPasswordCheckFailedCount()), } - return nil, zerrors.ThrowPreconditionFailed(wrongPasswordError, "COMMAND-SFA3t", "Errors.User.Locked") + return nil, "", zerrors.ThrowPreconditionFailed(wrongPasswordError, "COMMAND-SFA3t", "Errors.User.Locked") } if err == nil { @@ -399,17 +442,23 @@ func checkPassword(ctx context.Context, userID, password string, es *eventstore. if updated != "" { commands = append(commands, user.NewHumanPasswordHashUpdatedEvent(ctx, userAgg, updated)) } - return commands, nil + return commands, updated, nil } commands = append(commands, user.NewHumanPasswordCheckFailedEvent(ctx, userAgg, optionalAuthRequestInfo)) - lockoutPolicy, lockoutErr := getLockoutPolicy(ctx, wm.ResourceOwner, es.FilterToQueryReducer) + lockoutPolicy, lockoutErr := getLockoutPolicy(ctx, wm.GetResourceOwner(), es.FilterToQueryReducer) logging.OnError(lockoutErr).Error("unable to get lockout policy") - if lockoutPolicy != nil && lockoutPolicy.MaxPasswordAttempts > 0 && wm.PasswordCheckFailedCount+1 >= lockoutPolicy.MaxPasswordAttempts { + if lockoutPolicy != nil && lockoutPolicy.MaxPasswordAttempts > 0 && wm.GetPasswordCheckFailedCount()+1 >= lockoutPolicy.MaxPasswordAttempts { commands = append(commands, user.NewUserLockedEvent(ctx, userAgg)) } - return commands, err + // in case the login policy ignores unknown usernames, + // we do not slow down the response time with a tarpit + // since this would leak the user existence + if tarpit != nil { + tarpit(wm.GetPasswordCheckFailedCount() + 1) + } + return commands, "", err } func (c *Commands) passwordWriteModel(ctx context.Context, userID, resourceOwner string) (writeModel *HumanPasswordWriteModel, err error) { diff --git a/internal/command/user_human_password_model.go b/internal/command/user_human_password_model.go index e43731d866b..e08552080a9 100644 --- a/internal/command/user_human_password_model.go +++ b/internal/command/user_human_password_model.go @@ -25,6 +25,26 @@ type HumanPasswordWriteModel struct { UserState domain.UserState } +func (wm *HumanPasswordWriteModel) GetUserState() domain.UserState { + return wm.UserState +} + +func (wm *HumanPasswordWriteModel) GetPasswordCheckFailedCount() uint64 { + return wm.PasswordCheckFailedCount +} + +func (wm *HumanPasswordWriteModel) GetEncodedHash() string { + return wm.EncodedHash +} + +func (wm *HumanPasswordWriteModel) GetResourceOwner() string { + return wm.ResourceOwner +} + +func (wm *HumanPasswordWriteModel) GetWriteModel() *eventstore.WriteModel { + return &wm.WriteModel +} + func NewHumanPasswordWriteModel(userID, resourceOwner string) *HumanPasswordWriteModel { return &HumanPasswordWriteModel{ WriteModel: eventstore.WriteModel{ diff --git a/internal/command/user_human_password_test.go b/internal/command/user_human_password_test.go index 9eee9d5b932..ca0ed6bb673 100644 --- a/internal/command/user_human_password_test.go +++ b/internal/command/user_human_password_test.go @@ -760,6 +760,7 @@ func TestCommandSide_SetPasswordWithVerifyCode(t *testing.T) { func TestCommandSide_ChangePassword(t *testing.T) { type fields struct { userPasswordHasher *crypto.Hasher + tarpit Tarpit } type args struct { ctx context.Context @@ -782,8 +783,10 @@ func TestCommandSide_ChangePassword(t *testing.T) { res res }{ { - name: "userid missing, invalid argument error", - fields: fields{}, + name: "userid missing, invalid argument error", + fields: fields{ + tarpit: expectTarpit(0), + }, args: args{ ctx: context.Background(), oldPassword: "password", @@ -796,8 +799,10 @@ func TestCommandSide_ChangePassword(t *testing.T) { }, }, { - name: "old password missing, invalid argument error", - fields: fields{}, + name: "old password missing, invalid argument error", + fields: fields{ + tarpit: expectTarpit(0), + }, args: args{ ctx: context.Background(), userID: "user1", @@ -810,8 +815,10 @@ func TestCommandSide_ChangePassword(t *testing.T) { }, }, { - name: "new password missing, invalid argument error", - fields: fields{}, + name: "new password missing, invalid argument error", + fields: fields{ + tarpit: expectTarpit(0), + }, args: args{ ctx: context.Background(), userID: "user1", @@ -824,8 +831,10 @@ func TestCommandSide_ChangePassword(t *testing.T) { }, }, { - name: "user not existing, precondition error", - fields: fields{}, + name: "user not existing, precondition error", + fields: fields{ + tarpit: expectTarpit(0), + }, args: args{ ctx: context.Background(), userID: "user1", @@ -844,6 +853,7 @@ func TestCommandSide_ChangePassword(t *testing.T) { name: "existing password empty, precondition error", fields: fields{ userPasswordHasher: mockPasswordHasher("x"), + tarpit: expectTarpit(0), }, args: args{ ctx: context.Background(), @@ -878,6 +888,7 @@ func TestCommandSide_ChangePassword(t *testing.T) { name: "password not matching complexity policy, invalid argument error", fields: fields{ userPasswordHasher: mockPasswordHasher("x"), + tarpit: expectTarpit(0), }, args: args{ ctx: context.Background(), @@ -914,6 +925,7 @@ func TestCommandSide_ChangePassword(t *testing.T) { false, "")), ), + expectFilter(), // recheck of user locking relevant events expectFilter( eventFromEventPusher( org.NewPasswordComplexityPolicyAddedEvent( @@ -936,6 +948,7 @@ func TestCommandSide_ChangePassword(t *testing.T) { name: "password not matching, invalid argument error", fields: fields{ userPasswordHasher: mockPasswordHasher("x"), + tarpit: expectTarpit(1), }, args: args{ ctx: context.Background(), @@ -972,6 +985,89 @@ func TestCommandSide_ChangePassword(t *testing.T) { false, "")), ), + expectFilter(), // recheck of user locking relevant events + expectFilter( + eventFromEventPusher( + org.NewLockoutPolicyAddedEvent(context.Background(), + &org.NewAggregate("org1").Aggregate, + 0, + 0, + false, + ), + ), + ), + expectPush( + user.NewHumanPasswordCheckFailedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + nil, + ), + ), + }, + res: res{ + err: zerrors.IsErrorInvalidArgument, + }, + }, + { + name: "password not matching, lockout policy active, invalid argument error", + fields: fields{ + userPasswordHasher: mockPasswordHasher("x"), + tarpit: expectTarpit(1), + }, + args: args{ + ctx: context.Background(), + userID: "user1", + oldPassword: "password-old", + newPassword: "password1", + resourceOwner: "org1", + }, + expect: []expect{ + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "username", + "firstname", + "lastname", + "nickname", + "displayname", + language.German, + domain.GenderUnspecified, + "email@test.ch", + true, + ), + ), + eventFromEventPusher( + user.NewHumanEmailVerifiedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + ), + ), + eventFromEventPusher( + user.NewHumanPasswordChangedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "$plain$x$password", + false, + "")), + ), + expectFilter(), // recheck of user locking relevant events + expectFilter( + eventFromEventPusher( + org.NewLockoutPolicyAddedEvent(context.Background(), + &org.NewAggregate("org1").Aggregate, + 1, + 0, + false, + ), + ), + ), + expectPush( + user.NewHumanPasswordCheckFailedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + nil, + ), + user.NewUserLockedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + ), + ), }, res: res{ err: zerrors.IsErrorInvalidArgument, @@ -981,6 +1077,7 @@ func TestCommandSide_ChangePassword(t *testing.T) { name: "change password, ok", fields: fields{ userPasswordHasher: mockPasswordHasher("x"), + tarpit: expectTarpit(0), }, args: args{ ctx: context.Background(), @@ -1017,6 +1114,7 @@ func TestCommandSide_ChangePassword(t *testing.T) { false, "")), ), + expectFilter(), // recheck of user locking relevant events expectFilter( eventFromEventPusher( org.NewPasswordComplexityPolicyAddedEvent(context.Background(), @@ -1048,6 +1146,7 @@ func TestCommandSide_ChangePassword(t *testing.T) { name: "change password with userAgentID, ok", fields: fields{ userPasswordHasher: mockPasswordHasher("x"), + tarpit: expectTarpit(0), }, args: args{ ctx: context.Background(), @@ -1085,6 +1184,7 @@ func TestCommandSide_ChangePassword(t *testing.T) { false, "")), ), + expectFilter(), // recheck of user locking relevant events expectFilter( eventFromEventPusher( org.NewPasswordComplexityPolicyAddedEvent(context.Background(), @@ -1116,6 +1216,7 @@ func TestCommandSide_ChangePassword(t *testing.T) { name: "change password with changeRequired, ok", fields: fields{ userPasswordHasher: mockPasswordHasher("x"), + tarpit: expectTarpit(0), }, args: args{ ctx: context.Background(), @@ -1154,6 +1255,7 @@ func TestCommandSide_ChangePassword(t *testing.T) { false, "")), ), + expectFilter(), // recheck of user locking relevant events expectFilter( eventFromEventPusher( org.NewPasswordComplexityPolicyAddedEvent(context.Background(), @@ -1185,8 +1287,9 @@ func TestCommandSide_ChangePassword(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Commands{ - eventstore: eventstoreExpect(t, tt.expect...), + eventstore: expectEventstore(tt.expect...)(t), userPasswordHasher: tt.fields.userPasswordHasher, + tarpit: tt.fields.tarpit.tarpit, } got, err := r.ChangePassword(tt.args.ctx, tt.args.resourceOwner, tt.args.userID, tt.args.oldPassword, tt.args.newPassword, tt.args.userAgentID, tt.args.changeRequired) if tt.res.err == nil { @@ -1198,6 +1301,7 @@ func TestCommandSide_ChangePassword(t *testing.T) { if tt.res.err == nil { assertObjectDetails(t, tt.res.want, got) } + tt.fields.tarpit.metExpectedCalls(t) }) } } @@ -1597,6 +1701,7 @@ func TestCommandSide_CheckPassword(t *testing.T) { type fields struct { eventstore func(*testing.T) *eventstore.Eventstore userPasswordHasher *crypto.Hasher + tarpit Tarpit } type args struct { ctx context.Context @@ -1618,6 +1723,7 @@ func TestCommandSide_CheckPassword(t *testing.T) { name: "userid missing, invalid argument error", fields: fields{ eventstore: expectEventstore(), + tarpit: expectTarpit(0), }, args: args{ ctx: context.Background(), @@ -1632,6 +1738,7 @@ func TestCommandSide_CheckPassword(t *testing.T) { name: "password missing, invalid argument error", fields: fields{ eventstore: expectEventstore(), + tarpit: expectTarpit(0), }, args: args{ ctx: context.Background(), @@ -1649,6 +1756,7 @@ func TestCommandSide_CheckPassword(t *testing.T) { expectFilter(), expectFilter(), ), + tarpit: expectTarpit(0), }, args: args{ ctx: context.Background(), @@ -1689,6 +1797,7 @@ func TestCommandSide_CheckPassword(t *testing.T) { ), ), ), + tarpit: expectTarpit(0), }, args: args{ ctx: context.Background(), @@ -1730,6 +1839,8 @@ func TestCommandSide_CheckPassword(t *testing.T) { ), expectFilter(), ), + userPasswordHasher: mockPasswordHasher("x"), + tarpit: expectTarpit(0), }, args: args{ ctx: context.Background(), @@ -1791,6 +1902,8 @@ func TestCommandSide_CheckPassword(t *testing.T) { ), ), ), + userPasswordHasher: mockPasswordHasher("x"), + tarpit: expectTarpit(0), }, args: args{ ctx: context.Background(), @@ -1848,6 +1961,7 @@ func TestCommandSide_CheckPassword(t *testing.T) { ), ), userPasswordHasher: mockPasswordHasher("x"), + tarpit: expectTarpit(0), }, args: args{ ctx: context.Background(), @@ -1933,6 +2047,97 @@ func TestCommandSide_CheckPassword(t *testing.T) { ), ), userPasswordHasher: mockPasswordHasher("x"), + tarpit: expectTarpit(1), + }, + args: args{ + ctx: context.Background(), + userID: "user1", + password: "password1", + resourceOwner: "org1", + authReq: &domain.AuthRequest{ + ID: "request1", + AgentID: "agent1", + }, + }, + res: res{ + err: zerrors.IsErrorInvalidArgument, + }, + }, + { + name: "password not matching, ignore unknow usernames (no tarpit), precondition error", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + org.NewLoginPolicyAddedEvent(context.Background(), + &org.NewAggregate("org1").Aggregate, + true, + false, + false, + false, + false, + false, + true, + false, + false, + false, + domain.PasswordlessTypeNotAllowed, + "", + time.Hour*1, + time.Hour*2, + time.Hour*3, + time.Hour*4, + time.Hour*5, + ), + ), + ), + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "username", + "firstname", + "lastname", + "nickname", + "displayname", + language.German, + domain.GenderUnspecified, + "email@test.ch", + true, + ), + ), + eventFromEventPusher( + user.NewHumanEmailVerifiedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + ), + ), + eventFromEventPusher( + user.NewHumanPasswordChangedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "$plain$x$password", + false, + "")), + ), + expectFilter(), + expectFilter( + eventFromEventPusher( + org.NewLockoutPolicyAddedEvent(context.Background(), + &org.NewAggregate("org1").Aggregate, + 0, 0, false, + )), + ), + expectPush( + user.NewHumanPasswordCheckFailedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + &user.AuthRequestInfo{ + ID: "request1", + UserAgentID: "agent1", + }, + ), + ), + ), + userPasswordHasher: mockPasswordHasher("x"), + tarpit: expectTarpit(0), }, args: args{ ctx: context.Background(), @@ -2026,6 +2231,7 @@ func TestCommandSide_CheckPassword(t *testing.T) { ), ), userPasswordHasher: mockPasswordHasher("x"), + tarpit: expectTarpit(1), }, args: args{ ctx: context.Background(), @@ -2108,6 +2314,7 @@ func TestCommandSide_CheckPassword(t *testing.T) { ), ), userPasswordHasher: mockPasswordHasher("x"), + tarpit: expectTarpit(0), }, args: args{ ctx: context.Background(), @@ -2193,6 +2400,7 @@ func TestCommandSide_CheckPassword(t *testing.T) { ), ), userPasswordHasher: mockPasswordHasher("x"), + tarpit: expectTarpit(0), }, args: args{ ctx: context.Background(), @@ -2270,6 +2478,7 @@ func TestCommandSide_CheckPassword(t *testing.T) { ), ), userPasswordHasher: mockPasswordHasher("x"), + tarpit: expectTarpit(0), }, args: args{ ctx: context.Background(), @@ -2366,6 +2575,7 @@ func TestCommandSide_CheckPassword(t *testing.T) { ), ), userPasswordHasher: mockPasswordHasher("x"), + tarpit: expectTarpit(0), }, args: args{ ctx: context.Background(), @@ -2385,6 +2595,7 @@ func TestCommandSide_CheckPassword(t *testing.T) { r := &Commands{ eventstore: tt.fields.eventstore(t), userPasswordHasher: tt.fields.userPasswordHasher, + tarpit: tt.fields.tarpit.tarpit, } err := r.HumanCheckPassword(tt.args.ctx, tt.args.resourceOwner, tt.args.userID, tt.args.password, tt.args.authReq) if tt.res.err == nil { @@ -2393,6 +2604,7 @@ func TestCommandSide_CheckPassword(t *testing.T) { if tt.res.err != nil && !tt.res.err(err) { t.Errorf("got wrong err: %v ", err) } + tt.fields.tarpit.metExpectedCalls(t) }) } } diff --git a/internal/command/user_v2_human.go b/internal/command/user_v2_human.go index 558ad345889..34030820af9 100644 --- a/internal/command/user_v2_human.go +++ b/internal/command/user_v2_human.go @@ -488,7 +488,7 @@ func (c *Commands) changeUserPassword(ctx context.Context, cmds []eventstore.Com } // ...or old password if password.OldPassword != "" { - verification = c.checkCurrentPassword(password.Password, password.EncodedPasswordHash, password.OldPassword, wm.PasswordEncodedHash) + verification = c.checkCurrentPassword(password.Password, password.EncodedPasswordHash, password.OldPassword, wm, c.tarpit) } cmd, err := c.setPasswordCommand( ctx, diff --git a/internal/command/user_v2_human_test.go b/internal/command/user_v2_human_test.go index 767e1474d86..05a57b2e3f3 100644 --- a/internal/command/user_v2_human_test.go +++ b/internal/command/user_v2_human_test.go @@ -2100,6 +2100,7 @@ func TestCommandSide_ChangeUserHuman(t *testing.T) { checkPermission domain.PermissionCheck defaultSecretGenerators *SecretGenerators defaultEmailCodeURLTemplate func(ctx context.Context) string + tarpit Tarpit } type args struct { ctx context.Context @@ -2135,6 +2136,7 @@ func TestCommandSide_ChangeUserHuman(t *testing.T) { expectFilter(), ), checkPermission: newMockPermissionCheckAllowed(), + tarpit: expectTarpit(0), }, args: args{ ctx: context.Background(), @@ -2160,6 +2162,7 @@ func TestCommandSide_ChangeUserHuman(t *testing.T) { ), ), checkPermission: newMockPermissionCheckNotAllowed(), + tarpit: expectTarpit(0), }, args: args{ ctx: context.Background(), @@ -2181,6 +2184,7 @@ func TestCommandSide_ChangeUserHuman(t *testing.T) { expectFilter(), ), checkPermission: newMockPermissionCheckAllowed(), + tarpit: expectTarpit(0), }, args: args{ ctx: context.Background(), @@ -2224,6 +2228,7 @@ func TestCommandSide_ChangeUserHuman(t *testing.T) { ), ), checkPermission: newMockPermissionCheckAllowed(), + tarpit: expectTarpit(0), }, args: args{ ctx: context.Background(), @@ -2251,6 +2256,7 @@ func TestCommandSide_ChangeUserHuman(t *testing.T) { ), ), checkPermission: newMockPermissionCheckAllowed(), + tarpit: expectTarpit(0), }, args: args{ ctx: context.Background(), @@ -2278,6 +2284,7 @@ func TestCommandSide_ChangeUserHuman(t *testing.T) { ), ), checkPermission: newMockPermissionCheckNotAllowed(), + tarpit: expectTarpit(0), }, args: args{ ctx: context.Background(), @@ -2326,6 +2333,7 @@ func TestCommandSide_ChangeUserHuman(t *testing.T) { ), ), checkPermission: newMockPermissionCheckAllowed(), + tarpit: expectTarpit(0), }, args: args{ ctx: context.Background(), @@ -2360,6 +2368,7 @@ func TestCommandSide_ChangeUserHuman(t *testing.T) { ), ), checkPermission: newMockPermissionCheckAllowed(), + tarpit: expectTarpit(0), }, args: args{ ctx: context.Background(), @@ -2415,6 +2424,7 @@ func TestCommandSide_ChangeUserHuman(t *testing.T) { checkPermission: newMockPermissionCheckAllowed(), newCode: mockEncryptedCode("emailCode", time.Hour), defaultEmailCodeURLTemplate: func(ctx context.Context) string { return "http://example.com/{{.user}}/email/{{.code}}" }, + tarpit: expectTarpit(0), }, args: args{ ctx: context.Background(), @@ -2445,6 +2455,7 @@ func TestCommandSide_ChangeUserHuman(t *testing.T) { ), ), checkPermission: newMockPermissionCheckAllowed(), + tarpit: expectTarpit(0), }, args: args{ ctx: context.Background(), @@ -2474,6 +2485,7 @@ func TestCommandSide_ChangeUserHuman(t *testing.T) { ), ), checkPermission: newMockPermissionCheckNotAllowed(), + tarpit: expectTarpit(0), }, args: args{ ctx: context.Background(), @@ -2511,6 +2523,7 @@ func TestCommandSide_ChangeUserHuman(t *testing.T) { ), ), checkPermission: newMockPermissionCheckAllowed(), + tarpit: expectTarpit(0), }, args: args{ ctx: context.Background(), @@ -2546,6 +2559,7 @@ func TestCommandSide_ChangeUserHuman(t *testing.T) { ), ), checkPermission: newMockPermissionCheckAllowed(), + tarpit: expectTarpit(0), }, args: args{ ctx: context.Background(), @@ -2596,6 +2610,7 @@ func TestCommandSide_ChangeUserHuman(t *testing.T) { checkPermission: newMockPermissionCheckAllowed(), newCode: mockEncryptedCode("emailCode", time.Hour), defaultEmailCodeURLTemplate: func(ctx context.Context) string { return "http://example.com/{{.user}}/email/{{.code}}" }, + tarpit: expectTarpit(0), }, args: args{ ctx: context.Background(), @@ -2678,6 +2693,7 @@ func TestCommandSide_ChangeUserHuman(t *testing.T) { checkPermission: newMockPermissionCheckAllowed(), newEncryptedCodeWithDefault: mockEncryptedCodeWithDefault("phoneCode", time.Hour), defaultSecretGenerators: defaultGenerators, + tarpit: expectTarpit(0), }, args: args{ ctx: context.Background(), @@ -2753,6 +2769,7 @@ func TestCommandSide_ChangeUserHuman(t *testing.T) { checkPermission: newMockPermissionCheckAllowed(), newEncryptedCodeWithDefault: mockEncryptedCodeWithDefault("phoneCode", time.Hour), defaultSecretGenerators: defaultGenerators, + tarpit: expectTarpit(0), }, args: args{ ctx: context.Background(), @@ -2783,6 +2800,7 @@ func TestCommandSide_ChangeUserHuman(t *testing.T) { ), ), checkPermission: newMockPermissionCheckNotAllowed(), + tarpit: expectTarpit(0), }, args: args{ ctx: context.Background(), @@ -2820,6 +2838,7 @@ func TestCommandSide_ChangeUserHuman(t *testing.T) { ), ), checkPermission: newMockPermissionCheckAllowed(), + tarpit: expectTarpit(0), }, args: args{ ctx: context.Background(), @@ -2855,6 +2874,7 @@ func TestCommandSide_ChangeUserHuman(t *testing.T) { ), ), checkPermission: newMockPermissionCheckAllowed(), + tarpit: expectTarpit(0), }, args: args{ ctx: context.Background(), @@ -2934,6 +2954,7 @@ func TestCommandSide_ChangeUserHuman(t *testing.T) { checkPermission: newMockPermissionCheckAllowed(), newEncryptedCodeWithDefault: mockEncryptedCodeWithDefault("phoneCode", time.Hour), defaultSecretGenerators: defaultGenerators, + tarpit: expectTarpit(0), }, args: args{ ctx: context.Background(), @@ -2961,6 +2982,7 @@ func TestCommandSide_ChangeUserHuman(t *testing.T) { eventstore: expectEventstore(), checkPermission: newMockPermissionCheckAllowed(), userPasswordHasher: mockPasswordHasher("x"), + tarpit: expectTarpit(0), }, args: args{ ctx: context.Background(), @@ -2994,6 +3016,7 @@ func TestCommandSide_ChangeUserHuman(t *testing.T) { ), checkPermission: newMockPermissionCheckAllowed(), userPasswordHasher: mockPasswordHasher("x"), + tarpit: expectTarpit(0), }, args: args{ ctx: context.Background(), @@ -3026,6 +3049,7 @@ func TestCommandSide_ChangeUserHuman(t *testing.T) { ), ), ), + expectFilter(), // recheck of user locking relevant events expectFilter( eventFromEventPusher( org.NewPasswordComplexityPolicyAddedEvent(context.Background(), @@ -3041,6 +3065,7 @@ func TestCommandSide_ChangeUserHuman(t *testing.T) { ), checkPermission: newMockPermissionCheckAllowed(), userPasswordHasher: mockPasswordHasher("x"), + tarpit: expectTarpit(0), }, args: args{ ctx: context.Background(), @@ -3065,6 +3090,7 @@ func TestCommandSide_ChangeUserHuman(t *testing.T) { eventstore: expectEventstore(), checkPermission: newMockPermissionCheckAllowed(), userPasswordHasher: mockPasswordHasher("x"), + tarpit: expectTarpit(0), }, args: args{ ctx: context.Background(), @@ -3099,6 +3125,7 @@ func TestCommandSide_ChangeUserHuman(t *testing.T) { ), userPasswordHasher: mockPasswordHasher("x"), checkPermission: newMockPermissionCheckNotAllowed(), + tarpit: expectTarpit(0), }, args: args{ ctx: context.Background(), @@ -3153,6 +3180,7 @@ func TestCommandSide_ChangeUserHuman(t *testing.T) { ), userPasswordHasher: mockPasswordHasher("x"), checkPermission: newMockPermissionCheckAllowed(), + tarpit: expectTarpit(0), }, args: args{ ctx: context.Background(), @@ -3186,6 +3214,7 @@ func TestCommandSide_ChangeUserHuman(t *testing.T) { ), ), ), + expectFilter(), // recheck of user locking relevant events expectFilter( eventFromEventPusher( org.NewPasswordComplexityPolicyAddedEvent(context.Background(), @@ -3209,6 +3238,7 @@ func TestCommandSide_ChangeUserHuman(t *testing.T) { ), checkPermission: newMockPermissionCheckAllowed(), userPasswordHasher: mockPasswordHasher("x"), + tarpit: expectTarpit(0), }, args: args{ ctx: context.Background(), @@ -3243,9 +3273,27 @@ func TestCommandSide_ChangeUserHuman(t *testing.T) { ), ), ), + expectFilter(), // recheck of user locking relevant events + expectFilter( + eventFromEventPusher( + org.NewLockoutPolicyAddedEvent(context.Background(), + &userAgg.Aggregate, + 0, + 0, + false, + ), + ), + ), + expectPush( + user.NewHumanPasswordCheckFailedEvent(context.Background(), + &userAgg.Aggregate, + nil, + ), + ), ), checkPermission: newMockPermissionCheckAllowed(), userPasswordHasher: mockPasswordHasher("x"), + tarpit: expectTarpit(1), }, args: args{ ctx: context.Background(), @@ -3317,6 +3365,7 @@ func TestCommandSide_ChangeUserHuman(t *testing.T) { ), checkPermission: newMockPermissionCheckAllowed(), userPasswordHasher: mockPasswordHasher("x"), + tarpit: expectTarpit(0), }, args: args{ ctx: context.Background(), @@ -3371,6 +3420,7 @@ func TestCommandSide_ChangeUserHuman(t *testing.T) { ), checkPermission: newMockPermissionCheckAllowed(), userPasswordHasher: mockPasswordHasher("x"), + tarpit: expectTarpit(0), }, args: args{ ctx: context.Background(), @@ -3435,6 +3485,7 @@ func TestCommandSide_ChangeUserHuman(t *testing.T) { ), checkPermission: newMockPermissionCheckAllowed(), userPasswordHasher: mockPasswordHasher("x"), + tarpit: expectTarpit(0), }, args: args{ ctx: context.Background(), @@ -3493,6 +3544,7 @@ func TestCommandSide_ChangeUserHuman(t *testing.T) { ), checkPermission: newMockPermissionCheckAllowed(), userPasswordHasher: mockPasswordHasher("x"), + tarpit: expectTarpit(0), }, args: args{ ctx: context.Background(), @@ -3515,7 +3567,7 @@ func TestCommandSide_ChangeUserHuman(t *testing.T) { }, }, { - name: "change human password and password encoded, password code, encoded used", + name: "change human password encoded, old password, ok", fields: fields{ eventstore: expectEventstore( expectFilter( @@ -3567,15 +3619,15 @@ func TestCommandSide_ChangeUserHuman(t *testing.T) { ), checkPermission: newMockPermissionCheckAllowed(), userPasswordHasher: mockPasswordHasher("x"), + tarpit: expectTarpit(0), }, args: args{ ctx: context.Background(), orgID: "org1", human: &ChangeHuman{ Password: &Password{ - Password: "passwordnotused", + OldPassword: "password", EncodedPasswordHash: "$plain$x$password2", - PasswordCode: "code", ChangeRequired: true, }, }, @@ -3601,6 +3653,7 @@ func TestCommandSide_ChangeUserHuman(t *testing.T) { defaultSecretGenerators: tt.fields.defaultSecretGenerators, userEncryption: tt.args.codeAlg, defaultEmailCodeURLTemplate: tt.fields.defaultEmailCodeURLTemplate, + tarpit: tt.fields.tarpit.tarpit, } err := r.ChangeUserHuman(tt.args.ctx, tt.args.human, tt.args.codeAlg) if tt.res.err == nil { @@ -3616,6 +3669,7 @@ func TestCommandSide_ChangeUserHuman(t *testing.T) { assert.Equal(t, tt.res.wantEmailCode, tt.args.human.EmailCode) assert.Equal(t, tt.res.wantPhoneCode, tt.args.human.PhoneCode) } + tt.fields.tarpit.metExpectedCalls(t) }) } } diff --git a/internal/command/user_v2_model.go b/internal/command/user_v2_model.go index 92346bf3b60..b2cd93a6849 100644 --- a/internal/command/user_v2_model.go +++ b/internal/command/user_v2_model.go @@ -80,6 +80,26 @@ type UserV2WriteModel struct { Metadata map[string][]byte } +func (wm *UserV2WriteModel) GetUserState() domain.UserState { + return wm.UserState +} + +func (wm *UserV2WriteModel) GetPasswordCheckFailedCount() uint64 { + return wm.PasswordCheckFailedCount +} + +func (wm *UserV2WriteModel) GetEncodedHash() string { + return wm.PasswordEncodedHash +} + +func (wm *UserV2WriteModel) GetResourceOwner() string { + return wm.ResourceOwner +} + +func (wm *UserV2WriteModel) GetWriteModel() *eventstore.WriteModel { + return &wm.WriteModel +} + func NewUserExistsWriteModel(userID, resourceOwner string) *UserV2WriteModel { return newUserV2WriteModel(userID, resourceOwner, WithHuman(), WithMachine()) } @@ -292,6 +312,7 @@ func (wm *UserV2WriteModel) Reduce() error { case *user.HumanPasswordChangedEvent: wm.PasswordEncodedHash = crypto.SecretOrEncodedHash(e.Secret, e.EncodedHash) wm.PasswordChangeRequired = e.ChangeRequired + wm.PasswordCheckFailedCount = 0 wm.EmptyPasswordCode() case *user.HumanPasswordCodeAddedEvent: wm.SetPasswordCode(e) diff --git a/internal/config/systemdefaults/system_defaults.go b/internal/config/systemdefaults/system_defaults.go index 827dd61f73e..2d3ace7c31d 100644 --- a/internal/config/systemdefaults/system_defaults.go +++ b/internal/config/systemdefaults/system_defaults.go @@ -2,7 +2,7 @@ package systemdefaults import ( "time" - + "github.com/zitadel/zitadel/internal/crypto" ) @@ -11,6 +11,7 @@ type SystemDefaults struct { PasswordHasher crypto.HashConfig SecretHasher crypto.HashConfig Multifactors MultifactorConfig + Tarpit TarpitConfig DomainVerification DomainVerification Notifications Notifications KeyConfig KeyConfig diff --git a/internal/config/systemdefaults/tarpit.go b/internal/config/systemdefaults/tarpit.go new file mode 100644 index 00000000000..a5b8b6f6a50 --- /dev/null +++ b/internal/config/systemdefaults/tarpit.go @@ -0,0 +1,34 @@ +package systemdefaults + +import "time" + +type TarpitConfig struct { + // After how many failed attempts, the tarpit should start. + MinFailedAttempts uint64 + // The seconds that will be added per step. + StepDuration time.Duration + // The failed attempts that are needed to increase the tarpit by one step. + StepSize uint64 + // The maximum duration the tarpit can reach. + MaxDuration time.Duration +} + +func (t *TarpitConfig) Tarpit() func(failedCount uint64) { + return func(failedCount uint64) { + time.Sleep(t.duration(failedCount)) + } +} + +func (t *TarpitConfig) duration(failedCount uint64) time.Duration { + if failedCount < t.MinFailedAttempts { + return 0 + } + // calculate the step we are at + // every StepSize failed attempts increase the step by one + step := (failedCount - t.MinFailedAttempts) / t.StepSize + duration := time.Duration(step) * t.StepDuration + if duration < t.MaxDuration { + return duration + } + return t.MaxDuration +} diff --git a/internal/config/systemdefaults/tarpit_test.go b/internal/config/systemdefaults/tarpit_test.go new file mode 100644 index 00000000000..f57d6c2b633 --- /dev/null +++ b/internal/config/systemdefaults/tarpit_test.go @@ -0,0 +1,83 @@ +package systemdefaults + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestTarpitConfig_duration(t *testing.T) { + type fields struct { + MinFailedAttempts uint64 + StepDuration time.Duration + StepSize uint64 + MaxDuration time.Duration + } + type args struct { + failedCount uint64 + } + tests := []struct { + name string + fields fields + args args + want time.Duration + }{ + { + "no tarpit", + fields{ + MinFailedAttempts: 2, + StepDuration: time.Second, + StepSize: 1, + MaxDuration: 5 * time.Second, + }, + args{failedCount: 1}, + 0, + }, + { + "first step", + fields{ + MinFailedAttempts: 2, + StepDuration: time.Second, + StepSize: 1, + MaxDuration: 5 * time.Second, + }, + args{failedCount: 3}, + time.Second, + }, + { + "second step", + fields{ + MinFailedAttempts: 2, + StepDuration: time.Second, + StepSize: 1, + MaxDuration: 5 * time.Second, + }, + args{failedCount: 4}, + 2 * time.Second, + }, + { + "exceeding max duration", + fields{ + MinFailedAttempts: 2, + StepDuration: time.Second, + StepSize: 1, + MaxDuration: 5 * time.Second, + }, + args{failedCount: 20}, + 5 * time.Second, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &TarpitConfig{ + MinFailedAttempts: tt.fields.MinFailedAttempts, + StepDuration: tt.fields.StepDuration, + StepSize: tt.fields.StepSize, + MaxDuration: tt.fields.MaxDuration, + } + got := c.duration(tt.args.failedCount) + assert.Equal(t, tt.want, got) + }) + } +}