General working Keycloak filter state

This commit is contained in:
AFaust 2019-11-15 17:05:34 +01:00
parent d857dbc9a3
commit ad7f404846
27 changed files with 3985 additions and 21 deletions

15
pom.xml
View File

@ -78,6 +78,7 @@
<apache.httpcore.version>4.4.3</apache.httpcore.version>
<acosix.utility.version>1.0.7.0</acosix.utility.version>
<ootbee.support-tools.version>1.1.0.0</ootbee.support-tools.version>
</properties>
<dependencyManagement>
@ -176,6 +177,20 @@
<classifier>installable</classifier>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.orderofthebee.support-tools</groupId>
<artifactId>support-tools-repo</artifactId>
<version>${ootbee.support-tools.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.orderofthebee.support-tools</groupId>
<artifactId>support-tools-share</artifactId>
<version>${ootbee.support-tools.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
</dependencyManagement>

View File

@ -3,4 +3,6 @@ module.title=${project.name}
module.description=${project.description}
module.version=${noSnapshotVersion}
module.repo.version.min=5
module.repo.version.min=5
module.depends.acosix-utility-core=1.0.3.1-*

View File

@ -28,22 +28,135 @@
<name>Acosix Alfresco Keycloak - Repository Module</name>
<properties>
<!-- Alfresco 6.x bundles an old version of adapter libraries -->
<!-- adapt dependencies to match -->
<keycloak.version>4.6.0.Final</keycloak.version>
<docker.tests.keycloakPort>8380</docker.tests.keycloakPort>
</properties>
<dependencies>
<dependency>
<groupId>org.alfresco</groupId>
<artifactId>alfresco-repository</artifactId>
<artifactId>alfresco-remote-api</artifactId>
</dependency>
<dependency>
<groupId>javax.servlet</groupId>
<artifactId>javax.servlet-api</artifactId>
</dependency>
<!-- not included / packaged by Alfresco 6.x - only SPI -->
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-servlet-filter-adapter</artifactId>
<exclusions>
<exclusion>
<groupId>org.bouncycastle</groupId>
<artifactId>*</artifactId>
</exclusion>
<exclusion>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>*</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>de.acosix.alfresco.utility</groupId>
<artifactId>de.acosix.alfresco.utility.core.repo</artifactId>
<classifier>installable</classifier>
</dependency>
<dependency>
<groupId>org.orderofthebee.support-tools</groupId>
<artifactId>support-tools-repo</artifactId>
</dependency>
</dependencies>
<build>
<pluginManagement>
<plugins>
<!-- some image customisations -->
<!-- Maven + docker-maven-plugin result in somewhat weird inheritance handling -->
<!-- (relying on positional order of images for overrides) -->
<plugin>
<groupId>io.fabric8</groupId>
<artifactId>docker-maven-plugin</artifactId>
<configuration>
<images>
<image>
<!-- no change to postgres image (first image in parent POM) -->
</image>
<image>
<!-- customise repository image (second image in parent POM) -->
<run>
<env>
<DOCKER_HOST_NAME>${docker.tests.host.name}</DOCKER_HOST_NAME>
</env>
<!-- add log directory mount to just the contentstore -->
<!-- (cannot be done in parent POM due to hard requirement on specific project structure -->
<!-- for tests to easily check contentstore files, we also mount alf_data locally, not in a volume -->
<volumes>
<bind>
<volume>${moduleId}-repository-test-contentstore:/usr/local/tomcat/alf_data</volume>
<volume>${project.build.directory}/docker/repository-logs:/usr/local/tomcat/logs</volume>
</bind>
</volumes>
<dependsOn>
<container>postgres</container>
<container>keycloak</container>
</dependsOn>
</run>
</image>
<image>
<!-- no change to Share image (we don't use it) -->
</image>
<image>
<!-- no change to Search image (we don't use it) -->
</image>
<image>
<name>jboss/keycloak</name>
<alias>keycloak</alias>
<run>
<hostname>keycloak</hostname>
<env>
<KEYCLOAK_USER>admin</KEYCLOAK_USER>
<KEYCLOAK_PASSWORD>admin</KEYCLOAK_PASSWORD>
<KEYCLOAK_IMPORT>/tmp/test-realm.json</KEYCLOAK_IMPORT>
<DB_VENDOR>h2</DB_VENDOR>
</env>
<ports>
<port>${docker.tests.keycloakPort}:8080</port>
</ports>
<network>
<mode>custom</mode>
<name>${moduleId}-test</name>
<alias>keycloak</alias>
</network>
<volumes>
<bind>
<volume>${project.build.directory}/docker/test-realm.json:/tmp/test-realm.json</volume>
</bind>
</volumes>
</run>
</image>
</images>
</configuration>
</plugin>
</plugins>
</pluginManagement>
<plugins>
<plugin>
<groupId>io.fabric8</groupId>
<artifactId>docker-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>

View File

@ -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

View File

@ -19,6 +19,32 @@
http://www.springframework.org/schema/beans
http://www.springframework.org/schema/beans/spring-beans-3.0.xsd">
<!-- this is required for this module to work at all -->
<bean id="${moduleId}-enhanceAuthenticationChildApplicationContextManager"
class="de.acosix.alfresco.utility.common.spring.ImplementationClassReplacingBeanDefinitionRegistryPostProcessor">
<property name="enabled" value="true" />
<property name="propertiesSource" ref="global-properties" />
<property name="targetBeanName" value="Authentication" />
<property name="originalClassName" value="org.alfresco.repo.management.subsystems.DefaultChildApplicationContextManager" />
<property name="replacementClassName"
value="de.acosix.alfresco.utility.repo.subsystems.SubsystemChildApplicationContextManager" />
</bean>
<bean name="${moduleId}-sessionIdMapper-ssoToSessionCache" factory-bean="cacheFactory" factory-method="createCache">
<constructor-arg value="cache.${moduleId}.ssoToSessionCache" />
</bean>
<bean name="${moduleId}-sessionIdMapper-sessionToSsoCache" factory-bean="cacheFactory" factory-method="createCache">
<constructor-arg value="cache.${moduleId}.sessionToSsoCache" />
</bean>
<bean name="${moduleId}-sessionIdMapper-principalToSessionCache" factory-bean="cacheFactory" factory-method="createCache">
<constructor-arg value="cache.${moduleId}.principalToSessionCache" />
</bean>
<bean name="${moduleId}-sessionIdMapper-sessionToPrincipalCache" factory-bean="cacheFactory" factory-method="createCache">
<constructor-arg value="cache.${moduleId}.sessionToPrincipalCache" />
</bean>
</beans>

View File

@ -0,0 +1,137 @@
<?xml version='1.0' encoding='UTF-8'?>
<!--
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.
-->
<beans xmlns="http://www.springframework.org/schema/beans" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="
http://www.springframework.org/schema/beans
http://www.springframework.org/schema/beans/spring-beans-3.0.xsd">
<bean id="subsystem-properties" class="de.acosix.alfresco.utility.repo.subsystems.SubsystemEffectivePropertiesFactoryBean">
<property name="subsystemChildApplicationContextManager" ref="Authentication" />
</bean>
<bean id="keycloakAdapterConfig" class="${project.artifactId}.spring.KeycloakAdapterConfigBeanFactory">
<property name="propertiesSource" ref="subsystem-properties" />
<property name="configPropertyPrefix" value="keycloak.adapter" />
</bean>
<bean id="keycloakDeployment" class="${project.artifactId}.spring.KeycloakDeploymentBeanFactory">
<property name="adapterConfig" ref="keycloakAdapterConfig" />
<property name="connectionTimeout" value="${keycloak.authentication.connectionTimeout}" />
<property name="socketTimeout" value="${keycloak.authentication.socketTimeout}" />
</bean>
<bean id="sessionIdMapper" class="${project.artifactId}.authentication.SimpleCacheBackedSessionIdMapper">
<property name="ssoToSession" ref="${moduleId}-sessionIdMapper-ssoToSessionCache" />
<property name="sessionToSso" ref="${moduleId}-sessionIdMapper-sessionToSsoCache" />
<property name="principalToSession" ref="${moduleId}-sessionIdMapper-principalToSessionCache" />
<property name="sessionToPrincipal" ref="${moduleId}-sessionIdMapper-sessionToPrincipalCache" />
</bean>
<bean id="authenticationComponent" class="${project.artifactId}.authentication.KeycloakAuthenticationComponent"
parent="authenticationComponentBase">
<property name="nodeService" ref="nodeService" />
<property name="personService" ref="personService" />
<property name="transactionService" ref="transactionService" />
<property name="active" value="${keycloak.authentication.enabled}" />
<property name="defaultAdministratorUserNameList" value="${keycloak.authentication.defaultAdministratorUserNames}" />
<property name="allowUserNamePasswordLogin" value="${keycloak.authentication.allowUserNamePasswordLogin}" />
<property name="allowGuestLogin" value="${keycloak.authentication.allowGuestLogin}" />
<property name="adapterConfig" ref="keycloakAdapterConfig" />
<property name="connectionTimeout" value="${keycloak.authentication.connectionTimeout}" />
<property name="socketTimeout" value="${keycloak.authentication.socketTimeout}" />
</bean>
<!-- Wrapped version to be used within subsystem -->
<bean id="AuthenticationComponent" class="org.springframework.transaction.interceptor.TransactionProxyFactoryBean">
<property name="proxyInterfaces">
<list>
<value>org.alfresco.repo.security.authentication.AuthenticationComponent</value>
</list>
</property>
<property name="transactionManager" ref="transactionManager" />
<property name="target" ref="authenticationComponent" />
<property name="transactionAttributes">
<props>
<prop key="*">${server.transaction.mode.default}</prop>
</props>
</property>
</bean>
<!-- Authentication service for chaining -->
<bean id="localAuthenticationService" class="org.alfresco.repo.security.authentication.AuthenticationServiceImpl">
<property name="ticketComponent" ref="ticketComponent" />
<property name="authenticationComponent" ref="authenticationComponent" />
<property name="sysAdminParams" ref="sysAdminParams" />
<property name="protectedUsersCache" ref="protectedUsersCache" />
<property name="protectionEnabled" value="${authentication.protection.enabled}" />
<property name="protectionLimit" value="${authentication.protection.limit}" />
<property name="protectionPeriodSeconds" value="${authentication.protection.periodSeconds}" />
<property name="personService" ref="personService" />
</bean>
<bean id="ftpAuthenticator" class="org.alfresco.filesys.auth.ftp.AlfrescoFtpAuthenticator" parent="ftpAuthenticatorBase">
<property name="active" value="${keycloak.authentication.authenticateFTP}" />
</bean>
<bean id="authenticationDao" class="org.alfresco.repo.security.authentication.RepositoryAuthenticationDao">
<property name="nodeService" ref="nodeService" />
<property name="authorityService" ref="authorityService" />
<property name="tenantService" ref="tenantService" />
<property name="namespaceService" ref="namespaceService" />
<property name="compositePasswordEncoder" ref="compositePasswordEncoder" />
<property name="policyComponent" ref="policyComponent" />
<property name="authenticationCache" ref="authenticationCache" />
<property name="singletonCache" ref="immutableSingletonCache" />
<property name="transactionService" ref="transactionService" />
</bean>
<bean id="remoteUserMapper" class="${project.artifactId}.authentication.KeycloakRemoteUserMapper">
<property name="active" value="${keycloak.authentication.enabled}" />
<property name="validationFailureSilent" value="${keycloak.authentication.silentValidationFailure}" />
<property name="keycloakDeployment" ref="keycloakDeployment" />
<property name="personService" ref="PersonService" />
</bean>
<bean id="webscriptAuthenticationFilter" class="org.alfresco.web.app.servlet.WebScriptSSOAuthenticationFilter">
<property name="active" value="${keycloak.authentication.enabled}" />
<property name="authenticationService" ref="AuthenticationService" />
<property name="authenticationComponent" ref="AuthenticationComponent" />
<property name="personService" ref="personService" />
<property name="nodeService" ref="NodeService" />
<property name="transactionService" ref="TransactionService" />
<property name="container" ref="webscripts.container" />
</bean>
<bean id="globalAuthenticationFilter" class="${project.artifactId}.authentication.KeycloakAuthenticationFilter">
<property name="active" value="${keycloak.authentication.sso.enabled}" />
<property name="allowTicketLogon" value="${keycloak.authentication.allowTicketLogons}" />
<property name="allowLocalBasicLogon" value="${keycloak.authentication.allowLocalBasicLogon}" />
<property name="loginPageUrl" value="${keycloak.authentication.loginPageUrl}" />
<property name="bodyBufferLimit" value="${keycloak.authentication.bodyBufferLimit}" />
<property name="sslRedirectPort" value="${keycloak.authentication.sslRedirectPort}" />
<property name="keycloakDeployment" ref="keycloakDeployment" />
<property name="sessionIdMapper" ref="sessionIdMapper" />
<property name="authenticationService" ref="AuthenticationService" />
<property name="authenticationComponent" ref="AuthenticationComponent" />
<property name="authenticationListener" ref="globalAuthenticationListener" />
<property name="personService" ref="personService" />
<property name="nodeService" ref="NodeService" />
<property name="transactionService" ref="TransactionService" />
<property name="remoteUserMapper" ref="RemoteUserMapper" />
</bean>
</beans>

View File

@ -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

View File

@ -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<String, Object> 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;
}
}

View File

@ -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<String> 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);
}
}

View File

@ -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;
}
}

View File

@ -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<String> 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;
}
}

View File

@ -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<Pair<String, String>, javax.servlet.http.Cookie> cookies = new HashMap<>();
protected final Map<String, List<String>> 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<javax.servlet.http.Cookie> getCookies()
{
return new ArrayList<>(this.cookies.values());
}
/**
* @return the headers
*/
public Map<String, List<String>> getHeaders()
{
final Map<String, List<String>> 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
}
}
}

View File

@ -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<String, String> ssoToSession;
protected SimpleCache<String, String> sessionToSso;
protected SimpleCache<String, Set<String>> principalToSession;
protected SimpleCache<String, String> 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<String, String> ssoToSession)
{
this.ssoToSession = ssoToSession;
}
/**
* @param sessionToSso
* the sessionToSso to set
*/
public void setSessionToSso(final SimpleCache<String, String> sessionToSso)
{
this.sessionToSso = sessionToSso;
}
/**
* @param principalToSession
* the principalToSession to set
*/
public void setPrincipalToSession(final SimpleCache<String, Set<String>> principalToSession)
{
this.principalToSession = principalToSession;
}
/**
* @param sessionToPrincipal
* the sessionToPrincipal to set
*/
public void setSessionToPrincipal(final SimpleCache<String, String> 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<String> getUserSessions(final String principal)
{
Set<String> userSessions = Collections.emptySet();
final Set<String> 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<String> 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<String> sessions = this.principalToSession.get(principal);
sessions.remove(session);
if (sessions.isEmpty())
{
this.principalToSession.remove(principal);
}
}
}
}

View File

@ -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<AdapterConfig>, InitializingBean
{
private static final Logger LOGGER = LoggerFactory.getLogger(KeycloakAdapterConfigBeanFactory.class);
private static final Map<String, Method> SETTER_BY_CONFIG_NAME;
private static final Map<String, Class<?>> VALUE_TYPE_BY_CONFIG_NAME;
private static final List<String> CONFIG_NAMES;
static
{
final Map<String, Method> setterByConfigName = new HashMap<>();
final Map<String, Class<?>> valueTypeByConfigName = new HashMap<>();
final List<String> configNames = new ArrayList<>();
final Set<Class<?>> supportedValueTypes = new HashSet<>(Arrays.asList(String.class, Map.class));
final Map<Class<?>, 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<String, Object> loadConfigMap(final String configFieldName)
{
final Map<String, Object> 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;
}
}

View File

@ -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<KeycloakDeployment>, 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;
}
}

View File

@ -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"]

View File

@ -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

View File

@ -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

View File

@ -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 "$@"

View File

@ -0,0 +1,100 @@
<?xml version='1.0' encoding='UTF-8'?>
<!--
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.
-->
<assembly xmlns="http://maven.apache.org/plugins/maven-assembly-plugin/assembly/1.1.3"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/plugins/maven-assembly-plugin/assembly/1.1.3 http://maven.apache.org/xsd/assembly-1.1.3.xsd">
<id>repository-it-docker</id>
<formats>
<format>dir</format>
</formats>
<includeBaseDirectory>false</includeBaseDirectory>
<fileSets>
<fileSet>
<directory>${project.build.directory}</directory>
<outputDirectory>WEB-INF/lib</outputDirectory>
<includes>
<include>${project.artifactId}-${project.version}-installable.jar</include>
</includes>
</fileSet>
<fileSet>
<directory>${project.basedir}/src/test/resources</directory>
<outputDirectory>WEB-INF/classes</outputDirectory>
<includes>
<include>*.properties</include>
<include>**/*.properties</include>
</includes>
<filtered>true</filtered>
<lineEnding>lf</lineEnding>
</fileSet>
<fileSet>
<directory>${project.basedir}/src/test/docker/alfresco</directory>
<outputDirectory>WEB-INF/classes/alfresco</outputDirectory>
<includes>
<include>*</include>
<include>**/*</include>
</includes>
<excludes>
<exclude>*.js</exclude>
<exclude>**/*.js</exclude>
<exclude>*.ftl</exclude>
<exclude>**/*.ftl</exclude>
<exclude>*.keystore</exclude>
<exclude>**/*.keystore</exclude>
</excludes>
<filtered>true</filtered>
<lineEnding>lf</lineEnding>
</fileSet>
<fileSet>
<directory>${project.basedir}/src/test/docker/alfresco</directory>
<outputDirectory>WEB-INF/classes/alfresco</outputDirectory>
<includes>
<include>*.js</include>
<include>**/*.js</include>
<include>*.ftl</include>
<include>**/*.ftl</include>
<include>*.keystore</include>
<include>**/*.keystore</include>
</includes>
</fileSet>
</fileSets>
<dependencySets>
<dependencySet>
<outputDirectory>WEB-INF/lib</outputDirectory>
<includes>
<!-- everything else from Keycloak should already be bundled in Alfresco 6.x -->
<include>org.keycloak:keycloak-servlet-filter-adapter:*</include>
</includes>
<scope>compile</scope>
</dependencySet>
<dependencySet>
<outputDirectory>WEB-INF/lib</outputDirectory>
<includes>
<!-- TODO: Report bug against Maven PatternIncludesArtifactFilter#matchAgainst for incorrect return false-->
<!-- when patterns with 5 tokens are listed in includes (like the installable JAR of Acosix Utility Core Repo), they may prevent evaluation of any additional patterns -->
<!-- this cost me half a day to track down when the following three patterns were sorted last -->
<include>org.orderofthebee.support-tools:*</include>
<include>com.cronutils:*</include>
<include>net.time4j:*</include>
<include>de.acosix.alfresco.utility:de.acosix.alfresco.utility.common:*</include>
<include>de.acosix.alfresco.utility:de.acosix.alfresco.utility.core.repo.quartz1:*</include>
<include>de.acosix.alfresco.utility:de.acosix.alfresco.utility.core.repo.quartz2:*</include>
<include>de.acosix.alfresco.utility:de.acosix.alfresco.utility.core.repo:jar:installable:*</include>
</includes>
<scope>test</scope>
</dependencySet>
</dependencySets>
</assembly>

View File

@ -0,0 +1 @@
# only exists to ensure Maven creates path in project ./target

File diff suppressed because it is too large Load Diff

View File

@ -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);
}
}

View File

@ -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 {}",

View File

@ -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
{

View File

@ -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<Pair<String, String>, 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);
}
/**