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:
parent
f8b1b3ee03
commit
b60621d819
16 changed files with 226 additions and 32 deletions
|
@ -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);
|
||||||
|
|
||||||
|
|
|
@ -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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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");
|
||||||
|
|
|
@ -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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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) {
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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 {
|
||||||
// arrange
|
return subject.equals(m.getSubject());
|
||||||
realm.setPermanentLockout(true);
|
} catch (MessagingException ex) {
|
||||||
realm.setQuickLoginCheckMilliSeconds(0L);
|
return false;
|
||||||
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 {
|
||||||
|
testingClient.testing().addEventsToEmailEventListenerProvider(Collections.singletonList(EventType.USER_DISABLED_BY_TEMPORARY_LOCKOUT));
|
||||||
|
try (RealmAttributeUpdater updater = new RealmAttributeUpdater(testRealm())
|
||||||
|
.addEventsListener(EmailEventListenerProviderFactory.ID).update()) {
|
||||||
loginInvalidPassword("test-user@localhost");
|
loginInvalidPassword("test-user@localhost");
|
||||||
loginInvalidPassword("test-user@localhost", false);
|
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
|
||||||
|
|
|
@ -0,0 +1,4 @@
|
||||||
|
<#import "template.ftl" as layout>
|
||||||
|
<@layout.emailLayout>
|
||||||
|
${kcSanitize(msg("eventUserDisabledByPermanentLockoutHtml", event.date))?no_esc}
|
||||||
|
</@layout.emailLayout>
|
|
@ -0,0 +1,4 @@
|
||||||
|
<#import "template.ftl" as layout>
|
||||||
|
<@layout.emailLayout>
|
||||||
|
${kcSanitize(msg("eventUserDisabledByTemporaryLockoutHtml", event.date))?no_esc}
|
||||||
|
</@layout.emailLayout>
|
|
@ -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
|
||||||
|
|
|
@ -0,0 +1,2 @@
|
||||||
|
<#ftl output_format="plainText">
|
||||||
|
${msg("eventUserDisabledByPermanentLockoutBody", event.date)}
|
|
@ -0,0 +1,2 @@
|
||||||
|
<#ftl output_format="plainText">
|
||||||
|
${msg("eventUserDisabledByTemporaryLockoutBody", event.date)}
|
Loading…
Reference in a new issue