Allow brute force to have http request/response and send emails

Closes #29542

Signed-off-by: rmartinc <rmartinc@redhat.com>
This commit is contained in:
rmartinc 2024-07-09 17:47:04 +02:00 committed by Marek Posolda
parent f8b1b3ee03
commit b60621d819
16 changed files with 226 additions and 32 deletions

View file

@ -17,6 +17,7 @@
package org.keycloak.services.managers; package org.keycloak.services.managers;
import jakarta.ws.rs.core.UriInfo;
import org.keycloak.common.ClientConnection; import org.keycloak.common.ClientConnection;
import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel; import org.keycloak.models.RealmModel;
@ -30,9 +31,9 @@ import org.keycloak.provider.Provider;
public interface BruteForceProtector extends Provider { public interface BruteForceProtector extends Provider {
String DISABLED_BY_PERMANENT_LOCKOUT = "permanentLockout"; String DISABLED_BY_PERMANENT_LOCKOUT = "permanentLockout";
void failedLogin(RealmModel realm, UserModel user, ClientConnection clientConnection); void failedLogin(RealmModel realm, UserModel user, ClientConnection clientConnection, UriInfo uriInfo);
void successfulLogin(RealmModel realm, UserModel user, ClientConnection clientConnection); void successfulLogin(RealmModel realm, UserModel user, ClientConnection clientConnection, UriInfo uriInfo);
boolean isTemporarilyDisabled(KeycloakSession session, RealmModel realm, UserModel user); boolean isTemporarilyDisabled(KeycloakSession session, RealmModel realm, UserModel user);

View file

@ -720,7 +720,7 @@ public class AuthenticationProcessor {
if (realm.isBruteForceProtected()) { if (realm.isBruteForceProtected()) {
UserModel user = AuthenticationManager.lookupUserForBruteForceLog(session, realm, authenticationSession); UserModel user = AuthenticationManager.lookupUserForBruteForceLog(session, realm, authenticationSession);
if (user != null) { if (user != null) {
getBruteForceProtector().failedLogin(realm, user, connection); getBruteForceProtector().failedLogin(realm, user, connection, session.getContext().getHttpRequest().getUri());
} }
} }
} }

View file

@ -54,6 +54,14 @@ public class EmailEventListenerProviderFactory implements EventListenerProviderF
return new EmailEventListenerProvider(session, includedEvents); return new EmailEventListenerProvider(session, includedEvents);
} }
public void addIncludedEvents(EventType... types) {
includedEvents.addAll(Arrays.asList(types));
}
public void removeIncludedEvents(EventType... types) {
includedEvents.removeAll(Arrays.asList(types));
}
@Override @Override
public void init(Config.Scope config) { public void init(Config.Scope config) {
String[] include = config.getArray("include-events"); String[] include = config.getArray("include-events");

View file

@ -1615,7 +1615,7 @@ public class AuthenticationManager {
UserModel user = lookupUserForBruteForceLog(session, realm, authSession); UserModel user = lookupUserForBruteForceLog(session, realm, authSession);
if (user != null) { if (user != null) {
BruteForceProtector bruteForceProtector = session.getProvider(BruteForceProtector.class); BruteForceProtector bruteForceProtector = session.getProvider(BruteForceProtector.class);
bruteForceProtector.successfulLogin(realm, user, session.getContext().getConnection()); bruteForceProtector.successfulLogin(realm, user, session.getContext().getConnection(), session.getContext().getHttpRequest().getUri());
} }
} }
} }

View file

@ -16,6 +16,7 @@
*/ */
package org.keycloak.services.managers; package org.keycloak.services.managers;
import jakarta.ws.rs.core.UriInfo;
import java.util.Collections; import java.util.Collections;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.Map; import java.util.Map;
@ -133,10 +134,10 @@ public class DefaultBlockingBruteForceProtector extends DefaultBruteForceProtect
} }
@Override @Override
protected void processLogin(RealmModel realm, UserModel user, ClientConnection clientConnection, boolean success) { protected void processLogin(RealmModel realm, UserModel user, ClientConnection clientConnection, UriInfo uriInfo, boolean success) {
// mark the off-thread is started for this request // mark the off-thread is started for this request
loginAttempts.computeIfPresent(user.getId(), (k, v) -> v + OFF_THREAD_STARTED); loginAttempts.computeIfPresent(user.getId(), (k, v) -> v + OFF_THREAD_STARTED);
super.processLogin(realm, user, clientConnection, success); super.processLogin(realm, user, clientConnection, uriInfo, success);
} }
@Override @Override

View file

@ -24,6 +24,9 @@ import org.keycloak.events.Details;
import org.keycloak.events.EventBuilder; import org.keycloak.events.EventBuilder;
import org.keycloak.events.EventType; import org.keycloak.events.EventType;
import org.keycloak.executors.ExecutorsProvider; import org.keycloak.executors.ExecutorsProvider;
import org.keycloak.http.FormPartValue;
import org.keycloak.http.HttpRequest;
import org.keycloak.http.HttpResponse;
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;
@ -32,6 +35,12 @@ import org.keycloak.models.UserModel;
import org.keycloak.models.utils.KeycloakModelUtils; import org.keycloak.models.utils.KeycloakModelUtils;
import org.keycloak.storage.ReadOnlyException; import org.keycloak.storage.ReadOnlyException;
import jakarta.ws.rs.core.HttpHeaders;
import jakarta.ws.rs.core.MultivaluedHashMap;
import jakarta.ws.rs.core.MultivaluedMap;
import jakarta.ws.rs.core.NewCookie;
import jakarta.ws.rs.core.UriInfo;
import java.security.cert.X509Certificate;
import java.time.Instant; import java.time.Instant;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.ZoneId; import java.time.ZoneId;
@ -169,8 +178,8 @@ public class DefaultBruteForceProtector implements BruteForceProtector {
} }
@Override @Override
public void failedLogin(RealmModel realm, UserModel user, ClientConnection clientConnection) { public void failedLogin(RealmModel realm, UserModel user, ClientConnection clientConnection, UriInfo uriInfo) {
processLogin(realm, user, clientConnection, false); processLogin(realm, user, clientConnection, uriInfo, false);
// 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
// todo failure HTTP responses should be queued via async HTTP // todo failure HTTP responses should be queued via async HTTP
@ -179,18 +188,22 @@ public class DefaultBruteForceProtector implements BruteForceProtector {
} }
@Override @Override
public void successfulLogin(RealmModel realm, UserModel user, ClientConnection clientConnection) { public void successfulLogin(RealmModel realm, UserModel user, ClientConnection clientConnection, UriInfo uriInfo) {
processLogin(realm, user, clientConnection, true); processLogin(realm, user, clientConnection, uriInfo, true);
logger.trace("sent success event"); logger.trace("sent success event");
} }
protected void processLogin(RealmModel realm, UserModel user, ClientConnection clientConnection, boolean success) { protected void processLogin(RealmModel realm, UserModel user, ClientConnection clientConnection, UriInfo uriInfo, boolean success) {
ExecutorService executor = KeycloakModelUtils.runJobInTransactionWithResult(factory, session -> { ExecutorService executor = KeycloakModelUtils.runJobInTransactionWithResult(factory, session -> {
ExecutorsProvider provider = session.getProvider(ExecutorsProvider.class); ExecutorsProvider provider = session.getProvider(ExecutorsProvider.class);
return provider.getExecutor("bruteforce"); return provider.getExecutor("bruteforce");
}); });
final HttpRequest bruteForceHttpRequest = new BruteForceHttpRequest(uriInfo);
final HttpResponse bruteForceHttpResponse = new BruteForceHttpResponse();
executor.execute(() -> KeycloakModelUtils.runJobInTransaction(factory, s -> { executor.execute(() -> KeycloakModelUtils.runJobInTransaction(factory, s -> {
s.getContext().setRealm(s.realms().getRealm(realm.getId())); s.getContext().setRealm(s.realms().getRealm(realm.getId()));
s.getContext().setHttpRequest(bruteForceHttpRequest);
s.getContext().setHttpResponse(bruteForceHttpResponse);
if (success) { if (success) {
success(s, realm, user.getId()); success(s, realm, user.getId());
} else { } else {
@ -218,7 +231,15 @@ public class DefaultBruteForceProtector implements BruteForceProtector {
@Override @Override
public boolean isPermanentlyLockedOut(KeycloakSession session, RealmModel realm, UserModel user) { public boolean isPermanentlyLockedOut(KeycloakSession session, RealmModel realm, UserModel user) {
return !user.isEnabled() && DISABLED_BY_PERMANENT_LOCKOUT.equals(user.getFirstAttribute(DISABLED_REASON)); if (!user.isEnabled() && DISABLED_BY_PERMANENT_LOCKOUT.equals(user.getFirstAttribute(DISABLED_REASON))) {
return true;
}
if (!realm.isPermanentLockout()) return false;
// recheck failures just in case we are in a race
UserLoginFailureModel userLoginFailure = getUserFailureModel(session, realm, user.getId());
return userLoginFailure != null && userLoginFailure.getNumTemporaryLockouts() > realm.getMaxTemporaryLockouts();
} }
@Override @Override
@ -230,4 +251,66 @@ public class DefaultBruteForceProtector implements BruteForceProtector {
@Override @Override
public void close() {} public void close() {}
private static class BruteForceHttpRequest implements HttpRequest {
private final UriInfo uriInfo;
BruteForceHttpRequest(UriInfo uriInfo) {
this.uriInfo = uriInfo;
}
@Override
public String getHttpMethod() {
return "";
}
@Override
public MultivaluedMap<String, String> getDecodedFormParameters() {
return new MultivaluedHashMap<>();
}
@Override
public MultivaluedMap<String, FormPartValue> getMultiPartFormParameters() {
return new MultivaluedHashMap<>();
}
@Override
public HttpHeaders getHttpHeaders() {
return null;
}
@Override
public X509Certificate[] getClientCertificateChain() {
return null;
}
@Override
public UriInfo getUri() {
return uriInfo;
}
}
private static class BruteForceHttpResponse implements HttpResponse {
@Override
public int getStatus() {
return -1;
}
@Override
public void setStatus(int statusCode) {
}
@Override
public void addHeader(String name, String value) {
}
@Override
public void setHeader(String name, String value) {
}
@Override
public void setCookieIfAbsent(NewCookie cookie) {
}
}
} }

View file

@ -41,6 +41,7 @@ import org.keycloak.component.ComponentModel;
import org.keycloak.cookie.CookieProvider; import org.keycloak.cookie.CookieProvider;
import org.keycloak.cookie.CookieType; import org.keycloak.cookie.CookieType;
import org.keycloak.events.Event; import org.keycloak.events.Event;
import org.keycloak.events.EventListenerProvider;
import org.keycloak.events.EventQuery; import org.keycloak.events.EventQuery;
import org.keycloak.events.EventStoreProvider; import org.keycloak.events.EventStoreProvider;
import org.keycloak.events.EventType; import org.keycloak.events.EventType;
@ -48,6 +49,7 @@ import org.keycloak.events.admin.AdminEvent;
import org.keycloak.events.admin.AdminEventQuery; import org.keycloak.events.admin.AdminEventQuery;
import org.keycloak.events.admin.AuthDetails; import org.keycloak.events.admin.AuthDetails;
import org.keycloak.events.admin.OperationType; import org.keycloak.events.admin.OperationType;
import org.keycloak.events.email.EmailEventListenerProviderFactory;
import org.keycloak.http.HttpRequest; import org.keycloak.http.HttpRequest;
import org.keycloak.models.AuthenticatedClientSessionModel; import org.keycloak.models.AuthenticatedClientSessionModel;
import org.keycloak.models.AuthenticationFlowModel; import org.keycloak.models.AuthenticationFlowModel;
@ -1195,4 +1197,26 @@ public class TestingResourceProvider implements RealmResourceProvider {
.orElseThrow(() -> new RuntimeException("No authenticatedClientSession found.")); .orElseThrow(() -> new RuntimeException("No authenticatedClientSession found."));
return PreAuthorizedCodeGrantType.getPreAuthorizedCode(session, ascm, expiration); return PreAuthorizedCodeGrantType.getPreAuthorizedCode(session, ascm, expiration);
} }
@POST
@Path("/email-event-litener-provide/add-events")
@Consumes(MediaType.APPLICATION_JSON)
public void addEventsToEmailEventListenerProvider(List<EventType> events) {
if (events != null && !events.isEmpty()) {
EmailEventListenerProviderFactory prov = (EmailEventListenerProviderFactory) session.getKeycloakSessionFactory()
.getProviderFactory(EventListenerProvider.class, EmailEventListenerProviderFactory.ID);
prov.addIncludedEvents(events.toArray(EventType[]::new));
}
}
@POST
@Path("/email-event-litener-provide/remove-events")
@Consumes(MediaType.APPLICATION_JSON)
public void removeEventsToEmailEventListenerProvider(List<EventType> events) {
if (events != null && !events.isEmpty()) {
EmailEventListenerProviderFactory prov = (EmailEventListenerProviderFactory) session.getKeycloakSessionFactory()
.getProviderFactory(EventListenerProvider.class, EmailEventListenerProviderFactory.ID);
prov.removeIncludedEvents(events.toArray(EventType[]::new));
}
}
} }

View file

@ -20,6 +20,7 @@ package org.keycloak.testsuite.client.resources;
import org.jboss.resteasy.reactive.NoCache; import org.jboss.resteasy.reactive.NoCache;
import org.keycloak.common.Profile; import org.keycloak.common.Profile;
import org.keycloak.common.enums.HostnameVerificationPolicy; import org.keycloak.common.enums.HostnameVerificationPolicy;
import org.keycloak.events.EventType;
import org.keycloak.representations.idm.AdminEventRepresentation; import org.keycloak.representations.idm.AdminEventRepresentation;
import org.keycloak.representations.idm.AuthenticationFlowRepresentation; import org.keycloak.representations.idm.AuthenticationFlowRepresentation;
import org.keycloak.representations.idm.EventRepresentation; import org.keycloak.representations.idm.EventRepresentation;
@ -470,4 +471,22 @@ public interface TestingResource {
@GET @GET
@Path("/pre-authorized-code") @Path("/pre-authorized-code")
String getPreAuthorizedCode(@QueryParam("realm") final String realmName, @QueryParam("userSessionId") final String userSessionId, @QueryParam("clientId") final String clientId, @QueryParam("expiration") final int expiration); String getPreAuthorizedCode(@QueryParam("realm") final String realmName, @QueryParam("userSessionId") final String userSessionId, @QueryParam("clientId") final String clientId, @QueryParam("expiration") final int expiration);
/**
* Adds the following types to the email event listener included list.
* @param events The events to be included
*/
@POST
@Path("/email-event-litener-provide/add-events")
@Consumes(MediaType.APPLICATION_JSON)
public void addEventsToEmailEventListenerProvider(List<EventType> events);
/**
* Removes the following types from the email event listener included list.
* @param events The events to be removed
*/
@POST
@Path("/email-event-litener-provide/remove-events")
@Consumes(MediaType.APPLICATION_JSON)
public void removeEventsToEmailEventListenerProvider(List<EventType> events);
} }

View file

@ -3,6 +3,7 @@ package org.keycloak.testsuite.updaters;
import org.keycloak.admin.client.resource.RealmResource; import org.keycloak.admin.client.resource.RealmResource;
import org.keycloak.representations.idm.RealmRepresentation; import org.keycloak.representations.idm.RealmRepresentation;
import java.util.ArrayList;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
@ -94,6 +95,23 @@ public class RealmAttributeUpdater extends ServerResourceUpdater<RealmAttributeU
return this; return this;
} }
public RealmAttributeUpdater setPermanentLockout(Boolean value) {
rep.setPermanentLockout(value);
return this;
}
public RealmAttributeUpdater setEventsListeners(List<String> eventListanets) {
rep.setEventsListeners(eventListanets);
return this;
}
public RealmAttributeUpdater addEventsListener(String value) {
List<String> list = new ArrayList<>(rep.getEventsListeners());
list.add(value);
rep.setEventsListeners(list);
return this;
}
public RealmAttributeUpdater setDuplicateEmailsAllowed(Boolean value) { public RealmAttributeUpdater setDuplicateEmailsAllowed(Boolean value) {
rep.setDuplicateEmailsAllowed(value); rep.setDuplicateEmailsAllowed(value);
return this; return this;

View file

@ -83,7 +83,11 @@ public class AssertEvents implements TestRule {
} }
public EventRepresentation poll() { public EventRepresentation poll() {
EventRepresentation event = fetchNextEvent(); return poll(0);
}
public EventRepresentation poll(int seconds) {
EventRepresentation event = fetchNextEvent(seconds);
Assert.assertNotNull("Event expected", event); Assert.assertNotNull("Event expected", event);
return event; return event;

View file

@ -27,6 +27,7 @@ import org.keycloak.OAuth2Constants;
import org.keycloak.events.Details; import org.keycloak.events.Details;
import org.keycloak.events.Errors; import org.keycloak.events.Errors;
import org.keycloak.events.EventType; import org.keycloak.events.EventType;
import org.keycloak.events.email.EmailEventListenerProviderFactory;
import org.keycloak.executors.ExecutorsProvider; import org.keycloak.executors.ExecutorsProvider;
import org.keycloak.models.Constants; import org.keycloak.models.Constants;
import org.keycloak.models.UserModel; import org.keycloak.models.UserModel;
@ -46,12 +47,14 @@ import org.keycloak.testsuite.pages.LoginPasswordResetPage;
import org.keycloak.testsuite.pages.LoginPasswordUpdatePage; import org.keycloak.testsuite.pages.LoginPasswordUpdatePage;
import org.keycloak.testsuite.pages.LoginTotpPage; import org.keycloak.testsuite.pages.LoginTotpPage;
import org.keycloak.testsuite.pages.RegisterPage; import org.keycloak.testsuite.pages.RegisterPage;
import org.keycloak.testsuite.updaters.RealmAttributeUpdater;
import org.keycloak.testsuite.util.GreenMailRule; import org.keycloak.testsuite.util.GreenMailRule;
import org.keycloak.testsuite.util.MailUtils; import org.keycloak.testsuite.util.MailUtils;
import org.keycloak.testsuite.util.OAuthClient; import org.keycloak.testsuite.util.OAuthClient;
import org.keycloak.testsuite.util.RealmRepUtil; import org.keycloak.testsuite.util.RealmRepUtil;
import org.keycloak.testsuite.util.UserBuilder; import org.keycloak.testsuite.util.UserBuilder;
import jakarta.mail.MessagingException;
import jakarta.mail.internet.MimeMessage; import jakarta.mail.internet.MimeMessage;
import java.net.MalformedURLException; import java.net.MalformedURLException;
@ -106,7 +109,7 @@ public class BruteForceTest extends AbstractTestRealmKeycloakTest {
@Override @Override
public void configureTestRealm(RealmRepresentation testRealm) { public void configureTestRealm(RealmRepresentation testRealm) {
UserRepresentation user = RealmRepUtil.findUser(testRealm, "test-user@localhost"); UserRepresentation user = RealmRepUtil.findUser(testRealm, "test-user@localhost");
UserBuilder.edit(user).totpSecret("totpSecret"); UserBuilder.edit(user).totpSecret("totpSecret").emailVerified(true);
testRealm.setBruteForceProtected(true); testRealm.setBruteForceProtected(true);
testRealm.setFailureFactor(failureFactor); testRealm.setFailureFactor(failureFactor);
@ -553,15 +556,21 @@ public class BruteForceTest extends AbstractTestRealmKeycloakTest {
assertUserDisabledEvent(Errors.INVALID_AUTHENTICATION_SESSION); assertUserDisabledEvent(Errors.INVALID_AUTHENTICATION_SESSION);
} }
@Test private void checkEmailPresent(String subject) {
public void testPermanentLockout() { Assert.assertFalse("No email with subject: " + subject, Arrays.stream(greenMail.getReceivedMessages()).filter(m -> {
RealmRepresentation realm = testRealm().toRepresentation(); try {
try { return subject.equals(m.getSubject());
// arrange } catch (MessagingException ex) {
realm.setPermanentLockout(true); return false;
realm.setQuickLoginCheckMilliSeconds(0L); }
testRealm().update(realm); }).findAny().isEmpty());
}
@Test
public void testPermanentLockout() throws Exception {
testingClient.testing().addEventsToEmailEventListenerProvider(Collections.singletonList(EventType.USER_DISABLED_BY_PERMANENT_LOCKOUT));
try (RealmAttributeUpdater updater = new RealmAttributeUpdater(testRealm()).setPermanentLockout(true)
.addEventsListener(EmailEventListenerProviderFactory.ID).update()) {
// act // act
loginInvalidPassword("test-user@localhost"); loginInvalidPassword("test-user@localhost");
loginInvalidPassword("test-user@localhost", false); loginInvalidPassword("test-user@localhost", false);
@ -569,10 +578,12 @@ public class BruteForceTest extends AbstractTestRealmKeycloakTest {
// As of now, there are two events: USER_DISABLED_BY_PERMANENT_LOCKOUT and LOGIN_ERROR but Order is not // 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 // guarantee though since the brute force detector is running separately "in its own thread" named
// "Brute Force Protector". // "Brute Force Protector".
List<EventRepresentation> actualEvents = Arrays.asList(events.poll(), events.poll()); List<EventRepresentation> actualEvents = Arrays.asList(events.poll(), events.poll(5));
assertIsContained(events.expect(EventType.USER_DISABLED_BY_PERMANENT_LOCKOUT).client((String) null).detail(Details.REASON, "brute_force_attack detected"), actualEvents); 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); assertIsContained(events.expect(EventType.LOGIN_ERROR).error(Errors.INVALID_USER_CREDENTIALS), actualEvents);
checkEmailPresent("User disabled by permament lockout");
// assert // assert
expectPermanentlyDisabled(); expectPermanentlyDisabled();
@ -584,9 +595,8 @@ public class BruteForceTest extends AbstractTestRealmKeycloakTest {
updateUser(user); updateUser(user);
assertUserDisabledReason(null); assertUserDisabledReason(null);
} finally { } finally {
realm.setPermanentLockout(false); testingClient.testing().removeEventsToEmailEventListenerProvider(Collections.singletonList(EventType.USER_DISABLED_BY_PERMANENT_LOCKOUT));
testRealm().update(realm); UserRepresentation user = testRealm().users().search("test-user@localhost", 0, 1).get(0);
UserRepresentation user = adminClient.realm("test").users().search("test-user@localhost", 0, 1).get(0);
user.setEnabled(true); user.setEnabled(true);
updateUser(user); updateUser(user);
} }
@ -612,7 +622,7 @@ public class BruteForceTest extends AbstractTestRealmKeycloakTest {
// As of now, there are two events: USER_DISABLED_BY_PERMANENT_LOCKOUT and LOGIN_ERROR but Order is not // 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 // guarantee though since the brute force detector is running separately "in its own thread" named
// "Brute Force Protector". // "Brute Force Protector".
List<EventRepresentation> actualEvents = Arrays.asList(events.poll(), events.poll()); List<EventRepresentation> actualEvents = Arrays.asList(events.poll(), events.poll(5));
assertIsContained(events.expect(EventType.USER_DISABLED_BY_PERMANENT_LOCKOUT).client((String) null).detail(Details.REASON, "brute_force_attack detected"), actualEvents); 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); assertIsContained(events.expect(EventType.LOGIN_ERROR).error(Errors.INVALID_USER_CREDENTIALS), actualEvents);
@ -637,12 +647,20 @@ public class BruteForceTest extends AbstractTestRealmKeycloakTest {
@Test @Test
public void testTemporaryLockout() throws Exception { public void testTemporaryLockout() throws Exception {
loginInvalidPassword("test-user@localhost"); testingClient.testing().addEventsToEmailEventListenerProvider(Collections.singletonList(EventType.USER_DISABLED_BY_TEMPORARY_LOCKOUT));
loginInvalidPassword("test-user@localhost", false); try (RealmAttributeUpdater updater = new RealmAttributeUpdater(testRealm())
.addEventsListener(EmailEventListenerProviderFactory.ID).update()) {
loginInvalidPassword("test-user@localhost");
loginInvalidPassword("test-user@localhost", false);
List<EventRepresentation> actualEvents = Arrays.asList(events.poll(), events.poll()); List<EventRepresentation> actualEvents = Arrays.asList(events.poll(), events.poll(5));
assertIsContained(events.expect(EventType.USER_DISABLED_BY_TEMPORARY_LOCKOUT).client((String) null).detail(Details.REASON, "brute_force_attack detected"), actualEvents); assertIsContained(events.expect(EventType.USER_DISABLED_BY_TEMPORARY_LOCKOUT).client((String) null).detail(Details.REASON, "brute_force_attack detected"), actualEvents);
assertIsContained(events.expect(EventType.LOGIN_ERROR).error(Errors.INVALID_USER_CREDENTIALS), actualEvents); assertIsContained(events.expect(EventType.LOGIN_ERROR).error(Errors.INVALID_USER_CREDENTIALS), actualEvents);
checkEmailPresent("User disabled by temporary lockout");
} finally {
testingClient.testing().removeEventsToEmailEventListenerProvider(Collections.singletonList(EventType.USER_DISABLED_BY_TEMPORARY_LOCKOUT));
}
} }
@Test @Test

View file

@ -0,0 +1,4 @@
<#import "template.ftl" as layout>
<@layout.emailLayout>
${kcSanitize(msg("eventUserDisabledByPermanentLockoutHtml", event.date))?no_esc}
</@layout.emailLayout>

View file

@ -0,0 +1,4 @@
<#import "template.ftl" as layout>
<@layout.emailLayout>
${kcSanitize(msg("eventUserDisabledByTemporaryLockoutHtml", event.date))?no_esc}
</@layout.emailLayout>

View file

@ -39,6 +39,12 @@ eventUpdateCredentialBodyHtml=<p>Your {0} credential was changed on {1} from {2}
eventRemoveCredentialSubject=Remove credential eventRemoveCredentialSubject=Remove credential
eventRemoveCredentialBody=Credential {0} was removed from your account on {1} from {2}. If this was not you, please contact an administrator. eventRemoveCredentialBody=Credential {0} was removed from your account on {1} from {2}. If this was not you, please contact an administrator.
eventRemoveCredentialBodyHtml=<p>Credential {0} was removed from your account on {1} from {2}. If this was not you, please contact an administrator.</p> eventRemoveCredentialBodyHtml=<p>Credential {0} was removed from your account on {1} from {2}. If this was not you, please contact an administrator.</p>
eventUserDisabledByTemporaryLockoutSubject=User disabled by temporary lockout
eventUserDisabledByTemporaryLockoutBody=Your user has been disabled temporarily because of multiple failed attemps on {0}. Please contact an administrator if needed.
eventUserDisabledByTemporaryLockoutHtml=<p>Your user has been disabled temporarily because of multiple failed attemps on {0}. Please contact an administrator if needed.</p>
eventUserDisabledByPermanentLockoutSubject=User disabled by permament lockout
eventUserDisabledByPermanentLockoutBody=Your user has been disabled permanently because of multiple failed attemps on {0}. Please contact an administrator.
eventUserDisabledByPermanentLockoutHtml=<p>Your user has been disabled permanently because of multiple failed attemps on {0}. Please contact an administrator.</p>
requiredAction.CONFIGURE_TOTP=Configure OTP requiredAction.CONFIGURE_TOTP=Configure OTP
requiredAction.TERMS_AND_CONDITIONS=Terms and Conditions requiredAction.TERMS_AND_CONDITIONS=Terms and Conditions

View file

@ -0,0 +1,2 @@
<#ftl output_format="plainText">
${msg("eventUserDisabledByPermanentLockoutBody", event.date)}

View file

@ -0,0 +1,2 @@
<#ftl output_format="plainText">
${msg("eventUserDisabledByTemporaryLockoutBody", event.date)}