From 921b10ee804224ff60396c0c70af5f97b612ba10 Mon Sep 17 00:00:00 2001 From: Christian Glasmachers <43938163+chehrhar@users.noreply.github.com> Date: Wed, 10 Dec 2025 11:20:19 +0100 Subject: [PATCH] Login failure cache: Evict entries after the configured failure reset time Closes #44801 Signed-off-by: Alexander Schwartz Signed-off-by: Alexander Schwartz Signed-off-by: Pedro Ruivo Co-authored-by: Christian Glasmachers Co-authored-by: Alexander Schwartz Co-authored-by: Alexander Schwartz Co-authored-by: Pedro Ruivo --- .../topics/threat/brute-force.adoc | 8 +- .../topics/changes/changes-26_5_0.adoc | 6 ++ .../models/cache/infinispan/RealmAdapter.java | 47 +++++++++++ .../InfinispanUserLoginFailureProvider.java | 36 +++++++- .../loginfailures/LoginFailuresUpdater.java | 7 +- .../infinispan/query/LoginFailureQueries.java | 47 +++++++++++ .../RemoteUserLoginFailureProvider.java | 37 ++++++++ .../infinispan/util/SessionTimeouts.java | 18 +++- .../models/utils/KeycloakModelUtils.java | 4 +- .../models/UserLoginFailureProvider.java | 5 ++ .../testsuite/forms/BruteForceTest.java | 84 +++++++++++-------- .../model/UserSessionProviderTest.java | 2 + .../model/infinispan/RetryAndBackOffTest.java | 8 ++ .../loginfailure/RemoteLoginFailureTest.java | 4 + 14 files changed, 269 insertions(+), 44 deletions(-) create mode 100644 model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/query/LoginFailureQueries.java diff --git a/docs/documentation/server_admin/topics/threat/brute-force.adoc b/docs/documentation/server_admin/topics/threat/brute-force.adoc index 63042fc7fac..1340ea34eb6 100644 --- a/docs/documentation/server_admin/topics/threat/brute-force.adoc +++ b/docs/documentation/server_admin/topics/threat/brute-force.adoc @@ -19,7 +19,7 @@ To enable this protection: . Click *Realm Settings* in the menu . Click the *Security Defenses* tab. . Click the *Brute Force Detection* tab. -. Choose the *Brute Force Mode* which best fit to your requirements. +. Choose the *Brute Force Mode* which best fit to your requirements. + .Brute force detection image:images/brute-force.png[] @@ -132,7 +132,7 @@ wait time will never reach the value you have set to `Max wait`. *Strategies to set Wait Time* -{project_name} provides two strategies to calculate wait time: By multiples or Linear. By multiples is the first strategy introduced by {project_name}, so that is the default one. +{project_name} provides two strategies to calculate wait time: By multiples or Linear. By multiples is the first strategy introduced by {project_name}, so that is the default one. By multiples strategy, wait time is incremented when the number (or count) of failures are multiples of `Max Login Failure`. For instance, if you set `Max Login Failures` to `5` and a `Wait Increment` to `30` seconds, the effective time that an account is disabled after several failed authentication attempts will be: @@ -151,7 +151,7 @@ By multiples strategy, wait time is incremented when the number (or count) of fa |**10** |**30** | 5 | **60** |=== -At the fifth failed attempt, the account is disabled for `30` seconds. After reaching the next multiple of `Max Login Failures`, in this case `10`, the time increases from `30` to `60` seconds. +At the fifth failed attempt, the account is disabled for `30` seconds. After reaching the next multiple of `Max Login Failures`, in this case `10`, the time increases from `30` to `60` seconds. The By multiple strategy uses the following formula to calculate wait time: _Wait Increment in Seconds_ * (`count` / _Max Login Failures_). The division is an integer division rounded down to a whole number. @@ -177,7 +177,7 @@ At the fifth failed attempt, the account is disabled for `30` seconds. Each new The linear strategy uses the following formula to calculate wait time: _Wait Increment in Seconds_ * (1 + `count` - _Max Login Failures_). ==== Lockout permanently after temporary lockout -Mixed mode. Locks user temporarily for specified number of times and then locks user permanently. +Mixed mode. Locks user temporarily for specified number of times and then locks user permanently. .Lockout permanently after temporary lockout image:images/brute-force-mixed.png[] diff --git a/docs/documentation/upgrading/topics/changes/changes-26_5_0.adoc b/docs/documentation/upgrading/topics/changes/changes-26_5_0.adoc index 15c2734a505..b66a22c5fb4 100644 --- a/docs/documentation/upgrading/topics/changes/changes-26_5_0.adoc +++ b/docs/documentation/upgrading/topics/changes/changes-26_5_0.adoc @@ -81,6 +81,12 @@ If you are running on PostgreSQL as a database for {project_name}, ensure that t This is used during upgrades of {project_name} to determine an estimated number of rows in a table. If {project_name} does not have permissions to access these tables, it will log a warning and proceed with the less efficient `+SELECT COUNT(*) ...+` operation during the upgrade to determine the number of rows in tables affected by schema changes. +=== Expiration of login failures from the embedded caches + +Previous entries in the `loginFailures` cache never expired, and entries accumulated as users entered wrong credentials, increasing the memory consumption. + +Starting with this release, entries will expire based on the "`Failure reset time`" configured in the "`Brute force detection`" for the modes "`Lockout temporarily`" and "`Lockout permanently after temporary lockout`". For "`Lockout permanently`", entries will not expire as before, as this mode does not have a "`Failure reset time`". + === Not recommended to use org.keycloak.credential.UserCredentialManager directly in your extensions If you have user storage extension and you reference the class `org.keycloak.credential.UserCredentialManager` from your providers, it is recommended to avoid using this class directly as it might be diff --git a/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/RealmAdapter.java b/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/RealmAdapter.java index 1943c5c112b..111e7a97abd 100755 --- a/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/RealmAdapter.java +++ b/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/RealmAdapter.java @@ -44,6 +44,7 @@ import org.keycloak.models.IdentityProviderMapperModel; import org.keycloak.models.IdentityProviderModel; import org.keycloak.models.IdentityProviderQuery; import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; import org.keycloak.models.OAuth2DeviceConfig; import org.keycloak.models.OTPPolicy; import org.keycloak.models.ParConfig; @@ -53,10 +54,12 @@ import org.keycloak.models.RequiredActionConfigModel; import org.keycloak.models.RequiredActionProviderModel; import org.keycloak.models.RequiredCredentialModel; import org.keycloak.models.RoleModel; +import org.keycloak.models.UserLoginFailureProvider; 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.models.utils.KeycloakModelUtils; import org.keycloak.representations.idm.RealmRepresentation; import org.keycloak.storage.UserStorageProvider; import org.keycloak.storage.UserStorageUtil; @@ -259,9 +262,44 @@ public class RealmAdapter implements CachedRealmModel { @Override public void setBruteForceProtected(boolean value) { getDelegateForUpdate(); + if (updated.isBruteForceProtected() != value) { + updateBruteForceSettings(); + } updated.setBruteForceProtected(value); } + boolean updateBruteForceSettings = false; + + private void updateBruteForceSettings() { + // TODO: This should really be an event where the recipient could figure out what has changed and can react accordingly + if (!updateBruteForceSettings) { + updateBruteForceSettings = true; + KeycloakSessionFactory sf = session.getKeycloakSessionFactory(); + session.getTransactionManager().enlistAfterCompletion(new AbstractKeycloakTransaction() { + @Override + protected void commitImpl() { + runUpdateOfLoginFailureProvider(sf, cached.getId()); + // Should not be necessary, as the cache entry of the realm will be discarded + updateBruteForceSettings = false; + } + + @Override + protected void rollbackImpl() { + updateBruteForceSettings = false; + } + }); + } + } + + private static void runUpdateOfLoginFailureProvider(KeycloakSessionFactory keycloakSessionFactory, String realmId) { + KeycloakModelUtils.runJobInTransaction(keycloakSessionFactory, + s -> { + UserLoginFailureProvider provider = s.getProvider(UserLoginFailureProvider.class); + RealmModel realm = s.realms().getRealm(realmId); + provider.updateWithLatestRealmSettings(realm); + }); + } + @Override public boolean isPermanentLockout() { if(isUpdated()) return updated.isPermanentLockout(); @@ -271,6 +309,9 @@ public class RealmAdapter implements CachedRealmModel { @Override public void setPermanentLockout(final boolean val) { getDelegateForUpdate(); + if (updated.isPermanentLockout() != val) { + updateBruteForceSettings(); + } updated.setPermanentLockout(val); } @@ -283,6 +324,9 @@ public class RealmAdapter implements CachedRealmModel { @Override public void setMaxTemporaryLockouts(final int val) { getDelegateForUpdate(); + if (updated.getMaxTemporaryLockouts() != val) { + updateBruteForceSettings(); + } updated.setMaxTemporaryLockouts(val); } @@ -355,6 +399,9 @@ public class RealmAdapter implements CachedRealmModel { @Override public void setMaxDeltaTimeSeconds(int val) { getDelegateForUpdate(); + if (updated.getMaxDeltaTimeSeconds() != val) { + updateBruteForceSettings(); + } updated.setMaxDeltaTimeSeconds(val); } diff --git a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanUserLoginFailureProvider.java b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanUserLoginFailureProvider.java index aeafc14a5f4..7dc83f5e6ce 100644 --- a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanUserLoginFailureProvider.java +++ b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanUserLoginFailureProvider.java @@ -16,7 +16,9 @@ */ package org.keycloak.models.sessions.infinispan; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; import org.keycloak.models.KeycloakSession; import org.keycloak.models.RealmModel; @@ -32,10 +34,14 @@ import org.keycloak.models.sessions.infinispan.entities.LoginFailureKey; import org.keycloak.models.sessions.infinispan.events.RemoveAllUserLoginFailuresEvent; import org.keycloak.models.sessions.infinispan.events.SessionEventsSenderTransaction; import org.keycloak.models.sessions.infinispan.stream.Mappers; +import org.keycloak.models.sessions.infinispan.stream.RemoveKeyConsumer; import org.keycloak.models.sessions.infinispan.stream.SessionWrapperPredicate; import org.keycloak.models.sessions.infinispan.util.FuturesHelper; +import org.keycloak.models.sessions.infinispan.util.SessionTimeouts; import org.infinispan.Cache; +import org.infinispan.context.Flag; +import org.infinispan.util.function.SerializableBiFunction; import org.jboss.logging.Logger; import static org.keycloak.common.util.StackUtil.getShortStackTrace; @@ -118,7 +124,7 @@ public class InfinispanUserLoginFailureProvider implements UserLoginFailureProvi .map(Mappers.loginFailureId()) .forEach(loginFailureKey -> { // Remove loginFailure from remoteCache too. Use removeAsync for better perf - Future future = localCache.removeAsync(loginFailureKey); + Future future = removeKeyFromCache(localCache, loginFailureKey); futures.addTask(future); }); @@ -145,4 +151,32 @@ public class InfinispanUserLoginFailureProvider implements UserLoginFailureProvi public void close() { } + + @Override + public void updateWithLatestRealmSettings(RealmModel realm) { + Cache> cache = loginFailuresTx.getCache(); + if (!realm.isBruteForceProtected()) { + cache.entrySet().stream() + .filter(SessionWrapperPredicate.create(realm.getId())) + .forEach(RemoveKeyConsumer.getInstance()); + } else { + final long maxDeltaTimeMillis = realm.getMaxDeltaTimeSeconds() * 1000L; + final boolean isPermanentLockout = realm.isPermanentLockout(); + final int maxTemporaryLockouts = realm.getMaxTemporaryLockouts(); + cache.entrySet().stream() + .filter(SessionWrapperPredicate.create(realm.getId())) + .>forEach((c, entry) -> { + var entity = entry.getValue().getEntity(); + long lifespan = SessionTimeouts.getLoginFailuresLifespanMs(isPermanentLockout, maxTemporaryLockouts, maxDeltaTimeMillis, entity); + c.getAdvancedCache() + .withFlags(Flag.ZERO_LOCK_ACQUISITION_TIMEOUT,Flag.FAIL_SILENTLY, Flag.IGNORE_RETURN_VALUES) + .computeIfPresent(entry.getKey(), (SerializableBiFunction, ? extends SessionEntityWrapper>) (key, value) -> value, lifespan, TimeUnit.MILLISECONDS); + }); + } + } + + private static CompletableFuture> removeKeyFromCache(Cache> cache, LoginFailureKey key) { + return cache.removeAsync(key); + } + } diff --git a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/changes/remote/updater/loginfailures/LoginFailuresUpdater.java b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/changes/remote/updater/loginfailures/LoginFailuresUpdater.java index 2ad41ab1f19..3de7bb14ef5 100644 --- a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/changes/remote/updater/loginfailures/LoginFailuresUpdater.java +++ b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/changes/remote/updater/loginfailures/LoginFailuresUpdater.java @@ -21,6 +21,7 @@ import java.util.List; import java.util.Objects; import java.util.function.Consumer; +import org.keycloak.models.RealmModel; import org.keycloak.models.UserLoginFailureModel; import org.keycloak.models.sessions.infinispan.changes.remote.updater.BaseUpdater; import org.keycloak.models.sessions.infinispan.changes.remote.updater.Expiration; @@ -28,6 +29,7 @@ import org.keycloak.models.sessions.infinispan.changes.remote.updater.Updater; import org.keycloak.models.sessions.infinispan.entities.LoginFailureEntity; import org.keycloak.models.sessions.infinispan.entities.LoginFailureKey; import org.keycloak.models.sessions.infinispan.util.SessionTimeouts; +import org.keycloak.utils.KeycloakSessionUtil; /** * Implementation of {@link Updater} and {@link UserLoginFailureModel}. @@ -62,9 +64,10 @@ public class LoginFailuresUpdater extends BaseUpdater searchByRealmId(RemoteCache cache, String realmId) { + return cache.query(BY_REALM_ID) + .setParameter("realmId", realmId); + } +} diff --git a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/remote/RemoteUserLoginFailureProvider.java b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/remote/RemoteUserLoginFailureProvider.java index 926fc792dc0..9edf2dc1643 100644 --- a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/remote/RemoteUserLoginFailureProvider.java +++ b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/remote/RemoteUserLoginFailureProvider.java @@ -18,14 +18,25 @@ package org.keycloak.models.sessions.infinispan.remote; import java.lang.invoke.MethodHandles; import java.util.Objects; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.TimeUnit; +import java.util.function.Function; import org.keycloak.models.RealmModel; import org.keycloak.models.UserLoginFailureModel; import org.keycloak.models.UserLoginFailureProvider; import org.keycloak.models.sessions.infinispan.entities.LoginFailureEntity; import org.keycloak.models.sessions.infinispan.entities.LoginFailureKey; +import org.keycloak.models.sessions.infinispan.query.LoginFailureQueries; +import org.keycloak.models.sessions.infinispan.query.QueryHelper; import org.keycloak.models.sessions.infinispan.remote.transaction.LoginFailureChangeLogTransaction; +import org.keycloak.models.sessions.infinispan.util.SessionTimeouts; +import io.reactivex.rxjava3.schedulers.Schedulers; +import org.infinispan.client.hotrod.RemoteCache; +import org.infinispan.commons.api.query.Query; +import org.infinispan.commons.util.concurrent.CompletionStages; +import org.infinispan.util.concurrent.WithinThreadExecutor; import org.jboss.logging.Logger; import static org.keycloak.common.util.StackUtil.getShortStackTrace; @@ -77,6 +88,32 @@ public class RemoteUserLoginFailureProvider implements UserLoginFailureProvider transaction.removeByRealmId(realm.getId()); } + @Override + public void updateWithLatestRealmSettings(RealmModel realm) { + RemoteCache cache = transaction.getCache(); + if (!realm.isBruteForceProtected()) { + removeAllUserLoginFailures(realm); + } else { + final long maxDeltaTimeMillis = realm.getMaxDeltaTimeSeconds() * 1000L; + final boolean isPermanentLockout = realm.isPermanentLockout(); + final int maxTemporaryLockouts = realm.getMaxTemporaryLockouts(); + Query query = LoginFailureQueries.searchByRealmId(cache, realm.getId()); + CompletionStages.performConcurrently( + QueryHelper.streamAll(query, 20, Function.identity()), + 20, + Schedulers.from(new WithinThreadExecutor()), + entry -> updateLifetimeOfCacheEntry(entry, cache, isPermanentLockout, maxTemporaryLockouts, maxDeltaTimeMillis)); + } + } + + private static CompletionStage updateLifetimeOfCacheEntry(LoginFailureEntity entry, RemoteCache cache, boolean isPermanentLockout, int maxTemporaryLockouts, long maxDeltaTimeMillis) { + long lifespan = SessionTimeouts.getLoginFailuresLifespanMs(isPermanentLockout, maxTemporaryLockouts, maxDeltaTimeMillis, entry); + return cache.computeIfPresentAsync(new LoginFailureKey(entry.getRealmId(), entry.getUserId()), + // Keep the original value - this should only update the lifespan and idle time + (loginFailureKey, loginFailureEntitySessionEntityWrapper) -> loginFailureEntitySessionEntityWrapper, + lifespan, TimeUnit.MILLISECONDS); + } + @Override public void close() { diff --git a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/util/SessionTimeouts.java b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/util/SessionTimeouts.java index 55a38a09b8d..336eb640601 100644 --- a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/util/SessionTimeouts.java +++ b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/util/SessionTimeouts.java @@ -205,7 +205,23 @@ public class SessionTimeouts { * @return */ public static long getLoginFailuresLifespanMs(RealmModel realm, ClientModel client, LoginFailureEntity loginFailureEntity) { - return IMMORTAL_FLAG; + return getLoginFailuresLifespanMs(realm.isPermanentLockout(), realm.getMaxTemporaryLockouts(), realm.getMaxDeltaTimeSeconds() * 1000L, loginFailureEntity); + } + + public static long getLoginFailuresLifespanMs(boolean isPermanentLockout, int maxTemporaryLockouts, long maxDeltaTimeMillis, LoginFailureEntity loginFailureEntity) { + if (loginFailureEntity.getLastFailure() == 0) { + // If login failure has been reset, expire the entry. + return 0; + } else if (isPermanentLockout && maxTemporaryLockouts == 0) { + // If mode is permanent lockout only, the "failure reset time" cannot be configured and login failures should never expire. + return IMMORTAL_FLAG; + } else { + // Use realm-specific "failure reset time" configured in the brute force detection settings. + // If the time between login failures is greater than the failure reset time, + // the brute force detector will reset the failure counter. + // So we can safely evict the login failure entry from the cache after this time. + return Math.max(0, maxDeltaTimeMillis - (Time.currentTimeMillis() - loginFailureEntity.getLastFailure())); + } } diff --git a/server-spi-private/src/main/java/org/keycloak/models/utils/KeycloakModelUtils.java b/server-spi-private/src/main/java/org/keycloak/models/utils/KeycloakModelUtils.java index 625aac6e5c0..b3789ed2230 100755 --- a/server-spi-private/src/main/java/org/keycloak/models/utils/KeycloakModelUtils.java +++ b/server-spi-private/src/main/java/org/keycloak/models/utils/KeycloakModelUtils.java @@ -464,9 +464,9 @@ public final class KeycloakModelUtils { } catch (Throwable t) { session.getTransactionManager().setRollbackOnly(); throw t; - } finally { - KeycloakSessionUtil.setKeycloakSession(existing); } + } finally { + KeycloakSessionUtil.setKeycloakSession(existing); } return result; } diff --git a/server-spi/src/main/java/org/keycloak/models/UserLoginFailureProvider.java b/server-spi/src/main/java/org/keycloak/models/UserLoginFailureProvider.java index 8b35e580911..da05180cf4c 100644 --- a/server-spi/src/main/java/org/keycloak/models/UserLoginFailureProvider.java +++ b/server-spi/src/main/java/org/keycloak/models/UserLoginFailureProvider.java @@ -52,4 +52,9 @@ public interface UserLoginFailureProvider extends Provider { */ void removeAllUserLoginFailures(RealmModel realm); + /** + * This is called when the realm settings change in relation to the brute force timeouts. + */ + default void updateWithLatestRealmSettings(RealmModel realm) {}; + } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/BruteForceTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/BruteForceTest.java index f3508e14b85..87d243d62fa 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/BruteForceTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/BruteForceTest.java @@ -33,6 +33,8 @@ import org.keycloak.events.Errors; import org.keycloak.events.EventType; import org.keycloak.events.email.EmailEventListenerProviderFactory; import org.keycloak.models.Constants; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserLoginFailureModel; import org.keycloak.models.UserModel; import org.keycloak.models.credential.PasswordCredentialModel; import org.keycloak.models.utils.TimeBasedOTP; @@ -44,6 +46,7 @@ import org.keycloak.services.managers.BruteForceProtector; import org.keycloak.testsuite.AbstractChangeImportedUserPasswordsTest; import org.keycloak.testsuite.AssertEvents; import org.keycloak.testsuite.AssertEvents.ExpectedEvent; +import org.keycloak.testsuite.model.infinispan.InfinispanTestUtil; import org.keycloak.testsuite.pages.AppPage; import org.keycloak.testsuite.pages.AppPage.RequestType; import org.keycloak.testsuite.pages.LoginPage; @@ -80,8 +83,6 @@ import static org.junit.Assert.assertTrue; */ public class BruteForceTest extends AbstractChangeImportedUserPasswordsTest { - private static String userId; - @Rule public AssertEvents events = new AssertEvents(this); @@ -108,8 +109,6 @@ public class BruteForceTest extends AbstractChangeImportedUserPasswordsTest { private TimeBasedOTP totp = new TimeBasedOTP(); - private int lifespan; - private static final Integer failureFactor = 2; @Override @@ -125,9 +124,6 @@ public class BruteForceTest extends AbstractChangeImportedUserPasswordsTest { testRealm.setMaxFailureWaitSeconds(100); testRealm.setWaitIncrementSeconds(20); testRealm.setOtpPolicyCodeReusable(true); - //testRealm.setQuickLoginCheckMilliSeconds(0L); - - userId = user.getId(); RealmRepUtil.findClientByClientId(testRealm, "test-app").setDirectAccessGrantsEnabled(true); testRealm.getUsers().add(UserBuilder.create().username("user2").email("user2@localhost").password(generatePassword("user2")).build()); @@ -136,6 +132,7 @@ public class BruteForceTest extends AbstractChangeImportedUserPasswordsTest { @Before public void config() { try { + testingClient.server().run(InfinispanTestUtil::setTestingTimeService); clearUserFailures(); clearAllUserFailures(); RealmRepresentation realm = adminClient.realm("test").toRepresentation(); @@ -156,6 +153,7 @@ public class BruteForceTest extends AbstractChangeImportedUserPasswordsTest { @After public void slowItDown() throws Exception { try { + testingClient.server().run(InfinispanTestUtil::revertTimeService); clearUserFailures(); clearAllUserFailures(); RealmRepresentation realm = adminClient.realm("test").toRepresentation(); @@ -178,7 +176,7 @@ public class BruteForceTest extends AbstractChangeImportedUserPasswordsTest { totp = new TimeBasedOTP(); } - public String getAdminToken() throws Exception { + public String getAdminToken() { return oauth.realm("master").client(Constants.ADMIN_CLI_CLIENT_ID).doPasswordGrantRequest( "admin", "admin").getAccessToken(); } @@ -186,16 +184,16 @@ public class BruteForceTest extends AbstractChangeImportedUserPasswordsTest { return oauth.passwordGrantRequest("test-user@localhost", password).otp(totp).send(); } - protected void clearUserFailures() throws Exception { + protected void clearUserFailures() { adminClient.realm("test").attackDetection().clearBruteForceForUser(findUser("test-user@localhost").getId()); } - protected void clearAllUserFailures() throws Exception { + protected void clearAllUserFailures() { adminClient.realm("test").attackDetection().clearAllBruteForce(); } @Test - public void testInvalidConfiguration() throws Exception { + public void testInvalidConfiguration() { RealmRepresentation realm = testRealm().toRepresentation(); realm.setFailureFactor(-1); try { @@ -261,7 +259,7 @@ public class BruteForceTest extends AbstractChangeImportedUserPasswordsTest { } @Test - public void testGrantInvalidPassword() throws Exception { + public void testGrantInvalidPassword() { { String totpSecret = totp.generateTOTP("totpSecret"); AccessTokenResponse response = getTestToken(getPassword("test-user@localhost"), totpSecret); @@ -307,7 +305,7 @@ public class BruteForceTest extends AbstractChangeImportedUserPasswordsTest { } @Test - public void testGrantInvalidOtp() throws Exception { + public void testGrantInvalidOtp() { { String totpSecret = totp.generateTOTP("totpSecret"); AccessTokenResponse response = getTestToken(getPassword("test-user@localhost"), totpSecret); @@ -355,7 +353,7 @@ public class BruteForceTest extends AbstractChangeImportedUserPasswordsTest { } @Test - public void testGrantMissingOtp() throws Exception { + public void testGrantMissingOtp() { { String totpSecret = totp.generateTOTP("totpSecret"); AccessTokenResponse response = getTestToken(getPassword("test-user@localhost"), totpSecret); @@ -399,7 +397,7 @@ public class BruteForceTest extends AbstractChangeImportedUserPasswordsTest { } @Test - public void testNumberOfFailuresForDisabledUsersWithPasswordGrantType() throws Exception { + public void testNumberOfFailuresForDisabledUsersWithPasswordGrantType() { try { UserRepresentation user = adminClient.realm("test").users().search("test-user@localhost", 0, 1).get(0); assertUserNumberOfFailures(user.getId(), 0); @@ -466,7 +464,7 @@ public class BruteForceTest extends AbstractChangeImportedUserPasswordsTest { } @Test - public void testBrowserInvalidPassword() throws Exception { + public void testBrowserInvalidPassword() { loginSuccess(); loginInvalidPassword(); loginInvalidPassword(); @@ -483,7 +481,7 @@ public class BruteForceTest extends AbstractChangeImportedUserPasswordsTest { } @Test - public void testFailureResetForTemporaryLockout() throws Exception { + public void testFailureResetForTemporaryLockout() { RealmRepresentation realm = testRealm().toRepresentation(); try { realm.setMaxDeltaTimeSeconds(5); @@ -503,7 +501,25 @@ public class BruteForceTest extends AbstractChangeImportedUserPasswordsTest { } @Test - public void testNoFailureResetForPermanentLockout() throws Exception { + public void testCacheExpiryForTemporaryLockout() { + RealmRepresentation realm = testRealm().toRepresentation(); + loginInvalidPassword(); + + //Wait for brute force executor to process the login and then wait for delta time + WaitUtils.waitForBruteForceExecutors(testingClient); + setTimeOffset(realm.getMaxDeltaTimeSeconds()); + + String realmId = realm.getId(); + testingClient.server().run(session -> { + RealmModel realmModel = session.realms().getRealm(realmId); + UserModel userModel = session.users().getUserByEmail(realmModel, "test-user@localhost"); + UserLoginFailureModel userLoginFailure = session.loginFailures().getUserLoginFailure(realmModel, userModel.getId()); + Assert.assertNull("cache entry should have expired", userLoginFailure); + }); + } + + @Test + public void testNoFailureResetForPermanentLockout() { RealmRepresentation realm = testRealm().toRepresentation(); try { realm.setMaxDeltaTimeSeconds(5); @@ -528,7 +544,7 @@ public class BruteForceTest extends AbstractChangeImportedUserPasswordsTest { } @Test - public void testWait() throws Exception { + public void testWait() { loginSuccess(); loginInvalidPassword(); loginInvalidPassword(); @@ -562,7 +578,7 @@ public class BruteForceTest extends AbstractChangeImportedUserPasswordsTest { } @Test - public void testByMultipleStrategy() throws Exception { + public void testByMultipleStrategy() { try { UserRepresentation user = adminClient.realm("test").users().search("test-user@localhost", 0, 1).get(0); @@ -583,7 +599,7 @@ public class BruteForceTest extends AbstractChangeImportedUserPasswordsTest { } @Test - public void testLinearStrategy() throws Exception { + public void testLinearStrategy() { RealmRepresentation realm = testRealm().toRepresentation(); UserRepresentation user = adminClient.realm("test").users().search("test-user@localhost", 0, 1).get(0); try { @@ -612,7 +628,7 @@ public class BruteForceTest extends AbstractChangeImportedUserPasswordsTest { } @Test - public void testBrowserInvalidPasswordDifferentCase() throws Exception { + public void testBrowserInvalidPasswordDifferentCase() { loginSuccess("test-user@localhost"); loginInvalidPassword("test-User@localhost"); loginInvalidPassword("Test-user@localhost"); @@ -622,7 +638,7 @@ public class BruteForceTest extends AbstractChangeImportedUserPasswordsTest { } @Test - public void testEmail() throws Exception { + public void testEmail() { String userId = adminClient.realm("test").users().search("user2", null, null, null, 0, 1).get(0).getId(); loginInvalidPassword("user2"); @@ -632,7 +648,7 @@ public class BruteForceTest extends AbstractChangeImportedUserPasswordsTest { } @Test - public void testUserDisabledTemporaryLockout() throws Exception { + public void testUserDisabledTemporaryLockout() { String userId = adminClient.realm("test").users().search("test-user@localhost", null, null, null, 0, 1).get(0).getId(); loginInvalidPassword(); @@ -645,7 +661,7 @@ public class BruteForceTest extends AbstractChangeImportedUserPasswordsTest { } @Test - public void testUserDisabledAfterSwitchFromMixedToPermanentLockout() throws Exception { + public void testUserDisabledAfterSwitchFromMixedToPermanentLockout() { UsersResource users = testRealm().users(); UserRepresentation user = users.search("test-user@localhost", null, null, null, 0, 1).get(0); @@ -696,7 +712,7 @@ public class BruteForceTest extends AbstractChangeImportedUserPasswordsTest { } @Test - public void testBrowserMissingPassword() throws Exception { + public void testBrowserMissingPassword() { loginSuccess(); loginMissingPassword(); loginMissingPassword(); @@ -704,7 +720,7 @@ public class BruteForceTest extends AbstractChangeImportedUserPasswordsTest { } @Test - public void testBrowserInvalidTotp() throws Exception { + public void testBrowserInvalidTotp() { loginSuccess(); loginInvalidPassword(); loginWithTotpFailure(); @@ -712,7 +728,7 @@ public class BruteForceTest extends AbstractChangeImportedUserPasswordsTest { } @Test - public void testBrowserMissingTotp() throws Exception { + public void testBrowserMissingTotp() { loginSuccess(); loginWithMissingTotp(); loginWithMissingTotp(); @@ -720,7 +736,7 @@ public class BruteForceTest extends AbstractChangeImportedUserPasswordsTest { } @Test - public void testBrowserTotpSessionInvalidAfterLockout() throws Exception { + public void testBrowserTotpSessionInvalidAfterLockout() { long start = System.currentTimeMillis(); loginWithTotpFailure(); continueLoginWithInvalidTotp(); @@ -916,7 +932,7 @@ public class BruteForceTest extends AbstractChangeImportedUserPasswordsTest { } @Test - public void testFailureCountResetWithPasswordGrantType() throws Exception { + public void testFailureCountResetWithPasswordGrantType() { String totpSecret = totp.generateTOTP("totpSecret"); AccessTokenResponse response = getTestToken("invalid", totpSecret); Assert.assertNull(response.getAccessToken()); @@ -937,7 +953,7 @@ public class BruteForceTest extends AbstractChangeImportedUserPasswordsTest { } @Test - public void testNonExistingAccounts() throws Exception { + public void testNonExistingAccounts() { loginInvalidPassword("non-existent-user"); loginInvalidPassword("non-existent-user"); @@ -1208,7 +1224,7 @@ public class BruteForceTest extends AbstractChangeImportedUserPasswordsTest { events.clear(); } - public void loginWithMissingTotp() throws Exception { + public void loginWithMissingTotp() { loginPage.open(); loginPage.login("test-user@localhost", getPassword("test-user@localhost")); @@ -1286,7 +1302,7 @@ public class BruteForceTest extends AbstractChangeImportedUserPasswordsTest { MatcherAssert.assertThat((Integer) userAttackInfo.get("numFailures"), is(numberOfFailures)); } - private void sendInvalidPasswordPasswordGrant() throws Exception { + private void sendInvalidPasswordPasswordGrant() { String totpSecret = totp.generateTOTP("totpSecret"); AccessTokenResponse response = getTestToken("invalid", totpSecret); Assert.assertNull(response.getAccessToken()); @@ -1295,7 +1311,7 @@ public class BruteForceTest extends AbstractChangeImportedUserPasswordsTest { events.clear(); } - private void lockUserWithPasswordGrant() throws Exception { + private void lockUserWithPasswordGrant() { String totpSecret = totp.generateTOTP("totpSecret"); AccessTokenResponse response = getTestToken(getPassword("test-user@localhost"), totpSecret); Assert.assertNotNull(response.getAccessToken()); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/model/UserSessionProviderTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/model/UserSessionProviderTest.java index 4d32735c373..160f1a1d906 100755 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/model/UserSessionProviderTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/model/UserSessionProviderTest.java @@ -800,10 +800,12 @@ public class UserSessionProviderTest extends AbstractTestRealmKeycloakTest { kcSession.getContext().setRealm(realm); UserLoginFailureModel failure1 = kcSession.loginFailures().addUserLoginFailure(realm, "user1"); failure1.incrementFailures(); + failure1.setLastFailure(Time.currentTimeMillis()); UserLoginFailureModel failure2 = kcSession.loginFailures().addUserLoginFailure(realm, "user2"); failure2.incrementFailures(); failure2.incrementFailures(); + failure2.setLastFailure(Time.currentTimeMillis()); }); testingClient.server().run((KeycloakSession kcSession) -> { diff --git a/testsuite/model/src/test/java/org/keycloak/testsuite/model/infinispan/RetryAndBackOffTest.java b/testsuite/model/src/test/java/org/keycloak/testsuite/model/infinispan/RetryAndBackOffTest.java index b93d75d90cf..421a6b2ecf6 100644 --- a/testsuite/model/src/test/java/org/keycloak/testsuite/model/infinispan/RetryAndBackOffTest.java +++ b/testsuite/model/src/test/java/org/keycloak/testsuite/model/infinispan/RetryAndBackOffTest.java @@ -97,6 +97,7 @@ public class RetryAndBackOffTest extends KeycloakModelTest { inComittedTransaction(session -> { var realm = session.realms().getRealm(realmId); + session.getContext().setRealm(realm); var loginFailures = session.loginFailures().addUserLoginFailure(realm, userId); loginFailures.incrementFailures(); }); @@ -108,6 +109,7 @@ public class RetryAndBackOffTest extends KeycloakModelTest { public void testRetryWithReplace() { inComittedTransaction(session -> { var realm = session.realms().getRealm(realmId); + session.getContext().setRealm(realm); var loginFailures = session.loginFailures().addUserLoginFailure(realm, userId); loginFailures.incrementFailures(); }); @@ -116,6 +118,7 @@ public class RetryAndBackOffTest extends KeycloakModelTest { inComittedTransaction(session -> { var realm = session.realms().getRealm(realmId); + session.getContext().setRealm(realm); var loginFailures = session.loginFailures().getUserLoginFailure(realm, userId); loginFailures.incrementFailures(); }); @@ -127,6 +130,7 @@ public class RetryAndBackOffTest extends KeycloakModelTest { public void testRetryWithRemove() { inComittedTransaction(session -> { var realm = session.realms().getRealm(realmId); + session.getContext().setRealm(realm); var loginFailures = session.loginFailures().addUserLoginFailure(realm, userId); loginFailures.incrementFailures(); }); @@ -135,6 +139,7 @@ public class RetryAndBackOffTest extends KeycloakModelTest { inComittedTransaction(session -> { var realm = session.realms().getRealm(realmId); + session.getContext().setRealm(realm); session.loginFailures().removeUserLoginFailure(realm, userId); }); @@ -146,6 +151,7 @@ public class RetryAndBackOffTest extends KeycloakModelTest { // compute is implemented with get() and replace() inComittedTransaction(session -> { var realm = session.realms().getRealm(realmId); + session.getContext().setRealm(realm); var loginFailures = session.loginFailures().addUserLoginFailure(realm, userId); loginFailures.incrementFailures(); }); @@ -156,6 +162,7 @@ public class RetryAndBackOffTest extends KeycloakModelTest { inComittedTransaction(session -> { var realm = session.realms().getRealm(realmId); + session.getContext().setRealm(realm); var loginFailures = session.loginFailures().getUserLoginFailure(realm, userId); loginFailures.incrementFailures(); }); @@ -172,6 +179,7 @@ public class RetryAndBackOffTest extends KeycloakModelTest { var ce = assertThrows(CompletionException.class, () -> inComittedTransaction(session -> { var realm = session.realms().getRealm(realmId); + session.getContext().setRealm(realm); session.loginFailures().addUserLoginFailure(realm, userId); })); assertTrue(String.valueOf(ce.getCause()), ce.getCause() instanceof HotRodClientException); diff --git a/testsuite/model/src/test/java/org/keycloak/testsuite/model/loginfailure/RemoteLoginFailureTest.java b/testsuite/model/src/test/java/org/keycloak/testsuite/model/loginfailure/RemoteLoginFailureTest.java index 25161722a31..ae3865ca9dd 100644 --- a/testsuite/model/src/test/java/org/keycloak/testsuite/model/loginfailure/RemoteLoginFailureTest.java +++ b/testsuite/model/src/test/java/org/keycloak/testsuite/model/loginfailure/RemoteLoginFailureTest.java @@ -80,6 +80,7 @@ public class RemoteLoginFailureTest extends KeycloakModelTest { inComittedTransaction(session -> { var realm = session.realms().getRealm(realmId); + session.getContext().setRealm(realm); var loginFailures = session.loginFailures().addUserLoginFailure(realm, userIds.get(0)); loginFailures.incrementFailures(); }); @@ -104,6 +105,7 @@ public class RemoteLoginFailureTest extends KeycloakModelTest { inComittedTransaction(session -> { var realm = session.realms().getRealm(realmId); + session.getContext().setRealm(realm); var loginFailures = session.loginFailures().getUserLoginFailure(realm, userIds.get(0)); // update all fields @@ -134,6 +136,7 @@ public class RemoteLoginFailureTest extends KeycloakModelTest { inComittedTransaction(session -> { var realm = session.realms().getRealm(realmId); + session.getContext().setRealm(realm); var loginFailures = session.loginFailures().getUserLoginFailure(realm, userIds.get(0)); // update all fields @@ -166,6 +169,7 @@ public class RemoteLoginFailureTest extends KeycloakModelTest { inComittedTransaction(session -> { var realm = session.realms().getRealm(realmId); + session.getContext().setRealm(realm); var loginFailures = session.loginFailures().getUserLoginFailure(realm, userIds.get(0)); loginFailures.incrementTemporaryLockouts(); loginFailures.clearFailures();