add linear strategy to brute force

closes #25917

Signed-off-by: Gilvan Filho <gilvan.sfilho@gmail.com>
This commit is contained in:
Gilvan Filho 2024-08-06 09:50:34 -03:00 committed by Pedro Igor
parent 6d52520730
commit c4005d29f0
16 changed files with 182 additions and 8 deletions

View file

@ -92,6 +92,7 @@ public class RealmRepresentation {
protected Boolean bruteForceProtected;
protected Boolean permanentLockout;
protected Integer maxTemporaryLockouts;
protected BruteForceStrategy bruteForceStrategy;
protected Integer maxFailureWaitSeconds;
protected Integer minimumQuickLoginWaitSeconds;
protected Integer waitIncrementSeconds;
@ -777,6 +778,14 @@ public class RealmRepresentation {
this.maxTemporaryLockouts = maxTemporaryLockouts;
}
public BruteForceStrategy getBruteForceStrategy() {
return this.bruteForceStrategy;
}
public void setBruteForceStrategy(BruteForceStrategy bruteForceStrategy) {
this.bruteForceStrategy = bruteForceStrategy;
}
public Integer getMaxFailureWaitSeconds() {
return maxFailureWaitSeconds;
}
@ -1450,4 +1459,8 @@ public class RealmRepresentation {
}
organizations.add(org);
}
public enum BruteForceStrategy {
LINEAR, MULTIPLE;
}
}

View file

@ -75,15 +75,19 @@ wait time will never reach the value you have set to `Max wait`.
.. If the time between this failure and the last failure is greater than _Failure Reset Time_
... Reset `count`
.. Increment `count`
.. Calculate `wait` using _Wait Increment_ * (`count` / _Max Login Failures_). The division is an integer division rounded down to a whole number
.. If `wait` equals 0 and the time between this failure and the last failure is less than _Quick Login Check Milliseconds_, set `wait` to _Minimum Quick Login Wait_.
.. Calculate `wait` according brute force strategy defined (see below Strategies to set Wait Time).
.. If `wait` equals to or less than 0 and the time between this failure and the last failure is less than _Quick Login Check Milliseconds_, set `wait` to _Minimum Quick Login Wait_.
... Temporarily disable the user for the smallest of `wait` and _Max Wait_ seconds
... Increment the temporary lockout counter
`count` does not increment when a temporarily disabled account commits a login failure.
====
For instance, if you have set `Max Login Failures` to `5` and a `Wait Increment` of `30` seconds, the effective time an account will be disabled after several failed authentication attempts will be:
*Strategies to set Wait Time*
Keycloak provides two strategies to calculate wait time: By multiples or Linear. By multiples is the first strategy introduced by keycloak, so that is the default one.
With by multiples strategy wait time will be incremented when number (or count) of failures are multiple of `Max Login Failure`. For instance, if you have set `Max Login Failures` to `5` and a `Wait Increment` of `30` seconds, the effective time an account will be disabled after several failed authentication attempts will be:
[cols="1,1,1,1"]
|===
@ -100,9 +104,30 @@ For instance, if you have set `Max Login Failures` to `5` and a `Wait Increment`
|**10** |**30** | 5 | **60**
|===
Note that the `Effective Wait Time` at the 5th failed attempt will disable the account for `30` seconds. Only after reaching
the next multiple of `Max Login Failures`, in this case `10`, will the time increase from `30` to `60`. The time the account will be disabled
is only increased when reaching multiples of `Max Login Failures`.
Note that the `Effective Wait Time` at the 5th failed attempt will disable the account for `30` seconds. Only after reaching the next multiple of `Max Login Failures`, in this case `10`, will the time increase from `30` to `60`. The time the account will be disabled is only increased when reaching multiples of `Max Login Failures`.
The by multiple strategy uses the following formula to calculate wait time: _Wait Increment_ * (`count` / _Max Login Failures_). The division is an integer division rounded down to a whole number.
With linear strategy wait time will be incremented when number (or count) of failures are equal to or greater than `Max Login Failure`. For instance, if you have set `Max Login Failures` to `5` and a `Wait Increment` of `30` seconds, the effective time an account will be disabled after several failed authentication attempts will be:
[cols="1,1,1,1"]
|===
|`Number of Failures` | `Wait Increment` | `Max Login Failures` | `Effective Wait Time`
|1 |30 | 5 | 0
|2 |30 | 5 | 0
|3 |30 | 5 | 0
|4 |30 | 5 | 0
|**5** |**30** | 5 | **30**
|**6** |**30** | 5 | **60**
|**7** |**30** | 5 | **90**
|**8** |**30** | 5 | **120**
|**9** |**30** | 5 | **150**
|**10** |**30** | 5 | **180**
|===
Note that the `Effective Wait Time` at the 5th failed attempt will disable the account for `30` seconds. Each new failed attempt will increase wait time.
The linear strategy uses the following formula to calculate wait time: _Wait Increment_ * (1 + `count` - _Max Login Failures_).
*Permanent Lockout Parameters*

View file

@ -729,6 +729,10 @@ rowSaveBtnAriaLabel=Save edits for {{messageBundle}}
permanentLockout=Permanent lockout
maxTemporaryLockouts=Maximum temporary lockouts
maxTemporaryLockoutsHelp=The number of temporary lockouts permitted before the user is permanently locked out.
bruteForceStrategy=Strategy to increase wait time
bruteForceStrategyHelp=Multiple means wait time will be increased only when number of failures are multiples of '{{failureFactor}}'. Linear means each new failure starting at '{{failureFactor}}' will increase wait time.
bruteForceStrategy.LINEAR=Linear
bruteForceStrategy.MULTIPLE=Multiple
debug=Debug
webAuthnPolicyRequireResidentKey=Require discoverable credential
unlockUsersConfirm=All the users that are temporarily locked will be unlocked.

View file

@ -4,6 +4,7 @@ import {
KeycloakSelect,
NumberControl,
SelectVariant,
SelectControl,
} from "@keycloak/keycloak-ui-shared";
import {
ActionGroup,
@ -52,6 +53,8 @@ export const BruteForceDetection = ({
BruteForceMode.PermanentAfterTemporaryLockout,
];
const bruteForceStrategyTypes = ["MULTIPLE", "LINEAR"];
const setupForm = () => {
convertToFormValues(realm, setValue);
setIsBruteForceModeUpdated(false);
@ -155,6 +158,16 @@ export const BruteForceDetection = ({
bruteForceMode ===
BruteForceMode.PermanentAfterTemporaryLockout) && (
<>
<SelectControl
name="bruteForceStrategy"
label={t("bruteForceStrategy")}
labelIcon={t("bruteForceStrategyHelp")}
controller={{ defaultValue: "" }}
options={bruteForceStrategyTypes.map((key) => ({
key,
value: t(`bruteForceStrategy.${key}`),
}))}
/>
<Time name="waitIncrementSeconds" />
<Time name="maxFailureWaitSeconds" />
<Time name="maxDeltaTimeSeconds" />

View file

@ -74,6 +74,7 @@ export default interface RealmRepresentation {
maxDeltaTimeSeconds?: number;
maxFailureWaitSeconds?: number;
maxTemporaryLockouts?: number;
bruteForceStrategy?: "MULTIPLE" | "LINEAR";
minimumQuickLoginWaitSeconds?: number;
notBefore?: number;
oauth2DeviceCodeLifespan?: number;

View file

@ -47,6 +47,7 @@ import org.keycloak.models.WebAuthnPolicy;
import org.keycloak.models.cache.CachedRealmModel;
import org.keycloak.models.cache.UserCache;
import org.keycloak.models.cache.infinispan.entities.CachedRealm;
import org.keycloak.representations.idm.RealmRepresentation;
import org.keycloak.storage.UserStorageProvider;
import org.keycloak.storage.UserStorageUtil;
import org.keycloak.storage.client.ClientStorageProvider;
@ -283,6 +284,18 @@ public class RealmAdapter implements CachedRealmModel {
updated.setMaxTemporaryLockouts(val);
}
@Override
public RealmRepresentation.BruteForceStrategy getBruteForceStrategy() {
if(isUpdated()) return updated.getBruteForceStrategy();
return cached.getBruteForceStrategy();
}
@Override
public void setBruteForceStrategy(final RealmRepresentation.BruteForceStrategy val) {
getDelegateForUpdate();
updated.setBruteForceStrategy(val);
}
@Override
public int getMaxFailureWaitSeconds() {
if (isUpdated()) return updated.getMaxFailureWaitSeconds();

View file

@ -52,6 +52,7 @@ import org.keycloak.models.RequiredCredentialModel;
import org.keycloak.models.WebAuthnPolicy;
import org.keycloak.models.cache.infinispan.DefaultLazyLoader;
import org.keycloak.models.cache.infinispan.LazyLoader;
import org.keycloak.representations.idm.RealmRepresentation;
/**
* @author <a href="mailto:bill@burkecentral.com">Bill Burke</a>
@ -78,6 +79,7 @@ public class CachedRealm extends AbstractExtendableRevisioned {
protected boolean bruteForceProtected;
protected boolean permanentLockout;
protected int maxTemporaryLockouts;
protected RealmRepresentation.BruteForceStrategy bruteForceStrategy;
protected int maxFailureWaitSeconds;
protected int minimumQuickLoginWaitSeconds;
protected int waitIncrementSeconds;
@ -193,6 +195,7 @@ public class CachedRealm extends AbstractExtendableRevisioned {
bruteForceProtected = model.isBruteForceProtected();
permanentLockout = model.isPermanentLockout();
maxTemporaryLockouts = model.getMaxTemporaryLockouts();
bruteForceStrategy = model.getBruteForceStrategy();
maxFailureWaitSeconds = model.getMaxFailureWaitSeconds();
minimumQuickLoginWaitSeconds = model.getMinimumQuickLoginWaitSeconds();
waitIncrementSeconds = model.getWaitIncrementSeconds();
@ -376,6 +379,10 @@ public class CachedRealm extends AbstractExtendableRevisioned {
return maxTemporaryLockouts;
}
public RealmRepresentation.BruteForceStrategy getBruteForceStrategy() {
return bruteForceStrategy;
}
public int getMaxFailureWaitSeconds() {
return this.maxFailureWaitSeconds;
}

View file

@ -36,6 +36,8 @@ import org.keycloak.provider.ProviderConfigProperty;
import jakarta.persistence.EntityManager;
import jakarta.persistence.LockModeType;
import jakarta.persistence.TypedQuery;
import org.keycloak.representations.idm.RealmRepresentation;
import java.util.HashMap;
import java.util.Iterator;
import java.util.Map;
@ -268,6 +270,20 @@ public class RealmAdapter implements StorageProviderRealmModel, JpaModel<RealmEn
return getAttribute("maxTemporaryLockouts", 0);
}
@Override
public RealmRepresentation.BruteForceStrategy getBruteForceStrategy() {
String name = getAttribute("bruteForceStrategy");
if(name == null)
return RealmRepresentation.BruteForceStrategy.MULTIPLE;
return RealmRepresentation.BruteForceStrategy.valueOf(name);
}
@Override
public void setBruteForceStrategy(final RealmRepresentation.BruteForceStrategy val) {
setAttribute("bruteForceStrategy", val.toString());
}
@Override
public void setMaxTemporaryLockouts(final int val) {
setAttribute("maxTemporaryLockouts", val);

View file

@ -186,6 +186,7 @@ public class DefaultExportImportManager implements ExportImportManager {
if (rep.isBruteForceProtected() != null) newRealm.setBruteForceProtected(rep.isBruteForceProtected());
if (rep.isPermanentLockout() != null) newRealm.setPermanentLockout(rep.isPermanentLockout());
if (rep.getMaxTemporaryLockouts() != null) newRealm.setMaxTemporaryLockouts(rep.getMaxTemporaryLockouts());
if (rep.getBruteForceStrategy() != null) newRealm.setBruteForceStrategy(rep.getBruteForceStrategy());
if (rep.getMaxFailureWaitSeconds() != null) newRealm.setMaxFailureWaitSeconds(rep.getMaxFailureWaitSeconds());
if (rep.getMinimumQuickLoginWaitSeconds() != null)
newRealm.setMinimumQuickLoginWaitSeconds(rep.getMinimumQuickLoginWaitSeconds());
@ -751,6 +752,7 @@ public class DefaultExportImportManager implements ExportImportManager {
if (rep.isBruteForceProtected() != null) realm.setBruteForceProtected(rep.isBruteForceProtected());
if (rep.isPermanentLockout() != null) realm.setPermanentLockout(rep.isPermanentLockout());
if (rep.getMaxTemporaryLockouts() != null) realm.setMaxTemporaryLockouts(rep.getMaxTemporaryLockouts());
if (rep.getBruteForceStrategy() != null) realm.setBruteForceStrategy(rep.getBruteForceStrategy());
if (rep.getMaxFailureWaitSeconds() != null) realm.setMaxFailureWaitSeconds(rep.getMaxFailureWaitSeconds());
if (rep.getMinimumQuickLoginWaitSeconds() != null)
realm.setMinimumQuickLoginWaitSeconds(rep.getMinimumQuickLoginWaitSeconds());

View file

@ -84,6 +84,7 @@ public class ModelToRepresentation {
REALM_EXCLUDED_ATTRIBUTES.add("bruteForceProtected");
REALM_EXCLUDED_ATTRIBUTES.add("permanentLockout");
REALM_EXCLUDED_ATTRIBUTES.add("maxTemporaryLockouts");
REALM_EXCLUDED_ATTRIBUTES.add("bruteForceStrategy");
REALM_EXCLUDED_ATTRIBUTES.add("maxFailureWaitSeconds");
REALM_EXCLUDED_ATTRIBUTES.add("waitIncrementSeconds");
REALM_EXCLUDED_ATTRIBUTES.add("quickLoginCheckMilliSeconds");
@ -372,6 +373,7 @@ public class ModelToRepresentation {
rep.setBruteForceProtected(realm.isBruteForceProtected());
rep.setPermanentLockout(realm.isPermanentLockout());
rep.setMaxTemporaryLockouts(realm.getMaxTemporaryLockouts());
rep.setBruteForceStrategy(realm.getBruteForceStrategy());
rep.setMaxFailureWaitSeconds(realm.getMaxFailureWaitSeconds());
rep.setMinimumQuickLoginWaitSeconds(realm.getMinimumQuickLoginWaitSeconds());
rep.setWaitIncrementSeconds(realm.getWaitIncrementSeconds());

View file

@ -40,6 +40,7 @@ import org.keycloak.models.RequiredCredentialModel;
import org.keycloak.models.RoleModel;
import org.keycloak.models.WebAuthnPolicy;
import org.keycloak.provider.Provider;
import org.keycloak.representations.idm.RealmRepresentation;
import java.util.Map;
import java.util.Set;
@ -187,6 +188,10 @@ public class RealmModelDelegate implements RealmModel {
delegate.setBruteForceProtected(value);
}
public RealmRepresentation.BruteForceStrategy getBruteForceStrategy() { return delegate.getBruteForceStrategy(); }
public void setBruteForceStrategy(RealmRepresentation.BruteForceStrategy value) { delegate.setBruteForceStrategy(value); }
public boolean isPermanentLockout() {
return delegate.isPermanentLockout();
}

View file

@ -3,6 +3,7 @@ package org.keycloak.broker.provider.util;
import org.keycloak.common.enums.SslRequired;
import org.keycloak.component.ComponentModel;
import org.keycloak.models.*;
import org.keycloak.representations.idm.RealmRepresentation;
import java.util.HashMap;
import java.util.Map;
@ -688,6 +689,16 @@ public class IdentityBrokerStateTestHelpers {
}
@Override
public RealmRepresentation.BruteForceStrategy getBruteForceStrategy() {
return RealmRepresentation.BruteForceStrategy.MULTIPLE;
}
@Override
public void setBruteForceStrategy(RealmRepresentation.BruteForceStrategy val) {
}
@Override
public int getMaxFailureWaitSeconds() {
return 0;

View file

@ -22,6 +22,7 @@ import org.keycloak.common.enums.SslRequired;
import org.keycloak.component.ComponentModel;
import org.keycloak.provider.Provider;
import org.keycloak.provider.ProviderEvent;
import org.keycloak.representations.idm.RealmRepresentation;
import java.util.Map;
import java.util.Set;
@ -142,6 +143,8 @@ public interface RealmModel extends RoleContainerModel {
void setPermanentLockout(boolean val);
int getMaxTemporaryLockouts();
void setMaxTemporaryLockouts(int val);
RealmRepresentation.BruteForceStrategy getBruteForceStrategy();
void setBruteForceStrategy(RealmRepresentation.BruteForceStrategy val);
int getMaxFailureWaitSeconds();
void setMaxFailureWaitSeconds(int val);
int getWaitIncrementSeconds();

View file

@ -33,6 +33,7 @@ import org.keycloak.models.RealmModel;
import org.keycloak.models.UserLoginFailureModel;
import org.keycloak.models.UserModel;
import org.keycloak.models.utils.KeycloakModelUtils;
import org.keycloak.representations.idm.RealmRepresentation;
import org.keycloak.storage.ReadOnlyException;
import jakarta.ws.rs.core.HttpHeaders;
@ -90,13 +91,18 @@ public class DefaultBruteForceProtector implements BruteForceProtector {
int waitSeconds = 0;
if(!(realm.isPermanentLockout() && realm.getMaxTemporaryLockouts() == 0)) {
waitSeconds = realm.getWaitIncrementSeconds() * (userLoginFailure.getNumFailures() / realm.getFailureFactor());
if(RealmRepresentation.BruteForceStrategy.MULTIPLE.equals(realm.getBruteForceStrategy())) {
waitSeconds = realm.getWaitIncrementSeconds() * (userLoginFailure.getNumFailures() / realm.getFailureFactor());
} else {
waitSeconds = realm.getWaitIncrementSeconds() * (1 + userLoginFailure.getNumFailures() - realm.getFailureFactor());
}
}
logger.debugv("waitSeconds: {0}", waitSeconds);
logger.debugv("deltaTime: {0}", deltaTime);
boolean quickLoginFailure = false;
if (waitSeconds == 0) {
if (waitSeconds <= 0) {
if (last > 0 && deltaTime < realm.getQuickLoginCheckMilliSeconds()) {
logger.debugv("quick login, set min wait seconds");
waitSeconds = realm.getMinimumQuickLoginWaitSeconds();

View file

@ -253,6 +253,7 @@ public class RealmManager {
realm.setBruteForceProtected(false); // default settings off for now todo set it on
realm.setPermanentLockout(false);
realm.setMaxTemporaryLockouts(0);
realm.setBruteForceStrategy(RealmRepresentation.BruteForceStrategy.MULTIPLE);
realm.setMaxFailureWaitSeconds(900);
realm.setMinimumQuickLoginWaitSeconds(60);
realm.setWaitIncrementSeconds(60);

View file

@ -112,6 +112,7 @@ public class BruteForceTest extends AbstractTestRealmKeycloakTest {
UserBuilder.edit(user).totpSecret("totpSecret").emailVerified(true);
testRealm.setBruteForceProtected(true);
testRealm.setBruteForceStrategy(RealmRepresentation.BruteForceStrategy.MULTIPLE);
testRealm.setFailureFactor(failureFactor);
testRealm.setMaxDeltaTimeSeconds(60);
testRealm.setMaxFailureWaitSeconds(100);
@ -131,6 +132,7 @@ public class BruteForceTest extends AbstractTestRealmKeycloakTest {
clearUserFailures();
clearAllUserFailures();
RealmRepresentation realm = adminClient.realm("test").toRepresentation();
realm.setBruteForceStrategy(RealmRepresentation.BruteForceStrategy.MULTIPLE);
realm.setFailureFactor(failureFactor);
realm.setMaxDeltaTimeSeconds(60);
realm.setMaxFailureWaitSeconds(100);
@ -501,6 +503,56 @@ public class BruteForceTest extends AbstractTestRealmKeycloakTest {
testingClient.testing().setTimeOffset(Collections.singletonMap("offset", String.valueOf(0)));
}
@Test
public void testByMultipleStrategy() throws Exception {
try {
UserRepresentation user = adminClient.realm("test").users().search("test-user@localhost", 0, 1).get(0);
loginSuccess();
loginInvalidPassword();
loginInvalidPassword();
expectTemporarilyDisabled();
assertUserNumberOfFailures(user.getId(), 2);
this.setTimeOffset(30);
loginInvalidPassword();
assertUserNumberOfFailures(user.getId(), 3);
this.setTimeOffset(60);
loginSuccess();
} finally {
this.resetTimeOffset();
}
}
@Test
public void testLinearStrategy() throws Exception {
RealmRepresentation realm = testRealm().toRepresentation();
UserRepresentation user = adminClient.realm("test").users().search("test-user@localhost", 0, 1).get(0);
try {
realm.setBruteForceStrategy(RealmRepresentation.BruteForceStrategy.LINEAR);
testRealm().update(realm);
loginSuccess();
loginInvalidPassword();
loginInvalidPassword();
expectTemporarilyDisabled();
assertUserNumberOfFailures(user.getId(), 2);
this.setTimeOffset(30);
loginInvalidPassword();
assertUserNumberOfFailures(user.getId(), 3);
this.setTimeOffset(60);
expectTemporarilyDisabled();
} finally {
realm.setPermanentLockout(false);
realm.setBruteForceStrategy(RealmRepresentation.BruteForceStrategy.MULTIPLE);
testRealm().update(realm);
this.resetTimeOffset();
}
}
@Test
public void testBrowserInvalidPasswordDifferentCase() throws Exception {
loginSuccess("test-user@localhost");