KEYCLOAK-15985 Add Brute Force Detection Lockout Event
This commit is contained in:
parent
95ecf446ca
commit
f684a70048
4 changed files with 119 additions and 37 deletions
|
@ -155,8 +155,10 @@ public enum EventType implements EnumWithStableIndex {
|
|||
|
||||
// PAR request.
|
||||
PUSHED_AUTHORIZATION_REQUEST(51, false),
|
||||
PUSHED_AUTHORIZATION_REQUEST_ERROR(0x10000 + PUSHED_AUTHORIZATION_REQUEST.getStableIndex(), false);
|
||||
PUSHED_AUTHORIZATION_REQUEST_ERROR(0x10000 + PUSHED_AUTHORIZATION_REQUEST.getStableIndex(), false),
|
||||
|
||||
USER_DISABLED_BY_PERMANENT_LOCKOUT(52, true),
|
||||
USER_DISABLED_BY_PERMANENT_LOCKOUT_ERROR(0x10000 + USER_DISABLED_BY_PERMANENT_LOCKOUT.getStableIndex(), false);
|
||||
|
||||
private final int stableIndex;
|
||||
private final boolean saveByDefault;
|
||||
|
|
|
@ -20,6 +20,9 @@ package org.keycloak.services.managers;
|
|||
import org.jboss.logging.Logger;
|
||||
import org.keycloak.common.ClientConnection;
|
||||
import org.keycloak.common.util.Time;
|
||||
import org.keycloak.events.Details;
|
||||
import org.keycloak.events.EventBuilder;
|
||||
import org.keycloak.events.EventType;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.models.KeycloakSessionFactory;
|
||||
import org.keycloak.models.RealmModel;
|
||||
|
@ -59,12 +62,12 @@ public class DefaultBruteForceProtector implements Runnable, BruteForceProtector
|
|||
protected abstract class LoginEvent implements Comparable<LoginEvent> {
|
||||
protected final String realmId;
|
||||
protected final String userId;
|
||||
protected final String ip;
|
||||
protected final ClientConnection clientConnection;
|
||||
|
||||
protected LoginEvent(String realmId, String userId, String ip) {
|
||||
protected LoginEvent(String realmId, String userId, ClientConnection clientConnection) {
|
||||
this.realmId = realmId;
|
||||
this.userId = userId;
|
||||
this.ip = ip;
|
||||
this.clientConnection = new AdaptedClientConnection(clientConnection);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
@ -82,16 +85,58 @@ public class DefaultBruteForceProtector implements Runnable, BruteForceProtector
|
|||
protected class FailedLogin extends LoginEvent {
|
||||
protected final CountDownLatch latch = new CountDownLatch(1);
|
||||
|
||||
public FailedLogin(String realmId, String userId, String ip) {
|
||||
super(realmId, userId, ip);
|
||||
public FailedLogin(String realmId, String userId, ClientConnection clientConnection) {
|
||||
super(realmId, userId, clientConnection);
|
||||
}
|
||||
}
|
||||
|
||||
protected class SuccessfulLogin extends LoginEvent {
|
||||
protected final CountDownLatch latch = new CountDownLatch(1);
|
||||
|
||||
public SuccessfulLogin(String realmId, String userId, String ip) {
|
||||
super(realmId, userId, ip);
|
||||
public SuccessfulLogin(String realmId, String userId, ClientConnection clientConnection) {
|
||||
super(realmId, userId, clientConnection);
|
||||
}
|
||||
}
|
||||
|
||||
protected static class AdaptedClientConnection implements ClientConnection {
|
||||
|
||||
private final String remoteAddr;
|
||||
private final String remoteHost;
|
||||
private final int remotePort;
|
||||
private final String localAddr;
|
||||
private final int localPort;
|
||||
|
||||
public AdaptedClientConnection(ClientConnection c) {
|
||||
this.remoteAddr = c == null ? null : c.getRemoteAddr();
|
||||
this.remoteHost = c == null ? null : c.getRemoteHost();
|
||||
this.remotePort = c == null ? 0 : c.getRemotePort();
|
||||
this.localAddr = c == null ? null : c.getLocalAddr();
|
||||
this.localPort = c == null ? 0 : c.getLocalPort();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getRemoteAddr() {
|
||||
return this.remoteAddr;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getRemoteHost() {
|
||||
return this.remoteHost;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getRemotePort() {
|
||||
return this.remotePort;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getLocalAddr() {
|
||||
return this.localAddr;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getLocalPort() {
|
||||
return this.localPort;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -110,7 +155,7 @@ public class DefaultBruteForceProtector implements Runnable, BruteForceProtector
|
|||
if (userLoginFailure == null) {
|
||||
userLoginFailure = session.loginFailures().addUserLoginFailure(realm, userId);
|
||||
}
|
||||
userLoginFailure.setLastIPFailure(event.ip);
|
||||
userLoginFailure.setLastIPFailure(event.clientConnection.getRemoteAddr());
|
||||
long currentTime = Time.currentTimeMillis();
|
||||
long last = userLoginFailure.getLastFailure();
|
||||
long deltaTime = 0;
|
||||
|
@ -131,6 +176,12 @@ public class DefaultBruteForceProtector implements Runnable, BruteForceProtector
|
|||
logger.debugv("user {0} locked permanently due to too many login attempts", user.getUsername());
|
||||
user.setEnabled(false);
|
||||
user.setSingleAttribute(DISABLED_REASON, DISABLED_BY_PERMANENT_LOCKOUT);
|
||||
// Send event
|
||||
new EventBuilder(realm, session, event.clientConnection)
|
||||
.event(EventType.USER_DISABLED_BY_PERMANENT_LOCKOUT)
|
||||
.detail(Details.REASON, "brute_force_attack detected")
|
||||
.user(user)
|
||||
.success();
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -264,7 +315,7 @@ public class DefaultBruteForceProtector implements Runnable, BruteForceProtector
|
|||
}
|
||||
|
||||
protected void logFailure(LoginEvent event) {
|
||||
ServicesLogger.LOGGER.loginFailure(event.userId, event.ip);
|
||||
ServicesLogger.LOGGER.loginFailure(event.userId, event.clientConnection.getRemoteAddr());
|
||||
failures++;
|
||||
long delta = 0;
|
||||
if (lastFailure > 0) {
|
||||
|
@ -281,7 +332,7 @@ public class DefaultBruteForceProtector implements Runnable, BruteForceProtector
|
|||
@Override
|
||||
public void failedLogin(RealmModel realm, UserModel user, ClientConnection clientConnection) {
|
||||
try {
|
||||
FailedLogin event = new FailedLogin(realm.getId(), user.getId(), clientConnection.getRemoteAddr());
|
||||
FailedLogin event = new FailedLogin(realm.getId(), user.getId(), clientConnection);
|
||||
queue.offer(event);
|
||||
// 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
|
||||
|
@ -294,7 +345,7 @@ public class DefaultBruteForceProtector implements Runnable, BruteForceProtector
|
|||
|
||||
@Override
|
||||
public void successfulLogin(final RealmModel realm, final UserModel user, final ClientConnection clientConnection) {
|
||||
SuccessfulLogin event = new SuccessfulLogin(realm.getId(), user.getId(), clientConnection.getRemoteAddr());
|
||||
SuccessfulLogin event = new SuccessfulLogin(realm.getId(), user.getId(), clientConnection);
|
||||
queue.offer(event);
|
||||
logger.trace("sent success event");
|
||||
}
|
||||
|
|
|
@ -361,7 +361,7 @@ public class AssertEvents implements TestRule {
|
|||
}
|
||||
|
||||
public EventRepresentation assertEvent(EventRepresentation actual) {
|
||||
if (expected.getError() != null && ! expected.getType().toString().endsWith("_ERROR")) {
|
||||
if (expected.getError() != null && ! expected.getType().endsWith("_ERROR")) {
|
||||
expected.setType(expected.getType() + "_ERROR");
|
||||
}
|
||||
assertThat("type", actual.getType(), is(expected.getType()));
|
||||
|
|
|
@ -30,6 +30,7 @@ import org.keycloak.events.EventType;
|
|||
import org.keycloak.models.Constants;
|
||||
import org.keycloak.models.UserModel;
|
||||
import org.keycloak.models.utils.TimeBasedOTP;
|
||||
import org.keycloak.representations.idm.EventRepresentation;
|
||||
import org.keycloak.representations.idm.RealmRepresentation;
|
||||
import org.keycloak.representations.idm.UserRepresentation;
|
||||
import org.keycloak.services.managers.BruteForceProtector;
|
||||
|
@ -51,9 +52,7 @@ import org.keycloak.testsuite.util.UserBuilder;
|
|||
|
||||
import jakarta.mail.internet.MimeMessage;
|
||||
import java.net.MalformedURLException;
|
||||
import java.util.Calendar;
|
||||
import java.util.Collections;
|
||||
import java.util.Map;
|
||||
import java.util.*;
|
||||
|
||||
import static org.hamcrest.CoreMatchers.is;
|
||||
import static org.junit.Assert.assertEquals;
|
||||
|
@ -477,7 +476,7 @@ public class BruteForceTest extends AbstractTestRealmKeycloakTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
public void testPermanentLockout() throws Exception {
|
||||
public void testPermanentLockout() {
|
||||
RealmRepresentation realm = testRealm().toRepresentation();
|
||||
|
||||
try {
|
||||
|
@ -486,8 +485,15 @@ public class BruteForceTest extends AbstractTestRealmKeycloakTest {
|
|||
testRealm().update(realm);
|
||||
|
||||
// act
|
||||
loginInvalidPassword();
|
||||
loginInvalidPassword();
|
||||
loginInvalidPassword("test-user@localhost");
|
||||
loginInvalidPassword("test-user@localhost", false);
|
||||
|
||||
// As of now, there are two events: USER_DISABLED_BY_PERMANENT_LOCKOUT and LOGIN_ERROR but Order is not
|
||||
// guarantee though since the brute force detector is running separately "in its own thread" named
|
||||
// "Brute Force Protector".
|
||||
List<EventRepresentation> actualEvents = Arrays.asList(events.poll(), events.poll());
|
||||
assertIsContained(events.expect(EventType.USER_DISABLED_BY_PERMANENT_LOCKOUT).client((String) null).detail(Details.REASON, "brute_force_attack detected"), actualEvents);
|
||||
assertIsContained(events.expect(EventType.LOGIN_ERROR).error(Errors.INVALID_USER_CREDENTIALS), actualEvents);
|
||||
|
||||
// assert
|
||||
expectPermanentlyDisabled();
|
||||
|
@ -509,7 +515,7 @@ public class BruteForceTest extends AbstractTestRealmKeycloakTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
public void testResetLoginFailureCount() throws Exception {
|
||||
public void testResetLoginFailureCount() {
|
||||
RealmRepresentation realm = testRealm().toRepresentation();
|
||||
|
||||
try {
|
||||
|
@ -608,20 +614,19 @@ public class BruteForceTest extends AbstractTestRealmKeycloakTest {
|
|||
events.clear();
|
||||
}
|
||||
|
||||
public void expectTemporarilyDisabled() throws Exception {
|
||||
public void expectTemporarilyDisabled() {
|
||||
expectTemporarilyDisabled("test-user@localhost", null, "password");
|
||||
}
|
||||
|
||||
public void expectTemporarilyDisabled(String username, String userId) throws Exception {
|
||||
public void expectTemporarilyDisabled(String username, String userId) {
|
||||
expectTemporarilyDisabled(username, userId, "password");
|
||||
}
|
||||
|
||||
public void expectTemporarilyDisabled(String username, String userId, String password) throws Exception {
|
||||
public void expectTemporarilyDisabled(String username, String userId, String password) {
|
||||
loginPage.open();
|
||||
loginPage.login(username, password);
|
||||
|
||||
loginPage.assertCurrent();
|
||||
String src = driver.getPageSource();
|
||||
Assert.assertEquals("Invalid username or password.", loginPage.getInputError());
|
||||
ExpectedEvent event = events.expectLogin()
|
||||
.session((String) null)
|
||||
|
@ -634,11 +639,11 @@ public class BruteForceTest extends AbstractTestRealmKeycloakTest {
|
|||
event.assertEvent();
|
||||
}
|
||||
|
||||
public void expectPermanentlyDisabled() throws Exception {
|
||||
expectPermanentlyDisabled("test-user@localhost", null);
|
||||
public void expectPermanentlyDisabled() {
|
||||
expectPermanentlyDisabled("test-user@localhost");
|
||||
}
|
||||
|
||||
public void expectPermanentlyDisabled(String username, String userId) throws Exception {
|
||||
public void expectPermanentlyDisabled(String username) {
|
||||
loginPage.open();
|
||||
loginPage.login(username, "password");
|
||||
|
||||
|
@ -649,17 +654,14 @@ public class BruteForceTest extends AbstractTestRealmKeycloakTest {
|
|||
.error(Errors.USER_DISABLED)
|
||||
.detail(Details.USERNAME, username)
|
||||
.removeDetail(Details.CONSENT);
|
||||
if (userId != null) {
|
||||
event.user(userId);
|
||||
}
|
||||
event.assertEvent();
|
||||
}
|
||||
|
||||
public void loginSuccess() throws Exception {
|
||||
public void loginSuccess() {
|
||||
loginSuccess("test-user@localhost");
|
||||
}
|
||||
|
||||
public void loginSuccess(String username) throws Exception {
|
||||
public void loginSuccess(String username) {
|
||||
loginPage.open();
|
||||
loginPage.login(username, "password");
|
||||
|
||||
|
@ -676,8 +678,6 @@ public class BruteForceTest extends AbstractTestRealmKeycloakTest {
|
|||
String idTokenHint = oauth.doAccessTokenRequest(code, "password").getIdToken();
|
||||
appPage.logout(idTokenHint);
|
||||
events.clear();
|
||||
|
||||
|
||||
}
|
||||
|
||||
public void loginWithTotpFailure() {
|
||||
|
@ -752,11 +752,15 @@ public class BruteForceTest extends AbstractTestRealmKeycloakTest {
|
|||
events.clear();
|
||||
}
|
||||
|
||||
public void loginInvalidPassword() throws Exception {
|
||||
public void loginInvalidPassword() {
|
||||
loginInvalidPassword("test-user@localhost");
|
||||
}
|
||||
|
||||
public void loginInvalidPassword(String username) throws Exception {
|
||||
public void loginInvalidPassword(String username) {
|
||||
loginInvalidPassword(username, true);
|
||||
}
|
||||
|
||||
public void loginInvalidPassword(String username, boolean clearEventsQueue) {
|
||||
loginPage.open();
|
||||
loginPage.login(username, "invalid");
|
||||
|
||||
|
@ -764,7 +768,9 @@ public class BruteForceTest extends AbstractTestRealmKeycloakTest {
|
|||
|
||||
Assert.assertEquals("Invalid username or password.", loginPage.getInputError());
|
||||
|
||||
events.clear();
|
||||
if (clearEventsQueue) {
|
||||
events.clear();
|
||||
}
|
||||
}
|
||||
|
||||
public void loginMissingPassword() {
|
||||
|
@ -826,4 +832,27 @@ public class BruteForceTest extends AbstractTestRealmKeycloakTest {
|
|||
sendInvalidPasswordPasswordGrant();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies the given {@link ExpectedEvent} is "contained" in the collection of actual events. An
|
||||
* {@link ExpectedEvent expectedEvent} object is considered equal to a
|
||||
* {@link EventRepresentation eventRepresentation} object if {@code
|
||||
* expectedEvent.assertEvent(eventRepresentation)} does not throw any {@link AssertionError}.
|
||||
*
|
||||
* @param expectedEvent the expected event
|
||||
* @param actualEvents the collection of {@link EventRepresentation}
|
||||
*/
|
||||
public void assertIsContained(ExpectedEvent expectedEvent, List<? extends EventRepresentation> actualEvents) {
|
||||
List<String> messages = new ArrayList<>();
|
||||
for (EventRepresentation e : actualEvents) {
|
||||
try {
|
||||
expectedEvent.assertEvent(e);
|
||||
return;
|
||||
} catch (AssertionError error) {
|
||||
// silently fail
|
||||
messages.add(error.getMessage());
|
||||
}
|
||||
}
|
||||
Assert.fail(String.format("Expected event not found. Possible reasons are: %s", messages));
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue