KEYCLOAK-15985 Add Brute Force Detection Lockout Event

This commit is contained in:
paul 2023-09-15 10:25:57 +04:00 committed by Pedro Igor
parent 95ecf446ca
commit f684a70048
4 changed files with 119 additions and 37 deletions

View file

@ -155,8 +155,10 @@ public enum EventType implements EnumWithStableIndex {
// PAR request. // PAR request.
PUSHED_AUTHORIZATION_REQUEST(51, false), 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 int stableIndex;
private final boolean saveByDefault; private final boolean saveByDefault;

View file

@ -20,6 +20,9 @@ package org.keycloak.services.managers;
import org.jboss.logging.Logger; import org.jboss.logging.Logger;
import org.keycloak.common.ClientConnection; import org.keycloak.common.ClientConnection;
import org.keycloak.common.util.Time; 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.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory; import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.models.RealmModel; import org.keycloak.models.RealmModel;
@ -59,12 +62,12 @@ public class DefaultBruteForceProtector implements Runnable, BruteForceProtector
protected abstract class LoginEvent implements Comparable<LoginEvent> { protected abstract class LoginEvent implements Comparable<LoginEvent> {
protected final String realmId; protected final String realmId;
protected final String userId; 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.realmId = realmId;
this.userId = userId; this.userId = userId;
this.ip = ip; this.clientConnection = new AdaptedClientConnection(clientConnection);
} }
@Override @Override
@ -82,16 +85,58 @@ public class DefaultBruteForceProtector implements Runnable, BruteForceProtector
protected class FailedLogin extends LoginEvent { protected class FailedLogin extends LoginEvent {
protected final CountDownLatch latch = new CountDownLatch(1); protected final CountDownLatch latch = new CountDownLatch(1);
public FailedLogin(String realmId, String userId, String ip) { public FailedLogin(String realmId, String userId, ClientConnection clientConnection) {
super(realmId, userId, ip); super(realmId, userId, clientConnection);
} }
} }
protected class SuccessfulLogin extends LoginEvent { protected class SuccessfulLogin extends LoginEvent {
protected final CountDownLatch latch = new CountDownLatch(1); protected final CountDownLatch latch = new CountDownLatch(1);
public SuccessfulLogin(String realmId, String userId, String ip) { public SuccessfulLogin(String realmId, String userId, ClientConnection clientConnection) {
super(realmId, userId, ip); 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) { if (userLoginFailure == null) {
userLoginFailure = session.loginFailures().addUserLoginFailure(realm, userId); userLoginFailure = session.loginFailures().addUserLoginFailure(realm, userId);
} }
userLoginFailure.setLastIPFailure(event.ip); userLoginFailure.setLastIPFailure(event.clientConnection.getRemoteAddr());
long currentTime = Time.currentTimeMillis(); long currentTime = Time.currentTimeMillis();
long last = userLoginFailure.getLastFailure(); long last = userLoginFailure.getLastFailure();
long deltaTime = 0; 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()); logger.debugv("user {0} locked permanently due to too many login attempts", user.getUsername());
user.setEnabled(false); user.setEnabled(false);
user.setSingleAttribute(DISABLED_REASON, DISABLED_BY_PERMANENT_LOCKOUT); 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; return;
} }
@ -264,7 +315,7 @@ public class DefaultBruteForceProtector implements Runnable, BruteForceProtector
} }
protected void logFailure(LoginEvent event) { protected void logFailure(LoginEvent event) {
ServicesLogger.LOGGER.loginFailure(event.userId, event.ip); ServicesLogger.LOGGER.loginFailure(event.userId, event.clientConnection.getRemoteAddr());
failures++; failures++;
long delta = 0; long delta = 0;
if (lastFailure > 0) { if (lastFailure > 0) {
@ -281,7 +332,7 @@ public class DefaultBruteForceProtector implements Runnable, BruteForceProtector
@Override @Override
public void failedLogin(RealmModel realm, UserModel user, ClientConnection clientConnection) { public void failedLogin(RealmModel realm, UserModel user, ClientConnection clientConnection) {
try { try {
FailedLogin event = new FailedLogin(realm.getId(), user.getId(), clientConnection.getRemoteAddr()); FailedLogin event = new FailedLogin(realm.getId(), user.getId(), clientConnection);
queue.offer(event); queue.offer(event);
// wait a minimum of seconds for type to process so that a hacker // 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 // 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 @Override
public void successfulLogin(final RealmModel realm, final UserModel user, final ClientConnection clientConnection) { 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); queue.offer(event);
logger.trace("sent success event"); logger.trace("sent success event");
} }

View file

@ -361,7 +361,7 @@ public class AssertEvents implements TestRule {
} }
public EventRepresentation assertEvent(EventRepresentation actual) { 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"); expected.setType(expected.getType() + "_ERROR");
} }
assertThat("type", actual.getType(), is(expected.getType())); assertThat("type", actual.getType(), is(expected.getType()));

View file

@ -30,6 +30,7 @@ import org.keycloak.events.EventType;
import org.keycloak.models.Constants; import org.keycloak.models.Constants;
import org.keycloak.models.UserModel; import org.keycloak.models.UserModel;
import org.keycloak.models.utils.TimeBasedOTP; import org.keycloak.models.utils.TimeBasedOTP;
import org.keycloak.representations.idm.EventRepresentation;
import org.keycloak.representations.idm.RealmRepresentation; import org.keycloak.representations.idm.RealmRepresentation;
import org.keycloak.representations.idm.UserRepresentation; import org.keycloak.representations.idm.UserRepresentation;
import org.keycloak.services.managers.BruteForceProtector; import org.keycloak.services.managers.BruteForceProtector;
@ -51,9 +52,7 @@ import org.keycloak.testsuite.util.UserBuilder;
import jakarta.mail.internet.MimeMessage; import jakarta.mail.internet.MimeMessage;
import java.net.MalformedURLException; import java.net.MalformedURLException;
import java.util.Calendar; import java.util.*;
import java.util.Collections;
import java.util.Map;
import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.CoreMatchers.is;
import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertEquals;
@ -477,7 +476,7 @@ public class BruteForceTest extends AbstractTestRealmKeycloakTest {
} }
@Test @Test
public void testPermanentLockout() throws Exception { public void testPermanentLockout() {
RealmRepresentation realm = testRealm().toRepresentation(); RealmRepresentation realm = testRealm().toRepresentation();
try { try {
@ -486,8 +485,15 @@ public class BruteForceTest extends AbstractTestRealmKeycloakTest {
testRealm().update(realm); testRealm().update(realm);
// act // act
loginInvalidPassword(); loginInvalidPassword("test-user@localhost");
loginInvalidPassword(); 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 // assert
expectPermanentlyDisabled(); expectPermanentlyDisabled();
@ -509,7 +515,7 @@ public class BruteForceTest extends AbstractTestRealmKeycloakTest {
} }
@Test @Test
public void testResetLoginFailureCount() throws Exception { public void testResetLoginFailureCount() {
RealmRepresentation realm = testRealm().toRepresentation(); RealmRepresentation realm = testRealm().toRepresentation();
try { try {
@ -608,20 +614,19 @@ public class BruteForceTest extends AbstractTestRealmKeycloakTest {
events.clear(); events.clear();
} }
public void expectTemporarilyDisabled() throws Exception { public void expectTemporarilyDisabled() {
expectTemporarilyDisabled("test-user@localhost", null, "password"); 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"); 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.open();
loginPage.login(username, password); loginPage.login(username, password);
loginPage.assertCurrent(); loginPage.assertCurrent();
String src = driver.getPageSource();
Assert.assertEquals("Invalid username or password.", loginPage.getInputError()); Assert.assertEquals("Invalid username or password.", loginPage.getInputError());
ExpectedEvent event = events.expectLogin() ExpectedEvent event = events.expectLogin()
.session((String) null) .session((String) null)
@ -634,11 +639,11 @@ public class BruteForceTest extends AbstractTestRealmKeycloakTest {
event.assertEvent(); event.assertEvent();
} }
public void expectPermanentlyDisabled() throws Exception { public void expectPermanentlyDisabled() {
expectPermanentlyDisabled("test-user@localhost", null); expectPermanentlyDisabled("test-user@localhost");
} }
public void expectPermanentlyDisabled(String username, String userId) throws Exception { public void expectPermanentlyDisabled(String username) {
loginPage.open(); loginPage.open();
loginPage.login(username, "password"); loginPage.login(username, "password");
@ -649,17 +654,14 @@ public class BruteForceTest extends AbstractTestRealmKeycloakTest {
.error(Errors.USER_DISABLED) .error(Errors.USER_DISABLED)
.detail(Details.USERNAME, username) .detail(Details.USERNAME, username)
.removeDetail(Details.CONSENT); .removeDetail(Details.CONSENT);
if (userId != null) {
event.user(userId);
}
event.assertEvent(); event.assertEvent();
} }
public void loginSuccess() throws Exception { public void loginSuccess() {
loginSuccess("test-user@localhost"); loginSuccess("test-user@localhost");
} }
public void loginSuccess(String username) throws Exception { public void loginSuccess(String username) {
loginPage.open(); loginPage.open();
loginPage.login(username, "password"); loginPage.login(username, "password");
@ -676,8 +678,6 @@ public class BruteForceTest extends AbstractTestRealmKeycloakTest {
String idTokenHint = oauth.doAccessTokenRequest(code, "password").getIdToken(); String idTokenHint = oauth.doAccessTokenRequest(code, "password").getIdToken();
appPage.logout(idTokenHint); appPage.logout(idTokenHint);
events.clear(); events.clear();
} }
public void loginWithTotpFailure() { public void loginWithTotpFailure() {
@ -752,11 +752,15 @@ public class BruteForceTest extends AbstractTestRealmKeycloakTest {
events.clear(); events.clear();
} }
public void loginInvalidPassword() throws Exception { public void loginInvalidPassword() {
loginInvalidPassword("test-user@localhost"); 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.open();
loginPage.login(username, "invalid"); loginPage.login(username, "invalid");
@ -764,8 +768,10 @@ public class BruteForceTest extends AbstractTestRealmKeycloakTest {
Assert.assertEquals("Invalid username or password.", loginPage.getInputError()); Assert.assertEquals("Invalid username or password.", loginPage.getInputError());
if (clearEventsQueue) {
events.clear(); events.clear();
} }
}
public void loginMissingPassword() { public void loginMissingPassword() {
loginPage.open(); loginPage.open();
@ -826,4 +832,27 @@ public class BruteForceTest extends AbstractTestRealmKeycloakTest {
sendInvalidPasswordPasswordGrant(); 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));
}
} }