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.
|
// 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;
|
||||||
|
|
|
@ -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");
|
||||||
}
|
}
|
||||||
|
|
|
@ -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()));
|
||||||
|
|
|
@ -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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue