Support for blocking concurrent requests when brute force is enabled

Closes #31726

Signed-off-by: Pedro Igor <pigor.craveiro@gmail.com>
Signed-off-by: Douglas Palmer <dpalmer@redhat.com>
Signed-off-by: mposolda <mposolda@gmail.com>
This commit is contained in:
Pedro Igor 2024-05-17 11:16:04 -03:00 committed by Marek Posolda
parent 183cd6c957
commit a79761a447
3 changed files with 217 additions and 14 deletions

View file

@ -0,0 +1,118 @@
/*
* Copyright 2016 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.services.managers;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Map.Entry;
import org.keycloak.models.AbstractKeycloakTransaction;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.sessions.AuthenticationSessionModel;
public class DefaultBlockingBruteForceProtector extends DefaultBruteForceProtector {
// make this configurable?
private static final int DEFAULT_MAX_CONCURRENT_ATTEMPTS = 1000;
private static final float DEFAULT_LOAD_FACTOR = 0.75f;
private final Map<String, String> loginAttempts = Collections.synchronizedMap(new LinkedHashMap<>(100, DEFAULT_LOAD_FACTOR) {
@Override
protected boolean removeEldestEntry(Entry<String, String> eldest) {
return loginAttempts.size() > DEFAULT_MAX_CONCURRENT_ATTEMPTS;
}
});
DefaultBlockingBruteForceProtector(KeycloakSessionFactory factory) {
super(factory);
}
@Override
public boolean isPermanentlyLockedOut(KeycloakSession session, RealmModel realm, UserModel user) {
if (super.isPermanentlyLockedOut(session, realm, user)) {
return true;
}
if (!realm.isPermanentLockout()) return false;
return isLoginInProgress(session, user);
}
@Override
public boolean isTemporarilyDisabled(KeycloakSession session, RealmModel realm, UserModel user) {
if (super.isTemporarilyDisabled(session, realm, user)) {
return true;
}
return isLoginInProgress(session, user);
}
private boolean isLoginInProgress(KeycloakSession session, UserModel user) {
AuthenticationSessionModel authSession = session.getContext().getAuthenticationSession();
if (authSession == null) {
// not authenticating as there is no auth session bound to the session
return false;
}
if (isCurrentLoginAttempt(user)) {
return !tryEnlistBlockingTransaction(session, user);
}
return true;
}
// Return true if this thread successfully enlisted itself
private boolean tryEnlistBlockingTransaction(KeycloakSession session, UserModel user) {
String threadInProgress = loginAttempts.computeIfAbsent(user.getId(), k -> getThreadName());
// This means that this thread successfully added itself into the map. We can enlist transaction just in that case
if (threadInProgress.equals(getThreadName())) {
session.getTransactionManager().enlistAfterCompletion(new AbstractKeycloakTransaction() {
@Override
protected void commitImpl() {
unblock();
}
@Override
protected void rollbackImpl() {
unblock();
}
private void unblock() {
loginAttempts.remove(user.getId());
}
});
return true;
} else {
return false;
}
}
private boolean isCurrentLoginAttempt(UserModel user) {
return loginAttempts.getOrDefault(user.getId(), getThreadName()).equals(getThreadName());
}
private String getThreadName() {
return Thread.currentThread().getName();
}
}

View file

@ -28,6 +28,8 @@ import org.keycloak.models.KeycloakSessionFactory;
public class DefaultBruteForceProtectorFactory implements BruteForceProtectorFactory {
DefaultBruteForceProtector protector;
private boolean allowConcurrentRequests;
@Override
public BruteForceProtector create(KeycloakSession session) {
return protector;
@ -35,12 +37,13 @@ public class DefaultBruteForceProtectorFactory implements BruteForceProtectorFac
@Override
public void init(Config.Scope config) {
// this can be a brute force setting?
this.allowConcurrentRequests = config.getBoolean("allowConcurrentRequests", Boolean.FALSE);
}
@Override
public void postInit(KeycloakSessionFactory factory) {
protector = new DefaultBruteForceProtector(factory);
protector = allowConcurrentRequests ? new DefaultBruteForceProtector(factory) : new DefaultBlockingBruteForceProtector(factory);
}
@Override

View file

@ -471,7 +471,8 @@ public class BruteForceTest extends AbstractTestRealmKeycloakTest {
while (!threadPoolExecutor.getQueue().isEmpty()) {
try {
Thread.sleep(1000);
} catch (Exception e) {}
} catch (Exception e) {
}
}
assertEquals(numExecutors, threadPoolExecutor.getCompletedTaskCount());
});
@ -753,6 +754,87 @@ public class BruteForceTest extends AbstractTestRealmKeycloakTest {
events.clear();
}
@Test
public void testRaceAttackTemporaryLockout() throws Exception {
RealmRepresentation realm = testRealm().toRepresentation();
UserRepresentation user = adminClient.realm("test").users().search("test-user@localhost", 0, 1).get(0);
try {
realm.setWaitIncrementSeconds(120);
realm.setQuickLoginCheckMilliSeconds(120000L);
testRealm().update(realm);
clearUserFailures();
clearAllUserFailures();
user = adminClient.realm("test").users().search("test-user@localhost", 0, 1).get(0);
user.setEnabled(true);
testRealm().users().get(user.getId()).update(user);
String totpSecret = totp.generateTOTP("totpSecret");
OAuthClient.AccessTokenResponse response = getTestToken("password", totpSecret);
Assert.assertNotNull(response.getAccessToken());
raceAttack(user);
} finally {
realm.setWaitIncrementSeconds(5);
realm.setQuickLoginCheckMilliSeconds(100L);
testRealm().update(realm);
user.setEnabled(true);
updateUser(user);
}
}
@Test
public void testRaceAttackPermanentLockout() throws Exception {
RealmRepresentation realm = testRealm().toRepresentation();
UserRepresentation user = adminClient.realm("test").users().search("test-user@localhost", 0, 1).get(0);
try {
realm.setPermanentLockout(true);
testRealm().update(realm);
raceAttack(user);
clearUserFailures();
clearAllUserFailures();
user = adminClient.realm("test").users().search("test-user@localhost", 0, 1).get(0);
user.setEnabled(true);
testRealm().users().get(user.getId()).update(user);
String totpSecret = totp.generateTOTP("totpSecret");
OAuthClient.AccessTokenResponse response = getTestToken("password", totpSecret);
Assert.assertNotNull(response.getAccessToken());
} finally {
realm.setPermanentLockout(false);
testRealm().update(realm);
user.setEnabled(true);
updateUser(user);
}
}
private void raceAttack(UserRepresentation user) throws Exception {
int num = 100;
LoginThread[] threads = new LoginThread[num];
for (int i = 0; i < num; ++i) {
threads[i] = new LoginThread();
}
for (int i = 0; i < num; ++i) {
threads[i].start();
}
for (int i = 0; i < num; ++i) {
threads[i].join();
}
int invalidCount = (int) adminClient.realm("test").attackDetection().bruteForceUserStatus(user.getId()).get("numFailures");
assertTrue("Invalid count should be less than or equal 2 but was: " + invalidCount, invalidCount <= 2);
}
public class LoginThread extends Thread {
public void run() {
try {
String totpSecret = totp.generateTOTP("totpSecret");
OAuthClient.AccessTokenResponse response = getTestToken("invalid", totpSecret);
Assert.assertNull(response.getAccessToken());
Assert.assertEquals(response.getError(), "invalid_grant");
Assert.assertEquals(response.getErrorDescription(), "Invalid user credentials");
} catch (Exception ex) {
ex.printStackTrace();
}
}
}
public void expectTemporarilyDisabled() {
expectTemporarilyDisabled("test-user@localhost", null, "password");
}