Added event for temporary lockout for brute force protector (#26630)

This change adds event for brute force protector when user account is
temporarily disabled.

It also lowers the priority of free-text log for failed login attempts.

Signed-off-by: Tero Saarni <tero.saarni@est.tech>
Signed-off-by: Alexander Schwartz <aschwart@redhat.com>
Co-authored-by: Alexander Schwartz <aschwart@redhat.com>
This commit is contained in:
Tero Saarni 2024-02-07 16:13:33 +02:00 committed by GitHub
parent bcd423b270
commit ac1780a54f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 83 additions and 13 deletions

View file

@ -188,6 +188,14 @@ spec:
key: config.xml
----
= Temporary lockout log replaced with event
There is now a new event `USER_DISABLED_BY_TEMPORARY_LOCKOUT` when a user is temporarily locked out by the brute force protector.
The log with ID `KC-SERVICES0053` has been removed as the new event offers the information in a structured form.
For more details, check the
link:{upgradingguide_link}[{upgradingguide_name}].
= Updates to cookies
Cookie handling code has been refactored and improved, including a new Cookie Provider. This provides better consistency

View file

@ -64,6 +64,19 @@ image:images/search-user-event.png[Search user event]
|===
*Brute force protection:*
[cols="2",options="header"]
|===
|Event |Description
|User disabled by permanent lockout
|Brute force protection disabled the user account permanently due to too many login failures.
|User disabled by temporary lockout
|Brute force protection disabled the user account temporarily due to too many login failures.
|===
*Account events:*
[cols="2",options="header"]
@ -103,6 +116,7 @@ image:images/search-user-event.png[Search user event]
Each event has a corresponding error event.
[[event-listener]]
==== Event listener
Event listeners listen for events and perform actions based on that event. {project_name} includes two built-in listeners, the Logging Event Listener and Email Event Listener.

View file

@ -50,6 +50,8 @@
:adminguide_link_latest: {project_doc_base_url_latest}/server_admin/
:adminguide_bruteforce_name: Password guess: brute force attacks
:adminguide_bruteforce_link: {adminguide_link}#password-guess-brute-force-attacks
:adminguide_eventlistener_name: Event listener
:adminguide_eventlistener_link: {adminguide_link}#event-listener
:adminguide_timeouts_name: Timeouts
:adminguide_timeouts_link: {adminguide_link}#_timeouts
:adminguide_clearcache_name: Clearing Server Caches

View file

@ -329,7 +329,17 @@ After removal of the Map Store the following modules were renamed:
* `org.keycloak:keycloak-model-legacy-private` to `org.keycloak:keycloak-model-storage-private`
* `org.keycloak:keycloak-model-legacy-services` to `org.keycloak:keycloak-model-storage-services`
and `org.keycloak:keycloak-model-legacy` module was deprecated and will be removed in the next release in favour of `org.keycloak:keycloak-model-storage` module.
and `org.keycloak:keycloak-model-legacy` module was deprecated and will be removed in the next release in favour of `org.keycloak:keycloak-model-storage` module.
= Temporary lockout log replaced with event
There is now a new event `USER_DISABLED_BY_TEMPORARY_LOCKOUT` when a user is temporarily locked out by the brute force protector.
The log with ID `KC-SERVICES0053` has been removed as the new event offers the information in a structured form.
As it is a success event, the new event is logged by default at the `DEBUG` level.
Use the setting `spi-events-listener-jboss-logging-success-level` as described in the link:{adminguide_eventlistener_link}[{adminguide_eventlistener_name} chapter in the {adminguide_name}] to change the log level of all success events.
To trigger custom actions or custom log entries, write a custom event listener as described in the Event Listener SPI in the link:{developerguide_link}[{developerguide_name}].
= Updates to cookies

View file

@ -226,6 +226,8 @@ usermodel.attr.label=User Attribute
eventTypes.REGISTER.name=Register
eventTypes.USER_DISABLED_BY_PERMANENT_LOCKOUT.name=User disabled by permanent lockout
eventTypes.USER_DISABLED_BY_PERMANENT_LOCKOUT_ERROR.name=User disabled by permanent lockout error
eventTypes.USER_DISABLED_BY_TEMPORARY_LOCKOUT.name=User disabled by temporary lockout
eventTypes.USER_DISABLED_BY_TEMPORARY_LOCKOUT_ERROR.name=User disabled by temporary lockout error
deleteUser=Delete user
addedNodeSuccess=Node successfully added
eventTypes.INTROSPECT_TOKEN_ERROR.description=Introspect token error
@ -1293,6 +1295,8 @@ removeUser=Remove users
ownerManagedAccess=User-Managed access enabled
eventTypes.USER_DISABLED_BY_PERMANENT_LOCKOUT.description=User disabled by permanent lockout
eventTypes.USER_DISABLED_BY_PERMANENT_LOCKOUT_ERROR.description=User disabled by permanent lockout error
eventTypes.USER_DISABLED_BY_TEMPORARY_LOCKOUT.description=User disabled by temporary lockout
eventTypes.USER_DISABLED_BY_TEMPORARY_LOCKOUT_ERROR.description=User disabled by temporary lockout error
userModelAttributeNameHelp=Name of the model attribute to be added when importing user from LDAP
templateHelp=Template to use to format the username to import. Substitutions are enclosed in ${}. For example\: '${ALIAS}.${CLAIM.sub}'. ALIAS is the provider alias. CLAIM.<NAME> references an ID or Access token claim. The substitution can be converted to upper or lower case by appending |uppercase or |lowercase to the substituted value, e.g. '${CLAIM.sub | lowercase}
permissions=Permissions

View file

@ -87,4 +87,7 @@ public interface Details {
String CREDENTIAL_TYPE = "credential_type";
String SELECTED_CREDENTIAL_ID = "selected_credential_id";
String AUTHENTICATION_ERROR_DETAIL = "authentication_error_detail";
String NOT_BEFORE = "not_before";
String NUM_FAILURES = "num_failures";
}

View file

@ -158,7 +158,10 @@ public enum EventType implements EnumWithStableIndex {
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);
USER_DISABLED_BY_PERMANENT_LOCKOUT_ERROR(0x10000 + USER_DISABLED_BY_PERMANENT_LOCKOUT.getStableIndex(), false),
USER_DISABLED_BY_TEMPORARY_LOCKOUT(53,true),
USER_DISABLED_BY_TEMPORARY_LOCKOUT_ERROR(0x10000 + USER_DISABLED_BY_TEMPORARY_LOCKOUT.getStableIndex(), false);
private final int stableIndex;
private final boolean saveByDefault;

View file

@ -253,10 +253,6 @@ public interface ServicesLogger extends BasicLogger {
@Message(id=52, value="Failed processing type")
void failedProcessingType(@Cause Exception e);
@LogMessage(level = WARN)
@Message(id=53, value="login failure for user %s from ip %s")
void loginFailure(String user, String ip);
@LogMessage(level = ERROR)
@Message(id=54, value="Unknown action: %s")
void unknownAction(String action);

View file

@ -30,6 +30,10 @@ import org.keycloak.models.UserLoginFailureModel;
import org.keycloak.models.UserModel;
import org.keycloak.services.ServicesLogger;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.Collections;
import java.util.concurrent.CountDownLatch;
@ -176,12 +180,7 @@ public class DefaultBruteForceProtector implements Runnable, BruteForceProtector
logger.debugv("user {0} locked permanently due to too many login attempts", user.getUsername());
user.setEnabled(false);
user.setSingleAttribute(DISABLED_REASON, DISABLED_BY_PERMANENT_LOCKOUT);
// Send event
new EventBuilder(realm, session, event.clientConnection)
.event(EventType.USER_DISABLED_BY_PERMANENT_LOCKOUT)
.detail(Details.REASON, "brute_force_attack detected")
.user(user)
.success();
sendEvent(session, realm, userLoginFailure, EventType.USER_DISABLED_BY_PERMANENT_LOCKOUT);
return;
}
@ -191,6 +190,7 @@ public class DefaultBruteForceProtector implements Runnable, BruteForceProtector
int notBefore = (int) (currentTime / 1000) + waitSeconds;
logger.debugv("set notBefore: {0}", notBefore);
userLoginFailure.setFailedLoginNotBefore(notBefore);
sendEvent(session, realm, userLoginFailure, EventType.USER_DISABLED_BY_TEMPORARY_LOCKOUT);
}
return;
}
@ -219,6 +219,7 @@ public class DefaultBruteForceProtector implements Runnable, BruteForceProtector
int notBefore = (int) (currentTime / 1000) + waitSeconds;
logger.debugv("set notBefore: {0}", notBefore);
userLoginFailure.setFailedLoginNotBefore(notBefore);
sendEvent(session, realm, userLoginFailure, EventType.USER_DISABLED_BY_TEMPORARY_LOCKOUT);
}
}
@ -237,6 +238,26 @@ public class DefaultBruteForceProtector implements Runnable, BruteForceProtector
return realm;
}
protected void sendEvent(KeycloakSession session, RealmModel realm, UserLoginFailureModel userLoginFailure, EventType type) {
EventBuilder builder = new EventBuilder(realm, session)
.ipAddress(userLoginFailure.getLastIPFailure())
.event(type)
.detail(Details.REASON, "brute_force_attack detected")
.detail(Details.NUM_FAILURES, String.valueOf(userLoginFailure.getNumFailures()))
.user(userLoginFailure.getUserId());
if (type == EventType.USER_DISABLED_BY_TEMPORARY_LOCKOUT) {
long secondsSinceEpoch = userLoginFailure.getFailedLoginNotBefore();
Instant instant = Instant.ofEpochSecond(secondsSinceEpoch);
LocalDateTime timestamp = LocalDateTime.ofInstant(instant, ZoneId.systemDefault());
builder.detail(Details.NOT_BEFORE, timestamp.toString());
}
// Send event.
builder.success();
}
public void start() {
new Thread(this, "Brute Force Protector").start();
}
@ -315,7 +336,6 @@ public class DefaultBruteForceProtector implements Runnable, BruteForceProtector
}
protected void logFailure(LoginEvent event) {
ServicesLogger.LOGGER.loginFailure(event.userId, event.clientConnection.getRemoteAddr());
failures++;
long delta = 0;
if (lastFailure > 0) {

View file

@ -515,6 +515,16 @@ public class BruteForceTest extends AbstractTestRealmKeycloakTest {
}
}
@Test
public void testTemporaryLockout() throws Exception {
loginInvalidPassword("test-user@localhost");
loginInvalidPassword("test-user@localhost", false);
List<EventRepresentation> actualEvents = Arrays.asList(events.poll(), events.poll());
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);
}
@Test
public void testResetLoginFailureCount() {
RealmRepresentation realm = testRealm().toRepresentation();