session-limiting-feature (#8260)

Closes #10077
This commit is contained in:
Mauro de Wit 2022-02-08 19:16:06 +01:00 committed by GitHub
parent 45df1adba9
commit 2c238b9f04
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 506 additions and 3 deletions

View file

@ -157,6 +157,16 @@ public interface AbstractAuthenticationFlowContext {
* @param response Response that will be sent back to HTTP client
*/
void failure(AuthenticationFlowError error, Response response);
/**
* Aborts the current flow.
*
* @param error
* @param response Response that will be sent back to HTTP client
* @param eventDetails Details about the error event
* @param userErrorMessage A message describing the error to the user
*/
void failure(AuthenticationFlowError error, Response response, String eventDetails, String userErrorMessage);
/**
* Sends a challenge response back to the HTTP client. If the current execution requirement is optional, this response will not be
@ -204,4 +214,17 @@ public interface AbstractAuthenticationFlowContext {
* @return may return null if there was no error
*/
AuthenticationFlowError getError();
/**
* Get details of the event that caused an error
* @return may return null if not set
*/
String getEventDetails();
/**
* A custom error message that can be displayed to the user
* @return Optional error message
*/
String getUserErrorMessage();
}

View file

@ -46,5 +46,6 @@ public enum AuthenticationFlowError {
IDENTITY_PROVIDER_ERROR,
DISPLAY_NOT_SUPPORTED,
ACCESS_DENIED
ACCESS_DENIED,
GENERIC_AUTHENTICATION_ERROR
}

View file

@ -30,10 +30,18 @@ public class AuthenticationFlowException extends RuntimeException {
private AuthenticationFlowError error;
private Response response;
private List<AuthenticationFlowException> afeList;
private String eventDetails;
private String userErrorMessage;
public AuthenticationFlowException(AuthenticationFlowError error) {
this.error = error;
}
public AuthenticationFlowException(AuthenticationFlowError error, String eventDetails, String userErrorMessage) {
this.error = error;
this.eventDetails = eventDetails;
this.userErrorMessage = userErrorMessage;
}
public AuthenticationFlowException(AuthenticationFlowError error, Response response) {
this.error = error;
@ -76,4 +84,12 @@ public class AuthenticationFlowException extends RuntimeException {
public List<AuthenticationFlowException> getAfeList() {
return afeList;
}
public String getEventDetails() {
return eventDetails;
}
public String getUserErrorMessage() {
return userErrorMessage;
}
}

View file

@ -85,4 +85,5 @@ public interface Details {
String CREDENTIAL_TYPE = "credential_type";
String SELECTED_CREDENTIAL_ID = "selected_credential_id";
String AUTHENTICATION_ERROR_DETAIL = "authentication_error_detail";
}

View file

@ -110,4 +110,6 @@ public interface Errors {
String EXPIRED_OAUTH2_DEVICE_CODE = "expired_oauth2_device_code";
String INVALID_OAUTH2_USER_CODE = "invalid_oauth2_user_code";
String SLOW_DOWN = "slow_down";
String GENERIC_AUTHENTICATION_ERROR= "generic_authentication_error";
}

View file

@ -302,6 +302,8 @@ public class AuthenticationProcessor {
FormMessage errorMessage;
FormMessage successMessage;
List<AuthenticationSelectionOption> authenticationSelections;
String eventDetails;
String userErrorMessage;
private Result(AuthenticationExecutionModel execution, Authenticator authenticator, List<AuthenticationExecutionModel> currentExecutions) {
this.execution = execution;
@ -400,6 +402,15 @@ public class AuthenticationProcessor {
this.challenge = challenge;
}
@Override
public void failure(AuthenticationFlowError error, Response challenge, String eventDetails, String userErrorMessage) {
this.error = error;
this.status = FlowStatus.FAILED;
this.challenge = challenge;
this.eventDetails = eventDetails;
this.userErrorMessage = userErrorMessage;
}
@Override
public void attempted() {
@ -672,6 +683,16 @@ public class AuthenticationProcessor {
public FormMessage getSuccessMessage() {
return successMessage;
}
@Override
public String getEventDetails() {
return eventDetails;
}
@Override
public String getUserErrorMessage() {
return userErrorMessage;
}
}
public void logFailure() {
@ -811,6 +832,14 @@ public class AuthenticationProcessor {
event.error(Errors.INVALID_USER_CREDENTIALS);
if (e.getResponse() != null) return e.getResponse();
return ErrorPage.error(session, authenticationSession, Response.Status.BAD_REQUEST, Messages.CREDENTIAL_SETUP_REQUIRED);
} else if (e.getError() == AuthenticationFlowError.GENERIC_AUTHENTICATION_ERROR) {
ServicesLogger.LOGGER.failedAuthentication(e);
if (e.getEventDetails() != null) {
event.detail(Details.AUTHENTICATION_ERROR_DETAIL, e.getEventDetails());
}
event.error(Errors.GENERIC_AUTHENTICATION_ERROR);
if (e.getResponse() != null) return e.getResponse();
return ErrorPage.error(session, authenticationSession, Response.Status.BAD_REQUEST, e.getUserErrorMessage());
} else {
ServicesLogger.LOGGER.failedAuthentication(e);
event.error(Errors.INVALID_USER_CREDENTIALS);

View file

@ -501,7 +501,7 @@ public class DefaultAuthenticationFlow implements AuthenticationFlow {
if (result.getChallenge() != null) {
return sendChallenge(result, execution);
}
throw new AuthenticationFlowException(result.getError());
throw new AuthenticationFlowException(result.getError(), result.getEventDetails(), result.getUserErrorMessage());
case FORK:
logger.debugv("reset browser login from authenticator: {0}", execution.getAuthenticator());
processor.getAuthenticationSession().setAuthNote(AuthenticationProcessor.CURRENT_AUTHENTICATION_EXECUTION, execution.getId());

View file

@ -0,0 +1,163 @@
package org.keycloak.authentication.authenticators.sessionlimits;
import java.util.Collections;
import org.jboss.logging.Logger;
import org.keycloak.authentication.Authenticator;
import org.keycloak.authentication.AuthenticationFlowContext;
import org.keycloak.authentication.AuthenticationFlowError;
import org.keycloak.models.AuthenticatorConfigModel;
import org.keycloak.models.ClientModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.UserSessionModel;
import org.keycloak.services.managers.AuthenticationManager;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;
import javax.ws.rs.core.Response;
import org.keycloak.events.Errors;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.utils.StringUtil;
public class UserSessionLimitsAuthenticator implements Authenticator {
private static Logger logger = Logger.getLogger(UserSessionLimitsAuthenticator.class);
public static final String SESSION_LIMIT_EXCEEDED = "sessionLimitExceeded";
private static String realmEventDetailsTemplate = "Realm session limit exceeded. Realm: %s, Realm limit: %s. Session count: %s, User id: %s";
private static String clientEventDetailsTemplate = "Client session limit exceeded. Realm: %s, Client limit: %s. Session count: %s, User id: %s";
protected KeycloakSession session;
String behavior;
public UserSessionLimitsAuthenticator(KeycloakSession session) {
this.session = session;
}
@Override
public void authenticate(AuthenticationFlowContext context) {
AuthenticatorConfigModel authenticatorConfig = context.getAuthenticatorConfig();
Map<String, String> config = authenticatorConfig.getConfig();
// Get the configuration for this authenticator
behavior = config.get(UserSessionLimitsAuthenticatorFactory.BEHAVIOR);
int userRealmLimit = getIntConfigProperty(UserSessionLimitsAuthenticatorFactory.USER_REALM_LIMIT, config);
int userClientLimit = getIntConfigProperty(UserSessionLimitsAuthenticatorFactory.USER_CLIENT_LIMIT, config);
if (context.getRealm() != null && context.getUser() != null) {
// Get the session count in this realm for this specific user
List<UserSessionModel> userSessionsForRealm = session.sessions().getUserSessionsStream(context.getRealm(), context.getUser()).collect(Collectors.toList());
int userSessionCountForRealm = userSessionsForRealm.size();
// Get the session count related to the current client for this user
ClientModel currentClient = context.getAuthenticationSession().getClient();
logger.debugf("session-limiter's current keycloak clientId: %s", currentClient.getClientId());
List<UserSessionModel> userSessionsForClient = getUserSessionsForClientIfEnabled(userSessionsForRealm, currentClient, userClientLimit);
int userSessionCountForClient = userSessionsForClient.size();
logger.debugf("session-limiter's configured realm session limit: %s", userRealmLimit);
logger.debugf("session-limiter's configured client session limit: %s", userClientLimit);
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 (exceedsLimit(userSessionCountForRealm, userRealmLimit)) {
logger.infof("Too many session in this realm for the current user. Session count: %s", userSessionCountForRealm);
String eventDetails = String.format(realmEventDetailsTemplate, context.getRealm().getName(), userRealmLimit, userSessionCountForRealm, context.getUser().getId());
handleLimitExceeded(context, userSessionsForRealm, eventDetails);
} // 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 (exceedsLimit(userSessionCountForClient, userClientLimit)) {
logger.infof("Too many sessions related to the current client for this user. Session count: %s", userSessionCountForRealm);
String eventDetails = String.format(clientEventDetailsTemplate, context.getRealm().getName(), userClientLimit, userSessionCountForClient, context.getUser().getId());
handleLimitExceeded(context, userSessionsForClient, eventDetails);
} else {
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 count > limit - 1;
}
private int getIntConfigProperty(String key, Map<String, String> config) {
String value = config.get(key);
if (StringUtil.isBlank(value)) {
return -1;
}
return Integer.parseInt(value);
}
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) {
return Collections.EMPTY_LIST;
}
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;
}
@Override
public void action(AuthenticationFlowContext context) {
}
@Override
public boolean requiresUser() {
return false;
}
@Override
public boolean configuredFor(KeycloakSession session, RealmModel realm, UserModel user) {
return true;
}
@Override
public void setRequiredActions(KeycloakSession session, RealmModel realm, UserModel user) {
}
@Override
public void close() {
}
private void handleLimitExceeded(AuthenticationFlowContext context, List<UserSessionModel> userSessions, String eventDetails) {
switch (behavior) {
case UserSessionLimitsAuthenticatorFactory.DENY_NEW_SESSION:
logger.info("Denying new session");
String errorMessage = Optional.ofNullable(context.getAuthenticatorConfig())
.map(AuthenticatorConfigModel::getConfig)
.map(f -> f.get(UserSessionLimitsAuthenticatorFactory.ERROR_MESSAGE))
.orElse(SESSION_LIMIT_EXCEEDED);
context.getEvent().error(Errors.GENERIC_AUTHENTICATION_ERROR);
Response challenge = context.form()
.setError(errorMessage)
.createErrorPage(Response.Status.FORBIDDEN);
context.failure(AuthenticationFlowError.GENERIC_AUTHENTICATION_ERROR, challenge, eventDetails, errorMessage);
break;
case UserSessionLimitsAuthenticatorFactory.TERMINATE_OLDEST_SESSION:
logger.info("Terminating oldest session");
logoutOldestSession(userSessions);
context.success();
break;
}
}
private void logoutOldestSession(List<UserSessionModel> userSessions) {
logger.info("Logging out oldest session");
Optional<UserSessionModel> oldest = userSessions.stream().sorted(Comparator.comparingInt(UserSessionModel::getLastSessionRefresh)).findFirst();
oldest.ifPresent(userSession -> AuthenticationManager.backchannelLogout(session, userSession, true));
}
}

View file

@ -0,0 +1,114 @@
package org.keycloak.authentication.authenticators.sessionlimits;
import org.keycloak.Config;
import org.keycloak.authentication.Authenticator;
import org.keycloak.authentication.AuthenticatorFactory;
import org.keycloak.models.AuthenticationExecutionModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.provider.ProviderConfigProperty;
import java.util.Arrays;
import java.util.List;
public class UserSessionLimitsAuthenticatorFactory implements AuthenticatorFactory {
public static final String USER_REALM_LIMIT = "userRealmLimit";
public static final String USER_CLIENT_LIMIT = "userClientLimit";
public static final String BEHAVIOR = "behavior";
public static final String DENY_NEW_SESSION = "Deny new session";
public static final String TERMINATE_OLDEST_SESSION = "Terminate oldest session";
public static final String USER_SESSION_LIMITS = "user-session-limits";
public static final String ERROR_MESSAGE = "errorMessage";
private static AuthenticationExecutionModel.Requirement[] REQUIREMENT_CHOICES = {
AuthenticationExecutionModel.Requirement.REQUIRED,
AuthenticationExecutionModel.Requirement.DISABLED
};
@Override
public String getDisplayType() {
return "User session count limiter";
}
@Override
public String getReferenceCategory() {
return null;
}
@Override
public boolean isConfigurable() {
return true;
}
@Override
public AuthenticationExecutionModel.Requirement[] getRequirementChoices() {
return REQUIREMENT_CHOICES;
}
@Override
public boolean isUserSetupAllowed() {
return false;
}
@Override
public String getHelpText() {
return "Configures how many concurrent sessions a single user is allowed to create for this realm and/or client";
}
@Override
public List<ProviderConfigProperty> getConfigProperties() {
ProviderConfigProperty userRealmLimit = new ProviderConfigProperty();
userRealmLimit.setName(USER_REALM_LIMIT);
userRealmLimit.setLabel("Maximum concurrent sessions for each user within this realm.");
userRealmLimit.setHelpText("Provide a zero or negative value to disable this limit.");
userRealmLimit.setType(ProviderConfigProperty.STRING_TYPE);
userRealmLimit.setDefaultValue("3");
ProviderConfigProperty userClientLimit = new ProviderConfigProperty();
userClientLimit.setName(USER_CLIENT_LIMIT);
userClientLimit.setLabel("Maximum concurrent sessions for each user per keycloak client.");
userClientLimit.setHelpText("Provide a zero or negative value to disable this limit. In case a limit for the realm is enabled, specify this value below the total realm limit.");
userClientLimit.setType(ProviderConfigProperty.STRING_TYPE);
userClientLimit.setDefaultValue("0");
ProviderConfigProperty behaviourProperty = new ProviderConfigProperty();
behaviourProperty.setName(BEHAVIOR);
behaviourProperty.setLabel("Behavior when user session limit is exceeded");
behaviourProperty.setType(ProviderConfigProperty.LIST_TYPE);
behaviourProperty.setDefaultValue(DENY_NEW_SESSION);
behaviourProperty.setOptions(Arrays.asList(DENY_NEW_SESSION, TERMINATE_OLDEST_SESSION));
ProviderConfigProperty customErrorMessage = new ProviderConfigProperty();
customErrorMessage.setName(ERROR_MESSAGE);
customErrorMessage.setLabel("Optional custom error message");
customErrorMessage.setHelpText("If left empty a default error message is shown");
customErrorMessage.setType(ProviderConfigProperty.STRING_TYPE);
return Arrays.asList(userRealmLimit, userClientLimit, behaviourProperty, customErrorMessage);
}
@Override
public Authenticator create(KeycloakSession keycloakSession) {
return new UserSessionLimitsAuthenticator(keycloakSession);
}
@Override
public void init(Config.Scope scope) {
}
@Override
public void postInit(KeycloakSessionFactory keycloakSessionFactory) {
}
@Override
public void close() {
}
@Override
public String getId() {
return USER_SESSION_LIMITS;
}
}

View file

@ -283,4 +283,5 @@ public class Messages {
public static final String OAUTH2_DEVICE_VERIFICATION_FAILED = "oauth2DeviceVerificationFailedMessage";
public static final String OAUTH2_DEVICE_VERIFICATION_FAILED_HEADER = "oauth2DeviceVerificationFailedHeader";
public static final String OAUTH2_DEVICE_CONSENT_DENIED = "oauth2DeviceConsentDeniedMessage";
}

View file

@ -52,4 +52,5 @@ org.keycloak.authentication.authenticators.challenge.NoCookieFlowRedirectAuthent
org.keycloak.authentication.authenticators.browser.WebAuthnAuthenticatorFactory
org.keycloak.authentication.authenticators.browser.WebAuthnPasswordlessAuthenticatorFactory
org.keycloak.authentication.authenticators.access.DenyAccessAuthenticatorFactory
org.keycloak.authentication.authenticators.access.AllowAccessAuthenticatorFactory
org.keycloak.authentication.authenticators.access.AllowAccessAuthenticatorFactory
org.keycloak.authentication.authenticators.sessionlimits.UserSessionLimitsAuthenticatorFactory

View file

@ -224,6 +224,9 @@ public class ProvidersTest extends AbstractAuthenticationTest {
addProviderInfo(result, "conditional-level-of-authentication", "Condition - Level of Authentication",
"Flow is executed only if the configured LOA or a higher one has been requested but not yet satisfied. After the flow is successfully finished, the LOA in the session will be updated to value prescribed by this condition.");
addProviderInfo(result, "user-session-limits", "User session count limiter",
"Configures how many concurrent sessions a single user is allowed to create for this realm and/or client");
return result;
}

View file

@ -0,0 +1,148 @@
/*
* Copyright 2016 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.testsuite.sessionlimits;
import org.jboss.arquillian.graphene.page.Page;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.keycloak.authentication.authenticators.browser.UsernamePasswordFormFactory;
import org.keycloak.authentication.authenticators.sessionlimits.UserSessionLimitsAuthenticatorFactory;
import org.keycloak.models.AuthenticationExecutionModel;
import org.keycloak.models.AuthenticationFlowModel;
import org.keycloak.models.AuthenticatorConfigModel;
import org.keycloak.models.RealmModel;
import org.keycloak.representations.idm.RealmRepresentation;
import org.keycloak.representations.idm.UserRepresentation;
import org.keycloak.testsuite.AbstractTestRealmKeycloakTest;
import org.keycloak.testsuite.AssertEvents;
import org.keycloak.testsuite.pages.LoginPage;
import org.keycloak.testsuite.util.RealmBuilder;
import org.keycloak.testsuite.util.UserBuilder;
import java.util.HashMap;
import java.util.Map;
import org.junit.Assert;
import org.keycloak.testsuite.pages.AppPage;
import org.keycloak.testsuite.pages.ErrorPage;
import org.keycloak.testsuite.arquillian.annotation.AuthServerContainerExclude;
import static org.keycloak.testsuite.arquillian.annotation.AuthServerContainerExclude.AuthServer.REMOTE;
@AuthServerContainerExclude(REMOTE)
public class UserSessionLimitsTest extends AbstractTestRealmKeycloakTest {
private static final String LOGINTEST1 = "login-test-1";
private static final String PASSWORD1 = "password1";
private static final String ERROR_TO_DISPLAY = "This account has too many sessions";
@Override
public void configureTestRealm(RealmRepresentation testRealm) {
UserRepresentation user1 = UserBuilder.create()
.id(LOGINTEST1)
.username(LOGINTEST1)
.email("login1@test.com")
.enabled(true)
.password(PASSWORD1)
.build();
RealmBuilder.edit(testRealm).user(user1);
}
@Before
public void setupFlows() {
// Do this just once per class
if (testContext.isInitialized()) {
return;
}
testingClient.server().run(session -> {
RealmModel realm = session.realms().getRealmByName("test");
if (realm.getBrowserFlow().getAlias().equals("parent-flow")) {
return;
}
// Parent flow
AuthenticationFlowModel browser = new AuthenticationFlowModel();
browser.setAlias("parent-flow");
browser.setDescription("browser based authentication");
browser.setProviderId("basic-flow");
browser.setTopLevel(true);
browser.setBuiltIn(true);
browser = realm.addAuthenticationFlow(browser);
realm.setBrowserFlow(browser);
// username password
AuthenticationExecutionModel execution = new AuthenticationExecutionModel();
execution.setParentFlow(browser.getId());
execution.setRequirement(AuthenticationExecutionModel.Requirement.REQUIRED);
execution.setAuthenticator(UsernamePasswordFormFactory.PROVIDER_ID);
execution.setPriority(20);
execution.setAuthenticatorFlow(false);
realm.addAuthenticatorExecution(execution);
// user session limits authenticator
execution = new AuthenticationExecutionModel();
execution.setParentFlow(browser.getId());
execution.setRequirement(AuthenticationExecutionModel.Requirement.REQUIRED);
execution.setAuthenticator(UserSessionLimitsAuthenticatorFactory.USER_SESSION_LIMITS);
execution.setPriority(30);
execution.setAuthenticatorFlow(false);
AuthenticatorConfigModel configModel = new AuthenticatorConfigModel();
Map<String, String> sessionAuthenticatorConfig = new HashMap<>();
sessionAuthenticatorConfig.put(UserSessionLimitsAuthenticatorFactory.BEHAVIOR, UserSessionLimitsAuthenticatorFactory.DENY_NEW_SESSION);
sessionAuthenticatorConfig.put(UserSessionLimitsAuthenticatorFactory.USER_REALM_LIMIT, "1");
sessionAuthenticatorConfig.put(UserSessionLimitsAuthenticatorFactory.USER_CLIENT_LIMIT, "1");
sessionAuthenticatorConfig.put(UserSessionLimitsAuthenticatorFactory.ERROR_MESSAGE, ERROR_TO_DISPLAY);
configModel.setConfig(sessionAuthenticatorConfig);
configModel.setAlias("user-session-limits");
configModel = realm.addAuthenticatorConfig(configModel);
execution.setAuthenticatorConfig(configModel.getId());
realm.addAuthenticatorExecution(execution);
});
testContext.setInitialized(true);
}
@Rule
public AssertEvents events = new AssertEvents(this);
@Page
protected LoginPage loginPage;
@Page
protected AppPage appPage;
@Page
protected ErrorPage errorPage;
@Test
public void testSessionCountExceededAndNewSessionDenied() throws InterruptedException {
// Login and verify login was succesfull
loginPage.open();
loginPage.login(LOGINTEST1, PASSWORD1);
appPage.assertCurrent();
appPage.openAccount();
// Delete the cookies, while maintaining the server side session active
super.deleteCookies();
// Login the same user again and verify the configured error message is shown
loginPage.open();
loginPage.login(LOGINTEST1, PASSWORD1);
errorPage.assertCurrent();
Assert.assertEquals(ERROR_TO_DISPLAY, errorPage.getError());
}
}

View file

@ -210,6 +210,7 @@ expiredCodeMessage=Login timeout. Please sign in again.
expiredActionMessage=Action expired. Please continue with login now.
expiredActionTokenNoSessionMessage=Action expired.
expiredActionTokenSessionExistsMessage=Action expired. Please start again.
sessionLimitExceeded=There are too many sessions
missingFirstNameMessage=Please specify first name.
missingLastNameMessage=Please specify last name.