fix: respect lockout policy on password change (with old password) and add tar pit for checks

# Which Problems Are Solved

While the lockout policy was correctly applied on the session API and other authentication and management endpoints , it had no effect on the user service v2 endpoints.

# How the Problems Are Solved

- Correctly apply lockout policy on the user service v2 endpoints.
- Added tar pitting to auth factor checks (authentication and management API) to prevent brute-force attacks or denial of service because of user lockouts.
- Tar pitting is not active if `IgnoreUnknownUsername` option is active to prevent leaking information whether a user exists or not.

# Additional Changes

None

# Additional Context

- requires backports

* cleanup

(cherry picked from commit b8db8cdf9c)

(cherry picked from commit d3713dfaed)
This commit is contained in:
Livio Spring
2025-10-29 10:07:35 +01:00
parent d9cd4e9892
commit 8529ebdabc
17 changed files with 618 additions and 49 deletions

View File

@@ -725,6 +725,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

View File

@@ -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
@@ -207,6 +208,7 @@ func StartCommands(
repo.newHashedSecret = newHashedSecretWithDefault(secretHasher, defaultSecretGenerators.ClientSecret)
}
repo.phoneCodeVerifier = repo.phoneCodeVerifierFromConfig
repo.tarpit = defaults.Tarpit.Tarpit()
return repo, nil
}

View File

@@ -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

View File

@@ -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

View File

@@ -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
}

View File

@@ -1128,6 +1128,7 @@ func TestCheckTOTP(t *testing.T) {
type fields struct {
sessionWriteModel *SessionWriteModel
eventstore func(*testing.T) *eventstore.Eventstore
tarpit Tarpit
}
tests := []struct {
@@ -1146,6 +1147,7 @@ func TestCheckTOTP(t *testing.T) {
aggregate: sessAgg,
},
eventstore: expectEventstore(),
tarpit: expectTarpit(0),
},
wantErr: zerrors.ThrowInvalidArgument(nil, "COMMAND-8N9ds", "Errors.User.UserIDMissing"),
},
@@ -1161,6 +1163,7 @@ func TestCheckTOTP(t *testing.T) {
eventstore: expectEventstore(
expectFilterError(io.ErrClosedPipe),
),
tarpit: expectTarpit(0),
},
wantErr: io.ErrClosedPipe,
},
@@ -1180,6 +1183,7 @@ func TestCheckTOTP(t *testing.T) {
),
),
),
tarpit: expectTarpit(0),
},
wantErr: zerrors.ThrowPreconditionFailed(nil, "COMMAND-3Mif9s", "Errors.User.MFA.OTP.NotReady"),
},
@@ -1206,6 +1210,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),
@@ -1235,6 +1240,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),
@@ -1262,6 +1268,7 @@ func TestCheckTOTP(t *testing.T) {
),
expectFilter(), // recheck
),
tarpit: expectTarpit(0),
},
wantEventCommands: []eventstore.Command{
user.NewHumanOTPCheckSucceededEvent(ctx, userAgg, nil),
@@ -1290,6 +1297,7 @@ func TestCheckTOTP(t *testing.T) {
user.NewUserLockedEvent(ctx, userAgg),
),
),
tarpit: expectTarpit(0),
},
wantErr: zerrors.ThrowPreconditionFailed(nil, "COMMAND-SF3fg", "Errors.User.Locked"),
},
@@ -1301,11 +1309,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)
})
}
}

View File

@@ -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
}

View File

@@ -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)
})
}
}

View File

@@ -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) {

View File

@@ -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{

View File

@@ -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)
})
}
}

View File

@@ -475,7 +475,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,

View File

@@ -2096,6 +2096,7 @@ func TestCommandSide_ChangeUserHuman(t *testing.T) {
newEncryptedCodeWithDefault encryptedCodeWithDefaultFunc
checkPermission domain.PermissionCheck
defaultSecretGenerators *SecretGenerators
tarpit Tarpit
}
type args struct {
ctx context.Context
@@ -2131,6 +2132,7 @@ func TestCommandSide_ChangeUserHuman(t *testing.T) {
expectFilter(),
),
checkPermission: newMockPermissionCheckAllowed(),
tarpit: expectTarpit(0),
},
args: args{
ctx: context.Background(),
@@ -2156,6 +2158,7 @@ func TestCommandSide_ChangeUserHuman(t *testing.T) {
),
),
checkPermission: newMockPermissionCheckNotAllowed(),
tarpit: expectTarpit(0),
},
args: args{
ctx: context.Background(),
@@ -2177,6 +2180,7 @@ func TestCommandSide_ChangeUserHuman(t *testing.T) {
expectFilter(),
),
checkPermission: newMockPermissionCheckAllowed(),
tarpit: expectTarpit(0),
},
args: args{
ctx: context.Background(),
@@ -2220,6 +2224,7 @@ func TestCommandSide_ChangeUserHuman(t *testing.T) {
),
),
checkPermission: newMockPermissionCheckAllowed(),
tarpit: expectTarpit(0),
},
args: args{
ctx: context.Background(),
@@ -2247,6 +2252,7 @@ func TestCommandSide_ChangeUserHuman(t *testing.T) {
),
),
checkPermission: newMockPermissionCheckAllowed(),
tarpit: expectTarpit(0),
},
args: args{
ctx: context.Background(),
@@ -2274,6 +2280,7 @@ func TestCommandSide_ChangeUserHuman(t *testing.T) {
),
),
checkPermission: newMockPermissionCheckNotAllowed(),
tarpit: expectTarpit(0),
},
args: args{
ctx: context.Background(),
@@ -2322,6 +2329,7 @@ func TestCommandSide_ChangeUserHuman(t *testing.T) {
),
),
checkPermission: newMockPermissionCheckAllowed(),
tarpit: expectTarpit(0),
},
args: args{
ctx: context.Background(),
@@ -2356,6 +2364,7 @@ func TestCommandSide_ChangeUserHuman(t *testing.T) {
),
),
checkPermission: newMockPermissionCheckAllowed(),
tarpit: expectTarpit(0),
},
args: args{
ctx: context.Background(),
@@ -2410,6 +2419,7 @@ func TestCommandSide_ChangeUserHuman(t *testing.T) {
),
checkPermission: newMockPermissionCheckAllowed(),
newCode: mockEncryptedCode("emailCode", time.Hour),
tarpit: expectTarpit(0),
},
args: args{
ctx: context.Background(),
@@ -2440,6 +2450,7 @@ func TestCommandSide_ChangeUserHuman(t *testing.T) {
),
),
checkPermission: newMockPermissionCheckAllowed(),
tarpit: expectTarpit(0),
},
args: args{
ctx: context.Background(),
@@ -2469,6 +2480,7 @@ func TestCommandSide_ChangeUserHuman(t *testing.T) {
),
),
checkPermission: newMockPermissionCheckNotAllowed(),
tarpit: expectTarpit(0),
},
args: args{
ctx: context.Background(),
@@ -2506,6 +2518,7 @@ func TestCommandSide_ChangeUserHuman(t *testing.T) {
),
),
checkPermission: newMockPermissionCheckAllowed(),
tarpit: expectTarpit(0),
},
args: args{
ctx: context.Background(),
@@ -2541,6 +2554,7 @@ func TestCommandSide_ChangeUserHuman(t *testing.T) {
),
),
checkPermission: newMockPermissionCheckAllowed(),
tarpit: expectTarpit(0),
},
args: args{
ctx: context.Background(),
@@ -2590,6 +2604,7 @@ func TestCommandSide_ChangeUserHuman(t *testing.T) {
),
checkPermission: newMockPermissionCheckAllowed(),
newCode: mockEncryptedCode("emailCode", time.Hour),
tarpit: expectTarpit(0),
},
args: args{
ctx: context.Background(),
@@ -2672,6 +2687,7 @@ func TestCommandSide_ChangeUserHuman(t *testing.T) {
checkPermission: newMockPermissionCheckAllowed(),
newEncryptedCodeWithDefault: mockEncryptedCodeWithDefault("phoneCode", time.Hour),
defaultSecretGenerators: defaultGenerators,
tarpit: expectTarpit(0),
},
args: args{
ctx: context.Background(),
@@ -2747,6 +2763,7 @@ func TestCommandSide_ChangeUserHuman(t *testing.T) {
checkPermission: newMockPermissionCheckAllowed(),
newEncryptedCodeWithDefault: mockEncryptedCodeWithDefault("phoneCode", time.Hour),
defaultSecretGenerators: defaultGenerators,
tarpit: expectTarpit(0),
},
args: args{
ctx: context.Background(),
@@ -2777,6 +2794,7 @@ func TestCommandSide_ChangeUserHuman(t *testing.T) {
),
),
checkPermission: newMockPermissionCheckNotAllowed(),
tarpit: expectTarpit(0),
},
args: args{
ctx: context.Background(),
@@ -2814,6 +2832,7 @@ func TestCommandSide_ChangeUserHuman(t *testing.T) {
),
),
checkPermission: newMockPermissionCheckAllowed(),
tarpit: expectTarpit(0),
},
args: args{
ctx: context.Background(),
@@ -2849,6 +2868,7 @@ func TestCommandSide_ChangeUserHuman(t *testing.T) {
),
),
checkPermission: newMockPermissionCheckAllowed(),
tarpit: expectTarpit(0),
},
args: args{
ctx: context.Background(),
@@ -2928,6 +2948,7 @@ func TestCommandSide_ChangeUserHuman(t *testing.T) {
checkPermission: newMockPermissionCheckAllowed(),
newEncryptedCodeWithDefault: mockEncryptedCodeWithDefault("phoneCode", time.Hour),
defaultSecretGenerators: defaultGenerators,
tarpit: expectTarpit(0),
},
args: args{
ctx: context.Background(),
@@ -2955,6 +2976,7 @@ func TestCommandSide_ChangeUserHuman(t *testing.T) {
eventstore: expectEventstore(),
checkPermission: newMockPermissionCheckAllowed(),
userPasswordHasher: mockPasswordHasher("x"),
tarpit: expectTarpit(0),
},
args: args{
ctx: context.Background(),
@@ -2988,6 +3010,7 @@ func TestCommandSide_ChangeUserHuman(t *testing.T) {
),
checkPermission: newMockPermissionCheckAllowed(),
userPasswordHasher: mockPasswordHasher("x"),
tarpit: expectTarpit(0),
},
args: args{
ctx: context.Background(),
@@ -3020,6 +3043,7 @@ func TestCommandSide_ChangeUserHuman(t *testing.T) {
),
),
),
expectFilter(), // recheck of user locking relevant events
expectFilter(
eventFromEventPusher(
org.NewPasswordComplexityPolicyAddedEvent(context.Background(),
@@ -3035,6 +3059,7 @@ func TestCommandSide_ChangeUserHuman(t *testing.T) {
),
checkPermission: newMockPermissionCheckAllowed(),
userPasswordHasher: mockPasswordHasher("x"),
tarpit: expectTarpit(0),
},
args: args{
ctx: context.Background(),
@@ -3059,6 +3084,7 @@ func TestCommandSide_ChangeUserHuman(t *testing.T) {
eventstore: expectEventstore(),
checkPermission: newMockPermissionCheckAllowed(),
userPasswordHasher: mockPasswordHasher("x"),
tarpit: expectTarpit(0),
},
args: args{
ctx: context.Background(),
@@ -3093,6 +3119,7 @@ func TestCommandSide_ChangeUserHuman(t *testing.T) {
),
userPasswordHasher: mockPasswordHasher("x"),
checkPermission: newMockPermissionCheckNotAllowed(),
tarpit: expectTarpit(0),
},
args: args{
ctx: context.Background(),
@@ -3147,6 +3174,7 @@ func TestCommandSide_ChangeUserHuman(t *testing.T) {
),
userPasswordHasher: mockPasswordHasher("x"),
checkPermission: newMockPermissionCheckAllowed(),
tarpit: expectTarpit(0),
},
args: args{
ctx: context.Background(),
@@ -3180,6 +3208,7 @@ func TestCommandSide_ChangeUserHuman(t *testing.T) {
),
),
),
expectFilter(), // recheck of user locking relevant events
expectFilter(
eventFromEventPusher(
org.NewPasswordComplexityPolicyAddedEvent(context.Background(),
@@ -3203,6 +3232,7 @@ func TestCommandSide_ChangeUserHuman(t *testing.T) {
),
checkPermission: newMockPermissionCheckAllowed(),
userPasswordHasher: mockPasswordHasher("x"),
tarpit: expectTarpit(0),
},
args: args{
ctx: context.Background(),
@@ -3237,9 +3267,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(),
@@ -3311,6 +3359,7 @@ func TestCommandSide_ChangeUserHuman(t *testing.T) {
),
checkPermission: newMockPermissionCheckAllowed(),
userPasswordHasher: mockPasswordHasher("x"),
tarpit: expectTarpit(0),
},
args: args{
ctx: context.Background(),
@@ -3365,6 +3414,7 @@ func TestCommandSide_ChangeUserHuman(t *testing.T) {
),
checkPermission: newMockPermissionCheckAllowed(),
userPasswordHasher: mockPasswordHasher("x"),
tarpit: expectTarpit(0),
},
args: args{
ctx: context.Background(),
@@ -3429,6 +3479,7 @@ func TestCommandSide_ChangeUserHuman(t *testing.T) {
),
checkPermission: newMockPermissionCheckAllowed(),
userPasswordHasher: mockPasswordHasher("x"),
tarpit: expectTarpit(0),
},
args: args{
ctx: context.Background(),
@@ -3487,6 +3538,7 @@ func TestCommandSide_ChangeUserHuman(t *testing.T) {
),
checkPermission: newMockPermissionCheckAllowed(),
userPasswordHasher: mockPasswordHasher("x"),
tarpit: expectTarpit(0),
},
args: args{
ctx: context.Background(),
@@ -3509,7 +3561,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(
@@ -3561,15 +3613,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,
},
},
@@ -3594,6 +3646,7 @@ func TestCommandSide_ChangeUserHuman(t *testing.T) {
checkPermission: tt.fields.checkPermission,
defaultSecretGenerators: tt.fields.defaultSecretGenerators,
userEncryption: tt.args.codeAlg,
tarpit: tt.fields.tarpit.tarpit,
}
err := r.ChangeUserHuman(tt.args.ctx, tt.args.human, tt.args.codeAlg)
if tt.res.err == nil {
@@ -3609,6 +3662,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)
})
}
}

View File

@@ -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())
}
@@ -284,6 +304,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)

View File

@@ -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

View File

@@ -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
}

View File

@@ -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)
})
}
}