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:
parent
bcd423b270
commit
ac1780a54f
10 changed files with 83 additions and 13 deletions
|
@ -188,6 +188,14 @@ spec:
|
||||||
key: config.xml
|
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
|
= Updates to cookies
|
||||||
|
|
||||||
Cookie handling code has been refactored and improved, including a new Cookie Provider. This provides better consistency
|
Cookie handling code has been refactored and improved, including a new Cookie Provider. This provides better consistency
|
||||||
|
|
|
@ -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:*
|
*Account events:*
|
||||||
|
|
||||||
[cols="2",options="header"]
|
[cols="2",options="header"]
|
||||||
|
@ -103,6 +116,7 @@ image:images/search-user-event.png[Search user event]
|
||||||
|
|
||||||
Each event has a corresponding error event.
|
Each event has a corresponding error event.
|
||||||
|
|
||||||
|
[[event-listener]]
|
||||||
==== 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.
|
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.
|
||||||
|
|
|
@ -50,6 +50,8 @@
|
||||||
:adminguide_link_latest: {project_doc_base_url_latest}/server_admin/
|
:adminguide_link_latest: {project_doc_base_url_latest}/server_admin/
|
||||||
:adminguide_bruteforce_name: Password guess: brute force attacks
|
:adminguide_bruteforce_name: Password guess: brute force attacks
|
||||||
:adminguide_bruteforce_link: {adminguide_link}#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_name: Timeouts
|
||||||
:adminguide_timeouts_link: {adminguide_link}#_timeouts
|
:adminguide_timeouts_link: {adminguide_link}#_timeouts
|
||||||
:adminguide_clearcache_name: Clearing Server Caches
|
:adminguide_clearcache_name: Clearing Server Caches
|
||||||
|
|
|
@ -331,6 +331,16 @@ After removal of the Map Store the following modules were renamed:
|
||||||
|
|
||||||
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
|
= Updates to cookies
|
||||||
|
|
||||||
As part of refactoring cookie handling in Keycloak there are some changes to how cookies are set:
|
As part of refactoring cookie handling in Keycloak there are some changes to how cookies are set:
|
||||||
|
|
|
@ -226,6 +226,8 @@ usermodel.attr.label=User Attribute
|
||||||
eventTypes.REGISTER.name=Register
|
eventTypes.REGISTER.name=Register
|
||||||
eventTypes.USER_DISABLED_BY_PERMANENT_LOCKOUT.name=User disabled by permanent lockout
|
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_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
|
deleteUser=Delete user
|
||||||
addedNodeSuccess=Node successfully added
|
addedNodeSuccess=Node successfully added
|
||||||
eventTypes.INTROSPECT_TOKEN_ERROR.description=Introspect token error
|
eventTypes.INTROSPECT_TOKEN_ERROR.description=Introspect token error
|
||||||
|
@ -1293,6 +1295,8 @@ removeUser=Remove users
|
||||||
ownerManagedAccess=User-Managed access enabled
|
ownerManagedAccess=User-Managed access enabled
|
||||||
eventTypes.USER_DISABLED_BY_PERMANENT_LOCKOUT.description=User disabled by permanent lockout
|
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_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
|
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}
|
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
|
permissions=Permissions
|
||||||
|
|
|
@ -87,4 +87,7 @@ public interface Details {
|
||||||
String CREDENTIAL_TYPE = "credential_type";
|
String CREDENTIAL_TYPE = "credential_type";
|
||||||
String SELECTED_CREDENTIAL_ID = "selected_credential_id";
|
String SELECTED_CREDENTIAL_ID = "selected_credential_id";
|
||||||
String AUTHENTICATION_ERROR_DETAIL = "authentication_error_detail";
|
String AUTHENTICATION_ERROR_DETAIL = "authentication_error_detail";
|
||||||
|
|
||||||
|
String NOT_BEFORE = "not_before";
|
||||||
|
String NUM_FAILURES = "num_failures";
|
||||||
}
|
}
|
||||||
|
|
|
@ -158,7 +158,10 @@ public enum EventType implements EnumWithStableIndex {
|
||||||
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(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 int stableIndex;
|
||||||
private final boolean saveByDefault;
|
private final boolean saveByDefault;
|
||||||
|
|
|
@ -253,10 +253,6 @@ public interface ServicesLogger extends BasicLogger {
|
||||||
@Message(id=52, value="Failed processing type")
|
@Message(id=52, value="Failed processing type")
|
||||||
void failedProcessingType(@Cause Exception e);
|
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)
|
@LogMessage(level = ERROR)
|
||||||
@Message(id=54, value="Unknown action: %s")
|
@Message(id=54, value="Unknown action: %s")
|
||||||
void unknownAction(String action);
|
void unknownAction(String action);
|
||||||
|
|
|
@ -30,6 +30,10 @@ import org.keycloak.models.UserLoginFailureModel;
|
||||||
import org.keycloak.models.UserModel;
|
import org.keycloak.models.UserModel;
|
||||||
import org.keycloak.services.ServicesLogger;
|
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.ArrayList;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.concurrent.CountDownLatch;
|
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());
|
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
|
sendEvent(session, realm, userLoginFailure, EventType.USER_DISABLED_BY_PERMANENT_LOCKOUT);
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -191,6 +190,7 @@ public class DefaultBruteForceProtector implements Runnable, BruteForceProtector
|
||||||
int notBefore = (int) (currentTime / 1000) + waitSeconds;
|
int notBefore = (int) (currentTime / 1000) + waitSeconds;
|
||||||
logger.debugv("set notBefore: {0}", notBefore);
|
logger.debugv("set notBefore: {0}", notBefore);
|
||||||
userLoginFailure.setFailedLoginNotBefore(notBefore);
|
userLoginFailure.setFailedLoginNotBefore(notBefore);
|
||||||
|
sendEvent(session, realm, userLoginFailure, EventType.USER_DISABLED_BY_TEMPORARY_LOCKOUT);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -219,6 +219,7 @@ public class DefaultBruteForceProtector implements Runnable, BruteForceProtector
|
||||||
int notBefore = (int) (currentTime / 1000) + waitSeconds;
|
int notBefore = (int) (currentTime / 1000) + waitSeconds;
|
||||||
logger.debugv("set notBefore: {0}", notBefore);
|
logger.debugv("set notBefore: {0}", notBefore);
|
||||||
userLoginFailure.setFailedLoginNotBefore(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;
|
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() {
|
public void start() {
|
||||||
new Thread(this, "Brute Force Protector").start();
|
new Thread(this, "Brute Force Protector").start();
|
||||||
}
|
}
|
||||||
|
@ -315,7 +336,6 @@ public class DefaultBruteForceProtector implements Runnable, BruteForceProtector
|
||||||
}
|
}
|
||||||
|
|
||||||
protected void logFailure(LoginEvent event) {
|
protected void logFailure(LoginEvent event) {
|
||||||
ServicesLogger.LOGGER.loginFailure(event.userId, event.clientConnection.getRemoteAddr());
|
|
||||||
failures++;
|
failures++;
|
||||||
long delta = 0;
|
long delta = 0;
|
||||||
if (lastFailure > 0) {
|
if (lastFailure > 0) {
|
||||||
|
|
|
@ -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
|
@Test
|
||||||
public void testResetLoginFailureCount() {
|
public void testResetLoginFailureCount() {
|
||||||
RealmRepresentation realm = testRealm().toRepresentation();
|
RealmRepresentation realm = testRealm().toRepresentation();
|
||||||
|
|
Loading…
Reference in a new issue