mirror of
https://github.com/keycloak/keycloak.git
synced 2026-05-26 13:50:48 +00:00
Separate password and OTP brute force protection to prevent OTP bypass attacks by default
Closes #46164 Signed-off-by: Peter Skopek <peter.skopek@ibm.com> Update model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/changes/remote/updater/loginfailures/LoginFailuresUpdater.java Co-authored-by: Pedro Ruivo <pruivo@users.noreply.github.com> Signed-off-by: Peter Skopek <peter.skopek@ibm.com> Add recovery codes to the list of brute force checked authenticators. Closes #46164 Signed-off-by: Peter Skopek <peter.skopek@ibm.com>
This commit is contained in:
committed by
Marek Posolda
parent
16341be6ac
commit
d11136f671
@@ -100,6 +100,7 @@ public class RealmRepresentation {
|
||||
protected Long quickLoginCheckMilliSeconds;
|
||||
protected Integer maxDeltaTimeSeconds;
|
||||
protected Integer failureFactor;
|
||||
protected Integer maxSecondaryAuthFailures;
|
||||
//--- end brute force settings
|
||||
|
||||
@Deprecated
|
||||
@@ -853,6 +854,14 @@ public class RealmRepresentation {
|
||||
this.failureFactor = failureFactor;
|
||||
}
|
||||
|
||||
public Integer getMaxSecondaryAuthFailures() {
|
||||
return maxSecondaryAuthFailures;
|
||||
}
|
||||
|
||||
public void setMaxSecondaryAuthFailures(Integer maxSecondaryAuthFailures) {
|
||||
this.maxSecondaryAuthFailures = maxSecondaryAuthFailures;
|
||||
}
|
||||
|
||||
public Boolean isEventsEnabled() {
|
||||
return eventsEnabled;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
|
||||
A brute force attack attempts to guess a user's password by trying to log in multiple times. {project_name} has brute force detection capabilities and can permanently or temporarily disable a user account if the number of login failures exceeds a specified threshold.
|
||||
|
||||
The protection is applied only to authentication mechanisms prone to the brute force attacks. {project_name} applies it only to password, OTP and recovery codes.
|
||||
|
||||
[NOTE]
|
||||
====
|
||||
When a user is locked and attempts to log in, {project_name} displays the default `Invalid username or password` error message. This message is the same error message as the message displayed for an invalid username or invalid password to ensure the attacker is unaware the account is disabled.
|
||||
@@ -49,17 +51,19 @@ image:images/brute-force-permanently.png[]
|
||||
|
||||
|===
|
||||
|
||||
*Permanent Lockout Flow*
|
||||
*Permanent Lockout Algorithm*
|
||||
|
||||
====
|
||||
. On successful login
|
||||
.. Reset `count`
|
||||
.. call <<_secondary-authn-check, `secondary authentication check (success)`>>
|
||||
. On failed login
|
||||
.. Increment `count`
|
||||
.. If `count` is greater than or equals to `Max login failures`
|
||||
... locks the user
|
||||
.. Else if the time between this failure and the last failure is less than _Quick Login Check Milliseconds_
|
||||
... Locks the user for the time specified at _Minimum Quick Login Wait_
|
||||
.. call <<_secondary-authn-check, `secondary authentication check (fail)`>>
|
||||
====
|
||||
|
||||
[NOTE]
|
||||
@@ -73,6 +77,7 @@ Enabling an user account resets the `count`.
|
||||
.Lockout temporarily
|
||||
image:images/brute-force-temporarily.png[]
|
||||
|
||||
|
||||
*Temporary Lockout Parameters*
|
||||
|
||||
|===
|
||||
@@ -113,6 +118,7 @@ wait time will never reach the value you have set to `Max wait`.
|
||||
====
|
||||
. On successful login
|
||||
.. Reset `count`
|
||||
.. call <<_secondary-authn-check, `secondary authentication check (success)`>>
|
||||
. On failed login
|
||||
.. If the time between this failure and the last failure is greater than _Failure Reset Time_
|
||||
... Reset `count`
|
||||
@@ -122,6 +128,7 @@ wait time will never reach the value you have set to `Max wait`.
|
||||
... set `wait` to _Minimum Quick Login Wait_
|
||||
.. if `wait` is greater than 0
|
||||
... Temporarily disable the user for the smallest of `wait` and _Max Wait_ seconds
|
||||
.. call <<_secondary-authn-check, `secondary authentication check (fail)`>>
|
||||
|
||||
====
|
||||
|
||||
@@ -227,6 +234,7 @@ wait time will never reach the value you have set to `Max wait`.
|
||||
. On successful login
|
||||
.. Reset `count`
|
||||
.. Reset `temporary lockout` counter
|
||||
.. call <<_secondary-authn-check, `secondary authentication check (success)`>>
|
||||
. On failed login
|
||||
.. If the time between this failure and the last failure is greater than _Failure Reset Time_
|
||||
... Reset `count`
|
||||
@@ -240,6 +248,7 @@ wait time will never reach the value you have set to `Max wait`.
|
||||
... set `wait` to the smallest of `wait` and _Max Wait_ seconds
|
||||
.. if `quick login failure` is `false`
|
||||
... Increment `temporary lockout` counter
|
||||
.. call <<_secondary-authn-check, `secondary authentication check (fail)`>>
|
||||
.. If `temporary lockout` counter exceeds `Maximum temporary lockouts`
|
||||
... Permanently locks the user
|
||||
.. Else
|
||||
@@ -252,6 +261,26 @@ wait time will never reach the value you have set to `Max wait`.
|
||||
`count` does not increment when a temporarily disabled account commits a login failure.
|
||||
====
|
||||
|
||||
==== Secondary Authentication Failures Lockout
|
||||
When this lockout mechanism is enabled it is valid for all brute force modes.
|
||||
The mechanism is enabled when non zero value is set for `Maximum Secondary Authentication Failures`. It is disabled by default (`0`).
|
||||
|
||||
If the maximum number secondary authentication failures is exceeded the account is permanently locked. This behavior prevents attacks on second factor authenticators that can actually be brute forced (currently only OTP), when attacker already guessed the password. The reasonable value might be set for `100`.
|
||||
|
||||
[[_secondary-authn-check]]
|
||||
*Secondary Authentication Check Algorithm*
|
||||
====
|
||||
. If not OTP auth mechanism is used or secondary authentication check is disabled
|
||||
.. return
|
||||
. If called with `success` argument
|
||||
.. reset `Maximum Secondary Authentication Failures`
|
||||
. If called with `failure` argument
|
||||
.. Increment `Maximum Secondary Authentication Failures` counter
|
||||
.. If `Maximum Secondary Authentication Failures` exceeds desired number (100)
|
||||
... Permanently locks the user
|
||||
|
||||
====
|
||||
|
||||
==== Downside of {project_name} brute force detection
|
||||
|
||||
The downside of {project_name} brute force detection is that the server becomes vulnerable to denial of service attacks. When implementing a denial of service attack, an attacker can attempt to log in by guessing passwords for any accounts it knows and eventually causing {project_name} to disable the accounts.
|
||||
|
||||
@@ -94,6 +94,12 @@ See the section https://www.keycloak.org/server/reverseproxy#graceful-http-shutd
|
||||
|
||||
With `proxy-headers` set to `xforwarded`, the server can determine the proxy context path from the `X-Forwarded-Prefix` header.
|
||||
|
||||
=== New brute force locking mechanism
|
||||
|
||||
There is a new brute force locking mechanism applied to all brute force modes. It is called `Secondary Authentication Failures Lockout`. Brute force protector is counting secondary authentication failures only (for example OTP) and when maximum specified number is reached the account is permanently locked even in temporary locking modes. This prevents attacks on second factor authenticators such as OTP when attacker already guessed the password.
|
||||
|
||||
This new brute force locking mechanism also changes original behavior of brute force protection. It is applied only to a subset of authentication mechanisms (passwords, OTP and recovery codes).
|
||||
|
||||
=== Client secret authentication method
|
||||
|
||||
OIDC specification has multiple client authentication methods. Two of them `client_secret_basic` and `client_secret_post` are implemented in {project_name} by **Client Id and Secret**
|
||||
|
||||
+2
@@ -724,6 +724,7 @@ anyAlgorithm=Any algorithm
|
||||
enableSSL=Enable SSL
|
||||
general=General
|
||||
failureFactor=Max login failures
|
||||
maxSecondaryAuthFailures=Maximum Secondary Authentication Failures
|
||||
updateClientPoliciesSuccess=The client policies configuration was updated
|
||||
advancedSettings=Advanced settings
|
||||
attributeValueHelp=Value the attribute must have. If the attribute is a list, then the value must be contained in the list.
|
||||
@@ -1940,6 +1941,7 @@ pkceEnabledHelp=Use PKCE (Proof of Key-code exchange) for IdP Brokering
|
||||
settings=Settings
|
||||
webAuthnPolicyUserVerificationRequirement=User verification requirement
|
||||
failureFactorHelp=Number of failures before wait is triggered.
|
||||
maxSecondaryAuthFailuresHelp=Maximum number of secondary authentication failures before permanent lockout is triggered. Account will be locked even in temporary lockout brute force modes. Value of 0 means the behavior is disabled.
|
||||
unlinkAccountTitle=Unlink account from {{provider}}?
|
||||
noNodes=No nodes registered
|
||||
singleLogoutServiceUrlHelp=The Url that must be used to send logout requests.
|
||||
|
||||
@@ -143,6 +143,15 @@ export const BruteForceDetection = ({
|
||||
rules: { required: t("required"), min: 0 },
|
||||
}}
|
||||
/>
|
||||
<NumberControl
|
||||
name="maxSecondaryAuthFailures"
|
||||
label={t("maxSecondaryAuthFailures")}
|
||||
labelIcon={t("maxSecondaryAuthFailuresHelp")}
|
||||
controller={{
|
||||
defaultValue: 100,
|
||||
rules: { required: t("required"), min: 0 },
|
||||
}}
|
||||
/>
|
||||
{bruteForceMode ===
|
||||
BruteForceMode.PermanentAfterTemporaryLockout && (
|
||||
<NumberControl
|
||||
|
||||
@@ -63,6 +63,7 @@ export default interface RealmRepresentation {
|
||||
eventsExpiration?: number;
|
||||
eventsListeners?: string[];
|
||||
failureFactor?: number;
|
||||
maxSecondaryAuthFailures?: number;
|
||||
federatedUsers?: UserRepresentation[];
|
||||
groups?: GroupRepresentation[];
|
||||
id?: string;
|
||||
|
||||
@@ -31,6 +31,7 @@ describe("Attack Detection", () => {
|
||||
});
|
||||
expect(attackDetection).to.deep.equal({
|
||||
numFailures: 0,
|
||||
numSecondaryAuthFailures: 0,
|
||||
disabled: false,
|
||||
lastIPFailure: "n/a",
|
||||
lastFailure: 0,
|
||||
|
||||
+12
@@ -418,6 +418,18 @@ public class RealmAdapter implements CachedRealmModel {
|
||||
updated.setFailureFactor(failureFactor);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getMaxSecondaryAuthFailures() {
|
||||
if (isUpdated()) return updated.getMaxSecondaryAuthFailures();
|
||||
return cached.getMaxSecondaryAuthFailures();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setMaxSecondaryAuthFailures(int maxSecondaryAuthFailures) {
|
||||
getDelegateForUpdate();
|
||||
updated.setMaxSecondaryAuthFailures(maxSecondaryAuthFailures);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isVerifyEmail() {
|
||||
if (isUpdated()) return updated.isVerifyEmail();
|
||||
|
||||
Vendored
+6
@@ -90,6 +90,7 @@ public class CachedRealm extends AbstractExtendableRevisioned {
|
||||
protected long quickLoginCheckMilliSeconds;
|
||||
protected int maxDeltaTimeSeconds;
|
||||
protected int failureFactor;
|
||||
protected int maxSecondaryAuthFailures;
|
||||
//--- end brute force settings
|
||||
|
||||
protected String defaultSignatureAlgorithm;
|
||||
@@ -208,6 +209,7 @@ public class CachedRealm extends AbstractExtendableRevisioned {
|
||||
quickLoginCheckMilliSeconds = model.getQuickLoginCheckMilliSeconds();
|
||||
maxDeltaTimeSeconds = model.getMaxDeltaTimeSeconds();
|
||||
failureFactor = model.getFailureFactor();
|
||||
maxSecondaryAuthFailures = model.getMaxSecondaryAuthFailures();
|
||||
//--- end brute force settings
|
||||
|
||||
defaultSignatureAlgorithm = model.getDefaultSignatureAlgorithm();
|
||||
@@ -412,6 +414,10 @@ public class CachedRealm extends AbstractExtendableRevisioned {
|
||||
return failureFactor;
|
||||
}
|
||||
|
||||
public int getMaxSecondaryAuthFailures() {
|
||||
return maxSecondaryAuthFailures;
|
||||
}
|
||||
|
||||
public boolean isVerifyEmail() {
|
||||
return verifyEmail;
|
||||
}
|
||||
|
||||
+34
@@ -160,4 +160,38 @@ public class UserLoginFailureAdapter implements UserLoginFailureModel {
|
||||
return key.toString();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getNumSecondaryAuthFailures() {
|
||||
return entity.getNumSecondaryAuthFailures();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void incrementSecondaryAuthFailures() {
|
||||
LoginFailuresUpdateTask task = new LoginFailuresUpdateTask() {
|
||||
|
||||
@Override
|
||||
public void runUpdate(LoginFailureEntity entity) {
|
||||
entity.setNumSecondaryAuthFailures(entity.getNumSecondaryAuthFailures() + 1);
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
update(task);
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public void clearPrimaryAndSecondaryAuthFailures() {
|
||||
LoginFailuresUpdateTask task = new LoginFailuresUpdateTask() {
|
||||
|
||||
@Override
|
||||
public void runUpdate(LoginFailureEntity entity) {
|
||||
entity.clearPrimaryAndSecondaryAuthFailures();
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
update(task);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
+18
@@ -149,6 +149,22 @@ public class LoginFailuresUpdater extends BaseUpdater<LoginFailureKey, LoginFail
|
||||
addAndApplyChange(e -> e.setLastIPFailure(ip));
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getNumSecondaryAuthFailures() {
|
||||
return getValue().getNumSecondaryAuthFailures();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void incrementSecondaryAuthFailures() {
|
||||
addAndApplyChange(INCREMENT_SECONDARY_AUTH_FAILURES);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void clearPrimaryAndSecondaryAuthFailures() {
|
||||
changes.clear();
|
||||
addAndApplyChange(CLEAR_PRIMARY_AND_SECONDARY_AUTH_FAILURES);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected boolean isUnchanged() {
|
||||
return changes.isEmpty();
|
||||
@@ -160,6 +176,8 @@ public class LoginFailuresUpdater extends BaseUpdater<LoginFailureKey, LoginFail
|
||||
}
|
||||
|
||||
private static final Consumer<LoginFailureEntity> CLEAR = LoginFailureEntity::clearFailures;
|
||||
private static final Consumer<LoginFailureEntity> CLEAR_PRIMARY_AND_SECONDARY_AUTH_FAILURES = LoginFailureEntity::clearPrimaryAndSecondaryAuthFailures;
|
||||
private static final Consumer<LoginFailureEntity> INCREMENT_FAILURES = e -> e.setNumFailures(e.getNumFailures() + 1);
|
||||
private static final Consumer<LoginFailureEntity> INCREMENT_SECONDARY_AUTH_FAILURES = e -> e.setNumSecondaryAuthFailures(e.getNumSecondaryAuthFailures() + 1);
|
||||
private static final Consumer<LoginFailureEntity> INCREMENT_LOCK_OUTS = e -> e.setNumTemporaryLockouts(e.getNumTemporaryLockouts() + 1);
|
||||
}
|
||||
|
||||
+19
-2
@@ -41,13 +41,15 @@ public class LoginFailureEntity extends SessionEntity {
|
||||
private long lastFailure;
|
||||
private String lastIPFailure;
|
||||
|
||||
private int numSecondaryAuthFailures;
|
||||
|
||||
public LoginFailureEntity(String realmId, String userId) {
|
||||
super(Objects.requireNonNull(realmId));
|
||||
this.userId = Objects.requireNonNull(userId);
|
||||
}
|
||||
|
||||
@ProtoFactory
|
||||
LoginFailureEntity(String realmId, String userId, int failedLoginNotBefore, int numFailures, int numTemporaryLockouts, long lastFailure, String lastIPFailure) {
|
||||
LoginFailureEntity(String realmId, String userId, int failedLoginNotBefore, int numFailures, int numTemporaryLockouts, long lastFailure, String lastIPFailure, int numSecondaryAuthFailures) {
|
||||
super(realmId);
|
||||
this.userId = userId;
|
||||
this.failedLoginNotBefore = failedLoginNotBefore;
|
||||
@@ -55,6 +57,7 @@ public class LoginFailureEntity extends SessionEntity {
|
||||
this.numTemporaryLockouts = numTemporaryLockouts;
|
||||
this.lastFailure = lastFailure;
|
||||
this.lastIPFailure = lastIPFailure;
|
||||
this.numSecondaryAuthFailures = numSecondaryAuthFailures;
|
||||
}
|
||||
|
||||
@ProtoField(2)
|
||||
@@ -107,6 +110,15 @@ public class LoginFailureEntity extends SessionEntity {
|
||||
return lastIPFailure;
|
||||
}
|
||||
|
||||
@ProtoField(8)
|
||||
public int getNumSecondaryAuthFailures() {
|
||||
return numSecondaryAuthFailures;
|
||||
}
|
||||
|
||||
public void setNumSecondaryAuthFailures(int numSecondaryAuthFailures) {
|
||||
this.numSecondaryAuthFailures = numSecondaryAuthFailures;
|
||||
}
|
||||
|
||||
public void setLastIPFailure(String lastIPFailure) {
|
||||
this.lastIPFailure = lastIPFailure;
|
||||
}
|
||||
@@ -119,6 +131,11 @@ public class LoginFailureEntity extends SessionEntity {
|
||||
this.lastIPFailure = null;
|
||||
}
|
||||
|
||||
public void clearPrimaryAndSecondaryAuthFailures() {
|
||||
clearFailures();
|
||||
this.numSecondaryAuthFailures = 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) return true;
|
||||
@@ -136,7 +153,7 @@ public class LoginFailureEntity extends SessionEntity {
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return String.format("LoginFailureEntity [ userId=%s, realm=%s, numFailures=%d ]", userId, getRealmId(), numFailures);
|
||||
return String.format("LoginFailureEntity [ userId=%s, realm=%s, numFailures=%d numSecondaryAuthFailures=%s]", userId, getRealmId(), numFailures, numSecondaryAuthFailures);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -391,6 +391,16 @@ public class RealmAdapter implements StorageProviderRealmModel, JpaModel<RealmEn
|
||||
setAttribute("failureFactor", failureFactor);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getMaxSecondaryAuthFailures() {
|
||||
return getAttribute("maxSecondaryAuthFailures", 0);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setMaxSecondaryAuthFailures(int maxSecondaryAuthFailures) {
|
||||
setAttribute("maxSecondaryAuthFailures", maxSecondaryAuthFailures);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isVerifyEmail() {
|
||||
return realm.isVerifyEmail();
|
||||
|
||||
+2
@@ -218,6 +218,7 @@ public class DefaultExportImportManager implements ExportImportManager {
|
||||
if (rep.getQuickLoginCheckMilliSeconds() != null) newRealm.setQuickLoginCheckMilliSeconds(checkNonNegativeNumber(rep.getQuickLoginCheckMilliSeconds().intValue(), "Quick login check milliseconds"));
|
||||
if (rep.getMaxDeltaTimeSeconds() != null) newRealm.setMaxDeltaTimeSeconds(checkNonNegativeNumber(rep.getMaxDeltaTimeSeconds(),"Maximum delta time seconds"));
|
||||
if (rep.getFailureFactor() != null) newRealm.setFailureFactor(checkNonNegativeNumber(rep.getFailureFactor(),"Failure factor"));
|
||||
if (rep.getMaxSecondaryAuthFailures() != null) newRealm.setMaxSecondaryAuthFailures(checkNonNegativeNumber(rep.getMaxSecondaryAuthFailures(),"Maximum secondary authentication failures"));
|
||||
if (rep.isEventsEnabled() != null) newRealm.setEventsEnabled(rep.isEventsEnabled());
|
||||
if (rep.getEnabledEventTypes() != null)
|
||||
newRealm.setEnabledEventTypes(new HashSet<>(rep.getEnabledEventTypes()));
|
||||
@@ -814,6 +815,7 @@ public class DefaultExportImportManager implements ExportImportManager {
|
||||
if (rep.getQuickLoginCheckMilliSeconds() != null) realm.setQuickLoginCheckMilliSeconds(checkNonNegativeNumber(rep.getQuickLoginCheckMilliSeconds().intValue(), "Quick login check milliseconds"));
|
||||
if (rep.getMaxDeltaTimeSeconds() != null) realm.setMaxDeltaTimeSeconds(checkNonNegativeNumber(rep.getMaxDeltaTimeSeconds(),"Maximum delta time seconds"));
|
||||
if (rep.getFailureFactor() != null) realm.setFailureFactor(checkNonNegativeNumber(rep.getFailureFactor(),"Failure factor"));
|
||||
if (rep.getMaxSecondaryAuthFailures() != null) realm.setMaxSecondaryAuthFailures(checkNonNegativeNumber(rep.getMaxSecondaryAuthFailures(), "Maximum secondary authentication failures"));
|
||||
if (rep.isRegistrationAllowed() != null) realm.setRegistrationAllowed(rep.isRegistrationAllowed());
|
||||
if (rep.isRegistrationEmailAsUsername() != null)
|
||||
realm.setRegistrationEmailAsUsername(rep.isRegistrationEmailAsUsername());
|
||||
|
||||
@@ -159,6 +159,7 @@ public class ModelToRepresentation {
|
||||
REALM_EXCLUDED_ATTRIBUTES.add("minimumQuickLoginWaitSeconds");
|
||||
REALM_EXCLUDED_ATTRIBUTES.add("maxDeltaTimeSeconds");
|
||||
REALM_EXCLUDED_ATTRIBUTES.add("failureFactor");
|
||||
REALM_EXCLUDED_ATTRIBUTES.add("maxSecondaryAuthFailures");
|
||||
REALM_EXCLUDED_ATTRIBUTES.add("actionTokenGeneratedByAdminLifespan");
|
||||
REALM_EXCLUDED_ATTRIBUTES.add("actionTokenGeneratedByUserLifespan");
|
||||
REALM_EXCLUDED_ATTRIBUTES.add("offlineSessionMaxLifespanEnabled");
|
||||
@@ -484,6 +485,7 @@ public class ModelToRepresentation {
|
||||
rep.setQuickLoginCheckMilliSeconds(realm.getQuickLoginCheckMilliSeconds());
|
||||
rep.setMaxDeltaTimeSeconds(realm.getMaxDeltaTimeSeconds());
|
||||
rep.setFailureFactor(realm.getFailureFactor());
|
||||
rep.setMaxSecondaryAuthFailures(realm.getMaxSecondaryAuthFailures());
|
||||
if (Profile.isFeatureEnabled(Profile.Feature.AUTHORIZATION)) {
|
||||
rep.setUserManagedAccessAllowed(realm.isUserManagedAccessAllowed());
|
||||
} else {
|
||||
|
||||
@@ -256,6 +256,14 @@ public class RealmModelDelegate implements RealmModel {
|
||||
delegate.setFailureFactor(failureFactor);
|
||||
}
|
||||
|
||||
public int getMaxSecondaryAuthFailures() {
|
||||
return delegate.getMaxSecondaryAuthFailures();
|
||||
}
|
||||
|
||||
public void setMaxSecondaryAuthFailures(int maxSecondaryAuthFailures) {
|
||||
delegate.setMaxSecondaryAuthFailures(maxSecondaryAuthFailures);
|
||||
}
|
||||
|
||||
public boolean isVerifyEmail() {
|
||||
return delegate.isVerifyEmail();
|
||||
}
|
||||
|
||||
+2
-2
@@ -32,9 +32,9 @@ import org.keycloak.provider.Provider;
|
||||
public interface BruteForceProtector extends Provider {
|
||||
String DISABLED_BY_PERMANENT_LOCKOUT = "permanentLockout";
|
||||
|
||||
void failedLogin(RealmModel realm, UserModel user, ClientConnection clientConnection, UriInfo uriInfo);
|
||||
void failedLogin(RealmModel realm, UserModel user, ClientConnection clientConnection, UriInfo uriInfo, String authenticationCategory);
|
||||
|
||||
void successfulLogin(RealmModel realm, UserModel user, ClientConnection clientConnection, UriInfo uriInfo);
|
||||
void successfulLogin(RealmModel realm, UserModel user, ClientConnection clientConnection, UriInfo uriInfo, String authenticationCategory);
|
||||
|
||||
boolean isTemporarilyDisabled(KeycloakSession session, RealmModel realm, UserModel user);
|
||||
|
||||
|
||||
+10
@@ -779,6 +779,16 @@ public class IdentityBrokerStateTestHelpers {
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getMaxSecondaryAuthFailures() {
|
||||
return 100;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setMaxSecondaryAuthFailures(int maxSecondaryAuthFailures) {
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isVerifyEmail() {
|
||||
return false;
|
||||
|
||||
@@ -187,6 +187,8 @@ public interface RealmModel extends RoleContainerModel {
|
||||
void setMaxDeltaTimeSeconds(int val);
|
||||
int getFailureFactor();
|
||||
void setFailureFactor(int failureFactor);
|
||||
int getMaxSecondaryAuthFailures();
|
||||
void setMaxSecondaryAuthFailures(int maxSecondaryAuthFailures);
|
||||
//--- end brute force settings
|
||||
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ public interface UserLoginFailureModel {
|
||||
void setLastFailure(long lastFailure);
|
||||
String getLastIPFailure();
|
||||
void setLastIPFailure(String ip);
|
||||
|
||||
|
||||
int getNumSecondaryAuthFailures();
|
||||
void incrementSecondaryAuthFailures();
|
||||
void clearPrimaryAndSecondaryAuthFailures();
|
||||
}
|
||||
|
||||
@@ -764,11 +764,11 @@ public class AuthenticationProcessor {
|
||||
}
|
||||
}
|
||||
|
||||
public void logFailure() {
|
||||
public void logFailure(String executionId) {
|
||||
if (realm.isBruteForceProtected()) {
|
||||
UserModel user = AuthenticationManager.lookupUserForBruteForceLog(session, realm, authenticationSession);
|
||||
if (user != null) {
|
||||
getBruteForceProtector().failedLogin(realm, user, connection, session.getContext().getHttpRequest().getUri());
|
||||
getBruteForceProtector().failedLogin(realm, user, connection, session.getContext().getHttpRequest().getUri(), AuthenticationManager.getAuthenticationCategory(session, executionId));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1062,7 +1062,7 @@ public class AuthenticationProcessor {
|
||||
AuthenticationExecutionModel model = realm.getAuthenticationExecutionById(execution);
|
||||
if (model == null) {
|
||||
logger.debug("Cannot find execution, reseting flow");
|
||||
logFailure();
|
||||
logFailure(null);
|
||||
resetFlow();
|
||||
return authenticate();
|
||||
}
|
||||
|
||||
@@ -516,7 +516,7 @@ public class DefaultAuthenticationFlow implements AuthenticationFlow {
|
||||
return null;
|
||||
case FAILED:
|
||||
logger.debugv("authenticator FAILED: {0}", execution.getAuthenticator());
|
||||
processor.logFailure();
|
||||
processor.logFailure(execution.getAuthenticator());
|
||||
setExecutionStatus(execution, AuthenticationSessionModel.ExecutionStatus.FAILED);
|
||||
if (result.getChallenge() != null) {
|
||||
return sendChallenge(result, execution);
|
||||
@@ -532,7 +532,7 @@ public class DefaultAuthenticationFlow implements AuthenticationFlow {
|
||||
return sendChallenge(result, execution);
|
||||
case FAILURE_CHALLENGE:
|
||||
logger.debugv("authenticator FAILURE_CHALLENGE: {0}", execution.getAuthenticator());
|
||||
processor.logFailure();
|
||||
processor.logFailure(execution.getAuthenticator());
|
||||
setExecutionStatus(execution, AuthenticationSessionModel.ExecutionStatus.CHALLENGED);
|
||||
return sendChallenge(result, execution);
|
||||
case ATTEMPTED:
|
||||
|
||||
@@ -228,7 +228,7 @@ public class FormAuthenticationFlow implements AuthenticationFlow {
|
||||
}
|
||||
|
||||
if (!errors.isEmpty()) {
|
||||
processor.logFailure();
|
||||
processor.logFailure(null);
|
||||
List<FormMessage> messages = new LinkedList<>();
|
||||
Set<String> fields = new HashSet<>();
|
||||
for (ValidationContextImpl v : errors) {
|
||||
|
||||
+2
-2
@@ -86,7 +86,7 @@ public class ValidateOTP extends AbstractDirectGrantAuthenticator implements Cre
|
||||
return;
|
||||
}
|
||||
|
||||
context.success();
|
||||
context.success(OTPCredentialModel.TYPE);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -117,7 +117,7 @@ public class ValidateOTP extends AbstractDirectGrantAuthenticator implements Cre
|
||||
|
||||
@Override
|
||||
public String getReferenceCategory() {
|
||||
return null;
|
||||
return OTPCredentialModel.TYPE;
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
+3
-2
@@ -31,6 +31,7 @@ import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.models.RealmModel;
|
||||
import org.keycloak.models.UserCredentialModel;
|
||||
import org.keycloak.models.UserModel;
|
||||
import org.keycloak.models.credential.PasswordCredentialModel;
|
||||
import org.keycloak.provider.ProviderConfigProperty;
|
||||
import org.keycloak.representations.idm.CredentialRepresentation;
|
||||
import org.keycloak.services.managers.AuthenticationManager;
|
||||
@@ -55,7 +56,7 @@ public class ValidatePassword extends AbstractDirectGrantAuthenticator {
|
||||
return;
|
||||
}
|
||||
context.getAuthenticationSession().setAuthNote(AuthenticationManager.PASSWORD_VALIDATED, "true");
|
||||
context.success();
|
||||
context.success(PasswordCredentialModel.TYPE);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -86,7 +87,7 @@ public class ValidatePassword extends AbstractDirectGrantAuthenticator {
|
||||
|
||||
@Override
|
||||
public String getReferenceCategory() {
|
||||
return null;
|
||||
return PasswordCredentialModel.TYPE;
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -47,6 +47,8 @@ import org.keycloak.TokenVerifier;
|
||||
import org.keycloak.TokenVerifier.TokenTypeCheck;
|
||||
import org.keycloak.authentication.AuthenticationFlowException;
|
||||
import org.keycloak.authentication.AuthenticationProcessor;
|
||||
import org.keycloak.authentication.Authenticator;
|
||||
import org.keycloak.authentication.AuthenticatorFactory;
|
||||
import org.keycloak.authentication.AuthenticatorUtil;
|
||||
import org.keycloak.authentication.InitiatedActionSupport;
|
||||
import org.keycloak.authentication.RequiredActionContext;
|
||||
@@ -88,6 +90,7 @@ import org.keycloak.models.SingleUseObjectProvider;
|
||||
import org.keycloak.models.UserConsentModel;
|
||||
import org.keycloak.models.UserModel;
|
||||
import org.keycloak.models.UserSessionModel;
|
||||
import org.keycloak.models.credential.OTPCredentialModel;
|
||||
import org.keycloak.models.utils.DefaultRequiredActions;
|
||||
import org.keycloak.models.utils.KeycloakModelUtils;
|
||||
import org.keycloak.models.utils.SessionExpirationUtils;
|
||||
@@ -1730,11 +1733,24 @@ public class AuthenticationManager {
|
||||
UserModel user = lookupUserForBruteForceLog(session, realm, authSession);
|
||||
if (user != null) {
|
||||
BruteForceProtector bruteForceProtector = session.getProvider(BruteForceProtector.class);
|
||||
bruteForceProtector.successfulLogin(realm, user, session.getContext().getConnection(), session.getContext().getHttpRequest().getUri());
|
||||
bruteForceProtector.successfulLogin(
|
||||
realm,
|
||||
user,
|
||||
session.getContext().getConnection(),
|
||||
session.getContext().getHttpRequest().getUri(),
|
||||
AuthenticatorUtil.getAuthnCredentials(authSession).contains(OTPCredentialModel.TYPE) ? OTPCredentialModel.TYPE : null
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static String getAuthenticationCategory(KeycloakSession session, String authenticator) {
|
||||
if (authenticator == null) return null;
|
||||
|
||||
AuthenticatorFactory factory = (AuthenticatorFactory) session.getKeycloakSessionFactory().getProviderFactory(Authenticator.class, authenticator);
|
||||
return factory != null ? factory.getReferenceCategory() : null;
|
||||
}
|
||||
|
||||
public static UserModel lookupUserForBruteForceLog(KeycloakSession session, RealmModel realm, AuthenticationSessionModel authenticationSession) {
|
||||
UserModel user = authenticationSession.getAuthenticatedUser();
|
||||
if (user != null) return user;
|
||||
|
||||
+7
-7
@@ -135,23 +135,23 @@ public class DefaultBlockingBruteForceProtector extends DefaultBruteForceProtect
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void processLogin(RealmModel realm, UserModel user, ClientConnection clientConnection, UriInfo uriInfo, boolean success) {
|
||||
protected void processLogin(RealmModel realm, UserModel user, ClientConnection clientConnection, UriInfo uriInfo, boolean success, String category) {
|
||||
// mark the off-thread is started for this request
|
||||
loginAttempts.computeIfPresent(user.getId(), (k, v) -> v + OFF_THREAD_STARTED);
|
||||
super.processLogin(realm, user, clientConnection, uriInfo, success);
|
||||
super.processLogin(realm, user, clientConnection, uriInfo, success, category);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void failure(KeycloakSession session, RealmModel realm, String userId, String remoteAddr, long failureTime) {
|
||||
protected void failure(KeycloakSession session, RealmModel realm, String userId, String remoteAddr, long failureTime, String category) {
|
||||
// remove the user from concurrent login attemps once it's processed
|
||||
enlistRemoval(session, userId);
|
||||
super.failure(session, realm, userId, remoteAddr, failureTime);
|
||||
super.failure(session, realm, userId, remoteAddr, failureTime, category);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void success(KeycloakSession session, RealmModel realm, String userId) {
|
||||
// remove the user from concurrent login attemps once it's processed
|
||||
protected void success(KeycloakSession session, RealmModel realm, String userId, String category) {
|
||||
// remove the user from concurrent login attempts once it's processed
|
||||
enlistRemoval(session, userId);
|
||||
super.success(session, realm, userId);
|
||||
super.success(session, realm, userId, category);
|
||||
}
|
||||
}
|
||||
|
||||
+72
-26
@@ -21,6 +21,8 @@ import java.security.cert.X509Certificate;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
|
||||
import jakarta.ws.rs.core.HttpHeaders;
|
||||
@@ -43,6 +45,9 @@ import org.keycloak.models.KeycloakSessionFactory;
|
||||
import org.keycloak.models.RealmModel;
|
||||
import org.keycloak.models.UserLoginFailureModel;
|
||||
import org.keycloak.models.UserModel;
|
||||
import org.keycloak.models.credential.OTPCredentialModel;
|
||||
import org.keycloak.models.credential.PasswordCredentialModel;
|
||||
import org.keycloak.models.credential.RecoveryAuthnCodesCredentialModel;
|
||||
import org.keycloak.models.utils.KeycloakModelUtils;
|
||||
import org.keycloak.representations.idm.RealmRepresentation;
|
||||
import org.keycloak.storage.ReadOnlyException;
|
||||
@@ -60,6 +65,16 @@ import static org.keycloak.models.UserModel.DISABLED_REASON;
|
||||
public class DefaultBruteForceProtector implements BruteForceProtector {
|
||||
private static final Logger logger = Logger.getLogger(DefaultBruteForceProtector.class);
|
||||
|
||||
public static final List<String> ALLOWED_AUTHENTICATION_CATEGORIES = new ArrayList<>(
|
||||
List.of(
|
||||
PasswordCredentialModel.TYPE,
|
||||
OTPCredentialModel.TYPE,
|
||||
RecoveryAuthnCodesCredentialModel.TYPE
|
||||
)
|
||||
);
|
||||
|
||||
public static final String OTP_CATEGORY = OTPCredentialModel.TYPE;
|
||||
|
||||
protected int maxDeltaTimeSeconds = 60 * 60 * 12; // 12 hours
|
||||
protected KeycloakSessionFactory factory;
|
||||
|
||||
@@ -67,8 +82,9 @@ public class DefaultBruteForceProtector implements BruteForceProtector {
|
||||
this.factory = factory;
|
||||
}
|
||||
|
||||
protected void failure(KeycloakSession session, RealmModel realm, String userId, String remoteAddr, long failureTime) {
|
||||
logger.debug("failure");
|
||||
protected void failure(KeycloakSession session, RealmModel realm, String userId, String remoteAddr, long failureTime, String category) {
|
||||
logger.debugf("failure (category=%s)", category);
|
||||
if (category != null && !ALLOWED_AUTHENTICATION_CATEGORIES.contains(category)) return;
|
||||
|
||||
UserLoginFailureModel userLoginFailure = getUserFailureModel(session, realm, userId);
|
||||
if (userLoginFailure == null) {
|
||||
@@ -89,7 +105,7 @@ public class DefaultBruteForceProtector implements BruteForceProtector {
|
||||
}
|
||||
}
|
||||
userLoginFailure.incrementFailures();
|
||||
logger.debugv("new num failures: {0}", userLoginFailure.getNumFailures());
|
||||
logger.debugf("new num failures: %s", userLoginFailure.getNumFailures());
|
||||
|
||||
long waitSeconds = 0L;
|
||||
if (!(realm.isPermanentLockout() && realm.getMaxTemporaryLockouts() == 0)) {
|
||||
@@ -128,28 +144,44 @@ public class DefaultBruteForceProtector implements BruteForceProtector {
|
||||
}
|
||||
}
|
||||
|
||||
if (OTP_CATEGORY.equals(category)) {
|
||||
int maxSecondaryAuthFailures = realm.getMaxSecondaryAuthFailures();
|
||||
boolean lockoutEnabled = maxSecondaryAuthFailures > 0;
|
||||
userLoginFailure.incrementSecondaryAuthFailures();
|
||||
logger.debugv("new num secondaryAuthFailures: {0}", Integer.valueOf(userLoginFailure.getNumSecondaryAuthFailures()));
|
||||
if (lockoutEnabled && userLoginFailure.getNumSecondaryAuthFailures() > maxSecondaryAuthFailures) {
|
||||
// permanently lock user account anyway
|
||||
permanentUserLockOut(session, realm, userLoginFailure);
|
||||
}
|
||||
}
|
||||
|
||||
if(!realm.isPermanentLockout()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if(userLoginFailure.getNumTemporaryLockouts() > realm.getMaxTemporaryLockouts() ||
|
||||
(realm.getMaxTemporaryLockouts() == 0 && userLoginFailure.getNumFailures() >= realm.getFailureFactor())) {
|
||||
UserModel user = session.users().getUserById(realm, userId);
|
||||
if (user == null) {
|
||||
return;
|
||||
}
|
||||
logger.debugv("user {0} locked permanently due to too many login attempts", user.getUsername());
|
||||
user.setEnabled(false);
|
||||
try {
|
||||
user.setSingleAttribute(DISABLED_REASON, DISABLED_BY_PERMANENT_LOCKOUT);
|
||||
}catch (ReadOnlyException e){
|
||||
logger.debug("Cannot set disabled reason on read only user");
|
||||
}
|
||||
// Send event
|
||||
sendEvent(session, realm, userLoginFailure, EventType.USER_DISABLED_BY_PERMANENT_LOCKOUT);
|
||||
permanentUserLockOut(session, realm, userLoginFailure);
|
||||
}
|
||||
}
|
||||
|
||||
private void permanentUserLockOut(KeycloakSession session, RealmModel realm, UserLoginFailureModel userLoginFailure) {
|
||||
UserModel user = session.users().getUserById(realm, userLoginFailure.getUserId());
|
||||
if (user == null) {
|
||||
return;
|
||||
}
|
||||
logger.debugv("user {0} locked permanently due to too many login attempts", user.getUsername());
|
||||
user.setEnabled(false);
|
||||
try {
|
||||
user.setSingleAttribute(DISABLED_REASON, DISABLED_BY_PERMANENT_LOCKOUT);
|
||||
}catch (ReadOnlyException e){
|
||||
logger.debug("Cannot set disabled reason on read only user");
|
||||
}
|
||||
// Send event
|
||||
sendEvent(session, realm, userLoginFailure, EventType.USER_DISABLED_BY_PERMANENT_LOCKOUT);
|
||||
}
|
||||
|
||||
|
||||
protected UserLoginFailureModel getUserFailureModel(KeycloakSession session, RealmModel realm, String userId) {
|
||||
if (realm == null) return null;
|
||||
return session.loginFailures().getUserLoginFailure(realm, userId);
|
||||
@@ -177,19 +209,29 @@ public class DefaultBruteForceProtector implements BruteForceProtector {
|
||||
|
||||
public void shutdown() {}
|
||||
|
||||
protected void success(KeycloakSession session, RealmModel realm, String userId) {
|
||||
protected void success(KeycloakSession session, RealmModel realm, String userId, String category) {
|
||||
UserLoginFailureModel userLoginFailure = getUserFailureModel(session, realm, userId);
|
||||
if(userLoginFailure == null) return;
|
||||
if (logger.isDebugEnabled()) {
|
||||
UserModel model = session.users().getUserById(realm, userId);
|
||||
logger.debugv("user {0} successfully logged in, clearing all failures", model.getUsername());
|
||||
logger.debugv("user {0} successfully logged in:", model.getUsername());
|
||||
}
|
||||
if (OTP_CATEGORY.equals(category)) {
|
||||
logger.debug("clearing primary and secondary (OTP) failures");
|
||||
userLoginFailure.clearPrimaryAndSecondaryAuthFailures();
|
||||
} else {
|
||||
logger.debug("clearing primary failures");
|
||||
userLoginFailure.clearFailures();
|
||||
}
|
||||
userLoginFailure.clearFailures();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void failedLogin(RealmModel realm, UserModel user, ClientConnection clientConnection, UriInfo uriInfo) {
|
||||
processLogin(realm, user, clientConnection, uriInfo, false);
|
||||
public void failedLogin(RealmModel realm, UserModel user, ClientConnection clientConnection, UriInfo uriInfo, String authenticationCategory) {
|
||||
if (authenticationCategory != null && !ALLOWED_AUTHENTICATION_CATEGORIES.contains(authenticationCategory)) {
|
||||
logger.debugf("'%s' authentication category not allowed for brute force", authenticationCategory);
|
||||
return;
|
||||
}
|
||||
processLogin(realm, user, clientConnection, uriInfo, false, authenticationCategory);
|
||||
// wait a minimum of seconds for type to process so that a hacker
|
||||
// cannot flood with failed logins and overwhelm the queue and not have notBefore updated to block next requests
|
||||
// todo failure HTTP responses should be queued via async HTTP
|
||||
@@ -198,12 +240,16 @@ public class DefaultBruteForceProtector implements BruteForceProtector {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void successfulLogin(RealmModel realm, UserModel user, ClientConnection clientConnection, UriInfo uriInfo) {
|
||||
processLogin(realm, user, clientConnection, uriInfo, true);
|
||||
public void successfulLogin(RealmModel realm, UserModel user, ClientConnection clientConnection, UriInfo uriInfo, String authenticationCategory) {
|
||||
if (authenticationCategory != null && !ALLOWED_AUTHENTICATION_CATEGORIES.contains(authenticationCategory)) {
|
||||
logger.debugf("'%s' authentication category not allowed for brute force", authenticationCategory);
|
||||
return;
|
||||
}
|
||||
processLogin(realm, user, clientConnection, uriInfo, true, authenticationCategory);
|
||||
logger.trace("sent success event");
|
||||
}
|
||||
|
||||
protected void processLogin(RealmModel realm, UserModel user, ClientConnection clientConnection, UriInfo uriInfo, boolean success) {
|
||||
protected void processLogin(RealmModel realm, UserModel user, ClientConnection clientConnection, UriInfo uriInfo, boolean success, String category) {
|
||||
ExecutorService executor = KeycloakModelUtils.runJobInTransactionWithResult(factory, session -> {
|
||||
ExecutorsProvider provider = session.getProvider(ExecutorsProvider.class);
|
||||
return provider.getExecutor("bruteforce");
|
||||
@@ -215,9 +261,9 @@ public class DefaultBruteForceProtector implements BruteForceProtector {
|
||||
s.getContext().setHttpRequest(bruteForceHttpRequest);
|
||||
s.getContext().setHttpResponse(bruteForceHttpResponse);
|
||||
if (success) {
|
||||
success(s, realm, user.getId());
|
||||
success(s, realm, user.getId(), category);
|
||||
} else {
|
||||
failure(s, realm, user.getId(), clientConnection.getRemoteHost(), Time.currentTimeMillis());
|
||||
failure(s, realm, user.getId(), clientConnection.getRemoteHost(), Time.currentTimeMillis(), category);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -277,6 +277,7 @@ public class RealmManager {
|
||||
realm.setQuickLoginCheckMilliSeconds(1000);
|
||||
realm.setMaxDeltaTimeSeconds(60 * 60 * 12); // 12 hours
|
||||
realm.setFailureFactor(30);
|
||||
realm.setMaxSecondaryAuthFailures(0);
|
||||
realm.setSslRequired(SslRequired.EXTERNAL);
|
||||
realm.setOTPPolicy(OTPPolicy.DEFAULT_POLICY);
|
||||
realm.setLoginWithEmailAllowed(true);
|
||||
|
||||
+2
@@ -97,6 +97,7 @@ public class AttackDetectionResource {
|
||||
Map<String, Object> data = new HashMap<>();
|
||||
data.put("disabled", false);
|
||||
data.put("numFailures", 0);
|
||||
data.put("numSecondaryAuthFailures", 0);
|
||||
data.put("numTemporaryLockouts", 0);
|
||||
data.put("lastFailure", 0);
|
||||
data.put("lastIPFailure", "n/a");
|
||||
@@ -118,6 +119,7 @@ public class AttackDetectionResource {
|
||||
}
|
||||
|
||||
data.put("numFailures", model.getNumFailures());
|
||||
data.put("numSecondaryAuthFailures", model.getNumSecondaryAuthFailures());
|
||||
data.put("numTemporaryLockouts", model.getNumTemporaryLockouts());
|
||||
data.put("lastFailure", model.getLastFailure());
|
||||
data.put("lastIPFailure", model.getLastIPFailure());
|
||||
|
||||
@@ -430,6 +430,16 @@ public class SamlProtocolTest {
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getMaxSecondaryAuthFailures() {
|
||||
return 100;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setMaxSecondaryAuthFailures(int maxSecondaryAuthFailures) {
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isVerifyEmail() {
|
||||
return false;
|
||||
|
||||
@@ -107,7 +107,7 @@ public class AttackDetectionResourceTest {
|
||||
}
|
||||
|
||||
private void assertBruteForce(Map<String, Object> status, Integer expectedNumFailures, Integer expectedNumTemporaryLockouts, Boolean expectedFailure, Boolean expectedDisabled) {
|
||||
assertEquals(6, status.size());
|
||||
assertEquals(7, status.size());
|
||||
assertEquals(expectedNumFailures, status.get("numFailures"));
|
||||
assertEquals(expectedNumTemporaryLockouts, status.get("numTemporaryLockouts"));
|
||||
assertEquals(expectedDisabled, status.get("disabled"));
|
||||
|
||||
+9
@@ -116,11 +116,20 @@ public class RealmAttributeUpdater extends ServerResourceUpdater<RealmAttributeU
|
||||
return this;
|
||||
}
|
||||
|
||||
public RealmAttributeUpdater setFailureFactor(Integer value) {
|
||||
rep.setFailureFactor(value);
|
||||
return this;
|
||||
}
|
||||
public RealmAttributeUpdater setMaxDeltaTimeSeconds(Integer value) {
|
||||
rep.setMaxDeltaTimeSeconds(value);
|
||||
return this;
|
||||
}
|
||||
|
||||
public RealmAttributeUpdater setMaxSecondaryAuthFailures(Integer value) {
|
||||
rep.setMaxSecondaryAuthFailures(value);
|
||||
return this;
|
||||
}
|
||||
|
||||
public RealmAttributeUpdater setEventsListeners(List<String> eventListanets) {
|
||||
rep.setEventsListeners(eventListanets);
|
||||
return this;
|
||||
|
||||
+52
@@ -124,6 +124,7 @@ public class BruteForceTest extends AbstractChangeImportedUserPasswordsTest {
|
||||
testRealm.setMaxFailureWaitSeconds(100);
|
||||
testRealm.setWaitIncrementSeconds(20);
|
||||
testRealm.setOtpPolicyCodeReusable(true);
|
||||
testRealm.setMaxSecondaryAuthFailures(10);
|
||||
|
||||
RealmRepUtil.findClientByClientId(testRealm, "test-app").setDirectAccessGrantsEnabled(true);
|
||||
testRealm.getUsers().add(UserBuilder.create().username("user2").email("user2@localhost").password(generatePassword("user2")).build());
|
||||
@@ -396,6 +397,46 @@ public class BruteForceTest extends AbstractChangeImportedUserPasswordsTest {
|
||||
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testGrantPermamentOtpAbsolut() throws Exception {
|
||||
try (RealmAttributeUpdater updater = new RealmAttributeUpdater(testRealm())
|
||||
.setFailureFactor(120) // more than 100
|
||||
.setQuickLoginCheckMilliSeconds(0L) // allow fail OTP
|
||||
.update()) {
|
||||
// assert that brute force is checking for secondary auth failures
|
||||
Assert.assertTrue(testRealm().toRepresentation().getMaxSecondaryAuthFailures() > 0);
|
||||
{ // successful login
|
||||
String totpSecret = totp.generateTOTP("totpSecret");
|
||||
AccessTokenResponse response = getTestToken(getPassword("test-user@localhost"), totpSecret);
|
||||
Assert.assertNotNull(response.getAccessToken());
|
||||
Assert.assertNull(response.getError());
|
||||
events.clear();
|
||||
}
|
||||
for (int i = 0; i <= testRealm().toRepresentation().getMaxSecondaryAuthFailures(); i++) {
|
||||
AccessTokenResponse response = getTestToken(getPassword("test-user@localhost"), null);
|
||||
Assert.assertNull(response.getAccessToken());
|
||||
Assert.assertEquals("invalid_grant", response.getError());
|
||||
Assert.assertEquals("Invalid user credentials", response.getErrorDescription());
|
||||
events.clear();
|
||||
}
|
||||
{
|
||||
String totpSecret = totp.generateTOTP("totpSecret");
|
||||
AccessTokenResponse response = getTestToken(getPassword("test-user@localhost"), totpSecret);
|
||||
assertTokenNull(response);
|
||||
Assert.assertNotNull(response.getError());
|
||||
Assert.assertEquals("invalid_grant", response.getError());
|
||||
Assert.assertEquals("Invalid user credentials", response.getErrorDescription());
|
||||
|
||||
assertUserDisabledReason(BruteForceProtector.DISABLED_BY_PERMANENT_LOCKOUT);
|
||||
}
|
||||
} finally {
|
||||
UserRepresentation user = adminClient.realm("test").users().search("test-user@localhost", 0, 1).get(0);
|
||||
user.setEnabled(true);
|
||||
updateUser(user);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
public void testNumberOfFailuresForDisabledUsersWithPasswordGrantType() {
|
||||
try {
|
||||
@@ -727,6 +768,17 @@ public class BruteForceTest extends AbstractChangeImportedUserPasswordsTest {
|
||||
continueLoginWithCorrectTotpExpectFailure();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testBrowserInvalidTotpAbsolut() {
|
||||
// assert that brute force is checking for secondary auth failures
|
||||
Assert.assertTrue(testRealm().toRepresentation().getMaxSecondaryAuthFailures() > 0);
|
||||
loginWithTotpFailure();
|
||||
for (int i = 0; i < testRealm().toRepresentation().getMaxSecondaryAuthFailures(); i++ ) {
|
||||
continueLoginWithInvalidTotp();
|
||||
}
|
||||
continueLoginWithCorrectTotpExpectFailure();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testBrowserMissingTotp() {
|
||||
loginSuccess();
|
||||
|
||||
+2
@@ -439,6 +439,7 @@ public class RecoveryAuthnCodesAuthenticatorTest extends AbstractChangeImportedU
|
||||
configureBrowserFlowWithRecoveryAuthnCodes(testingClient, 0);
|
||||
RealmRepresentation rep = testRealm().toRepresentation();
|
||||
rep.setBruteForceProtected(true);
|
||||
rep.setMaxSecondaryAuthFailures(100);
|
||||
testRealm().update(rep);
|
||||
|
||||
List<String> generatedRecoveryAuthnCodes = createRecoveryAuthnCodesForUser();
|
||||
@@ -465,6 +466,7 @@ public class RecoveryAuthnCodesAuthenticatorTest extends AbstractChangeImportedU
|
||||
} finally {
|
||||
RealmRepresentation rep = testRealm().toRepresentation();
|
||||
rep.setBruteForceProtected(false);
|
||||
rep.setMaxSecondaryAuthFailures(0);
|
||||
testRealm().update(rep);
|
||||
// Revert copy of browser flow to original to keep clean slate after this test
|
||||
BrowserFlowTest.revertFlows(testRealm(), BROWSER_FLOW_WITH_RECOVERY_AUTHN_CODES);
|
||||
|
||||
Reference in New Issue
Block a user