Repository navigation
Fix to UserSessionLimitsAuthenticator logic when client and realm limits exceeded. #48122
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
eed03ac
f2db5d2
5624c5b
345ce39
4247072
27794c5
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -65,6 +65,9 @@ public void authenticate(AuthenticationFlowContext context) { | |
|
|
||
| if (context.getRealm() != null && context.getUser() != null) { | ||
|
|
||
| long realmExcess = 0; | ||
| long clientExcess = 0; | ||
|
|
||
| // Get the session count in this realm for this specific user | ||
| List<UserSessionModel> userSessionsForRealm = session.sessions() | ||
| .getUserSessionsStream(context.getRealm(), context.getUser()) | ||
|
|
@@ -79,42 +82,45 @@ public void authenticate(AuthenticationFlowContext context) { | |
| logger.debugf("session-limiter's count of total user sessions for the entire realm (could be apps other than web apps): %s", userSessionCountForRealm); | ||
| logger.debugf("session-limiter's count of total user sessions for this keycloak client: %s", userSessionCountForClient); | ||
|
|
||
| // First check if the user has too many sessions in this realm | ||
| if (newUserSession && exceedsLimit(userSessionCountForRealm, userRealmLimit)) { | ||
| logger.infof("Too many session in this realm for the current user. Session count: %s", userSessionCountForRealm); | ||
| String eventDetails = String.format("Realm session limit exceeded. Realm: %s, Realm limit: %s. Session count: %s, User id: %s", | ||
| context.getRealm().getName(), userRealmLimit, userSessionCountForRealm, context.getUser().getId()); | ||
| // Calculate excess only when new sessions would be created | ||
| if (newUserSession && userRealmLimit > 0 && userSessionCountForRealm >= userRealmLimit) { | ||
| realmExcess = userSessionCountForRealm - userRealmLimit + 1; | ||
| } | ||
| if (newClientSession && userClientLimit > 0 && userSessionCountForClient >= userClientLimit) { | ||
| clientExcess = userSessionCountForClient - userClientLimit + 1; | ||
| } | ||
|
|
||
| boolean realmLimitHandled = false; | ||
| boolean clientLimitHandled = false; | ||
|
|
||
| var removedClientSessions = handleLimitExceeded(context, userSessionsForClient, eventDetails, userClientLimit); | ||
| if (exceedsLimit(userSessionCountForRealm - removedClientSessions.size(), userRealmLimit)) | ||
| { | ||
| List<UserSessionModel> remainingSessionsToBeRemoved = userSessionsForRealm | ||
| .stream() | ||
| .filter(userSessionModel -> !removedClientSessions.contains(userSessionModel)) | ||
| .collect(Collectors.toList()); | ||
| handleLimitExceeded(context, remainingSessionsToBeRemoved, eventDetails, userRealmLimit); | ||
| } | ||
| } // otherwise if the user is still allowed to create a new session in the realm, check if this applies for this specific client as well. | ||
| else if (newClientSession && exceedsLimit(userSessionCountForClient, userClientLimit)) { | ||
| // When both limits are exceeded, prioritize client limit (more restrictive) | ||
| // Check if this applies for this specific client first | ||
| if (newClientSession && clientExcess > 0) { | ||
| logger.infof("Too many sessions related to the current client for this user. Session count: %s", userSessionCountForClient); | ||
| String eventDetails = String.format("Client session limit exceeded. Realm: %s, Client limit: %s. Session count: %s, User id: %s", | ||
| context.getRealm().getName(), userClientLimit, userSessionCountForClient, context.getUser().getId()); | ||
| handleLimitExceeded(context, userSessionsForClient, eventDetails, userClientLimit); | ||
| } else { | ||
| clientLimitHandled = true; | ||
| } | ||
| // Check if the user has too many sessions in this realm | ||
| else if (newUserSession && realmExcess > 0) { | ||
| logger.infof("Too many sessions in this realm for the current user. Session count: %s", userSessionCountForRealm); | ||
| String eventDetails = String.format("Realm session limit exceeded. Realm: %s, Realm limit: %s. Session count: %s, User id: %s", | ||
| context.getRealm().getName(), userRealmLimit, userSessionCountForRealm, context.getUser().getId()); | ||
|
|
||
| handleLimitExceeded(context, userSessionsForRealm, eventDetails, userRealmLimit); | ||
| realmLimitHandled = true; | ||
| } | ||
|
|
||
| // Only call success if no limits were handled (handleLimitExceeded already calls context.success() or context.failure()) | ||
| if (!realmLimitHandled && !clientLimitHandled) { | ||
| context.success(); | ||
| } | ||
| } else { | ||
| context.success(); | ||
| } | ||
| } | ||
|
|
||
| private boolean exceedsLimit(long count, long limit) { | ||
| if (limit <= 0) { // if limit is zero or negative, consider the limit disabled | ||
| return false; | ||
| } | ||
| return getNumberOfSessionsThatNeedToBeLoggedOut(count, limit) > 0; | ||
| } | ||
|
|
||
| private long getNumberOfSessionsThatNeedToBeLoggedOut(long count, long limit) { | ||
| return Math.max(0, count - (limit - 1)); | ||
| } | ||
|
|
@@ -130,9 +136,9 @@ private int getIntConfigProperty(String key, Map<String, String> config) { | |
| private List<UserSessionModel> getUserSessionsForClientIfEnabled(List<UserSessionModel> userSessionsForRealm, ClientModel currentClient, int userClientLimit) { | ||
| // Only count this users sessions for this client only in case a limit is configured, otherwise skip this costly operation. | ||
| if (userClientLimit <= 0) { | ||
| logger.debugf("total user sessions for this keycloak client will not be counted. Will be logged as 0 (zero)"); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. +1 |
||
| return Collections.emptyList(); | ||
| } | ||
| logger.debugf("total user sessions for this keycloak client will not be counted. Will be logged as 0 (zero)"); | ||
| List<UserSessionModel> userSessionsForClient = userSessionsForRealm.stream().filter(session -> session.getAuthenticatedClientSessionByClient(currentClient.getId()) != null).collect(Collectors.toList()); | ||
| return userSessionsForClient; | ||
| } | ||
|
|
@@ -181,7 +187,7 @@ private List<UserSessionModel> handleLimitExceeded(AuthenticationFlowContext con | |
|
|
||
| case UserSessionLimitsAuthenticatorFactory.TERMINATE_OLDEST_SESSION: | ||
| logger.info("Terminating oldest session"); | ||
| var removedSessions = logoutOldestSessions(userSessions, limit, context.getEvent()); | ||
| var removedSessions = logoutOldestSessions(context, userSessions, limit, context.getEvent()); | ||
| context.success(); | ||
| return removedSessions; | ||
| } | ||
|
|
@@ -192,31 +198,48 @@ private List<UserSessionModel> handleLimitExceeded(AuthenticationFlowContext con | |
| /** | ||
| * @return A list of logged-out user sessions, if any. | ||
| */ | ||
| private List<UserSessionModel> logoutOldestSessions(List<UserSessionModel> userSessions, long limit, EventBuilder eventBuilder) { | ||
| private List<UserSessionModel> logoutOldestSessions(AuthenticationFlowContext context, List<UserSessionModel> userSessions, long limit, EventBuilder eventBuilder) { | ||
| long numberOfSessionsThatNeedToBeLoggedOut = getNumberOfSessionsThatNeedToBeLoggedOut(userSessions.size(), limit); | ||
|
|
||
| if (numberOfSessionsThatNeedToBeLoggedOut == 0) { | ||
| logger.debug("No additional sessions that need to be logged out"); | ||
| return Collections.emptyList(); | ||
| } else if (numberOfSessionsThatNeedToBeLoggedOut == 1) { | ||
| if (numberOfSessionsThatNeedToBeLoggedOut == 1) { | ||
| logger.info("Logging out oldest session"); | ||
| } else { | ||
| logger.infof("Logging out oldest %s sessions", numberOfSessionsThatNeedToBeLoggedOut); | ||
| } | ||
|
|
||
| // Find ID of the client the user is currently trying to log into | ||
| String currentClientId = context.getAuthenticationSession().getClient().getId(); | ||
|
|
||
| // Prioritize current client's sessions for removal | ||
| Comparator<UserSessionModel> clientPriorityComparator = (s1, s2) -> { | ||
| boolean s1IsCurrent = s1.getAuthenticatedClientSessionByClient(currentClientId) != null; | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do we need to check if the user session is current for every comparison? AFAIK, this kind of check is done beforehand in the https://github.com/keycloak/keycloak/blob/main/services/src/main/java/org/keycloak/authentication/authenticators/sessionlimits/UserSessionLimitsAuthenticator.java#L75. I'd just go with the comparator of the @msdaly200 WDYT?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good catch, will change comparator. |
||
| boolean s2IsCurrent = s2.getAuthenticatedClientSessionByClient(currentClientId) != null; | ||
|
|
||
| // Pick the current client's sessions first | ||
| if (s1IsCurrent && !s2IsCurrent) return -1; | ||
| if (!s1IsCurrent && s2IsCurrent) return 1; | ||
|
|
||
| // Fallback to age (Note: using getLastSessionRefresh, which is in seconds) | ||
| int timeCompare = Integer.compare(s1.getLastSessionRefresh(), s2.getLastSessionRefresh()); | ||
| if (timeCompare != 0) return timeCompare; | ||
|
|
||
| // Original Tie-Breaker | ||
| return s1.getId().compareTo(s2.getId()); | ||
| }; | ||
|
|
||
| List<UserSessionModel> userSessionsToBeRemoved = userSessions | ||
| .stream() | ||
| .sorted(Comparator.comparingInt(UserSessionModel::getLastSessionRefresh)) | ||
| .limit(numberOfSessionsThatNeedToBeLoggedOut) | ||
| .toList(); | ||
| .stream() | ||
| .sorted(clientPriorityComparator) | ||
| .limit(numberOfSessionsThatNeedToBeLoggedOut) | ||
| .toList(); | ||
|
|
||
| for (UserSessionModel userSession : userSessionsToBeRemoved) { | ||
| AuthenticationManager.backchannelLogout(session, userSession, true); | ||
| eventBuilder.clone() | ||
| .event(EventType.LOGOUT) | ||
| .user(userSession.getUser()) | ||
| .session(userSession.getId()) | ||
| .success(); | ||
| .event(EventType.LOGOUT) | ||
| .user(userSession.getUser()) | ||
| .session(userSession.getId()) | ||
| .success(); | ||
| } | ||
|
|
||
| return userSessionsToBeRemoved; | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is there any benefit of introducing the new counters for realmExcess + clientExcess?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good point, I can tighten that up more.