diff --git a/pom.xml b/pom.xml index 92e33ec..25020d6 100644 --- a/pom.xml +++ b/pom.xml @@ -78,6 +78,7 @@ 4.4.3 1.0.7.0 + 1.1.0.0 @@ -176,6 +177,20 @@ installable test + + + org.orderofthebee.support-tools + support-tools-repo + ${ootbee.support-tools.version} + test + + + + org.orderofthebee.support-tools + support-tools-share + ${ootbee.support-tools.version} + test + diff --git a/repository/module.properties b/repository/module.properties index b0ef680..0e84892 100644 --- a/repository/module.properties +++ b/repository/module.properties @@ -3,4 +3,6 @@ module.title=${project.name} module.description=${project.description} module.version=${noSnapshotVersion} -module.repo.version.min=5 \ No newline at end of file +module.repo.version.min=5 + +module.depends.acosix-utility-core=1.0.3.1-* \ No newline at end of file diff --git a/repository/pom.xml b/repository/pom.xml index 03436e6..b090479 100644 --- a/repository/pom.xml +++ b/repository/pom.xml @@ -28,22 +28,135 @@ Acosix Alfresco Keycloak - Repository Module + + + 4.6.0.Final + 8380 + org.alfresco - alfresco-repository + alfresco-remote-api + + + + javax.servlet + javax.servlet-api + + + + + org.keycloak + keycloak-servlet-filter-adapter + + + org.bouncycastle + * + + + com.fasterxml.jackson.core + * + + + + + + de.acosix.alfresco.utility + de.acosix.alfresco.utility.core.repo + installable + + + + org.orderofthebee.support-tools + support-tools-repo + + + + + + + + io.fabric8 + docker-maven-plugin + + + + + + + + + + ${docker.tests.host.name} + + + + + + + ${moduleId}-repository-test-contentstore:/usr/local/tomcat/alf_data + ${project.build.directory}/docker/repository-logs:/usr/local/tomcat/logs + + + + postgres + keycloak + + + + + + + + + + + jboss/keycloak + keycloak + + keycloak + + admin + admin + /tmp/test-realm.json + h2 + + + ${docker.tests.keycloakPort}:8080 + + + custom + ${moduleId}-test + keycloak + + + + ${project.build.directory}/docker/test-realm.json:/tmp/test-realm.json + + + + + + + + + + + + io.fabric8 docker-maven-plugin + \ No newline at end of file diff --git a/repository/src/main/config/alfresco-global.properties b/repository/src/main/config/alfresco-global.properties index e69de29..e7dd9ec 100644 --- a/repository/src/main/config/alfresco-global.properties +++ b/repository/src/main/config/alfresco-global.properties @@ -0,0 +1,51 @@ +cache.${moduleId}.ssoToSessionCache.maxItems=10000 +cache.${moduleId}.ssoToSessionCache.timeToLiveSeconds=0 +cache.${moduleId}.ssoToSessionCache.maxIdleSeconds=0 +cache.${moduleId}.ssoToSessionCache.cluster.type=fully-distributed +cache.${moduleId}.ssoToSessionCache.backup-count=1 +cache.${moduleId}.ssoToSessionCache.eviction-policy=LRU +cache.${moduleId}.ssoToSessionCache.merge-policy=com.hazelcast.map.merge.PutIfAbsentMapMergePolicy +cache.${moduleId}.ssoToSessionCache.readBackupData=false +# explicitly not clearable - should be cleared via Keycloak back-channel action +cache.${moduleId}.ssoToSessionCache.clearable=false +# replicate, not distribute +cache.${moduleId}.ssoToSessionCache.ignite.cache.type=replicated + +cache.${moduleId}.sessionToSsoCache.maxItems=10000 +cache.${moduleId}.sessionToSsoCache.timeToLiveSeconds=0 +cache.${moduleId}.sessionToSsoCache.maxIdleSeconds=0 +cache.${moduleId}.sessionToSsoCache.cluster.type=fully-distributed +cache.${moduleId}.sessionToSsoCache.backup-count=1 +cache.${moduleId}.sessionToSsoCache.eviction-policy=LRU +cache.${moduleId}.sessionToSsoCache.merge-policy=com.hazelcast.map.merge.PutIfAbsentMapMergePolicy +cache.${moduleId}.sessionToSsoCache.readBackupData=false +# explicitly not clearable - should be cleared via Keycloak back-channel action +cache.${moduleId}.sessionToSsoCache.clearable=false +# replicate, not distribute +cache.${moduleId}.sessionToSsoCache.ignite.cache.type=replicated + +cache.${moduleId}.principalToSessionCache.maxItems=10000 +cache.${moduleId}.principalToSessionCache.timeToLiveSeconds=0 +cache.${moduleId}.principalToSessionCache.maxIdleSeconds=0 +cache.${moduleId}.principalToSessionCache.cluster.type=fully-distributed +cache.${moduleId}.principalToSessionCache.backup-count=1 +cache.${moduleId}.principalToSessionCache.eviction-policy=LRU +cache.${moduleId}.principalToSessionCache.merge-policy=com.hazelcast.map.merge.PutIfAbsentMapMergePolicy +cache.${moduleId}.principalToSessionCache.readBackupData=false +# explicitly not clearable - should be cleared via Keycloak back-channel action +cache.${moduleId}.principalToSessionCache.clearable=false +# replicate, not distribute +cache.${moduleId}.principalToSessionCache.ignite.cache.type=replicated + +cache.${moduleId}.sessionToPrincipalCache.maxItems=10000 +cache.${moduleId}.sessionToPrincipalCache.timeToLiveSeconds=0 +cache.${moduleId}.sessionToPrincipalCache.maxIdleSeconds=0 +cache.${moduleId}.sessionToPrincipalCache.cluster.type=fully-distributed +cache.${moduleId}.sessionToPrincipalCache.backup-count=1 +cache.${moduleId}.sessionToPrincipalCache.eviction-policy=LRU +cache.${moduleId}.sessionToPrincipalCache.merge-policy=com.hazelcast.map.merge.PutIfAbsentMapMergePolicy +cache.${moduleId}.sessionToPrincipalCache.readBackupData=false +# explicitly not clearable - should be cleared via Keycloak back-channel action +cache.${moduleId}.sessionToPrincipalCache.clearable=false +# replicate, not distribute +cache.${moduleId}.sessionToPrincipalCache.ignite.cache.type=replicated \ No newline at end of file diff --git a/repository/src/main/config/module-context.xml b/repository/src/main/config/module-context.xml index 3a5432d..2051032 100644 --- a/repository/src/main/config/module-context.xml +++ b/repository/src/main/config/module-context.xml @@ -19,6 +19,32 @@ http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans-3.0.xsd"> + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/repository/src/main/globalConfig/.gitkeep b/repository/src/main/globalConfig/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/repository/src/main/globalConfig/subsystems/Authentication/keycloak/keycloak-authentication-context.xml b/repository/src/main/globalConfig/subsystems/Authentication/keycloak/keycloak-authentication-context.xml new file mode 100644 index 0000000..2300a01 --- /dev/null +++ b/repository/src/main/globalConfig/subsystems/Authentication/keycloak/keycloak-authentication-context.xml @@ -0,0 +1,137 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + org.alfresco.repo.security.authentication.AuthenticationComponent + + + + + + + ${server.transaction.mode.default} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/repository/src/main/globalConfig/subsystems/Authentication/keycloak/keycloak-authentication.properties b/repository/src/main/globalConfig/subsystems/Authentication/keycloak/keycloak-authentication.properties new file mode 100644 index 0000000..906b81f --- /dev/null +++ b/repository/src/main/globalConfig/subsystems/Authentication/keycloak/keycloak-authentication.properties @@ -0,0 +1,25 @@ +keycloak.authentication.enabled=true +keycloak.authentication.sso.enabled=true +keycloak.authentication.defaultAdministratorUserNames= +keycloak.authentication.allowTicketLogons=true +keycloak.authentication.allowLocalBasicLogon=true +keycloak.authentication.allowUserNamePasswordLogin=true +keycloak.authentication.allowGuestLogin=true +keycloak.authentication.authenticateFTP=true +keycloak.authentication.silentValidationFailure=true + +keycloak.authentication.connectionTimeout=-1 +keycloak.authentication.socketTimeout=-1 +keycloak.authentication.sslRedirectPort=8443 +keycloak.authentication.bodyBufferLimit=10485760 + +keycloak.adapter.auth-server-url=http://localhost:8180/auth +keycloak.adapter.direct-auth-server-url=${keycloak.adapter.auth-server-url} +keycloak.adapter.realm=alfresco +keycloak.adapter.resource=alfresco +keycloak.adapter.ssl-required=none +keycloak.adapter.public-client=false +keycloak.adapter.credentials.provider=secret +keycloak.adapter.credentials.secret= + +# TODO default settings (identical to AdapterConfig defaults) to better align with default Alfresco subsystem property handling \ No newline at end of file diff --git a/repository/src/main/java/de/acosix/alfresco/keycloak/repo/authentication/KeycloakAuthenticationComponent.java b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/authentication/KeycloakAuthenticationComponent.java new file mode 100644 index 0000000..b04ad65 --- /dev/null +++ b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/authentication/KeycloakAuthenticationComponent.java @@ -0,0 +1,223 @@ +/* + * Copyright 2019 Acosix GmbH + * + * 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 de.acosix.alfresco.keycloak.repo.authentication; + +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +import org.alfresco.repo.management.subsystems.ActivateableBean; +import org.alfresco.repo.security.authentication.AbstractAuthenticationComponent; +import org.alfresco.repo.security.authentication.AuthenticationException; +import org.alfresco.util.PropertyCheck; +import org.keycloak.adapters.HttpClientBuilder; +import org.keycloak.authorization.client.AuthzClient; +import org.keycloak.authorization.client.Configuration; +import org.keycloak.authorization.client.util.HttpResponseException; +import org.keycloak.representations.adapters.config.AdapterConfig; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.InitializingBean; + +/** + * @author Axel Faust + */ +public class KeycloakAuthenticationComponent extends AbstractAuthenticationComponent implements ActivateableBean, InitializingBean +{ + + private static final Logger LOGGER = LoggerFactory.getLogger(KeycloakAuthenticationComponent.class); + + protected boolean active; + + protected boolean allowUserNamePasswordLogin; + + protected boolean allowGuestLogin; + + protected AdapterConfig adapterConfig; + + protected int connectionTimeout; + + protected int socketTimeout; + + protected Configuration config; + + protected AuthzClient authzClient; + + /** + * + * {@inheritDoc} + */ + @Override + public void afterPropertiesSet() + { + PropertyCheck.mandatory(this, "adapterConfig", this.adapterConfig); + + if (this.allowUserNamePasswordLogin) + { + Map credentials = this.adapterConfig.getCredentials(); + if (credentials != null) + { + credentials = new HashMap<>(credentials); + } + + if (credentials == null || ((!credentials.containsKey("provider") || "secret".equals(credentials.get("provider"))) + && !credentials.containsKey("secret"))) + { + if (credentials == null) + { + credentials = new HashMap<>(); + } + credentials.put("secret", ""); + } + + HttpClientBuilder httpClientBuilder = new HttpClientBuilder(); + if (this.connectionTimeout > 0) + { + httpClientBuilder = httpClientBuilder.establishConnectionTimeout(this.connectionTimeout, TimeUnit.MILLISECONDS); + } + if (this.socketTimeout > 0) + { + httpClientBuilder = httpClientBuilder.socketTimeout(this.socketTimeout, TimeUnit.MILLISECONDS); + } + + this.config = new Configuration(this.adapterConfig.getAuthServerUrl(), this.adapterConfig.getRealm(), + this.adapterConfig.getResource(), credentials, httpClientBuilder.build(this.adapterConfig)); + try + { + this.authzClient = AuthzClient.create(this.config); + } + catch (final RuntimeException e) + { + if (LOGGER.isDebugEnabled()) + { + LOGGER.debug("Failed to pre-instantiate Keycloak authz client", e); + } + else + { + LOGGER.warn("Failed to pre-instantiate Keycloak authz client: {}", e.getMessage()); + } + } + } + } + + /** + * @param active + * the active to set + */ + public void setActive(final boolean active) + { + this.active = active; + } + + /** + * @param allowUserNamePasswordLogin + * the allowUserNamePasswordLogin to set + */ + public void setAllowUserNamePasswordLogin(final boolean allowUserNamePasswordLogin) + { + this.allowUserNamePasswordLogin = allowUserNamePasswordLogin; + } + + /** + * @param allowGuestLogin + * the allowGuestLogin to set + */ + public void setAllowGuestLogin(final boolean allowGuestLogin) + { + this.allowGuestLogin = allowGuestLogin; + } + + /** + * @param adapterConfig + * the adapterConfig to set + */ + public void setAdapterConfig(final AdapterConfig adapterConfig) + { + this.adapterConfig = adapterConfig; + } + + /** + * @param connectionTimeout + * the connectionTimeout to set + */ + public void setConnectionTimeout(final int connectionTimeout) + { + this.connectionTimeout = connectionTimeout; + } + + /** + * @param socketTimeout + * the socketTimeout to set + */ + public void setSocketTimeout(final int socketTimeout) + { + this.socketTimeout = socketTimeout; + } + + /** + * {@inheritDoc} + */ + @Override + public boolean isActive() + { + return this.active; + } + + /** + * {@inheritDoc} + */ + @Override + public void authenticateImpl(final String userName, final char[] password) throws AuthenticationException + { + if (!this.allowUserNamePasswordLogin) + { + throw new AuthenticationException("Simple login via user name + password is not allowed"); + } + + if (this.authzClient == null) + { + try + { + this.authzClient = AuthzClient.create(this.config); + } + catch (final RuntimeException e) + { + LOGGER.warn("Failed to pre-instantiate Keycloak authz client", e); + throw new AuthenticationException("Keycloak authentication cannot be performed", e); + } + } + + try + { + this.authzClient.obtainAccessToken(userName, new String(password)); + this.setCurrentUser(userName); + } + catch (final HttpResponseException e) + { + LOGGER.debug("Failed to authenticate user against Keycloak. Status: {} Reason: {}", e.getStatusCode(), e.getReasonPhrase()); + throw new AuthenticationException("Failed to authenticate user against Keycloak.", e); + } + } + + /** + * {@inheritDoc} + */ + @Override + protected boolean implementationAllowsGuestLogin() + { + return this.allowGuestLogin; + } +} diff --git a/repository/src/main/java/de/acosix/alfresco/keycloak/repo/authentication/KeycloakAuthenticationFilter.java b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/authentication/KeycloakAuthenticationFilter.java new file mode 100644 index 0000000..0973392 --- /dev/null +++ b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/authentication/KeycloakAuthenticationFilter.java @@ -0,0 +1,778 @@ +/* + * Copyright 2019 Acosix GmbH + * + * 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 de.acosix.alfresco.keycloak.repo.authentication; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.List; +import java.util.Locale; + +import javax.servlet.FilterChain; +import javax.servlet.ServletContext; +import javax.servlet.ServletException; +import javax.servlet.ServletRequest; +import javax.servlet.ServletResponse; +import javax.servlet.http.Cookie; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletRequestWrapper; +import javax.servlet.http.HttpServletResponse; +import javax.servlet.http.HttpSession; + +import org.alfresco.repo.SessionUser; +import org.alfresco.repo.management.subsystems.ActivateableBean; +import org.alfresco.repo.security.authentication.AuthenticationException; +import org.alfresco.repo.security.authentication.AuthenticationUtil; +import org.alfresco.repo.security.authentication.Authorization; +import org.alfresco.repo.web.auth.BasicAuthCredentials; +import org.alfresco.repo.web.auth.TicketCredentials; +import org.alfresco.repo.web.auth.UnknownCredentials; +import org.alfresco.repo.web.filter.beans.DependencyInjectedFilter; +import org.alfresco.repo.webdav.auth.AuthenticationDriver; +import org.alfresco.repo.webdav.auth.BaseAuthenticationFilter; +import org.alfresco.repo.webdav.auth.BaseSSOAuthenticationFilter; +import org.alfresco.util.PropertyCheck; +import org.apache.commons.codec.binary.Base64; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.keycloak.KeycloakSecurityContext; +import org.keycloak.adapters.AdapterDeploymentContext; +import org.keycloak.adapters.AuthenticatedActionsHandler; +import org.keycloak.adapters.KeycloakDeployment; +import org.keycloak.adapters.OidcKeycloakAccount; +import org.keycloak.adapters.PreAuthActionsHandler; +import org.keycloak.adapters.servlet.FilterRequestAuthenticator; +import org.keycloak.adapters.servlet.OIDCFilterSessionStore; +import org.keycloak.adapters.servlet.OIDCServletHttpFacade; +import org.keycloak.adapters.spi.AuthOutcome; +import org.keycloak.adapters.spi.AuthenticationError; +import org.keycloak.adapters.spi.KeycloakAccount; +import org.keycloak.adapters.spi.SessionIdMapper; +import org.keycloak.adapters.spi.UserSessionManagement; +import org.keycloak.representations.AccessToken; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.InitializingBean; + +/** + * This class provides a Keycloak-based authentication filter which can be used in the role of both global and WebDAV authentication filter. + * + * This class does not use the Alfresco default base {@link BaseSSOAuthenticationFilter SSO} filter class as a base class for inheritance + * since these classes are extremely NTLM / Kerberos centric and would require extremely weird hacks / workarounds to use its constraints to + * implement a Keycloak-based authentication. + * + * @author Axel Faust + */ +public class KeycloakAuthenticationFilter extends BaseAuthenticationFilter + implements InitializingBean, ActivateableBean, DependencyInjectedFilter +{ + + private static final String HEADER_AUTHORIZATION = "Authorization"; + + private static final Logger LOGGER = LoggerFactory.getLogger(KeycloakAuthenticationFilter.class); + + private static final String KEYCLOAK_ACTION_URL_PATTERN = "^(?:/wcs(?:ervice)?)?/keycloak/k_[^/]+$"; + + private static final int DEFAULT_BODY_BUFFER_LIMIT = 32 * 1024;// 32 KiB + + protected boolean active; + + protected boolean allowTicketLogon; + + protected boolean allowLocalBasicLogon; + + protected String loginPageUrl; + + protected int bodyBufferLimit = DEFAULT_BODY_BUFFER_LIMIT; + + // use 8443 as default SSL redirect based on Tomcat default server.xml configuration + // can't rely on SysAdminParams#getAlfrescoPort either because that may be proxied / non-SSL + protected int sslRedirectPort = 8443; + + protected KeycloakDeployment keycloakDeployment; + + protected SessionIdMapper sessionIdMapper; + + protected AdapterDeploymentContext deploymentContext; + + /** + * {@inheritDoc} + */ + @Override + public void afterPropertiesSet() + { + PropertyCheck.mandatory(this, "keycloakDeployment", this.keycloakDeployment); + PropertyCheck.mandatory(this, "sessionIdMapper", this.sessionIdMapper); + + // parent class does not check, so we do + PropertyCheck.mandatory(this, "authenticationService", this.authenticationService); + PropertyCheck.mandatory(this, "authenticationComponent", this.authenticationComponent); + PropertyCheck.mandatory(this, "authenticationListener", this.authenticationListener); + PropertyCheck.mandatory(this, "personService", this.personService); + PropertyCheck.mandatory(this, "nodeService", this.nodeService); + PropertyCheck.mandatory(this, "transactionService", this.transactionService); + + this.deploymentContext = new AdapterDeploymentContext(this.keycloakDeployment); + } + + /** + * {@inheritDoc} + */ + @Override + public boolean isActive() + { + return this.active; + } + + /** + * @param active + * the active to set + */ + public void setActive(final boolean active) + { + this.active = active; + } + + /** + * @param allowTicketLogon + * the allowTicketLogon to set + */ + public void setAllowTicketLogon(final boolean allowTicketLogon) + { + this.allowTicketLogon = allowTicketLogon; + } + + /** + * @param allowLocalBasicLogon + * the allowLocalBasicLogon to set + */ + public void setAllowLocalBasicLogon(final boolean allowLocalBasicLogon) + { + this.allowLocalBasicLogon = allowLocalBasicLogon; + } + + /** + * @param loginPageUrl + * the loginPageUrl to set + */ + public void setLoginPageUrl(final String loginPageUrl) + { + this.loginPageUrl = loginPageUrl; + } + + /** + * @param bodyBufferLimit + * the bodyBufferLimit to set + */ + public void setBodyBufferLimit(final int bodyBufferLimit) + { + this.bodyBufferLimit = bodyBufferLimit; + } + + /** + * @param sslRedirectPort + * the sslRedirectPort to set + */ + public void setSslRedirectPort(final int sslRedirectPort) + { + this.sslRedirectPort = sslRedirectPort; + } + + /** + * @param keycloakDeployment + * the keycloakDeployment to set + */ + public void setKeycloakDeployment(final KeycloakDeployment keycloakDeployment) + { + this.keycloakDeployment = keycloakDeployment; + } + + /** + * @param sessionIdMapper + * the sessionIdMapper to set + */ + public void setSessionIdMapper(final SessionIdMapper sessionIdMapper) + { + this.sessionIdMapper = sessionIdMapper; + } + + /** + * + * {@inheritDoc} + */ + @Override + public void doFilter(final ServletContext context, final ServletRequest request, final ServletResponse response, + final FilterChain chain) throws IOException, ServletException + { + final HttpServletRequest req = (HttpServletRequest) request; + final HttpServletResponse res = (HttpServletResponse) response; + + final boolean skip = this.checkForSkipCondition(context, req, res); + + if (skip) + { + chain.doFilter(request, response); + } + else + { + if (!this.checkAndProcessLocalBasicAuthentication(req)) + { + this.processKeycloakAuthenticationAndActions(context, req, res, chain); + } + else + { + chain.doFilter(request, response); + } + } + } + + /** + * Checks and processes any HTTP Basic authentication against the local Alfresco authentication services if allowed. + * + * @param req + * the servlet request + * + * @throws IOException + * if any error occurs during processing of HTTP Basic authentication + * @throws ServletException + * if any error occurs during processing of HTTP Basic authentication + * + * @return {@code true} if an existing HTTP Basic authentication header was successfully processed against the local Alfresco + * authentication services, {@code false} otherwise + */ + protected boolean checkAndProcessLocalBasicAuthentication(final HttpServletRequest req) throws IOException, ServletException + { + boolean basicAuthSucessfull = false; + final String authHeader = req.getHeader(HEADER_AUTHORIZATION); + if (authHeader != null && authHeader.toLowerCase(Locale.ENGLISH).startsWith("basic ")) + { + final String basicAuth = new String(Base64.decodeBase64(authHeader.substring(6).getBytes(StandardCharsets.UTF_8)), + StandardCharsets.UTF_8); + + String userName; + String password = ""; + + final int pos = basicAuth.indexOf(":"); + if (pos != -1) + { + userName = basicAuth.substring(0, pos); + password = basicAuth.substring(pos + 1); + } + else + { + userName = basicAuth; + } + + try + { + if (userName.equalsIgnoreCase(Authorization.TICKET_USERID)) + { + if (this.allowTicketLogon) + { + LOGGER.trace("Performing HTTP Basic ticket validation"); + this.authenticationService.validate(password); + + this.createUserEnvironment(req.getSession(), this.authenticationService.getCurrentUserName(), + this.authenticationService.getCurrentTicket(), false); + + LOGGER.debug("Authenticated user {} via HTTP Basic authentication using an authentication ticket", + AuthenticationUtil.maskUsername(this.authenticationService.getCurrentUserName())); + + this.authenticationListener.userAuthenticated(new TicketCredentials(password)); + + basicAuthSucessfull = true; + } + else + { + LOGGER.debug("Ticket in HTTP Basic authentication header detected but ticket logon is disabled"); + } + } + else if (this.allowLocalBasicLogon) + { + LOGGER.trace("Performing HTTP Basic user authentication against local Alfresco services"); + + this.authenticationService.authenticate(userName, password.toCharArray()); + + this.createUserEnvironment(req.getSession(), this.authenticationService.getCurrentUserName(), + this.authenticationService.getCurrentTicket(), false); + + LOGGER.debug("Authenticated user {} via HTTP Basic authentication using locally stored credentials", + AuthenticationUtil.maskUsername(this.authenticationService.getCurrentUserName())); + + this.authenticationListener.userAuthenticated(new BasicAuthCredentials(userName, password)); + + basicAuthSucessfull = true; + } + } + catch (final AuthenticationException e) + { + LOGGER.debug("HTTP Basic authentication against local Alfresco services failed", e); + + if (userName.equalsIgnoreCase(Authorization.TICKET_USERID)) + { + this.authenticationListener.authenticationFailed(new TicketCredentials(password), e); + } + else + { + this.authenticationListener.authenticationFailed(new BasicAuthCredentials(userName, password), e); + } + } + } + return basicAuthSucessfull; + } + + /** + * Processes Keycloak authentication and potential action operations. If a Keycloak action has been processed, the request processing + * will be terminated. Otherwise processing may continue with the filter chain (if still applicable). + * + * @param context + * the servlet context + * @param req + * the servlet request + * @param res + * the servlet response + * @param chain + * the filter chain + * @throws IOException + * if any error occurs during Keycloak authentication or processing of the filter chain + * @throws ServletException + * if any error occurs during Keycloak authentication or processing of the filter chain + */ + protected void processKeycloakAuthenticationAndActions(final ServletContext context, final HttpServletRequest req, + final HttpServletResponse res, final FilterChain chain) throws IOException, ServletException + { + LOGGER.trace("Processing Keycloak authentication and actions on request to {}", req.getRequestURL()); + + final OIDCServletHttpFacade facade = new OIDCServletHttpFacade(req, res); + + final String servletPath = req.getServletPath(); + final String pathInfo = req.getPathInfo(); + final String servletRequestUri = servletPath + (pathInfo != null ? pathInfo : ""); + if (servletRequestUri.matches(KEYCLOAK_ACTION_URL_PATTERN)) + { + LOGGER.trace("Applying Keycloak pre-auth actions handler"); + final PreAuthActionsHandler preActions = new PreAuthActionsHandler(new UserSessionManagement() + { + + /** + * + * {@inheritDoc} + */ + @Override + public void logoutAll() + { + KeycloakAuthenticationFilter.this.sessionIdMapper.clear(); + } + + /** + * + * {@inheritDoc} + */ + @Override + public void logoutHttpSessions(final List ids) + { + ids.forEach(KeycloakAuthenticationFilter.this.sessionIdMapper::removeSession); + } + }, this.deploymentContext, facade); + + if (preActions.handleRequest()) + { + LOGGER.debug("Keycloak pre-auth actions processed the request - stopping filter chain execution"); + return; + } + } + + final OIDCFilterSessionStore tokenStore = new OIDCFilterSessionStore(req, facade, + this.bodyBufferLimit > 0 ? this.bodyBufferLimit : DEFAULT_BODY_BUFFER_LIMIT, this.keycloakDeployment, this.sessionIdMapper); + final FilterRequestAuthenticator authenticator = new FilterRequestAuthenticator(this.keycloakDeployment, tokenStore, facade, req, + this.sslRedirectPort); + final AuthOutcome authOutcome = authenticator.authenticate(); + + if (authOutcome == AuthOutcome.AUTHENTICATED) + { + this.onKeycloakAuthenticationSuccess(context, req, res, chain, facade, tokenStore); + } + else if (authOutcome == AuthOutcome.NOT_ATTEMPTED) + { + LOGGER.trace("No authentication took place - sending authentication challenge"); + authenticator.getChallenge().challenge(facade); + } + else if (authOutcome == AuthOutcome.FAILED) + { + this.onKeycloakAuthenticationFailure(context, req, res); + + LOGGER.trace("Sending authentication challenge from failure"); + authenticator.getChallenge().challenge(facade); + } + } + + /** + * Processes a sucessfull authentication via Keycloak. + * + * @param context + * the servlet context + * @param req + * the servlet request + * @param res + * the servlet response + * @param chain + * the filter chain + * @param facade + * the Keycloak HTTP facade + * @param tokenStore + * the Keycloak token store + * @throws IOException + * if any error occurs during Keycloak authentication or processing of the filter chain + * @throws ServletException + * if any error occurs during Keycloak authentication or processing of the filter chain + */ + protected void onKeycloakAuthenticationSuccess(final ServletContext context, final HttpServletRequest req, + final HttpServletResponse res, final FilterChain chain, final OIDCServletHttpFacade facade, + final OIDCFilterSessionStore tokenStore) throws IOException, ServletException + { + final HttpSession session = req.getSession(); + final Object keycloakAccount = session != null ? session.getAttribute(KeycloakAccount.class.getName()) : null; + if (keycloakAccount instanceof OidcKeycloakAccount) + { + final KeycloakSecurityContext keycloakSecurityContext = ((OidcKeycloakAccount) keycloakAccount).getKeycloakSecurityContext(); + final AccessToken accessToken = keycloakSecurityContext.getToken(); + final String userId = accessToken.getPreferredUsername(); + + LOGGER.debug("User {} successfully authenticated via Keycloak", AuthenticationUtil.maskUsername(userId)); + + final SessionUser sessionUser = this.createUserEnvironment(session, userId); + // need different attribute name than default for integration with web scripts framework + // default attribute name seems to be no longer used + session.setAttribute(AuthenticationDriver.AUTHENTICATION_USER, sessionUser); + + this.authenticationListener.userAuthenticated(new KeycloakCredentials(accessToken)); + } + + if (facade.isEnded()) + { + LOGGER.debug("Keycloak authenticator processed the request - stopping filter chain execution"); + return; + } + + final String servletPath = req.getServletPath(); + final String pathInfo = req.getPathInfo(); + final String servletRequestUri = servletPath + (pathInfo != null ? pathInfo : ""); + + if (servletRequestUri.matches(KEYCLOAK_ACTION_URL_PATTERN)) + { + LOGGER.trace("Applying Keycloak authenticated actions handler"); + final AuthenticatedActionsHandler actions = new AuthenticatedActionsHandler(this.keycloakDeployment, facade); + if (actions.handledRequest()) + { + LOGGER.debug("Keycloak authenticated actions processed the request - stopping filter chain execution"); + return; + } + } + + LOGGER.trace("Continueing with filter chain processing"); + final HttpServletRequestWrapper requestWrapper = tokenStore.buildWrapper(); + chain.doFilter(requestWrapper, res); + } + + /** + * Processes a failed authentication via Keycloak. + * + * @param context + * the servlet context + * @param req + * the servlet request + * @param res + * the servlet response + * + * @throws IOException + * if any error occurs during processing of the filter chain + * @throws ServletException + * if any error occurs during processing of the filter chain + */ + protected void onKeycloakAuthenticationFailure(final ServletContext context, final HttpServletRequest req, + final HttpServletResponse res) throws IOException, ServletException + { + final Object authenticationError = req.getAttribute(AuthenticationError.class.getName()); + if (authenticationError != null) + { + LOGGER.warn("Keycloak authentication failed due to {}", authenticationError); + } + LOGGER.trace("Resetting session and state cookie before continueing with filter chain"); + + req.getSession().invalidate(); + + this.resetStateCookies(context, req, res); + + this.authenticationListener.authenticationFailed(new UnknownCredentials()); + } + + /** + * Checks if processing of the filter must be skipped for the specified request. + * + * @param context + * the servlet context + * @param req + * the servlet request to check for potential conditions to skip + * @param res + * the servlet response on which potential updates of cookies / response headers need to be set + * @return {@code true} if processing of the {@link #doFilter(ServletContext, ServletRequest, ServletResponse, FilterChain) filter + * operation} must be skipped, {@code false} otherwise + * + * @throws IOException + * if any error occurs during inspection of the request + * @throws ServletException + * if any error occurs during inspection of the request + */ + protected boolean checkForSkipCondition(final ServletContext context, final HttpServletRequest req, final HttpServletResponse res) + throws IOException, ServletException + { + boolean skip = false; + + final String authHeader = req.getHeader(HEADER_AUTHORIZATION); + + final String servletPath = req.getServletPath(); + final String pathInfo = req.getPathInfo(); + final String servletRequestUri = servletPath + (pathInfo != null ? pathInfo : ""); + + final SessionUser sessionUser = this.getSessionUser(context, req, res, true); + HttpSession session = req.getSession(); + + // check for back-channel logout (sessionIdMapper should now of all authenticated sessions) + if (this.active && sessionUser != null && session.getAttribute(KeycloakAccount.class.getName()) != null + && !this.sessionIdMapper.hasSession(session.getId())) + { + LOGGER.debug("Session {} for Keycloak-authenticated user {} was invalidated by back-channel logout", session.getId(), + AuthenticationUtil.maskUsername(sessionUser.getUserName())); + this.invalidateSession(req); + session = req.getSession(false); + } + + if (!this.active) + { + LOGGER.trace("Skipping doFilter as filter is not active"); + skip = true; + } + else if (req.getAttribute(NO_AUTH_REQUIRED) != null) + { + LOGGER.trace("Skipping doFilter as filter higher up in chain determined authentication as not required"); + } + else if (servletRequestUri.matches(KEYCLOAK_ACTION_URL_PATTERN)) + { + LOGGER.trace("Explicitly not skipping doFilter as Keycloak action URL is being called"); + } + else if (req.getParameter("state") != null && req.getParameter("code") != null && this.hasStateCookie(req)) + { + LOGGER.trace( + "Explicitly not skipping doFilter as state and code query parameters of OAuth2 redirect as well as state cookie are present"); + } + else if (authHeader != null && authHeader.toLowerCase(Locale.ENGLISH).startsWith("bearer ")) + { + LOGGER.trace("Explicitly not skipping doFilter as Bearer authorization header is present"); + } + else if (authHeader != null && authHeader.toLowerCase(Locale.ENGLISH).startsWith("basic ")) + { + LOGGER.trace("Explicitly not skipping doFilter as Basic authorization header is present"); + } + else if (authHeader != null) + { + LOGGER.trace("Skipping doFilter as non-OIDC / non-Basic authorization header is present"); + skip = true; + } + else if (this.allowTicketLogon && this.checkForTicketParameter(context, req, res)) + { + LOGGER.trace("Skipping doFilter as user was authenticated by ticket URL parameter"); + } + else if (sessionUser != null) + { + final KeycloakAccount keycloakAccount = (KeycloakAccount) session.getAttribute(KeycloakAccount.class.getName()); + if (keycloakAccount != null) + { + skip = this.validateAndRefreshKeycloakAuthentication(req, res, sessionUser.getUserName(), keycloakAccount); + } + else + { + LOGGER.trace("Skipping doFilter as non-Keycloak-authenticated session is already established"); + skip = true; + } + } + // TODO Check for login page URL (rarely configured since Repository by default has no login page since 5.0) + + return skip; + } + + /** + * Processes an existing Keycloak authentication, verifying the state of the underlying access token and potentially refreshing it if + * necessary or configured. + * + * @param req + * the HTTP servlet request + * @param res + * the HTTP servlet response + * @param userId + * the ID of the authenticated user + * @param keycloakAccount + * the Keycloak account object + * @return {@code true} if processing of the {@link #doFilter(ServletContext, ServletRequest, ServletResponse, FilterChain) filter + * operation} can be skipped as the account represents a valid and still active authentication, {@code false} otherwise + */ + protected boolean validateAndRefreshKeycloakAuthentication(final HttpServletRequest req, final HttpServletResponse res, + final String userId, final KeycloakAccount keycloakAccount) + { + final OIDCServletHttpFacade facade = new OIDCServletHttpFacade(req, res); + + final OIDCFilterSessionStore tokenStore = new OIDCFilterSessionStore(req, facade, + this.bodyBufferLimit > 0 ? this.bodyBufferLimit : DEFAULT_BODY_BUFFER_LIMIT, this.keycloakDeployment, null); + + final String oldSessionId = req.getSession().getId(); + + tokenStore.checkCurrentToken(); + + final HttpSession currentSession = req.getSession(false); + + boolean skip = false; + if (currentSession != null) + { + LOGGER.trace("Skipping doFilter as Keycloak-authentication session is still valid"); + skip = true; + } + else + { + this.sessionIdMapper.removeSession(oldSessionId); + LOGGER.debug("Keycloak-authenticated session for user {} was invalidated after token expiration", + AuthenticationUtil.maskUsername(userId)); + } + return skip; + } + + /** + * Check if the request has specified a ticket parameter to bypass the standard authentication. + * + * @param context + * the servlet context + * @param req + * the request + * @param resp + * the response + * + * @throws IOException + * if any error occurs during ticket processing + * @throws ServletException + * if any error occurs during ticket processing + * + * @return boolean + */ + // copied + adapted from BaseSSOAuthenticationFilter + protected boolean checkForTicketParameter(final ServletContext context, final HttpServletRequest req, final HttpServletResponse resp) + throws IOException, ServletException + { + boolean ticketValid = false; + final String ticket = req.getParameter(ARG_TICKET); + + if (ticket != null && ticket.length() != 0) + { + LOGGER.trace("Logon via ticket from {} ({}:{}) ticket={}", req.getRemoteHost(), req.getRemoteAddr(), req.getRemotePort(), + ticket); + + try + { + final SessionUser user = this.getSessionUser(context, req, resp, true); + + if (user != null && !ticket.equals(user.getTicket())) + { + LOGGER.debug("Invalidating current session as URL-provided authentication ticket does not match"); + this.invalidateSession(req); + } + + if (user == null) + { + this.authenticationService.validate(ticket); + + this.createUserEnvironment(req.getSession(), this.authenticationService.getCurrentUserName(), + this.authenticationService.getCurrentTicket(), true); + + LOGGER.debug("Authenticated user {} via URL-provided authentication ticket", + AuthenticationUtil.maskUsername(this.authenticationService.getCurrentUserName())); + + this.authenticationListener.userAuthenticated(new TicketCredentials(ticket)); + } + + ticketValid = true; + } + catch (final AuthenticationException authErr) + { + LOGGER.debug("Failed to authenticate user ticket: {}", authErr.getMessage(), authErr); + + this.authenticationListener.authenticationFailed(new TicketCredentials(ticket), authErr); + } + } + + return ticketValid; + } + + /** + * Checks if the HTTP request has set the Keycloak state cookie. + * + * @param req + * the HTTP request to check + * @return {@code true} if the state cookie is set, {@code false} otherwise + */ + protected boolean hasStateCookie(final HttpServletRequest req) + { + final String stateCookieName = this.keycloakDeployment.getStateCookieName(); + final Cookie[] cookies = req.getCookies(); + final boolean hasStateCookie = cookies != null + ? Arrays.asList(cookies).stream().map(Cookie::getName).filter(stateCookieName::equals).findAny().isPresent() + : false; + return hasStateCookie; + } + + /** + * Resets any Keycloak-related state cookies present in the current request. + * + * @param context + * the servlet context + * @param req + * the servlet request + * @param res + * the servlet response + */ + protected void resetStateCookies(final ServletContext context, final HttpServletRequest req, final HttpServletResponse res) + { + final Cookie[] cookies = req.getCookies(); + if (cookies != null) + { + final String stateCookieName = this.keycloakDeployment.getStateCookieName(); + Arrays.asList(cookies).stream().filter(cookie -> stateCookieName.equals(cookie.getName())).findAny().ifPresent(cookie -> { + final Cookie resetCookie = new Cookie(cookie.getName(), ""); + resetCookie.setPath(context.getContextPath()); + resetCookie.setMaxAge(0); + resetCookie.setHttpOnly(false); + resetCookie.setSecure(false); + res.addCookie(resetCookie); + }); + } + } + + /** + * {@inheritDoc} + */ + @Override + protected Log getLogger() + { + return LogFactory.getLog(KeycloakAuthenticationFilter.class); + } + +} diff --git a/repository/src/main/java/de/acosix/alfresco/keycloak/repo/authentication/KeycloakCredentials.java b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/authentication/KeycloakCredentials.java new file mode 100644 index 0000000..eaed9c1 --- /dev/null +++ b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/authentication/KeycloakCredentials.java @@ -0,0 +1,75 @@ +/* + * Copyright 2019 Acosix GmbH + * + * 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 de.acosix.alfresco.keycloak.repo.authentication; + +import org.alfresco.repo.web.auth.WebCredentials; +import org.alfresco.util.ParameterCheck; +import org.keycloak.representations.AccessToken; + +/** + * @author Axel Faust + */ +public class KeycloakCredentials implements WebCredentials +{ + + private static final long serialVersionUID = -4815212606223856908L; + + private final AccessToken accessToken; + + public KeycloakCredentials(final AccessToken accessToken) + { + ParameterCheck.mandatory("accessToken", accessToken); + this.accessToken = accessToken; + } + + /** + * + * {@inheritDoc} + */ + @Override + public int hashCode() + { + final int prime = 31; + int result = 1; + result = prime * result + this.accessToken.getId().hashCode(); + return result; + } + + /** + * + * {@inheritDoc} + */ + @Override + public boolean equals(final Object obj) + { + if (this == obj) + { + return true; + } + if (obj == null) + { + return false; + } + if (this.getClass() != obj.getClass()) + { + return false; + } + + final KeycloakCredentials other = (KeycloakCredentials) obj; + final boolean equal = this.accessToken.getId().equals(other.accessToken.getId()); + return equal; + } +} diff --git a/repository/src/main/java/de/acosix/alfresco/keycloak/repo/authentication/KeycloakRemoteUserMapper.java b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/authentication/KeycloakRemoteUserMapper.java new file mode 100644 index 0000000..3a7cf8f --- /dev/null +++ b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/authentication/KeycloakRemoteUserMapper.java @@ -0,0 +1,159 @@ +/* + * Copyright 2019 Acosix GmbH + * + * 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 de.acosix.alfresco.keycloak.repo.authentication; + +import java.util.List; + +import javax.servlet.http.HttpServletRequest; + +import org.alfresco.repo.management.subsystems.ActivateableBean; +import org.alfresco.repo.security.authentication.AuthenticationException; +import org.alfresco.repo.security.authentication.AuthenticationUtil; +import org.alfresco.repo.security.authentication.external.RemoteUserMapper; +import org.alfresco.service.cmr.security.PersonService; +import org.alfresco.util.PropertyCheck; +import org.keycloak.adapters.BearerTokenRequestAuthenticator; +import org.keycloak.adapters.KeycloakDeployment; +import org.keycloak.adapters.spi.AuthOutcome; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.InitializingBean; + +/** + * @author Axel Faust + */ +public class KeycloakRemoteUserMapper implements RemoteUserMapper, ActivateableBean, InitializingBean +{ + + private static final Logger LOGGER = LoggerFactory.getLogger(KeycloakRemoteUserMapper.class); + + protected boolean active; + + protected boolean validationFailureSilent; + + protected KeycloakDeployment keycloakDeployment; + + protected PersonService personService; + + /** + * + * {@inheritDoc} + */ + @Override + public void afterPropertiesSet() + { + PropertyCheck.mandatory(this, "keycloakDeployment", this.keycloakDeployment); + PropertyCheck.mandatory(this, "personService", this.personService); + + this.keycloakDeployment.setBearerOnly(true); + } + + /** + * @param active + * the active to set + */ + public void setActive(final boolean active) + { + this.active = active; + } + + /** + * @param validationFailureSilent + * the validationFailureSilent to set + */ + public void setValidationFailureSilent(final boolean validationFailureSilent) + { + this.validationFailureSilent = validationFailureSilent; + } + + /** + * @param keycloakDeployment + * the keycloakDeployment to set + */ + public void setKeycloakDeployment(final KeycloakDeployment keycloakDeployment) + { + this.keycloakDeployment = keycloakDeployment; + } + + /** + * @param personService + * the personService to set + */ + public void setPersonService(final PersonService personService) + { + this.personService = personService; + } + + /** + * {@inheritDoc} + */ + @Override + public boolean isActive() + { + return this.active; + } + + /** + * {@inheritDoc} + */ + @Override + public String getRemoteUser(final HttpServletRequest request) + { + String remoteUser = null; + if (this.active) + { + final ResponseHeaderCookieCaptureServletHttpFacade httpFacade = new ResponseHeaderCookieCaptureServletHttpFacade(request); + final BearerTokenRequestAuthenticator authenticator = new BearerTokenRequestAuthenticator(this.keycloakDeployment); + final AuthOutcome authOutcome = authenticator.authenticate(httpFacade); + + if (authOutcome == AuthOutcome.AUTHENTICATED) + { + final String preferredUsername = authenticator.getToken().getPreferredUsername(); + final String normalisedUserName = AuthenticationUtil + .runAsSystem(() -> this.personService.getUserIdentifier(preferredUsername)); + + LOGGER.debug("Authenticated user {} via bearer token, normalised as {}", preferredUsername, normalisedUserName); + + remoteUser = normalisedUserName; + } + else if (authOutcome == AuthOutcome.FAILED) + { + authenticator.getChallenge().challenge(httpFacade); + final List authenticateHeader = httpFacade.getHeaders().get("WWW-Authenticate"); + String errorDescription = null; + if (authenticateHeader != null && !authenticateHeader.isEmpty()) + { + final String headerValue = authenticateHeader.get(0); + final int idx = headerValue.indexOf(", error_description=\""); + if (idx != -1) + { + final int startIdx = idx + ", error_description=\"".length(); + errorDescription = headerValue.substring(startIdx, headerValue.indexOf('"', startIdx)); + } + } + + LOGGER.debug("Bearer token authentication failed due to: {}", errorDescription); + + if (!this.validationFailureSilent) + { + throw new AuthenticationException("Token validation failed: " + errorDescription); + } + } + } + + return remoteUser; + } +} diff --git a/repository/src/main/java/de/acosix/alfresco/keycloak/repo/authentication/ResponseHeaderCookieCaptureServletHttpFacade.java b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/authentication/ResponseHeaderCookieCaptureServletHttpFacade.java new file mode 100644 index 0000000..23a674e --- /dev/null +++ b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/authentication/ResponseHeaderCookieCaptureServletHttpFacade.java @@ -0,0 +1,213 @@ +/* + * Copyright 2019 Acosix GmbH + * + * 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 de.acosix.alfresco.keycloak.repo.authentication; + +import java.io.ByteArrayOutputStream; +import java.io.OutputStream; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import javax.servlet.http.HttpServletRequest; + +import org.alfresco.util.Pair; +import org.keycloak.adapters.servlet.ServletHttpFacade; +import org.keycloak.adapters.spi.HttpFacade; + +/** + * This {@link HttpFacade} wraps servlet requests and responses in such a way that any response headers / cookies being set by Keycloak + * authenticators are captured, and otherwise no output is written to the servlet response. This is required for some scenarios in which a + * redirect action should be included in the login form. + * + * @author Axel Faust + */ +public class ResponseHeaderCookieCaptureServletHttpFacade extends ServletHttpFacade +{ + + protected final Map, javax.servlet.http.Cookie> cookies = new HashMap<>(); + + protected final Map> headers = new HashMap<>(); + + protected int status = -1; + + protected String message; + + /** + * Creates a new instance of this class for the provided servlet request. + * + * @param request + * the servlet request to facade + */ + public ResponseHeaderCookieCaptureServletHttpFacade(final HttpServletRequest request) + { + super(request, null); + } + + /** + * + * {@inheritDoc} + */ + @Override + public Response getResponse() + { + return new ResponseCaptureFacade(); + } + + /** + * @return the cookies + */ + public List getCookies() + { + return new ArrayList<>(this.cookies.values()); + } + + /** + * @return the headers + */ + public Map> getHeaders() + { + final Map> headers = new HashMap<>(); + this.headers.forEach((headerName, values) -> headers.put(headerName, new ArrayList<>(values))); + return headers; + } + + /** + * @return the status + */ + public int getStatus() + { + return this.status; + } + + /** + * @return the message + */ + public String getMessage() + { + return this.message; + } + + /** + * + * @author Axel Faust + */ + private class ResponseCaptureFacade implements Response + { + + /** + * + * {@inheritDoc} + */ + @Override + public void setStatus(final int status) + { + // NO-OP + } + + /** + * + * {@inheritDoc} + */ + @Override + public void addHeader(final String name, final String value) + { + ResponseHeaderCookieCaptureServletHttpFacade.this.headers.computeIfAbsent(name, key -> new ArrayList<>()).add(value); + } + + /** + * + * {@inheritDoc} + */ + @Override + public void setHeader(final String name, final String value) + { + ResponseHeaderCookieCaptureServletHttpFacade.this.headers.put(name, new ArrayList<>(Collections.singleton(value))); + } + + /** + * + * {@inheritDoc} + */ + @Override + public void resetCookie(final String name, final String path) + { + ResponseHeaderCookieCaptureServletHttpFacade.this.cookies.remove(new Pair<>(name, path)); + } + + /** + * + * {@inheritDoc} + */ + @Override + public void setCookie(final String name, final String value, final String path, final String domain, final int maxAge, + final boolean secure, final boolean httpOnly) + { + final javax.servlet.http.Cookie cookie = new javax.servlet.http.Cookie(name, value); + cookie.setPath(path); + if (domain != null) + { + cookie.setDomain(domain); + } + cookie.setMaxAge(maxAge); + cookie.setSecure(secure); + cookie.setHttpOnly(httpOnly); + ResponseHeaderCookieCaptureServletHttpFacade.this.cookies.put(new Pair<>(name, path), cookie); + } + + /** + * + * {@inheritDoc} + */ + @Override + public OutputStream getOutputStream() + { + return new ByteArrayOutputStream(); + } + + /** + * + * {@inheritDoc} + */ + @Override + public void sendError(final int code) + { + ResponseHeaderCookieCaptureServletHttpFacade.this.status = code; + } + + /** + * + * {@inheritDoc} + */ + @Override + public void sendError(final int code, final String message) + { + ResponseHeaderCookieCaptureServletHttpFacade.this.status = code; + ResponseHeaderCookieCaptureServletHttpFacade.this.message = message; + } + + /** + * + * {@inheritDoc} + */ + @Override + public void end() + { + // NO-OP + } + } +} diff --git a/repository/src/main/java/de/acosix/alfresco/keycloak/repo/authentication/SimpleCacheBackedSessionIdMapper.java b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/authentication/SimpleCacheBackedSessionIdMapper.java new file mode 100644 index 0000000..0af6c74 --- /dev/null +++ b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/authentication/SimpleCacheBackedSessionIdMapper.java @@ -0,0 +1,186 @@ +/* + * Copyright 2019 Acosix GmbH + * + * 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 de.acosix.alfresco.keycloak.repo.authentication; + +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; + +import org.alfresco.repo.cache.SimpleCache; +import org.alfresco.util.PropertyCheck; +import org.keycloak.adapters.spi.SessionIdMapper; +import org.springframework.beans.factory.InitializingBean; + +/** + * @author Axel Faust + */ +public class SimpleCacheBackedSessionIdMapper implements SessionIdMapper, InitializingBean +{ + + protected SimpleCache ssoToSession; + + protected SimpleCache sessionToSso; + + protected SimpleCache> principalToSession; + + protected SimpleCache sessionToPrincipal; + + /** + * {@inheritDoc} + */ + @Override + public void afterPropertiesSet() + { + PropertyCheck.mandatory(this, "ssoToSession", this.ssoToSession); + PropertyCheck.mandatory(this, "sessionToSso", this.sessionToSso); + PropertyCheck.mandatory(this, "principalToSession", this.principalToSession); + PropertyCheck.mandatory(this, "sessionToPrincipal", this.sessionToPrincipal); + } + + /** + * @param ssoToSession + * the ssoToSession to set + */ + public void setSsoToSession(final SimpleCache ssoToSession) + { + this.ssoToSession = ssoToSession; + } + + /** + * @param sessionToSso + * the sessionToSso to set + */ + public void setSessionToSso(final SimpleCache sessionToSso) + { + this.sessionToSso = sessionToSso; + } + + /** + * @param principalToSession + * the principalToSession to set + */ + public void setPrincipalToSession(final SimpleCache> principalToSession) + { + this.principalToSession = principalToSession; + } + + /** + * @param sessionToPrincipal + * the sessionToPrincipal to set + */ + public void setSessionToPrincipal(final SimpleCache sessionToPrincipal) + { + this.sessionToPrincipal = sessionToPrincipal; + } + + /** + * {@inheritDoc} + */ + @Override + public boolean hasSession(final String id) + { + final boolean hasSession = this.sessionToSso.contains(id) || this.sessionToPrincipal.contains(id); + return hasSession; + } + + /** + * {@inheritDoc} + */ + @Override + public void clear() + { + this.ssoToSession.clear(); + this.sessionToSso.clear(); + this.principalToSession.clear(); + this.sessionToPrincipal.clear(); + } + + /** + * {@inheritDoc} + */ + @Override + public Set getUserSessions(final String principal) + { + Set userSessions = Collections.emptySet(); + final Set lookup = this.principalToSession.get(principal); + if (lookup != null) + { + userSessions = new HashSet<>(lookup); + } + return userSessions; + } + + /** + * {@inheritDoc} + */ + @Override + public String getSessionFromSSO(final String sso) + { + return this.ssoToSession.get(sso); + } + + /** + * {@inheritDoc} + */ + @Override + public void map(final String sso, final String principal, final String session) + { + if (sso != null) + { + this.ssoToSession.put(sso, session); + this.sessionToSso.put(session, sso); + } + + if (principal != null) + { + Set userSessions = this.principalToSession.get(principal); + if (userSessions == null) + { + userSessions = new HashSet<>(); + this.principalToSession.put(principal, userSessions); + + } + userSessions.add(session); + this.sessionToPrincipal.put(session, principal); + } + } + + /** + * {@inheritDoc} + */ + @Override + public void removeSession(final String session) + { + final String sso = this.sessionToSso.get(session); + this.sessionToSso.remove(session); + if (sso != null) + { + this.ssoToSession.remove(sso); + } + final String principal = this.sessionToPrincipal.get(session); + this.sessionToPrincipal.remove(session); + if (principal != null) + { + final Set sessions = this.principalToSession.get(principal); + sessions.remove(session); + if (sessions.isEmpty()) + { + this.principalToSession.remove(principal); + } + } + } + +} diff --git a/repository/src/main/java/de/acosix/alfresco/keycloak/repo/spring/KeycloakAdapterConfigBeanFactory.java b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/spring/KeycloakAdapterConfigBeanFactory.java new file mode 100644 index 0000000..db72960 --- /dev/null +++ b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/spring/KeycloakAdapterConfigBeanFactory.java @@ -0,0 +1,325 @@ +/* + * Copyright 2019 Acosix GmbH + * + * 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 de.acosix.alfresco.keycloak.repo.spring; + +import java.lang.reflect.Field; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Properties; +import java.util.Set; + +import org.alfresco.error.AlfrescoRuntimeException; +import org.alfresco.util.PropertyCheck; +import org.keycloak.representations.adapters.config.AdapterConfig; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.FactoryBean; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.beans.factory.config.PlaceholderConfigurerSupport; +import org.springframework.util.PropertyPlaceholderHelper; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * @author Axel Faust + */ +public class KeycloakAdapterConfigBeanFactory implements FactoryBean, InitializingBean +{ + + private static final Logger LOGGER = LoggerFactory.getLogger(KeycloakAdapterConfigBeanFactory.class); + + private static final Map SETTER_BY_CONFIG_NAME; + + private static final Map> VALUE_TYPE_BY_CONFIG_NAME; + + private static final List CONFIG_NAMES; + + static + { + final Map setterByConfigName = new HashMap<>(); + final Map> valueTypeByConfigName = new HashMap<>(); + final List configNames = new ArrayList<>(); + + final Set> supportedValueTypes = new HashSet<>(Arrays.asList(String.class, Map.class)); + final Map, Class> primitiveWrapperTypeMap = new HashMap<>(); + final Class[] wrapperTypes = { Integer.class, Long.class, Boolean.class, Short.class, Byte.class, Character.class, Float.class, + Double.class }; + final Class[] primitiveTypes = { int.class, long.class, boolean.class, short.class, byte.class, char.class, float.class, + double.class }; + for (int i = 0; i < primitiveTypes.length; i++) + { + supportedValueTypes.add(primitiveTypes[i]); + supportedValueTypes.add(wrapperTypes[i]); + primitiveWrapperTypeMap.put(primitiveTypes[i], wrapperTypes[i]); + } + + Class cls = AdapterConfig.class; + while (cls != null && !Object.class.equals(cls)) + { + final Field[] fields = cls.getDeclaredFields(); + for (final Field field : fields) + { + final JsonProperty annotation = field.getAnnotation(JsonProperty.class); + if (annotation != null) + { + final String configName = annotation.value(); + + final String fieldName = field.getName(); + final StringBuilder setterNameBuilder = new StringBuilder(3 + fieldName.length()); + setterNameBuilder.append("set"); + setterNameBuilder.append(fieldName.substring(0, 1).toUpperCase(Locale.ENGLISH)); + setterNameBuilder.append(fieldName.substring(1)); + final String setterName = setterNameBuilder.toString(); + + Class valueType = field.getType(); + try + { + final Method setter = cls.getDeclaredMethod(setterName, valueType); + + if (valueType.isPrimitive()) + { + valueType = primitiveWrapperTypeMap.get(valueType); + } + + if (supportedValueTypes.contains(valueType)) + { + setterByConfigName.put(configName, setter); + valueTypeByConfigName.put(configName, valueType); + configNames.add(configName); + } + } + catch (final NoSuchMethodException nsme) + { + LOGGER.warn("Cannot support Keycloak adapter config field {} as no appropriate setter {} could be found in {}", + fieldName, setterName, cls); + } + } + } + + cls = cls.getSuperclass(); + } + + SETTER_BY_CONFIG_NAME = Collections.unmodifiableMap(setterByConfigName); + VALUE_TYPE_BY_CONFIG_NAME = Collections.unmodifiableMap(valueTypeByConfigName); + CONFIG_NAMES = Collections.unmodifiableList(configNames); + } + + protected Properties propertiesSource; + + protected String configPropertyPrefix; + + protected String placeholderPrefix = PlaceholderConfigurerSupport.DEFAULT_PLACEHOLDER_PREFIX; + + protected String placeholderSuffix = PlaceholderConfigurerSupport.DEFAULT_PLACEHOLDER_SUFFIX; + + protected String valueSeparator = PlaceholderConfigurerSupport.DEFAULT_VALUE_SEPARATOR; + + protected PropertyPlaceholderHelper placeholderHelper; + + /** + * + * {@inheritDoc} + */ + @Override + public void afterPropertiesSet() + { + PropertyCheck.mandatory(this, "propertiesSource", this.propertiesSource); + PropertyCheck.mandatory(this, "propertyPrefix", this.configPropertyPrefix); + + this.placeholderHelper = new PropertyPlaceholderHelper(this.placeholderPrefix, this.placeholderSuffix, this.valueSeparator, true); + } + + /** + * @param propertiesSource + * the propertiesSource to set + */ + public void setPropertiesSource(final Properties propertiesSource) + { + this.propertiesSource = propertiesSource; + } + + /** + * @param configPropertyPrefix + * the configPropertyPrefix to set + */ + public void setConfigPropertyPrefix(final String configPropertyPrefix) + { + this.configPropertyPrefix = configPropertyPrefix; + } + + /** + * @param placeholderPrefix + * the placeholderPrefix to set + */ + public void setPlaceholderPrefix(final String placeholderPrefix) + { + this.placeholderPrefix = placeholderPrefix; + } + + /** + * @param placeholderSuffix + * the placeholderSuffix to set + */ + public void setPlaceholderSuffix(final String placeholderSuffix) + { + this.placeholderSuffix = placeholderSuffix; + } + + /** + * @param valueSeparator + * the valueSeparator to set + */ + public void setValueSeparator(final String valueSeparator) + { + this.valueSeparator = valueSeparator; + } + + /** + * {@inheritDoc} + */ + @Override + public AdapterConfig getObject() throws Exception + { + final AdapterConfig adapterConfig = new AdapterConfig(); + + CONFIG_NAMES.forEach(configFieldName -> { + final Class valueType = VALUE_TYPE_BY_CONFIG_NAME.get(configFieldName); + + Object value; + if (Map.class.isAssignableFrom(valueType)) + { + value = this.loadConfigMap(configFieldName); + } + else + { + value = this.loadConfigValue(configFieldName, valueType); + } + + if (value != null) + { + LOGGER.debug("Loaded {} as value of adapter config field {}", value, configFieldName); + try + { + final Method setter = SETTER_BY_CONFIG_NAME.get(configFieldName); + setter.invoke(adapterConfig, value); + } + catch (final IllegalAccessException | InvocationTargetException ex) + { + throw new AlfrescoRuntimeException("Error building adapter configuration", ex); + } + } + else + { + LOGGER.trace("No value specified for adapter config field {}", configFieldName); + } + }); + + return adapterConfig; + } + + /** + * {@inheritDoc} + */ + @Override + public Class getObjectType() + { + return AdapterConfig.class; + } + + protected Object loadConfigValue(final String configFieldName, final Class valueType) + { + Object effectiveValue; + + final String propertyName = this.configPropertyPrefix + "." + configFieldName; + String value = this.propertiesSource.getProperty(propertyName); + if (value != null) + { + value = this.placeholderHelper.replacePlaceholders(value, this.propertiesSource); + } + + if (value != null && !value.trim().isEmpty()) + { + final String trimmedValue = value.trim(); + if (Number.class.isAssignableFrom(valueType)) + { + try + { + effectiveValue = valueType.getMethod("valueOf", String.class).invoke(null, trimmedValue); + } + catch (final NoSuchMethodException | IllegalAccessException | InvocationTargetException ex) + { + LOGGER.error( + "Number-based value type {} does not provide a publicly accessible, static valueOf to handle conversion of value {}", + valueType, trimmedValue); + throw new AlfrescoRuntimeException("Failed to convert configuration value " + trimmedValue, ex); + } + } + else if (Boolean.class.equals(valueType)) + { + effectiveValue = Boolean.valueOf(trimmedValue); + } + else if (Character.class.equals(valueType)) + { + if (trimmedValue.length() > 1) + { + throw new IllegalStateException("Value " + trimmedValue + " has more than one character"); + } + effectiveValue = new Character(trimmedValue.charAt(0)); + } + else if (String.class.equals(valueType)) + { + effectiveValue = trimmedValue; + } + else + { + throw new UnsupportedOperationException("Unsupported value type " + valueType); + } + } + else + { + effectiveValue = null; + } + + return effectiveValue; + } + + protected Map loadConfigMap(final String configFieldName) + { + final Map configMap = new HashMap<>(); + final String propertyPrefix = this.configPropertyPrefix + "." + configFieldName + "."; + this.propertiesSource.stringPropertyNames().stream().filter(p -> p.startsWith(propertyPrefix)).forEach(propertyName -> { + final String propertyConfigSuffix = propertyName.substring(propertyPrefix.length()); + String value = this.propertiesSource.getProperty(propertyName); + value = this.placeholderHelper.replacePlaceholders(value, this.propertiesSource); + + LOGGER.debug("Resolved value {} for map key {} of config field {}", value, propertyConfigSuffix, configFieldName); + if (value != null && !value.trim().isEmpty()) + { + configMap.put(propertyConfigSuffix, value.trim()); + } + }); + + return configMap.isEmpty() ? null : configMap; + } +} \ No newline at end of file diff --git a/repository/src/main/java/de/acosix/alfresco/keycloak/repo/spring/KeycloakDeploymentBeanFactory.java b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/spring/KeycloakDeploymentBeanFactory.java new file mode 100644 index 0000000..943529a --- /dev/null +++ b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/spring/KeycloakDeploymentBeanFactory.java @@ -0,0 +1,119 @@ +/* + * Copyright 2019 Acosix GmbH + * + * 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 de.acosix.alfresco.keycloak.repo.spring; + +import java.util.concurrent.TimeUnit; + +import org.alfresco.util.PropertyCheck; +import org.keycloak.adapters.HttpClientBuilder; +import org.keycloak.adapters.KeycloakDeployment; +import org.keycloak.adapters.KeycloakDeploymentBuilder; +import org.keycloak.representations.adapters.config.AdapterConfig; +import org.springframework.beans.factory.FactoryBean; +import org.springframework.beans.factory.InitializingBean; + +/** + * @author Axel Faust + */ +public class KeycloakDeploymentBeanFactory implements FactoryBean, InitializingBean +{ + + protected AdapterConfig adapterConfig; + + protected int connectionTimeout; + + protected int socketTimeout; + + /** + * + * {@inheritDoc} + */ + @Override + public void afterPropertiesSet() + { + PropertyCheck.mandatory(this, "adapterConfig", this.adapterConfig); + } + + /** + * @param adapterConfig + * the adapterConfig to set + */ + public void setAdapterConfig(final AdapterConfig adapterConfig) + { + this.adapterConfig = adapterConfig; + } + + /** + * @param connectionTimeout + * the connectionTimeout to set + */ + public void setConnectionTimeout(final int connectionTimeout) + { + this.connectionTimeout = connectionTimeout; + } + + /** + * @param socketTimeout + * the socketTimeout to set + */ + public void setSocketTimeout(final int socketTimeout) + { + this.socketTimeout = socketTimeout; + } + + /** + * {@inheritDoc} + */ + @Override + public KeycloakDeployment getObject() throws Exception + { + final KeycloakDeployment keycloakDeployment = KeycloakDeploymentBuilder.build(this.adapterConfig); + + HttpClientBuilder httpClientBuilder = new HttpClientBuilder(); + if (this.connectionTimeout > 0) + { + httpClientBuilder = httpClientBuilder.establishConnectionTimeout(this.connectionTimeout, TimeUnit.MILLISECONDS); + } + if (this.socketTimeout > 0) + { + httpClientBuilder = httpClientBuilder.socketTimeout(this.socketTimeout, TimeUnit.MILLISECONDS); + } + keycloakDeployment.setClient(httpClientBuilder.build(this.adapterConfig)); + + return keycloakDeployment; + } + + /** + * + * {@inheritDoc} + */ + @Override + public boolean isSingleton() + { + // individual components may need to modify its configuration for their specific use case + // so this should not be a shared singleton + return false; + } + + /** + * {@inheritDoc} + */ + @Override + public Class getObjectType() + { + return KeycloakDeployment.class; + } +} \ No newline at end of file diff --git a/repository/src/test/docker/Repository-Dockerfile b/repository/src/test/docker/Repository-Dockerfile new file mode 100644 index 0000000..f533f5e --- /dev/null +++ b/repository/src/test/docker/Repository-Dockerfile @@ -0,0 +1,11 @@ +FROM ${docker.tests.repositoryBaseImage} +COPY maven ${docker.tests.repositoryWebappPath} + +# merge additions to alfresco-global.properties +RUN echo "" >> ${docker.tests.repositoryWebappPath}/../../shared/classes/alfresco-global.properties \ + && echo "#MergeGlobalProperties" >> ${docker.tests.repositoryWebappPath}/../../shared/classes/alfresco-global.properties \ + && sed -i '/#MergeGlobalProperties/r ${docker.tests.repositoryWebappPath}/WEB-INF/classes/alfresco/extension/alfresco-global.addition.properties' ${docker.tests.repositoryWebappPath}/../../shared/classes/alfresco-global.properties \ + && mv ${docker.tests.repositoryWebappPath}/WEB-INF/classes/alfresco/extension/entrypoint.sh $CATALINA_HOME/bin/ \ + && chmod +x $CATALINA_HOME/bin/entrypoint.sh + +CMD ["entrypoint.sh", "catalina.sh run -security"] \ No newline at end of file diff --git a/repository/src/test/docker/alfresco/extension/alfresco-global.addition.properties b/repository/src/test/docker/alfresco/extension/alfresco-global.addition.properties new file mode 100644 index 0000000..4d98028 --- /dev/null +++ b/repository/src/test/docker/alfresco/extension/alfresco-global.addition.properties @@ -0,0 +1,25 @@ +# +# Copyright 2019 Acosix GmbH +# +# 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. + +# note: this file is not named alfresco-global.properties to not override the default file in the image +# instead it relies on Dockerfile post-processing to merge with the default file + +authentication.chain=keycloak1:keycloak,alfrescoNtlm1:alfrescoNtlm + +keycloak.adapter.auth-server-url=http://${docker.tests.host.name}:${docker.tests.keycloakPort}/auth +keycloak.adapter.realm=test +keycloak.adapter.resource=alfresco +keycloak.adapter.credentials.provider=secret +keycloak.adapter.credentials.secret=6f70a28f-98cd-41ca-8f2f-368a8797d708 \ No newline at end of file diff --git a/repository/src/test/docker/alfresco/extension/dev-log4j.properties b/repository/src/test/docker/alfresco/extension/dev-log4j.properties new file mode 100644 index 0000000..2f921d2 --- /dev/null +++ b/repository/src/test/docker/alfresco/extension/dev-log4j.properties @@ -0,0 +1,25 @@ +# +# Copyright 2019 Acosix GmbH +# +# 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. + +log4j.rootLogger=error, File + +log4j.appender.File=org.apache.log4j.DailyRollingFileAppender +log4j.appender.File.File=\${catalina.base}/logs/alfresco.log +log4j.appender.File.Append=true +log4j.appender.File.DatePattern='.'yyyy-MM-dd +log4j.appender.File.layout=org.apache.log4j.PatternLayout +log4j.appender.File.layout.ConversionPattern=%d{ISO8601} %-5p [%c] [%t] %m%n + +log4j.logger.${project.artifactId}=DEBUG \ No newline at end of file diff --git a/repository/src/test/docker/alfresco/extension/entrypoint.sh b/repository/src/test/docker/alfresco/extension/entrypoint.sh new file mode 100644 index 0000000..4d81e7a --- /dev/null +++ b/repository/src/test/docker/alfresco/extension/entrypoint.sh @@ -0,0 +1,10 @@ +#!/bin/sh + +set -e + +ip=`hostname -I | awk '{print $1}'` +hostip=`echo "${ip}" | sed -E 's/([0-9]+\.[0-9]+)\.0\.[0-9]+/\1.0.1/'` +hostname="${DOCKER_HOST_NAME}" +echo "${hostip} ${hostname}" >> /etc/hosts + +bash -c "$@" \ No newline at end of file diff --git a/repository/src/test/docker/repository-it.xml b/repository/src/test/docker/repository-it.xml new file mode 100644 index 0000000..57016ee --- /dev/null +++ b/repository/src/test/docker/repository-it.xml @@ -0,0 +1,100 @@ + + + + repository-it-docker + + dir + + false + + + ${project.build.directory} + WEB-INF/lib + + ${project.artifactId}-${project.version}-installable.jar + + + + ${project.basedir}/src/test/resources + WEB-INF/classes + + *.properties + **/*.properties + + true + lf + + + ${project.basedir}/src/test/docker/alfresco + WEB-INF/classes/alfresco + + * + **/* + + + *.js + **/*.js + *.ftl + **/*.ftl + *.keystore + **/*.keystore + + true + lf + + + ${project.basedir}/src/test/docker/alfresco + WEB-INF/classes/alfresco + + *.js + **/*.js + *.ftl + **/*.ftl + *.keystore + **/*.keystore + + + + + + WEB-INF/lib + + + org.keycloak:keycloak-servlet-filter-adapter:* + + compile + + + WEB-INF/lib + + + + + org.orderofthebee.support-tools:* + com.cronutils:* + net.time4j:* + de.acosix.alfresco.utility:de.acosix.alfresco.utility.common:* + de.acosix.alfresco.utility:de.acosix.alfresco.utility.core.repo.quartz1:* + de.acosix.alfresco.utility:de.acosix.alfresco.utility.core.repo.quartz2:* + de.acosix.alfresco.utility:de.acosix.alfresco.utility.core.repo:jar:installable:* + + test + + + diff --git a/repository/src/test/docker/repository-logs/dummy.properties b/repository/src/test/docker/repository-logs/dummy.properties new file mode 100644 index 0000000..5d13f33 --- /dev/null +++ b/repository/src/test/docker/repository-logs/dummy.properties @@ -0,0 +1 @@ +# only exists to ensure Maven creates path in project ./target \ No newline at end of file diff --git a/repository/src/test/docker/test-realm.json b/repository/src/test/docker/test-realm.json new file mode 100644 index 0000000..fda30ca --- /dev/null +++ b/repository/src/test/docker/test-realm.json @@ -0,0 +1,1342 @@ +{ + "id": "test", + "realm": "test", + "users": [ + { + "id": "mustermann", + "username": "mmustermann", + "enabled": true, + "email": "max.mustermann@muster.com", + "firstName": "Max", + "lastName": "Mustermann", + "credentials": [ + { + "type": "password", + "value": "mmustermann" + } + ], + "realmRoles": [ + "user" + ], + "clientRoles": { + "account": [ + "view-profile", + "manage-account" + ] + } + } + ], + "clients": [ + { + "clientId": "alfresco", + "name": "Alfresco Repository", + "rootUrl": "http://localhost:${docker.tests.repositoryPort}/alfresco", + "adminUrl": "http://localhost:${docker.tests.repositoryPort}/alfresco/keycloak", + "baseUrl": "http://localhost:${docker.tests.repositoryPort}/alfresco", + "surrogateAuthRequired": false, + "enabled": true, + "clientAuthenticatorType": "client-secret", + "secret" : "6f70a28f-98cd-41ca-8f2f-368a8797d708", + "redirectUris": [ + "http://localhost:${docker.tests.repositoryPort}/alfresco/*" + ], + "webOrigins": [ + "http://localhost:${docker.tests.repositoryPort}" + ], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": true, + "serviceAccountsEnabled": false, + "publicClient": false, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "saml.assertion.signature": "false", + "saml.force.post.binding": "false", + "saml.multivalued.roles": "false", + "saml.encrypt": "false", + "saml.server.signature": "false", + "saml.server.signature.keyinfo.ext": "false", + "exclude.session.state.from.auth.response": "false", + "saml_force_name_id_format": "false", + "saml.client.signature": "false", + "tls.client.certificate.bound.access.tokens": "false", + "saml.authnstatement": "false", + "display.on.consent.screen": "false", + "saml.onetimeuse.condition": "false" + }, + "authenticationFlowBindingOverrides": { + + }, + "fullScopeAllowed": true, + "nodeReRegistrationTimeout": -1, + "defaultClientScopes": [ + "web-origins", + "role_list", + "roles", + "profile", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ], + "access": { + "view": true, + "configure": true, + "manage": true + } + } + ], + "notBefore": 0, + "revokeRefreshToken": false, + "refreshTokenMaxReuse": 0, + "accessTokenLifespan": 300, + "accessTokenLifespanForImplicitFlow": 900, + "ssoSessionIdleTimeout": 1800, + "ssoSessionMaxLifespan": 36000, + "ssoSessionIdleTimeoutRememberMe": 0, + "ssoSessionMaxLifespanRememberMe": 0, + "offlineSessionIdleTimeout": 2592000, + "offlineSessionMaxLifespanEnabled": false, + "offlineSessionMaxLifespan": 5184000, + "accessCodeLifespan": 60, + "accessCodeLifespanUserAction": 300, + "accessCodeLifespanLogin": 1800, + "actionTokenGeneratedByAdminLifespan": 43200, + "actionTokenGeneratedByUserLifespan": 300, + "enabled": true, + "sslRequired": "none", + "registrationAllowed": false, + "registrationEmailAsUsername": false, + "rememberMe": false, + "verifyEmail": false, + "loginWithEmailAllowed": true, + "duplicateEmailsAllowed": false, + "resetPasswordAllowed": false, + "editUsernameAllowed": false, + "bruteForceProtected": false, + "permanentLockout": false, + "maxFailureWaitSeconds": 900, + "minimumQuickLoginWaitSeconds": 60, + "waitIncrementSeconds": 60, + "quickLoginCheckMilliSeconds": 1000, + "maxDeltaTimeSeconds": 43200, + "failureFactor": 30, + "defaultRoles": [ + "offline_access", + "uma_authorization" + ], + "requiredCredentials": [ + "password" + ], + "otpPolicyType": "totp", + "otpPolicyAlgorithm": "HmacSHA1", + "otpPolicyInitialCounter": 0, + "otpPolicyDigits": 6, + "otpPolicyLookAheadWindow": 1, + "otpPolicyPeriod": 30, + "otpSupportedApplications": [ + "FreeOTP", + "Google Authenticator" + ], + "scopeMappings": [ + { + "clientScope": "offline_access", + "roles": [ + "offline_access" + ] + } + ], + "clientScopes": [ + { + "id": "0ee41513-079a-4156-9eab-5709b18be2a6", + "name": "address", + "description": "OpenID Connect built-in scope: address", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "consent.screen.text": "${addressScopeConsentText}" + }, + "protocolMappers": [ + { + "id": "1218801d-c159-4ddd-901c-2fc5afa06170", + "name": "address", + "protocol": "openid-connect", + "protocolMapper": "oidc-address-mapper", + "consentRequired": false, + "config": { + "user.attribute.formatted": "formatted", + "user.attribute.country": "country", + "user.attribute.postal_code": "postal_code", + "userinfo.token.claim": "true", + "user.attribute.street": "street", + "id.token.claim": "true", + "user.attribute.region": "region", + "access.token.claim": "true", + "user.attribute.locality": "locality" + } + } + ] + }, + { + "id": "bac1ffb3-92bf-481d-b6f8-8dec9bbe7291", + "name": "email", + "description": "OpenID Connect built-in scope: email", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "consent.screen.text": "${emailScopeConsentText}" + }, + "protocolMappers": [ + { + "id": "4f28826f-46c6-4fe9-b499-611b66d9bc6f", + "name": "email", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "email", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "email", + "jsonType.label": "String" + } + }, + { + "id": "34521446-1260-416f-bbab-b13143cdf163", + "name": "email verified", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "emailVerified", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "email_verified", + "jsonType.label": "boolean" + } + } + ] + }, + { + "id": "7a6e0dc7-5a3b-4c0e-9a6c-48c08d62b3e8", + "name": "microprofile-jwt", + "description": "Microprofile - JWT built-in scope", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "false" + }, + "protocolMappers": [ + { + "id": "a69c70ca-70c0-465b-8faf-fffd427f90d9", + "name": "groups", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-realm-role-mapper", + "consentRequired": false, + "config": { + "multivalued": "true", + "userinfo.token.claim": "true", + "user.attribute": "foo", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "groups", + "jsonType.label": "String" + } + }, + { + "id": "5fadd383-2a4a-4970-a79e-9e9bc917476e", + "name": "upn", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "username", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "upn", + "jsonType.label": "String" + } + } + ] + }, + { + "id": "3080e57a-c685-473d-977e-268f85e4d62d", + "name": "offline_access", + "description": "OpenID Connect built-in scope: offline_access", + "protocol": "openid-connect", + "attributes": { + "consent.screen.text": "${offlineAccessScopeConsentText}", + "display.on.consent.screen": "true" + } + }, + { + "id": "052c824f-caac-492c-9334-d68a1fe757e3", + "name": "phone", + "description": "OpenID Connect built-in scope: phone", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "consent.screen.text": "${phoneScopeConsentText}" + }, + "protocolMappers": [ + { + "id": "850fe82e-ea0b-4cda-a0bc-dcc9d355c5a8", + "name": "phone number verified", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "phoneNumberVerified", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "phone_number_verified", + "jsonType.label": "boolean" + } + }, + { + "id": "63bf589d-e07e-4bae-b057-e7a7b8c72dc4", + "name": "phone number", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "phoneNumber", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "phone_number", + "jsonType.label": "String" + } + } + ] + }, + { + "id": "d9c5f7c3-d075-4400-bf27-970df51c4217", + "name": "profile", + "description": "OpenID Connect built-in scope: profile", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "consent.screen.text": "${profileScopeConsentText}" + }, + "protocolMappers": [ + { + "id": "da5905e2-00ee-4ed0-ab7d-cdac7ead6206", + "name": "zoneinfo", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "zoneinfo", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "zoneinfo", + "jsonType.label": "String" + } + }, + { + "id": "ae7cf419-6550-449c-9df1-0386bb0ee652", + "name": "given name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "firstName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "given_name", + "jsonType.label": "String" + } + }, + { + "id": "ac275bed-9da2-4221-8b8b-f7b499cc2c4b", + "name": "full name", + "protocol": "openid-connect", + "protocolMapper": "oidc-full-name-mapper", + "consentRequired": false, + "config": { + "id.token.claim": "true", + "access.token.claim": "true", + "userinfo.token.claim": "true" + } + }, + { + "id": "3b88f7c9-62a1-42a2-b575-52181043e89c", + "name": "middle name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "middleName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "middle_name", + "jsonType.label": "String" + } + }, + { + "id": "90145df4-77c3-4c7a-8e15-59434c46bea1", + "name": "gender", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "gender", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "gender", + "jsonType.label": "String" + } + }, + { + "id": "5fea5786-6563-49c3-bdc1-26b125d5c8f0", + "name": "family name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "lastName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "family_name", + "jsonType.label": "String" + } + }, + { + "id": "f802a038-2b1a-4642-8758-2923a5d67d76", + "name": "nickname", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "nickname", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "nickname", + "jsonType.label": "String" + } + }, + { + "id": "7d050eba-1d77-480c-a12c-674d9201d790", + "name": "picture", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "picture", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "picture", + "jsonType.label": "String" + } + }, + { + "id": "6b54ca21-d615-4ef3-a703-0c0f20d9f393", + "name": "username", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "username", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "preferred_username", + "jsonType.label": "String" + } + }, + { + "id": "cd3664d6-1888-464f-b144-3d1d6f332956", + "name": "profile", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "profile", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "profile", + "jsonType.label": "String" + } + }, + { + "id": "aafa8ba3-d0cd-4dd0-9238-73506a15b6b3", + "name": "locale", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "locale", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "locale", + "jsonType.label": "String" + } + }, + { + "id": "52d6c5f9-d1cf-4dbd-a8f3-9ce438ee3868", + "name": "birthdate", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "birthdate", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "birthdate", + "jsonType.label": "String" + } + }, + { + "id": "7dba042a-bb94-4307-a547-a02a0ab2239e", + "name": "website", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "website", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "website", + "jsonType.label": "String" + } + }, + { + "id": "a6fb8c23-cfb4-4a5f-afcf-1bd360959c6b", + "name": "updated at", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "updatedAt", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "updated_at", + "jsonType.label": "String" + } + } + ] + }, + { + "id": "3ddbd9c1-92f1-4717-870e-4492df1d463a", + "name": "role_list", + "description": "SAML role list", + "protocol": "saml", + "attributes": { + "consent.screen.text": "${samlRoleListScopeConsentText}", + "display.on.consent.screen": "true" + }, + "protocolMappers": [ + { + "id": "51899037-a3df-4924-bd73-81f07cfb3aa9", + "name": "role list", + "protocol": "saml", + "protocolMapper": "saml-role-list-mapper", + "consentRequired": false, + "config": { + "single": "false", + "attribute.nameformat": "Basic", + "attribute.name": "Role" + } + } + ] + }, + { + "id": "4c15f94e-f490-4541-8c14-7b4734d6999b", + "name": "roles", + "description": "OpenID Connect scope for add user roles to the access token", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "display.on.consent.screen": "true", + "consent.screen.text": "${rolesScopeConsentText}" + }, + "protocolMappers": [ + { + "id": "1908b63f-1be6-4f87-a947-30ee66721d05", + "name": "audience resolve", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-resolve-mapper", + "consentRequired": false, + "config": { + + } + }, + { + "id": "b830b103-f7af-4d78-8c44-53c6879544d6", + "name": "client roles", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-client-role-mapper", + "consentRequired": false, + "config": { + "user.attribute": "foo", + "access.token.claim": "true", + "claim.name": "resource_access.${client_id}.roles", + "jsonType.label": "String", + "multivalued": "true" + } + }, + { + "id": "c3e6e3d7-e462-4e25-9a7c-99c5eee63194", + "name": "realm roles", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-realm-role-mapper", + "consentRequired": false, + "config": { + "user.attribute": "foo", + "access.token.claim": "true", + "claim.name": "realm_access.roles", + "jsonType.label": "String", + "multivalued": "true" + } + } + ] + }, + { + "id": "a6c18942-1bf9-4024-855e-65a5df06d810", + "name": "web-origins", + "description": "OpenID Connect scope for add allowed web origins to the access token", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "display.on.consent.screen": "false", + "consent.screen.text": "" + }, + "protocolMappers": [ + { + "id": "4652b835-1693-45ae-bad5-b61b534610de", + "name": "allowed web origins", + "protocol": "openid-connect", + "protocolMapper": "oidc-allowed-origins-mapper", + "consentRequired": false, + "config": { + + } + } + ] + } + ], + "defaultDefaultClientScopes": [ + "role_list", + "roles", + "web-origins", + "email", + "profile" + ], + "defaultOptionalClientScopes": [ + "phone", + "address", + "offline_access", + "microprofile-jwt" + ], + "browserSecurityHeaders": { + "contentSecurityPolicyReportOnly": "", + "xContentTypeOptions": "nosniff", + "xRobotsTag": "none", + "xFrameOptions": "SAMEORIGIN", + "xXSSProtection": "1; mode=block", + "contentSecurityPolicy": "frame-src 'self'; frame-ancestors 'self'; object-src 'none';", + "strictTransportSecurity": "max-age=31536000; includeSubDomains" + }, + "smtpServer": { + + }, + "eventsEnabled": false, + "eventsListeners": [ + "jboss-logging" + ], + "enabledEventTypes": [], + "adminEventsEnabled": false, + "adminEventsDetailsEnabled": false, + "components": { + "org.keycloak.services.clientregistration.policy.ClientRegistrationPolicy": [ + { + "id": "43a864c4-c4fa-4057-bf87-2ca409cde736", + "name": "Max Clients Limit", + "providerId": "max-clients", + "subType": "anonymous", + "subComponents": { + + }, + "config": { + "max-clients": [ + "200" + ] + } + }, + { + "id": "6d4772af-f62e-40e9-8370-44aebf0d694d", + "name": "Consent Required", + "providerId": "consent-required", + "subType": "anonymous", + "subComponents": { + + }, + "config": { + + } + }, + { + "id": "d474b02e-7d04-41c1-9c01-328221daa836", + "name": "Allowed Client Scopes", + "providerId": "allowed-client-templates", + "subType": "authenticated", + "subComponents": { + + }, + "config": { + "allow-default-scopes": [ + "true" + ] + } + }, + { + "id": "2f6ce962-4ad2-479b-87b2-35ea971039b1", + "name": "Allowed Protocol Mapper Types", + "providerId": "allowed-protocol-mappers", + "subType": "authenticated", + "subComponents": { + + }, + "config": { + "allowed-protocol-mapper-types": [ + "oidc-address-mapper", + "oidc-sha256-pairwise-sub-mapper", + "oidc-usermodel-property-mapper", + "oidc-usermodel-attribute-mapper", + "saml-user-property-mapper", + "saml-user-attribute-mapper", + "saml-role-list-mapper", + "oidc-full-name-mapper" + ] + } + }, + { + "id": "3f7ae6b8-63c8-4ddf-82d8-afebf153a41a", + "name": "Full Scope Disabled", + "providerId": "scope", + "subType": "anonymous", + "subComponents": { + + }, + "config": { + + } + }, + { + "id": "49326837-fe7d-47d6-85e7-5dd4028f0cd3", + "name": "Allowed Protocol Mapper Types", + "providerId": "allowed-protocol-mappers", + "subType": "anonymous", + "subComponents": { + + }, + "config": { + "allowed-protocol-mapper-types": [ + "oidc-usermodel-property-mapper", + "saml-role-list-mapper", + "oidc-sha256-pairwise-sub-mapper", + "oidc-full-name-mapper", + "saml-user-property-mapper", + "oidc-usermodel-attribute-mapper", + "oidc-address-mapper", + "saml-user-attribute-mapper" + ] + } + }, + { + "id": "c76f5e11-0576-4512-abf4-2d0b7cd5f355", + "name": "Trusted Hosts", + "providerId": "trusted-hosts", + "subType": "anonymous", + "subComponents": { + + }, + "config": { + "host-sending-registration-request-must-match": [ + "true" + ], + "client-uris-must-match": [ + "true" + ] + } + }, + { + "id": "144efd74-bbbd-4176-8760-0e0819a61e5c", + "name": "Allowed Client Scopes", + "providerId": "allowed-client-templates", + "subType": "anonymous", + "subComponents": { + + }, + "config": { + "allow-default-scopes": [ + "true" + ] + } + } + ], + "org.keycloak.keys.KeyProvider": [ + { + "id": "005993b6-dcb3-4ebf-b19c-7287c740bb79", + "name": "rsa-generated", + "providerId": "rsa-generated", + "subComponents": { + + }, + "config": { + "priority": [ + "100" + ] + } + }, + { + "id": "865ea0f7-9411-4985-8516-a3a7112204f4", + "name": "aes-generated", + "providerId": "aes-generated", + "subComponents": { + + }, + "config": { + "priority": [ + "100" + ] + } + }, + { + "id": "7783eb59-57b1-491b-a570-934811ac95c3", + "name": "hmac-generated", + "providerId": "hmac-generated", + "subComponents": { + + }, + "config": { + "priority": [ + "100" + ], + "algorithm": [ + "HS256" + ] + } + } + ] + }, + "internationalizationEnabled": false, + "supportedLocales": [], + "authenticationFlows": [ + { + "id": "50eda798-0ed6-4fd1-9071-977ca22b032f", + "alias": "Handle Existing Account", + "description": "Handle what to do if there is existing account with same email/username like authenticated identity provider", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "idp-confirm-link", + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "idp-email-verification", + "requirement": "ALTERNATIVE", + "priority": 20, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "requirement": "ALTERNATIVE", + "priority": 30, + "flowAlias": "Verify Existing Account by Re-authentication", + "userSetupAllowed": false, + "autheticatorFlow": true + } + ] + }, + { + "id": "49354897-5c61-4aca-8b08-0601202abf49", + "alias": "Verify Existing Account by Re-authentication", + "description": "Reauthentication of existing account", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "idp-username-password-form", + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "auth-otp-form", + "requirement": "OPTIONAL", + "priority": 20, + "userSetupAllowed": false, + "autheticatorFlow": false + } + ] + }, + { + "id": "f23bad00-b78e-4262-a025-5b2ef1d09dc4", + "alias": "browser", + "description": "browser based authentication", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "auth-cookie", + "requirement": "ALTERNATIVE", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "auth-spnego", + "requirement": "DISABLED", + "priority": 20, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "identity-provider-redirector", + "requirement": "ALTERNATIVE", + "priority": 25, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "requirement": "ALTERNATIVE", + "priority": 30, + "flowAlias": "forms", + "userSetupAllowed": false, + "autheticatorFlow": true + } + ] + }, + { + "id": "15d11f2c-9f51-4e41-8ca7-020b7b9e9335", + "alias": "clients", + "description": "Base authentication for clients", + "providerId": "client-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "client-secret", + "requirement": "ALTERNATIVE", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "client-jwt", + "requirement": "ALTERNATIVE", + "priority": 20, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "client-secret-jwt", + "requirement": "ALTERNATIVE", + "priority": 30, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "client-x509", + "requirement": "ALTERNATIVE", + "priority": 40, + "userSetupAllowed": false, + "autheticatorFlow": false + } + ] + }, + { + "id": "6846e2ce-b014-4296-a111-6f68ca10c985", + "alias": "direct grant", + "description": "OpenID Connect Resource Owner Grant", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "direct-grant-validate-username", + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "direct-grant-validate-password", + "requirement": "REQUIRED", + "priority": 20, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "direct-grant-validate-otp", + "requirement": "OPTIONAL", + "priority": 30, + "userSetupAllowed": false, + "autheticatorFlow": false + } + ] + }, + { + "id": "9425e1d1-4533-413d-83c5-38c9657a355f", + "alias": "docker auth", + "description": "Used by Docker clients to authenticate against the IDP", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "docker-http-basic-authenticator", + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + } + ] + }, + { + "id": "eb15d55b-a601-493c-9f49-069695624ada", + "alias": "first broker login", + "description": "Actions taken after first broker login with identity provider account, which is not yet linked to any Keycloak account", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticatorConfig": "review profile config", + "authenticator": "idp-review-profile", + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticatorConfig": "create unique user config", + "authenticator": "idp-create-user-if-unique", + "requirement": "ALTERNATIVE", + "priority": 20, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "requirement": "ALTERNATIVE", + "priority": 30, + "flowAlias": "Handle Existing Account", + "userSetupAllowed": false, + "autheticatorFlow": true + } + ] + }, + { + "id": "4fa685ea-65dd-4019-b3a3-a5f95b36e9f4", + "alias": "forms", + "description": "Username, password, otp and other auth forms.", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "auth-username-password-form", + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "auth-otp-form", + "requirement": "OPTIONAL", + "priority": 20, + "userSetupAllowed": false, + "autheticatorFlow": false + } + ] + }, + { + "id": "8edbb281-f132-4625-bf15-ebc314dd0c5d", + "alias": "http challenge", + "description": "An authentication flow based on challenge-response HTTP Authentication Schemes", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "no-cookie-redirect", + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "basic-auth", + "requirement": "REQUIRED", + "priority": 20, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "basic-auth-otp", + "requirement": "DISABLED", + "priority": 30, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "auth-spnego", + "requirement": "DISABLED", + "priority": 40, + "userSetupAllowed": false, + "autheticatorFlow": false + } + ] + }, + { + "id": "88374fbb-95fa-4bd5-940a-1289a9a051f6", + "alias": "registration", + "description": "registration flow", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "registration-page-form", + "requirement": "REQUIRED", + "priority": 10, + "flowAlias": "registration form", + "userSetupAllowed": false, + "autheticatorFlow": true + } + ] + }, + { + "id": "088ede2e-2dce-466d-b25f-62cc07c12bee", + "alias": "registration form", + "description": "registration form", + "providerId": "form-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "registration-user-creation", + "requirement": "REQUIRED", + "priority": 20, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "registration-profile-action", + "requirement": "REQUIRED", + "priority": 40, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "registration-password-action", + "requirement": "REQUIRED", + "priority": 50, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "registration-recaptcha-action", + "requirement": "DISABLED", + "priority": 60, + "userSetupAllowed": false, + "autheticatorFlow": false + } + ] + }, + { + "id": "065e575d-20fe-45e1-ab7d-d54ef056db8e", + "alias": "reset credentials", + "description": "Reset credentials for a user if they forgot their password or something", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "reset-credentials-choose-user", + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "reset-credential-email", + "requirement": "REQUIRED", + "priority": 20, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "reset-password", + "requirement": "REQUIRED", + "priority": 30, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "reset-otp", + "requirement": "OPTIONAL", + "priority": 40, + "userSetupAllowed": false, + "autheticatorFlow": false + } + ] + }, + { + "id": "e1f9c5ba-5e45-4c51-a3cb-c10303b8c02e", + "alias": "saml ecp", + "description": "SAML ECP Profile Authentication Flow", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "http-basic-authenticator", + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + } + ] + } + ], + "authenticatorConfig": [ + { + "id": "1593e111-7e5e-4ee2-b33d-f459834e4e3b", + "alias": "create unique user config", + "config": { + "require.password.update.after.registration": "false" + } + }, + { + "id": "ae5f6b14-fb84-4320-90a4-da8c80c379bf", + "alias": "review profile config", + "config": { + "update.profile.on.first.login": "missing" + } + } + ], + "requiredActions": [ + { + "alias": "CONFIGURE_TOTP", + "name": "Configure OTP", + "providerId": "CONFIGURE_TOTP", + "enabled": true, + "defaultAction": false, + "priority": 10, + "config": { + + } + }, + { + "alias": "terms_and_conditions", + "name": "Terms and Conditions", + "providerId": "terms_and_conditions", + "enabled": false, + "defaultAction": false, + "priority": 20, + "config": { + + } + }, + { + "alias": "UPDATE_PASSWORD", + "name": "Update Password", + "providerId": "UPDATE_PASSWORD", + "enabled": true, + "defaultAction": false, + "priority": 30, + "config": { + + } + }, + { + "alias": "UPDATE_PROFILE", + "name": "Update Profile", + "providerId": "UPDATE_PROFILE", + "enabled": true, + "defaultAction": false, + "priority": 40, + "config": { + + } + }, + { + "alias": "VERIFY_EMAIL", + "name": "Verify Email", + "providerId": "VERIFY_EMAIL", + "enabled": true, + "defaultAction": false, + "priority": 50, + "config": { + + } + } + ], + "browserFlow": "browser", + "registrationFlow": "registration", + "directGrantFlow": "direct grant", + "resetCredentialsFlow": "reset credentials", + "clientAuthenticationFlow": "clients", + "dockerAuthenticationFlow": "docker auth", + "attributes": { + "_browser_header.xXSSProtection": "1; mode=block", + "_browser_header.xFrameOptions": "SAMEORIGIN", + "_browser_header.strictTransportSecurity": "max-age=31536000; includeSubDomains", + "permanentLockout": "false", + "quickLoginCheckMilliSeconds": "1000", + "_browser_header.xRobotsTag": "none", + "maxFailureWaitSeconds": "900", + "minimumQuickLoginWaitSeconds": "60", + "failureFactor": "30", + "actionTokenGeneratedByUserLifespan": "300", + "maxDeltaTimeSeconds": "43200", + "_browser_header.xContentTypeOptions": "nosniff", + "offlineSessionMaxLifespan": "5184000", + "actionTokenGeneratedByAdminLifespan": "43200", + "_browser_header.contentSecurityPolicyReportOnly": "", + "bruteForceProtected": "false", + "_browser_header.contentSecurityPolicy": "frame-src 'self'; frame-ancestors 'self'; object-src 'none';", + "waitIncrementSeconds": "60", + "offlineSessionMaxLifespanEnabled": "false" + }, + "keycloakVersion": "6.0.1", + "userManagedAccessAllowed": false +} \ No newline at end of file diff --git a/share/src/main/java/de/acosix/alfresco/keycloak/share/config/KeycloakAdapterConfigElement.java b/share/src/main/java/de/acosix/alfresco/keycloak/share/config/KeycloakAdapterConfigElement.java index 8a8d1f5..ff396e7 100644 --- a/share/src/main/java/de/acosix/alfresco/keycloak/share/config/KeycloakAdapterConfigElement.java +++ b/share/src/main/java/de/acosix/alfresco/keycloak/share/config/KeycloakAdapterConfigElement.java @@ -306,11 +306,10 @@ public class KeycloakAdapterConfigElement extends BaseCustomConfigElement { for (final String configName : CONFIG_NAMES) { - final Method setter = SETTER_BY_CONFIG_NAME.get(configName); - final Object value = this.configValueByField.get(configName); if (value != null) { + final Method setter = SETTER_BY_CONFIG_NAME.get(configName); setter.invoke(config, value); } } diff --git a/share/src/main/java/de/acosix/alfresco/keycloak/share/config/KeycloakAdapterConfigElementReader.java b/share/src/main/java/de/acosix/alfresco/keycloak/share/config/KeycloakAdapterConfigElementReader.java index 2a287db..df043ab 100644 --- a/share/src/main/java/de/acosix/alfresco/keycloak/share/config/KeycloakAdapterConfigElementReader.java +++ b/share/src/main/java/de/acosix/alfresco/keycloak/share/config/KeycloakAdapterConfigElementReader.java @@ -84,7 +84,7 @@ public class KeycloakAdapterConfigElementReader implements ConfigElementReader configElement.setFieldValue(subElementName, valueType.getMethod("valueOf", String.class).invoke(null, textTrim)); } - catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException ex) + catch (final NoSuchMethodException | IllegalAccessException | InvocationTargetException ex) { LOGGER.error( "Number-based value type {} does not provide a publicly accessible, static valueOf to handle conversion of value {}", diff --git a/share/src/main/java/de/acosix/alfresco/keycloak/share/web/KeycloakAuthenticationFilter.java b/share/src/main/java/de/acosix/alfresco/keycloak/share/web/KeycloakAuthenticationFilter.java index f16dac6..6cc374f 100644 --- a/share/src/main/java/de/acosix/alfresco/keycloak/share/web/KeycloakAuthenticationFilter.java +++ b/share/src/main/java/de/acosix/alfresco/keycloak/share/web/KeycloakAuthenticationFilter.java @@ -19,6 +19,7 @@ import java.io.IOException; import java.util.ArrayList; import java.util.Arrays; import java.util.List; +import java.util.Locale; import java.util.concurrent.TimeUnit; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -549,7 +550,7 @@ public class KeycloakAuthenticationFilter implements DependencyInjectedFilter, I protected void prepareLoginFormEnhancement(final ServletContext context, final HttpServletRequest req, final HttpServletResponse res, final FilterRequestAuthenticator authenticator) { - final RedirectCaptureServletHttpFacade captureFacade = new RedirectCaptureServletHttpFacade(req); + final ResponseHeaderCookieCaptureServletHttpFacade captureFacade = new ResponseHeaderCookieCaptureServletHttpFacade(req); authenticator.getChallenge().challenge(captureFacade); @@ -603,7 +604,7 @@ public class KeycloakAuthenticationFilter implements DependencyInjectedFilter, I }; - final RedirectCaptureServletHttpFacade captureFacade = new RedirectCaptureServletHttpFacade(wrappedReq); + final ResponseHeaderCookieCaptureServletHttpFacade captureFacade = new ResponseHeaderCookieCaptureServletHttpFacade(wrappedReq); final OIDCFilterSessionStore tokenStore = new OIDCFilterSessionStore(req, captureFacade, bodyBufferLimit != null ? bodyBufferLimit.intValue() : DEFAULT_BODY_BUFFER_LIMIT, this.keycloakDeployment, null); @@ -786,6 +787,8 @@ public class KeycloakAuthenticationFilter implements DependencyInjectedFilter, I { boolean skip = false; + final String authHeader = req.getHeader(HEADER_AUTHORIZATION); + final String servletPath = req.getServletPath(); final String pathInfo = req.getPathInfo(); final String servletRequestUri = servletPath + (pathInfo != null ? pathInfo : ""); @@ -824,24 +827,25 @@ public class KeycloakAuthenticationFilter implements DependencyInjectedFilter, I LOGGER.debug( "Explicitly not skipping doFilter as state and code query parameters of OAuth2 redirect as well as state cookie are present"); } - else if (req.getHeader(HEADER_AUTHORIZATION) != null && req.getHeader(HEADER_AUTHORIZATION).startsWith("Bearer ")) + else if (authHeader != null && authHeader.toLowerCase(Locale.ENGLISH).startsWith("bearer ")) { LOGGER.debug("Explicitly not skipping doFilter as Bearer authorization header is present"); } - else if (req.getHeader(HEADER_AUTHORIZATION) != null) + else if (authHeader != null && authHeader.toLowerCase(Locale.ENGLISH).startsWith("basic ")) { - LOGGER.debug("Skipping doFilter as non-OIDC authorization header is present"); + LOGGER.debug("Explicitly not skipping doFilter as Basic authorization header is present"); + } + else if (authHeader != null) + { + LOGGER.debug("Skipping doFilter as non-OIDC / non-Basic authorization header is present"); skip = true; } - else if (req.getHeader(HEADER_AUTHORIZATION) == null && (currentSession != null && AuthenticationUtil.isAuthenticated(req))) + else if (currentSession != null && AuthenticationUtil.isAuthenticated(req)) { - final String userId = AuthenticationUtil.getUserId(req); - LOGGER.debug("Existing HTTP session is associated with user {}", userId); - final KeycloakAccount keycloakAccount = (KeycloakAccount) currentSession.getAttribute(KeycloakAccount.class.getName()); if (keycloakAccount != null) { - skip = this.validateAndRefreshKeycloakAuthentication(req, res, userId, keycloakAccount); + skip = this.validateAndRefreshKeycloakAuthentication(req, res, AuthenticationUtil.getUserId(req), keycloakAccount); } else { diff --git a/share/src/main/java/de/acosix/alfresco/keycloak/share/web/RedirectCaptureServletHttpFacade.java b/share/src/main/java/de/acosix/alfresco/keycloak/share/web/ResponseHeaderCookieCaptureServletHttpFacade.java similarity index 87% rename from share/src/main/java/de/acosix/alfresco/keycloak/share/web/RedirectCaptureServletHttpFacade.java rename to share/src/main/java/de/acosix/alfresco/keycloak/share/web/ResponseHeaderCookieCaptureServletHttpFacade.java index a25531e..80cbde7 100644 --- a/share/src/main/java/de/acosix/alfresco/keycloak/share/web/RedirectCaptureServletHttpFacade.java +++ b/share/src/main/java/de/acosix/alfresco/keycloak/share/web/ResponseHeaderCookieCaptureServletHttpFacade.java @@ -36,7 +36,7 @@ import org.keycloak.adapters.spi.HttpFacade; * * @author Axel Faust */ -public class RedirectCaptureServletHttpFacade extends ServletHttpFacade +public class ResponseHeaderCookieCaptureServletHttpFacade extends ServletHttpFacade { protected final Map, javax.servlet.http.Cookie> cookies = new HashMap<>(); @@ -49,7 +49,7 @@ public class RedirectCaptureServletHttpFacade extends ServletHttpFacade * @param request * the servlet request to facade */ - public RedirectCaptureServletHttpFacade(final HttpServletRequest request) + public ResponseHeaderCookieCaptureServletHttpFacade(final HttpServletRequest request) { super(request, null); } @@ -106,7 +106,7 @@ public class RedirectCaptureServletHttpFacade extends ServletHttpFacade @Override public void addHeader(final String name, final String value) { - RedirectCaptureServletHttpFacade.this.headers.computeIfAbsent(name, key -> new ArrayList<>()).add(value); + ResponseHeaderCookieCaptureServletHttpFacade.this.headers.computeIfAbsent(name, key -> new ArrayList<>()).add(value); } /** @@ -116,7 +116,7 @@ public class RedirectCaptureServletHttpFacade extends ServletHttpFacade @Override public void setHeader(final String name, final String value) { - RedirectCaptureServletHttpFacade.this.headers.put(name, new ArrayList<>(Collections.singleton(value))); + ResponseHeaderCookieCaptureServletHttpFacade.this.headers.put(name, new ArrayList<>(Collections.singleton(value))); } /** @@ -126,7 +126,7 @@ public class RedirectCaptureServletHttpFacade extends ServletHttpFacade @Override public void resetCookie(final String name, final String path) { - RedirectCaptureServletHttpFacade.this.cookies.remove(new Pair<>(name, path)); + ResponseHeaderCookieCaptureServletHttpFacade.this.cookies.remove(new Pair<>(name, path)); } /** @@ -146,7 +146,7 @@ public class RedirectCaptureServletHttpFacade extends ServletHttpFacade cookie.setMaxAge(maxAge); cookie.setSecure(secure); cookie.setHttpOnly(httpOnly); - RedirectCaptureServletHttpFacade.this.cookies.put(new Pair<>(name, path), cookie); + ResponseHeaderCookieCaptureServletHttpFacade.this.cookies.put(new Pair<>(name, path), cookie); } /**