parent
45df1adba9
commit
2c238b9f04
14 changed files with 506 additions and 3 deletions
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -46,5 +46,6 @@ public enum AuthenticationFlowError {
|
|||
IDENTITY_PROVIDER_ERROR,
|
||||
DISPLAY_NOT_SUPPORTED,
|
||||
|
||||
ACCESS_DENIED
|
||||
ACCESS_DENIED,
|
||||
GENERIC_AUTHENTICATION_ERROR
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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";
|
||||
}
|
||||
|
|
|
@ -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";
|
||||
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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());
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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";
|
||||
|
||||
}
|
||||
|
|
|
@ -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
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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());
|
||||
}
|
||||
|
||||
}
|
|
@ -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.
|
||||
|
|
Loading…
Reference in a new issue