Basic working state of repo-tier subsystem

- supports synch of users / groups
- supports configurable / extensible attribute mapping
- supports configurable / extensible filtering
- supports claim / role mapping
- supports Keycloak auth redirect, Bearer and Basic authentication
- bundles newer Keycloak libraries than Alfresco default via shaded
  dependency artifacts
This commit is contained in:
AFaust
2020-01-22 15:18:38 +01:00
parent ad7f404846
commit d82a93f83e
81 changed files with 6563 additions and 459 deletions

View File

@@ -186,7 +186,7 @@
same "printed page" as the copyright notice for easier same "printed page" as the copyright notice for easier
identification within third-party archives. identification within third-party archives.
Copyright 2019 Acosix GmbH Copyright 2019 - 2020 Acosix GmbH
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.

49
pom.xml
View File

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<!-- <!--
Copyright 2019 Acosix GmbH Copyright 2019 - 2020 Acosix GmbH
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@@ -20,8 +20,8 @@
<parent> <parent>
<groupId>de.acosix.alfresco.maven</groupId> <groupId>de.acosix.alfresco.maven</groupId>
<artifactId>de.acosix.alfresco.maven.project.parent-6.1.2</artifactId> <artifactId>de.acosix.alfresco.maven.project.parent-6.0.7</artifactId>
<version>1.2.1-SNAPSHOT</version> <version>1.3.0-SNAPSHOT</version>
</parent> </parent>
<groupId>de.acosix.alfresco.keycloak</groupId> <groupId>de.acosix.alfresco.keycloak</groupId>
@@ -30,7 +30,7 @@
<packaging>pom</packaging> <packaging>pom</packaging>
<name>Acosix Alfresco Keycloak - Parent</name> <name>Acosix Alfresco Keycloak - Parent</name>
<description>Addon to provide Keycloak-related customisations / extensions to out-of-the-box Alfresco authentication and authorization functionality</description> <description>Addon to provide Keycloak-related customisations / extensions to out-of-the-box Alfresco authentication and authorisation functionality</description>
<url>https://github.com/Acosix/alfresco-keycloak</url> <url>https://github.com/Acosix/alfresco-keycloak</url>
<licenses> <licenses>
@@ -68,12 +68,14 @@
<messages.packageId>acosix.keycloak</messages.packageId> <messages.packageId>acosix.keycloak</messages.packageId>
<moduleId>acosix-keycloak</moduleId> <moduleId>acosix-keycloak</moduleId>
<!-- Java 7 is out of support -->
<maven.compiler.source>1.8</maven.compiler.source> <maven.compiler.source>1.8</maven.compiler.source>
<maven.compiler.target>1.8</maven.compiler.target> <maven.compiler.target>1.8</maven.compiler.target>
<maven.shade.version>3.2.1</maven.shade.version>
<keycloak.version>6.0.1</keycloak.version> <keycloak.version>6.0.1</keycloak.version>
<!-- lowest common denominator of Repository / Share in 6.1 --> <resteasy.version>3.6.3.Final</resteasy.version>
<!-- lowest common denominator of Repository / Share in 6.0 -->
<apache.httpclient.version>4.5.1</apache.httpclient.version> <apache.httpclient.version>4.5.1</apache.httpclient.version>
<apache.httpcore.version>4.4.3</apache.httpcore.version> <apache.httpcore.version>4.4.3</apache.httpcore.version>
@@ -125,6 +127,30 @@
<artifactId>keycloak-authz-client</artifactId> <artifactId>keycloak-authz-client</artifactId>
<version>${keycloak.version}</version> <version>${keycloak.version}</version>
</dependency> </dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-admin-client</artifactId>
<version>${keycloak.version}</version>
</dependency>
<dependency>
<groupId>org.jboss.resteasy</groupId>
<artifactId>resteasy-client</artifactId>
<version>${resteasy.version}</version>
</dependency>
<dependency>
<groupId>org.jboss.resteasy</groupId>
<artifactId>resteasy-multipart-provider</artifactId>
<version>${resteasy.version}</version>
</dependency>
<dependency>
<groupId>org.jboss.resteasy</groupId>
<artifactId>resteasy-jackson2-provider</artifactId>
<version>${resteasy.version}</version>
</dependency>
<!-- HttpClient already bundled by both Repository and Share web apps --> <!-- HttpClient already bundled by both Repository and Share web apps -->
<dependency> <dependency>
@@ -205,11 +231,20 @@
</repositories> </repositories>
<build> <build>
<pluginManagement>
<plugins>
<plugin>
<artifactId>maven-shade-plugin</artifactId>
<version>${maven.shade.version}</version>
</plugin>
</plugins>
</pluginManagement>
</build> </build>
<modules> <modules>
<module>repository-dependencies</module>
<module>repository</module> <module>repository</module>
<module>share-dependencies</module>
<module>share</module> <module>share</module>
</modules> </modules>
</project> </project>

View File

@@ -0,0 +1,53 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
<parent>
<artifactId>de.acosix.alfresco.keycloak.parent</artifactId>
<groupId>de.acosix.alfresco.keycloak</groupId>
<version>1.1.0-SNAPSHOT</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>de.acosix.alfresco.keycloak.repo.deps</artifactId>
<name>Acosix Alfresco Keycloak - Repository Dependencies Module</name>
<description>Aggregate (Uber-)JAR of all dependencies for the Acosix Alfresco Keycloak Repository Module</description>
<build>
<pluginManagement>
<plugins>
<plugin>
<artifactId>maven-shade-plugin</artifactId>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
<configuration>
<relocations>
<relocation>
<pattern>org.keycloak</pattern>
<shadedPattern>de.acosix.alfresco.keycloak.repo.deps.keycloak</shadedPattern>
</relocation>
<relocation>
<pattern>org.jboss.logging</pattern>
<shadedPattern>de.acosix.alfresco.keycloak.repo.deps.jboss.logging</shadedPattern>
</relocation>
</relocations>
<transformers>
<transformer />
<transformer />
<transformer>
<addHeader>false</addHeader>
</transformer>
</transformers>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</pluginManagement>
<plugins>
<plugin>
<artifactId>maven-shade-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>

View File

@@ -0,0 +1,127 @@
<?xml version='1.0' encoding='UTF-8'?>
<!--
Copyright 2019 - 2020 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.
-->
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>de.acosix.alfresco.keycloak</groupId>
<artifactId>de.acosix.alfresco.keycloak.parent</artifactId>
<version>1.1.0-SNAPSHOT</version>
</parent>
<artifactId>de.acosix.alfresco.keycloak.repo.deps</artifactId>
<name>Acosix Alfresco Keycloak - Repository Dependencies Module</name>
<description>Aggregate (Uber-)JAR of all dependencies for the Acosix Alfresco Keycloak Repository Module</description>
<dependencies>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-adapter-core</artifactId>
<exclusions>
<exclusion>
<groupId>org.bouncycastle</groupId>
<artifactId>*</artifactId>
</exclusion>
<exclusion>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>*</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-servlet-adapter-spi</artifactId>
<exclusions>
<exclusion>
<groupId>org.bouncycastle</groupId>
<artifactId>*</artifactId>
</exclusion>
<exclusion>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>*</artifactId>
</exclusion>
</exclusions>
</dependency>
<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>
<exclusion>
<groupId>org.apache.httpcomponents</groupId>
<artifactId>*</artifactId>
</exclusion>
</exclusions>
</dependency>
</dependencies>
<build>
<pluginManagement>
<plugins>
<plugin>
<artifactId>maven-shade-plugin</artifactId>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
<configuration>
<relocations>
<relocation>
<pattern>org.keycloak</pattern>
<shadedPattern>de.acosix.alfresco.keycloak.repo.deps.keycloak</shadedPattern>
</relocation>
<relocation>
<pattern>org.jboss.logging</pattern>
<shadedPattern>de.acosix.alfresco.keycloak.repo.deps.jboss.logging</shadedPattern>
</relocation>
</relocations>
<transformers>
<transformer
implementation="org.apache.maven.plugins.shade.resource.ServicesResourceTransformer" />
<transformer
implementation="org.apache.maven.plugins.shade.resource.ApacheLicenseResourceTransformer" />
<transformer
implementation="org.apache.maven.plugins.shade.resource.ApacheNoticeResourceTransformer">
<addHeader>false</addHeader>
</transformer>
</transformers>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</pluginManagement>
<plugins>
<plugin>
<artifactId>maven-shade-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>

View File

@@ -1,6 +1,6 @@
<?xml version='1.0' encoding='UTF-8'?> <?xml version='1.0' encoding='UTF-8'?>
<!-- <!--
Copyright 2019 Acosix GmbH Copyright 2019 - 2020 Acosix GmbH
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@@ -28,9 +28,6 @@
<name>Acosix Alfresco Keycloak - Repository Module</name> <name>Acosix Alfresco Keycloak - Repository Module</name>
<properties> <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> <docker.tests.keycloakPort>8380</docker.tests.keycloakPort>
</properties> </properties>
@@ -39,6 +36,12 @@
<dependency> <dependency>
<groupId>org.alfresco</groupId> <groupId>org.alfresco</groupId>
<artifactId>alfresco-remote-api</artifactId> <artifactId>alfresco-remote-api</artifactId>
<exclusions>
<exclusion>
<groupId>org.keycloak</groupId>
<artifactId>*</artifactId>
</exclusion>
</exclusions>
</dependency> </dependency>
<dependency> <dependency>
@@ -46,17 +49,17 @@
<artifactId>javax.servlet-api</artifactId> <artifactId>javax.servlet-api</artifactId>
</dependency> </dependency>
<!-- not included / packaged by Alfresco 6.x - only SPI -->
<dependency> <dependency>
<groupId>org.keycloak</groupId> <groupId>${project.groupId}</groupId>
<artifactId>keycloak-servlet-filter-adapter</artifactId> <artifactId>de.acosix.alfresco.keycloak.repo.deps</artifactId>
<version>${project.version}</version>
<exclusions> <exclusions>
<exclusion> <exclusion>
<groupId>org.bouncycastle</groupId> <groupId>org.keycloak</groupId>
<artifactId>*</artifactId> <artifactId>*</artifactId>
</exclusion> </exclusion>
<exclusion> <exclusion>
<groupId>com.fasterxml.jackson.core</groupId> <groupId>org.jboss.resteasy</groupId>
<artifactId>*</artifactId> <artifactId>*</artifactId>
</exclusion> </exclusion>
</exclusions> </exclusions>
@@ -115,7 +118,7 @@
<!-- no change to Share image (we don't use it) --> <!-- no change to Share image (we don't use it) -->
</image> </image>
<image> <image>
<!-- no change to Search image (we don't use it) --> <!-- no change to Search image -->
</image> </image>
<image> <image>
<name>jboss/keycloak</name> <name>jboss/keycloak</name>

View File

@@ -0,0 +1,52 @@
<?xml version='1.0' encoding='UTF-8'?>
<!--
Copyright 2019 - 2020 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>amp</id>
<formats>
<format>amp</format>
</formats>
<includeBaseDirectory>false</includeBaseDirectory>
<componentDescriptors>
<componentDescriptor>assemblies/amp-lib-component.xml</componentDescriptor>
<componentDescriptor>assemblies/amp-config-component.xml</componentDescriptor>
<componentDescriptor>assemblies/amp-messages-component.xml</componentDescriptor>
<componentDescriptor>assemblies/amp-repo-webscript-component.xml</componentDescriptor>
<componentDescriptor>assemblies/amp-surf-webscript-component.xml</componentDescriptor>
<componentDescriptor>assemblies/amp-templates-component.xml</componentDescriptor>
<componentDescriptor>assemblies/amp-webapp-component.xml</componentDescriptor>
</componentDescriptors>
<fileSets>
<fileSet>
<directory>${project.basedir}</directory>
<outputDirectory></outputDirectory>
<includes>
<include>*.properties</include>
</includes>
<filtered>true</filtered>
<lineEnding>crlf</lineEnding>
</fileSet>
</fileSets>
<dependencySets>
<dependencySet>
<outputDirectory>lib</outputDirectory>
<includes>
<include>${project.groupId}:${project.artifactId}.deps:*</include>
</includes>
</dependencySet>
</dependencySets>
</assembly>

View File

@@ -1,3 +1,5 @@
${moduleId}.authorityServiceEnhancement.enabled=true
cache.${moduleId}.ssoToSessionCache.maxItems=10000 cache.${moduleId}.ssoToSessionCache.maxItems=10000
cache.${moduleId}.ssoToSessionCache.timeToLiveSeconds=0 cache.${moduleId}.ssoToSessionCache.timeToLiveSeconds=0
cache.${moduleId}.ssoToSessionCache.maxIdleSeconds=0 cache.${moduleId}.ssoToSessionCache.maxIdleSeconds=0
@@ -48,4 +50,18 @@ cache.${moduleId}.sessionToPrincipalCache.readBackupData=false
# explicitly not clearable - should be cleared via Keycloak back-channel action # explicitly not clearable - should be cleared via Keycloak back-channel action
cache.${moduleId}.sessionToPrincipalCache.clearable=false cache.${moduleId}.sessionToPrincipalCache.clearable=false
# replicate, not distribute # replicate, not distribute
cache.${moduleId}.sessionToPrincipalCache.ignite.cache.type=replicated cache.${moduleId}.sessionToPrincipalCache.ignite.cache.type=replicated
cache.${moduleId}.ticketTokenCache.maxItems=10000
cache.${moduleId}.ticketTokenCache.timeToLiveSeconds=0
cache.${moduleId}.ticketTokenCache.maxIdleSeconds=0
cache.${moduleId}.ticketTokenCache.cluster.type=fully-distributed
cache.${moduleId}.ticketTokenCache.backup-count=1
cache.${moduleId}.ticketTokenCache.eviction-policy=LRU
cache.${moduleId}.ticketTokenCache.merge-policy=com.hazelcast.map.merge.PutIfAbsentMapMergePolicy
cache.${moduleId}.ticketTokenCache.readBackupData=false
# dangerous to be cleared, as roles / claims can no longer be mapped
# would always be better to just invalidate the tickets themselves
cache.${moduleId}.ticketTokenCache.clearable=false
# replicate, not distribute
cache.${moduleId}.ticketTokenCache.ignite.cache.type=replicated

View File

@@ -1 +1,2 @@
log4j.logger.${project.artifactId}=INFO log4j.logger.${project.artifactId}=INFO
log4j.logger.${project.artifactId}.deps=ERROR

View File

@@ -1,6 +1,6 @@
<?xml version='1.0' encoding='UTF-8'?> <?xml version='1.0' encoding='UTF-8'?>
<!-- <!--
Copyright 2019 Acosix GmbH Copyright 2019 - 2020 Acosix GmbH
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@@ -31,20 +31,34 @@
value="de.acosix.alfresco.utility.repo.subsystems.SubsystemChildApplicationContextManager" /> value="de.acosix.alfresco.utility.repo.subsystems.SubsystemChildApplicationContextManager" />
</bean> </bean>
<bean name="${moduleId}-sessionIdMapper-ssoToSessionCache" factory-bean="cacheFactory" factory-method="createCache"> <bean id="${moduleId}-enhanceAuthorityService"
class="de.acosix.alfresco.utility.common.spring.ImplementationClassReplacingBeanDefinitionRegistryPostProcessor">
<property name="enabledPropertyKey" value="${moduleId}.authorityServiceEnhancement.enabled" />
<property name="propertiesSource" ref="global-properties" />
<property name="targetBeanName" value="authorityService" />
<property name="originalClassName" value="org.alfresco.repo.security.authority.AuthorityServiceImpl" />
<property name="replacementClassName" value="${project.artifactId}.authority.GrantedAuthorityAwareAuthorityServiceImpl" />
</bean>
<bean name="${moduleId}.ssoToSessionCache" factory-bean="cacheFactory" factory-method="createCache">
<constructor-arg value="cache.${moduleId}.ssoToSessionCache" /> <constructor-arg value="cache.${moduleId}.ssoToSessionCache" />
</bean> </bean>
<bean name="${moduleId}-sessionIdMapper-sessionToSsoCache" factory-bean="cacheFactory" factory-method="createCache"> <bean name="${moduleId}.sessionToSsoCache" factory-bean="cacheFactory" factory-method="createCache">
<constructor-arg value="cache.${moduleId}.sessionToSsoCache" /> <constructor-arg value="cache.${moduleId}.sessionToSsoCache" />
</bean> </bean>
<bean name="${moduleId}-sessionIdMapper-principalToSessionCache" factory-bean="cacheFactory" factory-method="createCache"> <bean name="${moduleId}.principalToSessionCache" factory-bean="cacheFactory" factory-method="createCache">
<constructor-arg value="cache.${moduleId}.principalToSessionCache" /> <constructor-arg value="cache.${moduleId}.principalToSessionCache" />
</bean> </bean>
<bean name="${moduleId}-sessionIdMapper-sessionToPrincipalCache" factory-bean="cacheFactory" factory-method="createCache"> <bean name="${moduleId}.sessionToPrincipalCache" factory-bean="cacheFactory" factory-method="createCache">
<constructor-arg value="cache.${moduleId}.sessionToPrincipalCache" /> <constructor-arg value="cache.${moduleId}.sessionToPrincipalCache" />
</bean> </bean>
<bean name="${moduleId}-ticketTokenCache" factory-bean="cacheFactory" factory-method="createCache">
<constructor-arg value="cache.${moduleId}.ticketTokenCache" />
</bean>
</beans> </beans>

View File

@@ -1,6 +1,6 @@
<?xml version='1.0' encoding='UTF-8'?> <?xml version='1.0' encoding='UTF-8'?>
<!-- <!--
Copyright 2019 Acosix GmbH Copyright 2019 - 2020 Acosix GmbH
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@@ -35,10 +35,10 @@
</bean> </bean>
<bean id="sessionIdMapper" class="${project.artifactId}.authentication.SimpleCacheBackedSessionIdMapper"> <bean id="sessionIdMapper" class="${project.artifactId}.authentication.SimpleCacheBackedSessionIdMapper">
<property name="ssoToSession" ref="${moduleId}-sessionIdMapper-ssoToSessionCache" /> <property name="ssoToSession" ref="${moduleId}.ssoToSessionCache" />
<property name="sessionToSso" ref="${moduleId}-sessionIdMapper-sessionToSsoCache" /> <property name="sessionToSso" ref="${moduleId}.sessionToSsoCache" />
<property name="principalToSession" ref="${moduleId}-sessionIdMapper-principalToSessionCache" /> <property name="principalToSession" ref="${moduleId}.principalToSessionCache" />
<property name="sessionToPrincipal" ref="${moduleId}-sessionIdMapper-sessionToPrincipalCache" /> <property name="sessionToPrincipal" ref="${moduleId}.sessionToPrincipalCache" />
</bean> </bean>
<bean id="authenticationComponent" class="${project.artifactId}.authentication.KeycloakAuthenticationComponent" <bean id="authenticationComponent" class="${project.artifactId}.authentication.KeycloakAuthenticationComponent"
@@ -49,10 +49,11 @@
<property name="active" value="${keycloak.authentication.enabled}" /> <property name="active" value="${keycloak.authentication.enabled}" />
<property name="defaultAdministratorUserNameList" value="${keycloak.authentication.defaultAdministratorUserNames}" /> <property name="defaultAdministratorUserNameList" value="${keycloak.authentication.defaultAdministratorUserNames}" />
<property name="allowUserNamePasswordLogin" value="${keycloak.authentication.allowUserNamePasswordLogin}" /> <property name="allowUserNamePasswordLogin" value="${keycloak.authentication.allowUserNamePasswordLogin}" />
<property name="allowGuestLogin" value="${keycloak.authentication.allowGuestLogin}" /> <property name="allowGuestLogin" value="${keycloak.authentication.failExpiredUserNamePasswordLoginTokens}" />
<property name="adapterConfig" ref="keycloakAdapterConfig" /> <property name="failExpiredTicketTokens" value="${keycloak.authentication.allowGuestLogin}" />
<property name="connectionTimeout" value="${keycloak.authentication.connectionTimeout}" /> <property name="mapRoles" value="${keycloak.authentication.mapRoles}" />
<property name="socketTimeout" value="${keycloak.authentication.socketTimeout}" /> <property name="mapPersonPropertiesOnLogin" value="${keycloak.authentication.mapPersonPropertiesOnLogin}" />
<property name="deployment" ref="keycloakDeployment" />
</bean> </bean>
<!-- Wrapped version to be used within subsystem --> <!-- Wrapped version to be used within subsystem -->
@@ -72,7 +73,7 @@
</bean> </bean>
<!-- Authentication service for chaining --> <!-- Authentication service for chaining -->
<bean id="localAuthenticationService" class="org.alfresco.repo.security.authentication.AuthenticationServiceImpl"> <bean id="localAuthenticationService" class="${project.artifactId}.authentication.KeycloakAuthenticationServiceImpl">
<property name="ticketComponent" ref="ticketComponent" /> <property name="ticketComponent" ref="ticketComponent" />
<property name="authenticationComponent" ref="authenticationComponent" /> <property name="authenticationComponent" ref="authenticationComponent" />
<property name="sysAdminParams" ref="sysAdminParams" /> <property name="sysAdminParams" ref="sysAdminParams" />
@@ -81,6 +82,7 @@
<property name="protectionLimit" value="${authentication.protection.limit}" /> <property name="protectionLimit" value="${authentication.protection.limit}" />
<property name="protectionPeriodSeconds" value="${authentication.protection.periodSeconds}" /> <property name="protectionPeriodSeconds" value="${authentication.protection.periodSeconds}" />
<property name="personService" ref="personService" /> <property name="personService" ref="personService" />
<property name="keycloakTicketTokenCache" ref="${moduleId}-ticketTokenCache" />
</bean> </bean>
<bean id="ftpAuthenticator" class="org.alfresco.filesys.auth.ftp.AlfrescoFtpAuthenticator" parent="ftpAuthenticatorBase"> <bean id="ftpAuthenticator" class="org.alfresco.filesys.auth.ftp.AlfrescoFtpAuthenticator" parent="ftpAuthenticatorBase">
@@ -101,7 +103,7 @@
<bean id="remoteUserMapper" class="${project.artifactId}.authentication.KeycloakRemoteUserMapper"> <bean id="remoteUserMapper" class="${project.artifactId}.authentication.KeycloakRemoteUserMapper">
<property name="active" value="${keycloak.authentication.enabled}" /> <property name="active" value="${keycloak.authentication.enabled}" />
<property name="validationFailureSilent" value="${keycloak.authentication.silentValidationFailure}" /> <property name="validationFailureSilent" value="${keycloak.authentication.silentRemoteUserValidationFailure}" />
<property name="keycloakDeployment" ref="keycloakDeployment" /> <property name="keycloakDeployment" ref="keycloakDeployment" />
<property name="personService" ref="PersonService" /> <property name="personService" ref="PersonService" />
</bean> </bean>
@@ -133,5 +135,77 @@
<property name="nodeService" ref="NodeService" /> <property name="nodeService" ref="NodeService" />
<property name="transactionService" ref="TransactionService" /> <property name="transactionService" ref="TransactionService" />
<property name="remoteUserMapper" ref="RemoteUserMapper" /> <property name="remoteUserMapper" ref="RemoteUserMapper" />
<property name="keycloakAuthenticationComponent" ref="authenticationComponent" />
<property name="keycloakTicketTokenCache" ref="${moduleId}-ticketTokenCache" />
</bean>
<bean id="idmClient" class="${project.artifactId}.client.IDMClientImpl">
<property name="deployment" ref="keycloakDeployment" />
<property name="userName" value="${keycloak.synchronization.user}" />
<property name="password" value="${keycloak.synchronization.password}" />
</bean>
<bean id="userRegistry" class="${project.artifactId}.sync.KeycloakUserRegistry">
<property name="active" value="${keycloak.synchronization.enabled}" />
<property name="idmClient" ref="idmClient" />
<property name="personLoadBatchSize" value="${keycloak.synchronization.personLoadBatchSize}" />
<property name="groupLoadBatchSize" value="${keycloak.synchronization.groupLoadBatchSize}" />
</bean>
<bean id="userAuthority.default" class="${project.artifactId}.authentication.DefaultAuthorityExtractor">
<property name="adapterConfig" ref="keycloakAdapterConfig" />
</bean>
<bean id="userToken.default" class="${project.artifactId}.authentication.DefaultPersonProcessor" />
<bean id="userFilter.containedInGroup" class="${project.artifactId}.sync.GroupContainmentUserFilter">
<property name="idmClient" ref="idmClient" />
</bean>
<bean id="groupFilter.containedInGroup" class="${project.artifactId}.sync.GroupContainmentGroupFilter">
<property name="idmClient" ref="idmClient" />
</bean>
<bean id="authorityMapper.simpleAttributes" abstract="true">
<property name="namespaceService" ref="namespaceService" />
</bean>
<bean id="userMapper.default" class="${project.artifactId}.sync.DefaultPersonProcessor" />
<bean id="groupMapper.default" class="${project.artifactId}.sync.DefaultGroupProcessor" />
<bean id="userMapper.simpleAttributes" parent="authorityMapper.simpleAttributes"
class="${project.artifactId}.sync.SimpleUserAttributeProcessor" />
<bean id="groupMapper.simpleAttributes" parent="authorityMapper.simpleAttributes"
class="${project.artifactId}.sync.SimpleGroupAttributeProcessor" />
<bean id="${moduleId}-dynamicAuthenticationComponentsEmitter"
class="de.acosix.alfresco.utility.common.spring.BeanDefinitionFromPropertiesPostProcessor">
<property name="enabled" value="true" />
<property name="propertyPrefix" value="keycloak.authentication" />
<property name="beanTypes">
<list>
<value>userAuthority</value>
<value>userToken</value>
</list>
</property>
<property name="propertiesSource" ref="subsystem-properties" />
</bean>
<bean id="${moduleId}-dynamicSynchronisationComponentsEmitter"
class="de.acosix.alfresco.utility.common.spring.BeanDefinitionFromPropertiesPostProcessor">
<property name="enabled" value="true" />
<property name="propertyPrefix" value="keycloak.synchronization" />
<property name="beanTypes">
<list>
<value>userFilter</value>
<value>userMapper</value>
<value>groupFilter</value>
<value>groupMapper</value>
</list>
</property>
<property name="propertiesSource" ref="subsystem-properties" />
</bean> </bean>
</beans> </beans>

View File

@@ -4,9 +4,12 @@ keycloak.authentication.defaultAdministratorUserNames=
keycloak.authentication.allowTicketLogons=true keycloak.authentication.allowTicketLogons=true
keycloak.authentication.allowLocalBasicLogon=true keycloak.authentication.allowLocalBasicLogon=true
keycloak.authentication.allowUserNamePasswordLogin=true keycloak.authentication.allowUserNamePasswordLogin=true
keycloak.authentication.failExpiredUserNamePasswordLoginTokens=false
keycloak.authentication.allowGuestLogin=true keycloak.authentication.allowGuestLogin=true
keycloak.authentication.mapRoles=true
keycloak.authentication.mapPersonPropertiesOnLogin=true
keycloak.authentication.authenticateFTP=true keycloak.authentication.authenticateFTP=true
keycloak.authentication.silentValidationFailure=true keycloak.authentication.silentRemoteUserValidationFailure=true
keycloak.authentication.connectionTimeout=-1 keycloak.authentication.connectionTimeout=-1
keycloak.authentication.socketTimeout=-1 keycloak.authentication.socketTimeout=-1
@@ -22,4 +25,75 @@ keycloak.adapter.public-client=false
keycloak.adapter.credentials.provider=secret keycloak.adapter.credentials.provider=secret
keycloak.adapter.credentials.secret= keycloak.adapter.credentials.secret=
# TODO default settings (identical to AdapterConfig defaults) to better align with default Alfresco subsystem property handling # TODO default settings (identical to AdapterConfig defaults) to better align with default Alfresco subsystem property handling
keycloak.authentication.userAuthority.default.property.enabled=true
keycloak.authentication.userAuthority.default.property.processRealmAccess=false
keycloak.authentication.userAuthority.default.property.processResourceAccess=true
keycloak.authentication.userAuthority.default.property.realmAccessAuthorityType=ROLE
keycloak.authentication.userAuthority.default.property.resourceAccessAuthorityType=ROLE
keycloak.authentication.userAuthority.default.property.realmAccessAuthorityNamePrefix=KEYCLOAK
keycloak.authentication.userAuthority.default.property.resourceAccessAuthorityNamePrefix=KEYCLOAK_${keycloak.adapter.resource}
keycloak.authentication.userAuthority.default.property.applyRealmAccessAuthorityCapitalisation=true
keycloak.authentication.userAuthority.default.property.applyResourceAccessAuthorityCapitalisation=true
keycloak.authentication.userAuthority.default.property.realmAccessAuthorityCapitalisationLocale=
keycloak.authentication.userAuthority.default.property.resourceAccessAuthorityCapitalisationLocale=
# user is a default realm role
keycloak.authentication.userAuthority.default.property.realmAccessExplicitMappings.map.user=ROLE_KEYCLOAK_USER
# default role mappings for common roles that might be created for an Alfresco client in Keycloak
keycloak.authentication.userAuthority.default.property.resourceAccessExplicitMappings.map.admin=ROLE_ADMINISTRATOR
keycloak.authentication.userAuthority.default.property.resourceAccessExplicitMappings.map.guest=ROLE_GUEST
keycloak.authentication.userAuthority.default.property.resourceAccessExplicitMappings.map.model-admin=GROUP_MODEL_ADMINISTRATORS
keycloak.authentication.userAuthority.default.property.resourceAccessExplicitMappings.map.search-admin=GROUP_SEARCH_ADMINISTRATORS
keycloak.authentication.userAuthority.default.property.resourceAccessExplicitMappings.map.site-admin=GROUP_SITE_ADMINISTRATORS
keycloak.authentication.userToken.default.property.enabled=true
keycloak.authentication.userToken.default.property.mapNull=true
keycloak.authentication.userToken.default.property.mapGivenName=true
keycloak.authentication.userToken.default.property.mapFamilyName=true
keycloak.authentication.userToken.default.property.mapEmail=true
keycloak.authentication.userToken.default.property.mapPhoneNumber=true
keycloak.authentication.userToken.default.property.mapPhoneNumberAsMobile=false
keycloak.synchronization.enabled=true
keycloak.synchronization.user=
keycloak.synchronization.password=
keycloak.synchronization.personLoadBatchSize=50
keycloak.synchronization.groupLoadBatchSize=50
keycloak.synchronization.userFilter.containedInGroup.property.groupPaths=
keycloak.synchronization.userFilter.containedInGroup.property.groupIds=
keycloak.synchronization.userFilter.containedInGroup.property.requireAll=false
keycloak.synchronization.userFilter.containedInGroup.property.allowTransitive=true
keycloak.synchronization.userFilter.containedInGroup.property.groupLoadBatchSize=${keycloak.synchronization.groupLoadBatchSize}
keycloak.synchronization.groupFilter.containedInGroup.property.groupPaths=
keycloak.synchronization.groupFilter.containedInGroup.property.groupIds=
keycloak.synchronization.groupFilter.containedInGroup.property.requireAll=false
keycloak.synchronization.groupFilter.containedInGroup.property.allowTransitive=true
keycloak.synchronization.userMapper.default.property.enabled=true
keycloak.synchronization.userMapper.default.property.mapNull=true
keycloak.synchronization.userMapper.default.property.mapFirstName=true
keycloak.synchronization.userMapper.default.property.mapLastName=true
keycloak.synchronization.userMapper.default.property.mapEmail=true
keycloak.synchronization.userMapper.default.property.mapEnabledState=true
keycloak.synchronization.userMapper.simpleAttributes.property.enabled=true
keycloak.synchronization.userMapper.simpleAttributes.property.attributePropertyMappings.map.middleName=cm:middleName
keycloak.synchronization.userMapper.simpleAttributes.property.attributePropertyMappings.map.organization=cm:organization
keycloak.synchronization.userMapper.simpleAttributes.property.attributePropertyMappings.map.jobTitle=cm:jobtitle
keycloak.synchronization.userMapper.simpleAttributes.property.attributePropertyMappings.map.location=cm:location
keycloak.synchronization.userMapper.simpleAttributes.property.attributePropertyMappings.map.telephone=cm:telephone
keycloak.synchronization.userMapper.simpleAttributes.property.attributePropertyMappings.map.mobile=cm:mobile
keycloak.synchronization.userMapper.simpleAttributes.property.attributePropertyMappings.map.companyAddress1=cm:companyaddress1
keycloak.synchronization.userMapper.simpleAttributes.property.attributePropertyMappings.map.companyAddress2=cm:companyaddress2
keycloak.synchronization.userMapper.simpleAttributes.property.attributePropertyMappings.map.companyAddress3=cm:companyaddress3
keycloak.synchronization.userMapper.simpleAttributes.property.attributePropertyMappings.map.companyPostCode=cm:companypostcode
keycloak.synchronization.userMapper.simpleAttributes.property.attributePropertyMappings.map.companyTelephone=cm:companytelephone
keycloak.synchronization.userMapper.simpleAttributes.property.attributePropertyMappings.map.companyFax=cm:companyfax
keycloak.synchronization.userMapper.simpleAttributes.property.attributePropertyMappings.map.companyEmail=cm:companyemail
keycloak.synchronization.groupMapper.simpleAttributes.property.enabled=true

View File

@@ -0,0 +1,45 @@
/*
* Copyright 2019 - 2020 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.Set;
import org.alfresco.service.cmr.security.AuthorityService;
import org.alfresco.service.cmr.security.AuthorityType;
import de.acosix.alfresco.keycloak.repo.deps.keycloak.representations.AccessToken;
/**
* Instances of this interface are used to map / extract authorities for an authenticated user from Keycloak authenticated users for use as
* {@link AuthorityService#getAuthorities() authorities of the current user}. Any mapped / extracted authority will be considered to be an
* authority of the user without the need of any explicit authority membership in the node structures of Alfresco - such authorities are
* typically used as global roles.
*
* @author Axel Faust
*/
public interface AuthorityExtractor
{
/**
* Maps / extracts authorities from a Keycloak access token.
*
* @param accessToken
* the Keycloak access token for the authenticated user
* @return the mapped / extracted authorities - never {@code null} and authorities must already include the appropriate prefix for the
* {@link AuthorityType authority type} as which they should be treated
*/
Set<String> extractAuthorities(AccessToken accessToken);
}

View File

@@ -0,0 +1,404 @@
/*
* Copyright 2019 - 2020 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.EnumSet;
import java.util.HashSet;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.alfresco.service.cmr.security.AuthorityType;
import org.alfresco.util.PropertyCheck;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.InitializingBean;
import de.acosix.alfresco.keycloak.repo.deps.keycloak.representations.AccessToken;
import de.acosix.alfresco.keycloak.repo.deps.keycloak.representations.AccessToken.Access;
import de.acosix.alfresco.keycloak.repo.deps.keycloak.representations.adapters.config.AdapterConfig;
/**
* Instances of this class provide a generalised default authority mapping / extraction logic for Keycloak authenticated users. The mapping
* / extraction processes both realm and client- / resource-specific roles, and provides configurable authority name transformation (e.g.
* consistent casing, authority type prefixes, potential subsystem prefixes for differentiation).
*
* @author Axel Faust
*/
public class DefaultAuthorityExtractor implements InitializingBean, AuthorityExtractor
{
private static final Logger LOGGER = LoggerFactory.getLogger(DefaultAuthorityExtractor.class);
protected boolean enabled;
protected boolean processRealmAccess;
protected boolean processResourceAccess;
protected Map<String, String> realmAccessExplicitMappings;
protected Map<String, String> resourceAccessExplicitMappings;
protected AuthorityType realmAccessAuthorityType;
protected AuthorityType resourceAccessAuthorityType;
protected String realmAccessAuthorityNamePrefix;
protected String resourceAccessAuthorityNamePrefix;
protected boolean applyRealmAccessAuthorityCapitalisation;
protected boolean applyResourceAccessAuthorityCapitalisation;
protected String realmAccessAuthorityCapitalisationLocale;
protected String resourceAccessAuthorityCapitalisationLocale;
protected Locale effectiveRealmAccessAuthorityCapitalisationLocale;
protected Locale effectiveResourceAccessAuthorityCapitalisationLocale;
protected AdapterConfig adapterConfig;
/**
*
* {@inheritDoc}
*/
@Override
public void afterPropertiesSet()
{
PropertyCheck.mandatory(this, "realmAccessAuthorityType", this.realmAccessAuthorityType);
PropertyCheck.mandatory(this, "resourceAccessAuthorityType", this.resourceAccessAuthorityType);
PropertyCheck.mandatory(this, "realmAccessAuthorityNamePrefix", this.realmAccessAuthorityNamePrefix);
PropertyCheck.mandatory(this, "resourceAccessAuthorityNamePrefix", this.resourceAccessAuthorityNamePrefix);
PropertyCheck.mandatory(this, "adapterConfig", this.adapterConfig);
final Set<AuthorityType> allowedTypes = EnumSet.of(AuthorityType.ROLE, AuthorityType.GROUP);
if (!allowedTypes.contains(this.realmAccessAuthorityType))
{
throw new IllegalStateException("Only ROLE and GROUP authority types are allowed for realmAccessAuthorityType");
}
if (!allowedTypes.contains(this.resourceAccessAuthorityType))
{
throw new IllegalStateException("Only ROLE and GROUP authority types are allowed for resourceAccessAuthorityType");
}
final Function<String, Locale> localeConversion = capitalisationLocale -> {
final Locale locale;
if (capitalisationLocale != null)
{
final String[] localeFragments = capitalisationLocale.split("[_\\-]");
if (localeFragments.length >= 3)
{
locale = new Locale(localeFragments[0], localeFragments[1], localeFragments[2]);
}
else if (localeFragments.length >= 2)
{
locale = new Locale(localeFragments[0], localeFragments[1]);
}
else
{
locale = new Locale(localeFragments[0]);
}
}
else
{
locale = Locale.getDefault();
}
return locale;
};
this.effectiveRealmAccessAuthorityCapitalisationLocale = localeConversion.apply(this.realmAccessAuthorityCapitalisationLocale);
this.effectiveResourceAccessAuthorityCapitalisationLocale = localeConversion
.apply(this.resourceAccessAuthorityCapitalisationLocale);
}
/**
* @param enabled
* the enabled to set
*/
public void setEnabled(final boolean enabled)
{
this.enabled = enabled;
}
/**
* @param processRealmAccess
* the processRealmAccess to set
*/
public void setProcessRealmAccess(final boolean processRealmAccess)
{
this.processRealmAccess = processRealmAccess;
}
/**
* @param processResourceAccess
* the processResourceAccess to set
*/
public void setProcessResourceAccess(final boolean processResourceAccess)
{
this.processResourceAccess = processResourceAccess;
}
/**
* @param realmAccessExplicitMappings
* the realmAccessExplicitMappings to set
*/
public void setRealmAccessExplicitMappings(final Map<String, String> realmAccessExplicitMappings)
{
this.realmAccessExplicitMappings = realmAccessExplicitMappings;
}
/**
* @param resourceAccessExplicitMappings
* the resourceAccessExplicitMappings to set
*/
public void setResourceAccessExplicitMappings(final Map<String, String> resourceAccessExplicitMappings)
{
this.resourceAccessExplicitMappings = resourceAccessExplicitMappings;
}
/**
* @param realmAccessAuthorityType
* the realmAccessAuthorityType to set
*/
public void setRealmAccessAuthorityType(final AuthorityType realmAccessAuthorityType)
{
this.realmAccessAuthorityType = realmAccessAuthorityType;
}
/**
* @param resourceAccessAuthorityType
* the resourceAccessAuthorityType to set
*/
public void setResourceAccessAuthorityType(final AuthorityType resourceAccessAuthorityType)
{
this.resourceAccessAuthorityType = resourceAccessAuthorityType;
}
/**
* @param realmAccessAuthorityNamePrefix
* the realmAccessAuthorityNamePrefix to set
*/
public void setRealmAccessAuthorityNamePrefix(final String realmAccessAuthorityNamePrefix)
{
this.realmAccessAuthorityNamePrefix = realmAccessAuthorityNamePrefix;
}
/**
* @param resourceAccessAuthorityNamePrefix
* the resourceAccessAuthorityNamePrefix to set
*/
public void setResourceAccessAuthorityNamePrefix(final String resourceAccessAuthorityNamePrefix)
{
this.resourceAccessAuthorityNamePrefix = resourceAccessAuthorityNamePrefix;
}
/**
* @param applyRealmAccessAuthorityCapitalisation
* the applyRealmAccessAuthorityCapitalisation to set
*/
public void setApplyRealmAccessAuthorityCapitalisation(final boolean applyRealmAccessAuthorityCapitalisation)
{
this.applyRealmAccessAuthorityCapitalisation = applyRealmAccessAuthorityCapitalisation;
}
/**
* @param applyResourceAccessAuthorityCapitalisation
* the applyResourceAccessAuthorityCapitalisation to set
*/
public void setApplyResourceAccessAuthorityCapitalisation(final boolean applyResourceAccessAuthorityCapitalisation)
{
this.applyResourceAccessAuthorityCapitalisation = applyResourceAccessAuthorityCapitalisation;
}
/**
* @param realmAccessAuthorityCapitalisationLocale
* the realmAccessAuthorityCapitalisationLocale to set
*/
public void setRealmAccessAuthorityCapitalisationLocale(final String realmAccessAuthorityCapitalisationLocale)
{
this.realmAccessAuthorityCapitalisationLocale = realmAccessAuthorityCapitalisationLocale;
}
/**
* @param resourceAccessAuthorityCapitalisationLocale
* the resourceAccessAuthorityCapitalisationLocale to set
*/
public void setResourceAccessAuthorityCapitalisationLocale(final String resourceAccessAuthorityCapitalisationLocale)
{
this.resourceAccessAuthorityCapitalisationLocale = resourceAccessAuthorityCapitalisationLocale;
}
/**
* @param adapterConfig
* the adapterConfig to set
*/
public void setAdapterConfig(final AdapterConfig adapterConfig)
{
this.adapterConfig = adapterConfig;
}
/**
* {@inheritDoc}
*/
@Override
public Set<String> extractAuthorities(final AccessToken accessToken)
{
Set<String> authorities = Collections.emptySet();
if (this.enabled)
{
if (this.processRealmAccess || this.processResourceAccess)
{
authorities = new HashSet<>();
if (this.processRealmAccess)
{
final Access realmAccess = accessToken.getRealmAccess();
if (realmAccess != null)
{
LOGGER.debug("Mapping authorities from realm access");
final Set<String> realmAuthorites = this.processAccess(realmAccess, this.realmAccessExplicitMappings,
this.realmAccessAuthorityType, this.realmAccessAuthorityNamePrefix,
this.applyRealmAccessAuthorityCapitalisation, this.effectiveRealmAccessAuthorityCapitalisationLocale);
LOGGER.debug("Mapped authorities from realm access: {}", realmAuthorites);
authorities.addAll(realmAuthorites);
}
else
{
LOGGER.debug("No realm access provided in access token");
}
}
else
{
LOGGER.debug("Mapping authorities from realm access is not enabled");
}
if (this.processResourceAccess)
{
final String resource = this.adapterConfig.getResource();
final Access resourceAccess = accessToken.getResourceAccess(resource);
if (resourceAccess != null)
{
LOGGER.debug("Mapping authorities from resource access on {}", resource);
final Set<String> resourceAuthorites = this.processAccess(resourceAccess, this.resourceAccessExplicitMappings,
this.resourceAccessAuthorityType, this.resourceAccessAuthorityNamePrefix,
this.applyResourceAccessAuthorityCapitalisation, this.effectiveResourceAccessAuthorityCapitalisationLocale);
LOGGER.debug("Mapped authorities from resource access on {}: {}", resource, resourceAuthorites);
authorities.addAll(resourceAuthorites);
}
else
{
LOGGER.debug("No resource access for {} provided in access token", resource);
}
}
else
{
LOGGER.debug("Mapping authorities from resource access is not enabled");
}
}
else
{
LOGGER.debug("Mapping authorities is not enabled for either realm or resource access");
}
}
else
{
LOGGER.debug("Mapping authorities from access token is not enabled");
}
return authorities;
}
/**
* Maps / extracts authorities from a Keycloak access representation.
*
* @param access
* the access representation component of an access token
* @param explicitMappings
* the explicit mappings of roles to authorities to consider - an explicit mapping for a role overrides the default mapping
* handling for that role only
* @param authorityType
* the authority type to use for mapped / extracted authorities
* @param prefix
* the static authority name prefix to use for mapped / extracted authorities
* @param capitalisation
* {@code true} if authorities should be standardised on fully capitalised names, {@code false} if names should be left as
* mapped from the access representation
* @param capitalisationLocale
* the locale to use when capitalising authority names
* @return the authorities mapped / extracted from the access representation
*/
protected Set<String> processAccess(final Access access, final Map<String, String> explicitMappings, final AuthorityType authorityType,
final String prefix, final boolean capitalisation, final Locale capitalisationLocale)
{
final Set<String> authorities;
final Set<String> roles = access.getRoles();
if (roles != null && !roles.isEmpty())
{
LOGGER.debug("Access representation contains roles {}", roles);
Stream<String> rolesStream = roles.stream();
if (explicitMappings != null && !explicitMappings.isEmpty())
{
LOGGER.debug("Explicit mappings for roles have been provided");
rolesStream = rolesStream.filter(r -> !explicitMappings.containsKey(r));
}
if (prefix != null && !prefix.isEmpty())
{
rolesStream = rolesStream.map(r -> prefix + "_" + r);
}
rolesStream = rolesStream.map(r -> authorityType.getPrefixString() + r);
if (capitalisation)
{
rolesStream = rolesStream.map(r -> r.toUpperCase(capitalisationLocale));
}
authorities = rolesStream.collect(Collectors.toSet());
LOGGER.debug("Generically mapped authorities: {}", authorities);
if (explicitMappings != null && !explicitMappings.isEmpty())
{
final Set<String> explicitlyMappedAuthorities = roles.stream().filter(explicitMappings::containsKey)
.map(explicitMappings::get).collect(Collectors.toSet());
LOGGER.debug("Explicitly mapped authorities: {}", explicitlyMappedAuthorities);
authorities.addAll(explicitlyMappedAuthorities);
}
}
else
{
LOGGER.debug("Access representation contains no roles");
authorities = Collections.emptySet();
}
return authorities;
}
}

View File

@@ -0,0 +1,140 @@
/*
* Copyright 2019 - 2020 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.Serializable;
import java.util.Map;
import org.alfresco.model.ContentModel;
import org.alfresco.service.namespace.QName;
import de.acosix.alfresco.keycloak.repo.deps.keycloak.representations.AccessToken;
import de.acosix.alfresco.keycloak.repo.deps.keycloak.representations.IDToken;
/**
* This user authentication mapping processor maps the default Alfresco person properties from an authenticated Keycloak user.
*
* @author Axel Faust
*/
public class DefaultPersonProcessor implements UserProcessor
{
protected boolean enabled;
protected boolean mapNull;
protected boolean mapGivenName;
protected boolean mapFamilyName;
protected boolean mapEmail;
protected boolean mapPhoneNumber;
protected boolean mapPhoneNumberAsMobile;
/**
* @param enabled
* the enabled to set
*/
public void setEnabled(final boolean enabled)
{
this.enabled = enabled;
}
/**
* @param mapNull
* the mapNull to set
*/
public void setMapNull(final boolean mapNull)
{
this.mapNull = mapNull;
}
/**
* @param mapGivenName
* the mapGivenName to set
*/
public void setMapGivenName(final boolean mapGivenName)
{
this.mapGivenName = mapGivenName;
}
/**
* @param mapFamilyName
* the mapFamilyName to set
*/
public void setMapFamilyName(final boolean mapFamilyName)
{
this.mapFamilyName = mapFamilyName;
}
/**
* @param mapEmail
* the mapEmail to set
*/
public void setMapEmail(final boolean mapEmail)
{
this.mapEmail = mapEmail;
}
/**
* @param mapPhoneNumber
* the mapPhoneNumber to set
*/
public void setMapPhoneNumber(final boolean mapPhoneNumber)
{
this.mapPhoneNumber = mapPhoneNumber;
}
/**
* @param mapPhoneNumberAsMobile
* the mapPhoneNumberAsMobile to set
*/
public void setMapPhoneNumberAsMobile(final boolean mapPhoneNumberAsMobile)
{
this.mapPhoneNumberAsMobile = mapPhoneNumberAsMobile;
}
/**
*
* {@inheritDoc}
*/
@Override
public void mapUser(final AccessToken accessToken, final IDToken idToken, final Map<QName, Serializable> personNodeProperties)
{
if (this.enabled && idToken != null)
{
if ((this.mapNull || idToken.getGivenName() != null) && this.mapGivenName)
{
personNodeProperties.put(ContentModel.PROP_FIRSTNAME, idToken.getGivenName());
}
if ((this.mapNull || idToken.getFamilyName() != null) && this.mapFamilyName)
{
personNodeProperties.put(ContentModel.PROP_LASTNAME, idToken.getFamilyName());
}
if ((this.mapNull || idToken.getEmail() != null) && this.mapEmail)
{
personNodeProperties.put(ContentModel.PROP_EMAIL, idToken.getEmail());
}
if ((this.mapNull || idToken.getPhoneNumber() != null) && this.mapPhoneNumber)
{
personNodeProperties.put(this.mapPhoneNumberAsMobile ? ContentModel.PROP_MOBILE : ContentModel.PROP_TELEPHONE,
idToken.getPhoneNumber());
}
}
}
}

View File

@@ -1,5 +1,5 @@
/* /*
* Copyright 2019 Acosix GmbH * Copyright 2019 - 2020 Acosix GmbH
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@@ -15,46 +15,95 @@
*/ */
package de.acosix.alfresco.keycloak.repo.authentication; package de.acosix.alfresco.keycloak.repo.authentication;
import java.io.IOException;
import java.io.InputStream;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap; import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.concurrent.TimeUnit; import java.util.Set;
import java.util.stream.Collectors;
import org.alfresco.repo.management.subsystems.ActivateableBean; import org.alfresco.repo.management.subsystems.ActivateableBean;
import org.alfresco.repo.security.authentication.AbstractAuthenticationComponent; import org.alfresco.repo.security.authentication.AbstractAuthenticationComponent;
import org.alfresco.repo.security.authentication.AuthenticationException; import org.alfresco.repo.security.authentication.AuthenticationException;
import org.alfresco.repo.transaction.AlfrescoTransactionSupport;
import org.alfresco.repo.transaction.AlfrescoTransactionSupport.TxnReadState;
import org.alfresco.service.cmr.repository.NodeRef;
import org.alfresco.service.cmr.repository.NodeService;
import org.alfresco.service.namespace.QName;
import org.alfresco.util.PropertyCheck; import org.alfresco.util.PropertyCheck;
import org.keycloak.adapters.HttpClientBuilder; import org.apache.http.HttpEntity;
import org.keycloak.authorization.client.AuthzClient; import org.apache.http.HttpResponse;
import org.keycloak.authorization.client.Configuration; import org.apache.http.NameValuePair;
import org.keycloak.authorization.client.util.HttpResponseException; import org.apache.http.client.HttpClient;
import org.keycloak.representations.adapters.config.AdapterConfig; import org.apache.http.client.entity.UrlEncodedFormEntity;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.message.BasicNameValuePair;
import org.apache.http.util.EntityUtils;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.InitializingBean; import org.springframework.beans.factory.InitializingBean;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
import de.acosix.alfresco.keycloak.repo.deps.keycloak.OAuth2Constants;
import de.acosix.alfresco.keycloak.repo.deps.keycloak.adapters.KeycloakDeployment;
import de.acosix.alfresco.keycloak.repo.deps.keycloak.adapters.ServerRequest;
import de.acosix.alfresco.keycloak.repo.deps.keycloak.adapters.authentication.ClientCredentialsProviderUtils;
import de.acosix.alfresco.keycloak.repo.deps.keycloak.adapters.rotation.AdapterTokenVerifier;
import de.acosix.alfresco.keycloak.repo.deps.keycloak.adapters.rotation.AdapterTokenVerifier.VerifiedTokens;
import de.acosix.alfresco.keycloak.repo.deps.keycloak.common.VerificationException;
import de.acosix.alfresco.keycloak.repo.deps.keycloak.common.util.KeycloakUriBuilder;
import de.acosix.alfresco.keycloak.repo.deps.keycloak.constants.ServiceUrlConstants;
import de.acosix.alfresco.keycloak.repo.deps.keycloak.representations.AccessToken;
import de.acosix.alfresco.keycloak.repo.deps.keycloak.representations.AccessTokenResponse;
import de.acosix.alfresco.keycloak.repo.deps.keycloak.representations.IDToken;
import de.acosix.alfresco.keycloak.repo.deps.keycloak.util.JsonSerialization;
import de.acosix.alfresco.keycloak.repo.util.AlfrescoCompatibilityUtil;
import de.acosix.alfresco.keycloak.repo.util.RefreshableAccessTokenHolder;
import net.sf.acegisecurity.Authentication;
import net.sf.acegisecurity.GrantedAuthority;
import net.sf.acegisecurity.GrantedAuthorityImpl;
import net.sf.acegisecurity.providers.UsernamePasswordAuthenticationToken;
/** /**
* @author Axel Faust * @author Axel Faust
*/ */
public class KeycloakAuthenticationComponent extends AbstractAuthenticationComponent implements ActivateableBean, InitializingBean public class KeycloakAuthenticationComponent extends AbstractAuthenticationComponent
implements InitializingBean, ActivateableBean, ApplicationContextAware
{ {
private static final Logger LOGGER = LoggerFactory.getLogger(KeycloakAuthenticationComponent.class); private static final Logger LOGGER = LoggerFactory.getLogger(KeycloakAuthenticationComponent.class);
protected final ThreadLocal<Boolean> lastTokenResponseStoreEnabled = new ThreadLocal<>();
protected final ThreadLocal<RefreshableAccessTokenHolder> lastTokenResponse = new ThreadLocal<>();
protected boolean active; protected boolean active;
protected ApplicationContext applicationContext;
protected boolean allowUserNamePasswordLogin; protected boolean allowUserNamePasswordLogin;
protected boolean failExpiredTicketTokens;
protected boolean allowGuestLogin; protected boolean allowGuestLogin;
protected AdapterConfig adapterConfig; protected boolean mapRoles;
protected int connectionTimeout; protected boolean mapPersonPropertiesOnLogin;
protected int socketTimeout; protected KeycloakDeployment deployment;
protected Configuration config; protected Collection<AuthorityExtractor> authorityExtractors;
protected AuthzClient authzClient; protected Collection<UserProcessor> userProcessors;
/** /**
* *
@@ -63,54 +112,22 @@ public class KeycloakAuthenticationComponent extends AbstractAuthenticationCompo
@Override @Override
public void afterPropertiesSet() public void afterPropertiesSet()
{ {
PropertyCheck.mandatory(this, "adapterConfig", this.adapterConfig); PropertyCheck.mandatory(this, "applicationContext", this.applicationContext);
PropertyCheck.mandatory(this, "keycloakDeployment", this.deployment);
if (this.allowUserNamePasswordLogin) this.authorityExtractors = Collections
{ .unmodifiableList(new ArrayList<>(this.applicationContext.getBeansOfType(AuthorityExtractor.class, false, true).values()));
Map<String, Object> credentials = this.adapterConfig.getCredentials(); this.userProcessors = Collections
if (credentials != null) .unmodifiableList(new ArrayList<>(this.applicationContext.getBeansOfType(UserProcessor.class, false, true).values()));
{ }
credentials = new HashMap<>(credentials);
}
if (credentials == null || ((!credentials.containsKey("provider") || "secret".equals(credentials.get("provider"))) /**
&& !credentials.containsKey("secret"))) * {@inheritDoc}
{ */
if (credentials == null) @Override
{ public boolean isActive()
credentials = new HashMap<>(); {
} return this.active;
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());
}
}
}
} }
/** /**
@@ -122,6 +139,15 @@ public class KeycloakAuthenticationComponent extends AbstractAuthenticationCompo
this.active = active; this.active = active;
} }
/**
* {@inheritDoc}
*/
@Override
public void setApplicationContext(final ApplicationContext applicationContext)
{
this.applicationContext = applicationContext;
}
/** /**
* @param allowUserNamePasswordLogin * @param allowUserNamePasswordLogin
* the allowUserNamePasswordLogin to set * the allowUserNamePasswordLogin to set
@@ -131,6 +157,15 @@ public class KeycloakAuthenticationComponent extends AbstractAuthenticationCompo
this.allowUserNamePasswordLogin = allowUserNamePasswordLogin; this.allowUserNamePasswordLogin = allowUserNamePasswordLogin;
} }
/**
* @param failExpiredTicketTokens
* the failExpiredTicketTokens to set
*/
public void setFailExpiredTicketTokens(final boolean failExpiredTicketTokens)
{
this.failExpiredTicketTokens = failExpiredTicketTokens;
}
/** /**
* @param allowGuestLogin * @param allowGuestLogin
* the allowGuestLogin to set * the allowGuestLogin to set
@@ -138,42 +173,130 @@ public class KeycloakAuthenticationComponent extends AbstractAuthenticationCompo
public void setAllowGuestLogin(final boolean allowGuestLogin) public void setAllowGuestLogin(final boolean allowGuestLogin)
{ {
this.allowGuestLogin = allowGuestLogin; this.allowGuestLogin = allowGuestLogin;
super.setAllowGuestLogin(Boolean.valueOf(allowGuestLogin));
} }
/** /**
* @param adapterConfig * @param allowGuestLogin
* the adapterConfig to set * the allowGuestLogin 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 @Override
public boolean isActive() public void setAllowGuestLogin(final Boolean allowGuestLogin)
{ {
return this.active; this.setAllowGuestLogin(Boolean.TRUE.equals(allowGuestLogin));
}
/**
* @param mapRoles
* the mapRoles to set
*/
public void setMapRoles(final boolean mapRoles)
{
this.mapRoles = mapRoles;
}
/**
* @param mapPersonPropertiesOnLogin
* the mapPersonPropertiesOnLogin to set
*/
public void setMapPersonPropertiesOnLogin(final boolean mapPersonPropertiesOnLogin)
{
this.mapPersonPropertiesOnLogin = mapPersonPropertiesOnLogin;
}
/**
* @param deployment
* the deployment to set
*/
public void setDeployment(final KeycloakDeployment deployment)
{
this.deployment = deployment;
}
/**
* Enables the thread-local storage of the last access token response and verified tokens beyond the internal needs of
* {@link #authenticateImpl(String, char[]) authenticateImpl}.
*/
public void enableLastTokenStore()
{
this.lastTokenResponseStoreEnabled.set(Boolean.TRUE);
}
/**
* Disables the thread-local storage of the last access token response and verified tokens beyond the internal needs of
* {@link #authenticateImpl(String, char[]) authenticateImpl}.
*/
public void disableLastTokenStore()
{
this.lastTokenResponseStoreEnabled.remove();
this.lastTokenResponse.remove();
}
/**
* Retrieves the last access token response kept in the thread-local storage. This will only return a result if the thread is currently
* in the process of {@link #authenticateImpl(String, char[]) authenticating a user} or {@link #enableLastTokenStore() storage of the
* last response is currently enabled}.
*
* @return the last token response or {@code null} if no response is stored in the thread local for the current thread
*/
public RefreshableAccessTokenHolder getLastTokenResponse()
{
return this.lastTokenResponse.get();
}
/**
* Checks a refreshable access token associated with an authentication ticket, refreshing it if necessary, and failing if the token has
* expired and the component has been configured to not accept expired tokens.
*
* @param ticketToken
* the refreshable access token to refresh
* @return the refreshed access token if a refresh was possible AND necessary, and a new access token has been retrieved from Keycloak -
* will be {@code null} if no refresh has taken place
*/
public RefreshableAccessTokenHolder checkAndRefreshTicketToken(final RefreshableAccessTokenHolder ticketToken)
throws AuthenticationException
{
if (this.failExpiredTicketTokens && ticketToken.isExpired())
{
throw new AuthenticationException("Keycloak access token has expired - authentication ticket is no longer valid");
}
RefreshableAccessTokenHolder result = null;
if (ticketToken.canRefresh() && ticketToken.shouldRefresh(this.deployment.getTokenMinimumTimeToLive()))
{
try
{
final AccessTokenResponse response = ServerRequest.invokeRefresh(this.deployment, ticketToken.getRefreshToken());
final VerifiedTokens tokens = AdapterTokenVerifier.verifyTokens(response.getToken(), response.getIdToken(),
this.deployment);
result = new RefreshableAccessTokenHolder(response, tokens);
}
catch (final ServerRequest.HttpFailure httpFailure)
{
LOGGER.error("Error refreshing Keycloak authentication - {} {}", httpFailure.getStatus(), httpFailure.getError());
throw new AuthenticationException(
"Failed to refresh Keycloak authentication: " + httpFailure.getStatus() + " " + httpFailure.getError());
}
catch (final VerificationException vex)
{
LOGGER.error("Error refreshing Keycloak authentication - access token verification failed", vex);
throw new AuthenticationException("Failed to refresh Keycloak authentication", vex);
}
catch (final IOException ioex)
{
LOGGER.error("Error refreshing Keycloak authentication - unexpected IO exception", ioex);
throw new AuthenticationException("Failed to refresh Keycloak authentication", ioex);
}
}
if (result != null || !ticketToken.isExpired())
{
this.handleUserTokens(result != null ? result.getAccessToken() : ticketToken.getAccessToken(),
result != null ? result.getIdToken() : ticketToken.getIdToken(), false);
}
return result;
} }
/** /**
@@ -187,28 +310,131 @@ public class KeycloakAuthenticationComponent extends AbstractAuthenticationCompo
throw new AuthenticationException("Simple login via user name + password is not allowed"); throw new AuthenticationException("Simple login via user name + password is not allowed");
} }
if (this.authzClient == null) final AccessTokenResponse response;
final VerifiedTokens tokens;
try
{ {
try response = this.getAccessTokenImpl(userName, new String(password));
tokens = AdapterTokenVerifier.verifyTokens(response.getToken(), response.getIdToken(), this.deployment);
// for potential one-off authentication, we do not care particularly about the token TTL - so no validation here
if (Boolean.TRUE.equals(this.lastTokenResponseStoreEnabled.get()))
{ {
this.authzClient = AuthzClient.create(this.config); this.lastTokenResponse.set(new RefreshableAccessTokenHolder(response, tokens));
} }
catch (final RuntimeException e) }
catch (final VerificationException vex)
{
LOGGER.error("Error authenticating against Keycloak - access token verification failed", vex);
throw new AuthenticationException("Failed to authenticate against Keycloak", vex);
}
catch (final IOException ioex)
{
LOGGER.error("Error authenticating against Keycloak - unexpected IO exception", ioex);
throw new AuthenticationException("Failed to authenticate against Keycloak", ioex);
}
this.setCurrentUser(userName);
this.handleUserTokens(tokens.getAccessToken(), tokens.getIdToken(), true);
}
/**
* Processes tokens for authenticated users, mapping them to Alfresco person properties or granted authorities as configured for this
* instance.
*
* @param accessToken
* the access token
* @param idToken
* the ID token
* @param freshLogin
* {@code true} if the tokens are fresh, that is have just been obtained from an initial login, {@code false} otherwise -
* Alfresco person node properties will only be mapped for fresh tokens, while granted authorities processors will always be
* handled if enabled
*/
public void handleUserTokens(final AccessToken accessToken, final IDToken idToken, final boolean freshLogin)
{
if (this.mapRoles)
{
LOGGER.debug("Mapping roles from Keycloak to user authorities");
final Set<String> mappedAuthorities = new HashSet<>();
this.authorityExtractors.stream().map(extractor -> extractor.extractAuthorities(accessToken))
.forEach(mappedAuthorities::addAll);
LOGGER.debug("Mapped user authorities from roles: {}", mappedAuthorities);
if (!mappedAuthorities.isEmpty())
{ {
LOGGER.warn("Failed to pre-instantiate Keycloak authz client", e); final Authentication currentAuthentication = this.getCurrentAuthentication();
throw new AuthenticationException("Keycloak authentication cannot be performed", e); if (currentAuthentication instanceof UsernamePasswordAuthenticationToken)
{
GrantedAuthority[] grantedAuthorities = currentAuthentication.getAuthorities();
final List<GrantedAuthority> grantedAuthoritiesL = new ArrayList<>(Arrays.asList(grantedAuthorities));
mappedAuthorities.stream().map(GrantedAuthorityImpl::new).forEach(grantedAuthoritiesL::add);
grantedAuthorities = grantedAuthoritiesL.toArray(new GrantedAuthority[0]);
((UsernamePasswordAuthenticationToken) currentAuthentication).setAuthorities(grantedAuthorities);
}
else
{
LOGGER.warn(
"Authentication for user is not of the expected type {} - roles from Keycloak cannot be mapped to granted authorities",
UsernamePasswordAuthenticationToken.class);
}
} }
} }
try if (freshLogin && this.mapPersonPropertiesOnLogin)
{ {
this.authzClient.obtainAccessToken(userName, new String(password)); final boolean requiresNew = AlfrescoTransactionSupport.getTransactionReadState() == TxnReadState.TXN_READ_ONLY;
this.setCurrentUser(userName); this.getTransactionService().getRetryingTransactionHelper().doInTransaction(() -> {
this.updatePerson(accessToken, idToken);
return null;
}, false, requiresNew);
} }
catch (final HttpResponseException e) }
/**
* Updates the person for the current user with data mapped from the Keycloak tokens.
*
* @param accessToken
* the access token
* @param idToken
* the ID token
*/
protected void updatePerson(final AccessToken accessToken, final IDToken idToken)
{
final String userName = this.getCurrentUserName();
LOGGER.debug("Mapping person property updates for user {}", AlfrescoCompatibilityUtil.maskUsername(userName));
final NodeRef person = this.getPersonService().getPerson(userName);
final Map<QName, Serializable> updates = new HashMap<>();
this.userProcessors.forEach(processor -> processor.mapUser(accessToken, idToken != null ? idToken : accessToken, updates));
LOGGER.debug("Determined property updates for person node of user {}", AlfrescoCompatibilityUtil.maskUsername(userName));
final Set<QName> propertiesToRemove = updates.keySet().stream().filter(k -> updates.get(k) == null).collect(Collectors.toSet());
updates.keySet().removeAll(propertiesToRemove);
final NodeService nodeService = this.getNodeService();
final Map<QName, Serializable> currentProperties = nodeService.getProperties(person);
propertiesToRemove.retainAll(currentProperties.keySet());
if (!propertiesToRemove.isEmpty())
{ {
LOGGER.debug("Failed to authenticate user against Keycloak. Status: {} Reason: {}", e.getStatusCode(), e.getReasonPhrase()); // there is no bulk-remove, so we need to use setProperties to achieve a single update event
throw new AuthenticationException("Failed to authenticate user against Keycloak.", e); final Map<QName, Serializable> newProperties = new HashMap<>(currentProperties);
newProperties.putAll(updates);
newProperties.keySet().removeAll(propertiesToRemove);
nodeService.setProperties(person, newProperties);
}
else if (!updates.isEmpty())
{
nodeService.addProperties(person, updates);
} }
} }
@@ -220,4 +446,75 @@ public class KeycloakAuthenticationComponent extends AbstractAuthenticationCompo
{ {
return this.allowGuestLogin; return this.allowGuestLogin;
} }
/**
* Retrieves an OIDC access token with the specific token request parameter up to the caller to define via the provided consumer.
*
* @param userName
* the user to use for synchronisation access
* @param password
* the password of the user
* @return the access token
* @throws IOException
* when errors occur in the HTTP interaction
*/
// implementing this method locally avoids having the dependency on Keycloak authz-client
// authz-client does not support refresh, so would be of limited value anyway
protected AccessTokenResponse getAccessTokenImpl(final String userName, final String password) throws IOException
{
AccessTokenResponse tokenResponse = null;
final HttpClient client = this.deployment.getClient();
final HttpPost post = new HttpPost(KeycloakUriBuilder.fromUri(this.deployment.getAuthServerBaseUrl())
.path(ServiceUrlConstants.TOKEN_PATH).build(this.deployment.getRealm()));
final List<NameValuePair> formParams = new ArrayList<>();
formParams.add(new BasicNameValuePair(OAuth2Constants.GRANT_TYPE, OAuth2Constants.PASSWORD));
formParams.add(new BasicNameValuePair("username", userName));
formParams.add(new BasicNameValuePair("password", password));
ClientCredentialsProviderUtils.setClientCredentials(this.deployment, post, formParams);
final UrlEncodedFormEntity form = new UrlEncodedFormEntity(formParams, "UTF-8");
post.setEntity(form);
final HttpResponse response = client.execute(post);
final int status = response.getStatusLine().getStatusCode();
final HttpEntity entity = response.getEntity();
if (status != 200)
{
final String statusReason = response.getStatusLine().getReasonPhrase();
LOGGER.debug("Failed to retrieve access token due to HTTP {}: {}", status, statusReason);
EntityUtils.consumeQuietly(entity);
LOGGER.debug("Failed to authenticate user against Keycloak. Status: {} Reason: {}", status, statusReason);
throw new AuthenticationException("Failed to authenticate against Keycloak - Status: " + status + ", Reason: " + statusReason);
}
if (entity == null)
{
LOGGER.debug("Failed to authenticate against Keycloak - Response did not contain a message body");
throw new AuthenticationException("Failed to authenticate against Keycloak - Response did not contain a message body");
}
final InputStream is = entity.getContent();
try
{
tokenResponse = JsonSerialization.readValue(is, AccessTokenResponse.class);
}
finally
{
try
{
is.close();
}
catch (final IOException e)
{
LOGGER.trace("Error closing entity stream", e);
}
}
return tokenResponse;
}
} }

View File

@@ -1,5 +1,5 @@
/* /*
* Copyright 2019 Acosix GmbH * Copyright 2019 - 2020 Acosix GmbH
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@@ -33,9 +33,9 @@ import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession; import javax.servlet.http.HttpSession;
import org.alfresco.repo.SessionUser; import org.alfresco.repo.SessionUser;
import org.alfresco.repo.cache.SimpleCache;
import org.alfresco.repo.management.subsystems.ActivateableBean; import org.alfresco.repo.management.subsystems.ActivateableBean;
import org.alfresco.repo.security.authentication.AuthenticationException; 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.security.authentication.Authorization;
import org.alfresco.repo.web.auth.BasicAuthCredentials; import org.alfresco.repo.web.auth.BasicAuthCredentials;
import org.alfresco.repo.web.auth.TicketCredentials; import org.alfresco.repo.web.auth.TicketCredentials;
@@ -48,25 +48,29 @@ import org.alfresco.util.PropertyCheck;
import org.apache.commons.codec.binary.Base64; import org.apache.commons.codec.binary.Base64;
import org.apache.commons.logging.Log; import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory; 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.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.InitializingBean; import org.springframework.beans.factory.InitializingBean;
import de.acosix.alfresco.keycloak.repo.deps.keycloak.KeycloakSecurityContext;
import de.acosix.alfresco.keycloak.repo.deps.keycloak.adapters.AdapterDeploymentContext;
import de.acosix.alfresco.keycloak.repo.deps.keycloak.adapters.AuthenticatedActionsHandler;
import de.acosix.alfresco.keycloak.repo.deps.keycloak.adapters.KeycloakDeployment;
import de.acosix.alfresco.keycloak.repo.deps.keycloak.adapters.OidcKeycloakAccount;
import de.acosix.alfresco.keycloak.repo.deps.keycloak.adapters.PreAuthActionsHandler;
import de.acosix.alfresco.keycloak.repo.deps.keycloak.adapters.RefreshableKeycloakSecurityContext;
import de.acosix.alfresco.keycloak.repo.deps.keycloak.adapters.servlet.FilterRequestAuthenticator;
import de.acosix.alfresco.keycloak.repo.deps.keycloak.adapters.servlet.OIDCFilterSessionStore;
import de.acosix.alfresco.keycloak.repo.deps.keycloak.adapters.servlet.OIDCServletHttpFacade;
import de.acosix.alfresco.keycloak.repo.deps.keycloak.adapters.spi.AuthOutcome;
import de.acosix.alfresco.keycloak.repo.deps.keycloak.adapters.spi.AuthenticationError;
import de.acosix.alfresco.keycloak.repo.deps.keycloak.adapters.spi.KeycloakAccount;
import de.acosix.alfresco.keycloak.repo.deps.keycloak.adapters.spi.SessionIdMapper;
import de.acosix.alfresco.keycloak.repo.deps.keycloak.adapters.spi.UserSessionManagement;
import de.acosix.alfresco.keycloak.repo.deps.keycloak.representations.AccessToken;
import de.acosix.alfresco.keycloak.repo.util.AlfrescoCompatibilityUtil;
import de.acosix.alfresco.keycloak.repo.util.RefreshableAccessTokenHolder;
/** /**
* This class provides a Keycloak-based authentication filter which can be used in the role of both global and WebDAV authentication filter. * This class provides a Keycloak-based authentication filter which can be used in the role of both global and WebDAV authentication filter.
* *
@@ -108,6 +112,10 @@ public class KeycloakAuthenticationFilter extends BaseAuthenticationFilter
protected AdapterDeploymentContext deploymentContext; protected AdapterDeploymentContext deploymentContext;
protected KeycloakAuthenticationComponent keycloakAuthenticationComponent;
protected SimpleCache<String, RefreshableAccessTokenHolder> keycloakTicketTokenCache;
/** /**
* {@inheritDoc} * {@inheritDoc}
*/ */
@@ -116,6 +124,8 @@ public class KeycloakAuthenticationFilter extends BaseAuthenticationFilter
{ {
PropertyCheck.mandatory(this, "keycloakDeployment", this.keycloakDeployment); PropertyCheck.mandatory(this, "keycloakDeployment", this.keycloakDeployment);
PropertyCheck.mandatory(this, "sessionIdMapper", this.sessionIdMapper); PropertyCheck.mandatory(this, "sessionIdMapper", this.sessionIdMapper);
PropertyCheck.mandatory(this, "keycloakTicketTokenCache", this.keycloakTicketTokenCache);
PropertyCheck.mandatory(this, "keycloakAuthenticationComponent", this.keycloakAuthenticationComponent);
// parent class does not check, so we do // parent class does not check, so we do
PropertyCheck.mandatory(this, "authenticationService", this.authenticationService); PropertyCheck.mandatory(this, "authenticationService", this.authenticationService);
@@ -209,6 +219,24 @@ public class KeycloakAuthenticationFilter extends BaseAuthenticationFilter
this.sessionIdMapper = sessionIdMapper; this.sessionIdMapper = sessionIdMapper;
} }
/**
* @param keycloakAuthenticationComponent
* the keycloakAuthenticationComponent to set
*/
public void setKeycloakAuthenticationComponent(final KeycloakAuthenticationComponent keycloakAuthenticationComponent)
{
this.keycloakAuthenticationComponent = keycloakAuthenticationComponent;
}
/**
* @param keycloakTicketTokenCache
* the keycloakTicketTokenCache to set
*/
public void setKeycloakTicketTokenCache(final SimpleCache<String, RefreshableAccessTokenHolder> keycloakTicketTokenCache)
{
this.keycloakTicketTokenCache = keycloakTicketTokenCache;
}
/** /**
* *
* {@inheritDoc} * {@inheritDoc}
@@ -289,7 +317,7 @@ public class KeycloakAuthenticationFilter extends BaseAuthenticationFilter
this.authenticationService.getCurrentTicket(), false); this.authenticationService.getCurrentTicket(), false);
LOGGER.debug("Authenticated user {} via HTTP Basic authentication using an authentication ticket", LOGGER.debug("Authenticated user {} via HTTP Basic authentication using an authentication ticket",
AuthenticationUtil.maskUsername(this.authenticationService.getCurrentUserName())); AlfrescoCompatibilityUtil.maskUsername(this.authenticationService.getCurrentUserName()));
this.authenticationListener.userAuthenticated(new TicketCredentials(password)); this.authenticationListener.userAuthenticated(new TicketCredentials(password));
@@ -310,7 +338,7 @@ public class KeycloakAuthenticationFilter extends BaseAuthenticationFilter
this.authenticationService.getCurrentTicket(), false); this.authenticationService.getCurrentTicket(), false);
LOGGER.debug("Authenticated user {} via HTTP Basic authentication using locally stored credentials", LOGGER.debug("Authenticated user {} via HTTP Basic authentication using locally stored credentials",
AuthenticationUtil.maskUsername(this.authenticationService.getCurrentUserName())); AlfrescoCompatibilityUtil.maskUsername(this.authenticationService.getCurrentUserName()));
this.authenticationListener.userAuthenticated(new BasicAuthCredentials(userName, password)); this.authenticationListener.userAuthenticated(new BasicAuthCredentials(userName, password));
@@ -451,14 +479,28 @@ public class KeycloakAuthenticationFilter extends BaseAuthenticationFilter
final AccessToken accessToken = keycloakSecurityContext.getToken(); final AccessToken accessToken = keycloakSecurityContext.getToken();
final String userId = accessToken.getPreferredUsername(); final String userId = accessToken.getPreferredUsername();
LOGGER.debug("User {} successfully authenticated via Keycloak", AuthenticationUtil.maskUsername(userId)); LOGGER.debug("User {} successfully authenticated via Keycloak", AlfrescoCompatibilityUtil.maskUsername(userId));
final SessionUser sessionUser = this.createUserEnvironment(session, userId); final SessionUser sessionUser = this.createUserEnvironment(session, userId);
this.keycloakAuthenticationComponent.handleUserTokens(accessToken, keycloakSecurityContext.getIdToken(), true);
// need different attribute name than default for integration with web scripts framework // need different attribute name than default for integration with web scripts framework
// default attribute name seems to be no longer used // default attribute name seems to be no longer used
session.setAttribute(AuthenticationDriver.AUTHENTICATION_USER, sessionUser); session.setAttribute(AuthenticationDriver.AUTHENTICATION_USER, sessionUser);
this.authenticationListener.userAuthenticated(new KeycloakCredentials(accessToken)); this.authenticationListener.userAuthenticated(new KeycloakCredentials(accessToken));
// store tokens in cache as well for ticket validation
// -> necessary i.e. because web script RemoteUserAuthenticator is "evil"
// it throws away any authentication from authentication filters like this,
// and re-validates via the ticket in the session user
final RefreshableAccessTokenHolder tokenHolder = new RefreshableAccessTokenHolder(keycloakSecurityContext.getToken(),
keycloakSecurityContext.getIdToken(), keycloakSecurityContext.getTokenString(),
keycloakSecurityContext instanceof RefreshableKeycloakSecurityContext
? ((RefreshableKeycloakSecurityContext) keycloakSecurityContext).getRefreshToken()
: null);
this.keycloakTicketTokenCache.put(sessionUser.getTicket(), tokenHolder);
} }
if (facade.isEnded()) if (facade.isEnded())
@@ -555,7 +597,7 @@ public class KeycloakAuthenticationFilter extends BaseAuthenticationFilter
&& !this.sessionIdMapper.hasSession(session.getId())) && !this.sessionIdMapper.hasSession(session.getId()))
{ {
LOGGER.debug("Session {} for Keycloak-authenticated user {} was invalidated by back-channel logout", session.getId(), LOGGER.debug("Session {} for Keycloak-authenticated user {} was invalidated by back-channel logout", session.getId(),
AuthenticationUtil.maskUsername(sessionUser.getUserName())); AlfrescoCompatibilityUtil.maskUsername(sessionUser.getUserName()));
this.invalidateSession(req); this.invalidateSession(req);
session = req.getSession(false); session = req.getSession(false);
} }
@@ -600,7 +642,7 @@ public class KeycloakAuthenticationFilter extends BaseAuthenticationFilter
final KeycloakAccount keycloakAccount = (KeycloakAccount) session.getAttribute(KeycloakAccount.class.getName()); final KeycloakAccount keycloakAccount = (KeycloakAccount) session.getAttribute(KeycloakAccount.class.getName());
if (keycloakAccount != null) if (keycloakAccount != null)
{ {
skip = this.validateAndRefreshKeycloakAuthentication(req, res, sessionUser.getUserName(), keycloakAccount); skip = this.validateAndRefreshKeycloakAuthentication(req, res, sessionUser.getUserName());
} }
else else
{ {
@@ -623,18 +665,40 @@ public class KeycloakAuthenticationFilter extends BaseAuthenticationFilter
* the HTTP servlet response * the HTTP servlet response
* @param userId * @param userId
* the ID of the authenticated user * 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 * @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 * 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, protected boolean validateAndRefreshKeycloakAuthentication(final HttpServletRequest req, final HttpServletResponse res,
final String userId, final KeycloakAccount keycloakAccount) final String userId)
{ {
final OIDCServletHttpFacade facade = new OIDCServletHttpFacade(req, res); final OIDCServletHttpFacade facade = new OIDCServletHttpFacade(req, res);
final OIDCFilterSessionStore tokenStore = new OIDCFilterSessionStore(req, facade, final OIDCFilterSessionStore tokenStore = new OIDCFilterSessionStore(req, facade,
this.bodyBufferLimit > 0 ? this.bodyBufferLimit : DEFAULT_BODY_BUFFER_LIMIT, this.keycloakDeployment, null); this.bodyBufferLimit > 0 ? this.bodyBufferLimit : DEFAULT_BODY_BUFFER_LIMIT, this.keycloakDeployment, null)
{
/**
*
* {@inheritDoc}
*/
@Override
public void refreshCallback(final RefreshableKeycloakSecurityContext securityContext)
{
// store tokens in cache as well for ticket validation
// -> necessary i.e. because web script RemoteUserAuthenticator is "evil"
// it throws away any authentication from authentication filters like this,
// and re-validates via the ticket in the session user
final SessionUser user = (SessionUser) req.getSession()
.getAttribute(KeycloakAuthenticationFilter.this.getUserAttributeName());
if (user != null)
{
final RefreshableAccessTokenHolder tokenHolder = new RefreshableAccessTokenHolder(securityContext.getToken(),
securityContext.getIdToken(), securityContext.getTokenString(), securityContext.getRefreshToken());
KeycloakAuthenticationFilter.this.keycloakTicketTokenCache.put(user.getTicket(), tokenHolder);
}
}
};
final String oldSessionId = req.getSession().getId(); final String oldSessionId = req.getSession().getId();
@@ -645,6 +709,15 @@ public class KeycloakAuthenticationFilter extends BaseAuthenticationFilter
boolean skip = false; boolean skip = false;
if (currentSession != null) if (currentSession != null)
{ {
final Object keycloakAccount = currentSession.getAttribute(KeycloakAccount.class.getName());
if (keycloakAccount instanceof OidcKeycloakAccount)
{
final KeycloakSecurityContext keycloakSecurityContext = ((OidcKeycloakAccount) keycloakAccount)
.getKeycloakSecurityContext();
this.keycloakAuthenticationComponent.handleUserTokens(keycloakSecurityContext.getToken(),
keycloakSecurityContext.getIdToken(), false);
}
LOGGER.trace("Skipping doFilter as Keycloak-authentication session is still valid"); LOGGER.trace("Skipping doFilter as Keycloak-authentication session is still valid");
skip = true; skip = true;
} }
@@ -652,7 +725,7 @@ public class KeycloakAuthenticationFilter extends BaseAuthenticationFilter
{ {
this.sessionIdMapper.removeSession(oldSessionId); this.sessionIdMapper.removeSession(oldSessionId);
LOGGER.debug("Keycloak-authenticated session for user {} was invalidated after token expiration", LOGGER.debug("Keycloak-authenticated session for user {} was invalidated after token expiration",
AuthenticationUtil.maskUsername(userId)); AlfrescoCompatibilityUtil.maskUsername(userId));
} }
return skip; return skip;
} }
@@ -704,7 +777,7 @@ public class KeycloakAuthenticationFilter extends BaseAuthenticationFilter
this.authenticationService.getCurrentTicket(), true); this.authenticationService.getCurrentTicket(), true);
LOGGER.debug("Authenticated user {} via URL-provided authentication ticket", LOGGER.debug("Authenticated user {} via URL-provided authentication ticket",
AuthenticationUtil.maskUsername(this.authenticationService.getCurrentUserName())); AlfrescoCompatibilityUtil.maskUsername(this.authenticationService.getCurrentUserName()));
this.authenticationListener.userAuthenticated(new TicketCredentials(ticket)); this.authenticationListener.userAuthenticated(new TicketCredentials(ticket));
} }

View File

@@ -0,0 +1,152 @@
/*
* Copyright 2019 - 2020 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.cache.SimpleCache;
import org.alfresco.repo.security.authentication.AuthenticationException;
import org.alfresco.repo.security.authentication.AuthenticationServiceImpl;
import org.alfresco.repo.security.authentication.AuthenticationUtil;
import org.alfresco.repo.security.authentication.TicketComponent;
import org.alfresco.util.PropertyCheck;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.InitializingBean;
import de.acosix.alfresco.keycloak.repo.util.AlfrescoCompatibilityUtil;
import de.acosix.alfresco.keycloak.repo.util.RefreshableAccessTokenHolder;
/**
* Instances of this specialised authentication service sub-class keep track of Keycloak token responses to password-based logins, and
* validate / refresh the Keycloak session whenever the associated user authentication ticket is validated.
*
* @author Axel Faust
*/
public class KeycloakAuthenticationServiceImpl extends AuthenticationServiceImpl implements InitializingBean
{
private static final Logger LOGGER = LoggerFactory.getLogger(KeycloakAuthenticationServiceImpl.class);
// need to copy these fields from base class because they are package-protected there
protected KeycloakAuthenticationComponent authenticationComponent;
protected TicketComponent ticketComponent;
protected SimpleCache<String, RefreshableAccessTokenHolder> keycloakTicketTokenCache;
/**
*
* {@inheritDoc}
*/
@Override
public void afterPropertiesSet()
{
PropertyCheck.mandatory(this, "authenticationComponent", this.authenticationComponent);
PropertyCheck.mandatory(this, "ticketComponent", this.ticketComponent);
PropertyCheck.mandatory(this, "keycloakTicketTokenCache", this.keycloakTicketTokenCache);
}
/**
* @param authenticationComponent
* the authenticationComponent to set
*/
public void setAuthenticationComponent(final KeycloakAuthenticationComponent authenticationComponent)
{
this.authenticationComponent = authenticationComponent;
super.setAuthenticationComponent(authenticationComponent);
}
/**
* {@inheritDoc}
*/
@Override
public void setTicketComponent(final TicketComponent ticketComponent)
{
this.ticketComponent = ticketComponent;
super.setTicketComponent(ticketComponent);
}
/**
* @param keycloakTicketTokenCache
* the keycloakTicketTokenCache to set
*/
public void setKeycloakTicketTokenCache(final SimpleCache<String, RefreshableAccessTokenHolder> keycloakTicketTokenCache)
{
this.keycloakTicketTokenCache = keycloakTicketTokenCache;
}
/**
* {@inheritDoc}
*/
@Override
public void authenticate(final String userName, final char[] password) throws AuthenticationException
{
this.authenticationComponent.enableLastTokenStore();
try
{
super.authenticate(userName, password);
final RefreshableAccessTokenHolder lastTokenResponse = this.authenticationComponent.getLastTokenResponse();
if (lastTokenResponse != null)
{
final String currentTicket = this.getCurrentTicket();
LOGGER.debug("Associating ticket {} for user {} with Keycloak access token", currentTicket,
AlfrescoCompatibilityUtil.maskUsername(userName));
this.keycloakTicketTokenCache.put(currentTicket, lastTokenResponse);
}
}
finally
{
this.authenticationComponent.disableLastTokenStore();
}
}
/**
*
* {@inheritDoc}
*/
@Override
public void validate(final String ticket) throws AuthenticationException
{
super.validate(ticket);
if (this.keycloakTicketTokenCache.contains(ticket))
{
final RefreshableAccessTokenHolder refreshableAccessToken = this.keycloakTicketTokenCache.get(ticket);
try
{
final RefreshableAccessTokenHolder refreshedToken = this.authenticationComponent
.checkAndRefreshTicketToken(refreshableAccessToken);
if (refreshedToken != null)
{
this.keycloakTicketTokenCache.put(ticket, refreshedToken);
}
// apparently expiration is allowed - remove from cache to avoid unnecessary checks in the future
else if (refreshableAccessToken.isExpired())
{
LOGGER.warn(
"The Keycloak access token associated with ticket {} for user {} has expired - Keycloak roles / claims are no longer available for the corresponding user",
ticket, AlfrescoCompatibilityUtil.maskUsername(AuthenticationUtil.getFullyAuthenticatedUser()));
this.keycloakTicketTokenCache.remove(ticket);
}
}
catch (final AuthenticationException ae)
{
this.clearCurrentSecurityContext();
throw ae;
}
}
}
}

View File

@@ -1,5 +1,5 @@
/* /*
* Copyright 2019 Acosix GmbH * Copyright 2019 - 2020 Acosix GmbH
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@@ -17,7 +17,8 @@ package de.acosix.alfresco.keycloak.repo.authentication;
import org.alfresco.repo.web.auth.WebCredentials; import org.alfresco.repo.web.auth.WebCredentials;
import org.alfresco.util.ParameterCheck; import org.alfresco.util.ParameterCheck;
import org.keycloak.representations.AccessToken;
import de.acosix.alfresco.keycloak.repo.deps.keycloak.representations.AccessToken;
/** /**
* @author Axel Faust * @author Axel Faust

View File

@@ -1,5 +1,5 @@
/* /*
* Copyright 2019 Acosix GmbH * Copyright 2019 - 2020 Acosix GmbH
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@@ -25,13 +25,14 @@ import org.alfresco.repo.security.authentication.AuthenticationUtil;
import org.alfresco.repo.security.authentication.external.RemoteUserMapper; import org.alfresco.repo.security.authentication.external.RemoteUserMapper;
import org.alfresco.service.cmr.security.PersonService; import org.alfresco.service.cmr.security.PersonService;
import org.alfresco.util.PropertyCheck; 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.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.InitializingBean; import org.springframework.beans.factory.InitializingBean;
import de.acosix.alfresco.keycloak.repo.deps.keycloak.adapters.BearerTokenRequestAuthenticator;
import de.acosix.alfresco.keycloak.repo.deps.keycloak.adapters.KeycloakDeployment;
import de.acosix.alfresco.keycloak.repo.deps.keycloak.adapters.spi.AuthOutcome;
/** /**
* @author Axel Faust * @author Axel Faust
*/ */

View File

@@ -1,5 +1,5 @@
/* /*
* Copyright 2019 Acosix GmbH * Copyright 2019 - 2020 Acosix GmbH
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@@ -26,8 +26,9 @@ import java.util.Map;
import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletRequest;
import org.alfresco.util.Pair; import org.alfresco.util.Pair;
import org.keycloak.adapters.servlet.ServletHttpFacade;
import org.keycloak.adapters.spi.HttpFacade; import de.acosix.alfresco.keycloak.repo.deps.keycloak.adapters.servlet.ServletHttpFacade;
import de.acosix.alfresco.keycloak.repo.deps.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 * This {@link HttpFacade} wraps servlet requests and responses in such a way that any response headers / cookies being set by Keycloak

View File

@@ -1,5 +1,5 @@
/* /*
* Copyright 2019 Acosix GmbH * Copyright 2019 - 2020 Acosix GmbH
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@@ -21,9 +21,10 @@ import java.util.Set;
import org.alfresco.repo.cache.SimpleCache; import org.alfresco.repo.cache.SimpleCache;
import org.alfresco.util.PropertyCheck; import org.alfresco.util.PropertyCheck;
import org.keycloak.adapters.spi.SessionIdMapper;
import org.springframework.beans.factory.InitializingBean; import org.springframework.beans.factory.InitializingBean;
import de.acosix.alfresco.keycloak.repo.deps.keycloak.adapters.spi.SessionIdMapper;
/** /**
* @author Axel Faust * @author Axel Faust
*/ */

View File

@@ -0,0 +1,47 @@
/*
* Copyright 2019 - 2020 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.Serializable;
import java.util.Map;
import org.alfresco.service.namespace.QName;
import de.acosix.alfresco.keycloak.repo.deps.keycloak.representations.AccessToken;
import de.acosix.alfresco.keycloak.repo.deps.keycloak.representations.IDToken;
/**
* Instances of this interface are used to map data from Keycloak authenticated users to the Alfresco person node. All instances of this
* interface in the Keycloak authentication subsystem will be consulted in the order the beans are defined in the Spring application
* context, resulting in an aggregated map of person node properties.
*
* @author Axel Faust
*/
public interface UserProcessor
{
/**
* Maps data from Keycloak access and ID tokens to a map of properties for the corresponding person node.
*
* @param accessToken
* the Keycloak access token for the authenticated user
* @param idToken
* the Keycloak ID token for the authenticated user - may be {@code null} if not contained in the authentication response
* @param personNodeProperties
* the properties to set on the Alfresco person node corresponding to the authenticated user
*/
void mapUser(AccessToken accessToken, IDToken idToken, Map<QName, Serializable> personNodeProperties);
}

View File

@@ -0,0 +1,72 @@
/*
* Copyright 2019 - 2020 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.authority;
import java.util.Set;
import org.alfresco.repo.security.authentication.AuthenticationUtil;
import org.alfresco.repo.security.authority.AuthorityServiceImpl;
import org.alfresco.service.cmr.security.AuthorityService;
import org.alfresco.service.cmr.security.PermissionService;
import net.sf.acegisecurity.Authentication;
import net.sf.acegisecurity.GrantedAuthority;
/**
* This specialisation of the Alfresco default authority service includes the current user's {@link GrantedAuthority granted authorities} in
* the set of authorities. This is necessary to ensure that operations such as {@link AuthorityService#isAdminAuthority(String) admin
* checks} work correctly, where as permission checks performed by the {@link PermissionService permission service} already take granted
* authorities into account.
*
* @author Axel Faust
*/
public class GrantedAuthorityAwareAuthorityServiceImpl extends AuthorityServiceImpl
{
/**
*
* {@inheritDoc}
*/
@Override
public Set<String> getAuthoritiesForUser(final String currentUserName)
{
final Set<String> authoritiesForUser = super.getAuthoritiesForUser(currentUserName);
final String runAsUser = AuthenticationUtil.getRunAsUser();
final String fullUser = AuthenticationUtil.getFullyAuthenticatedUser();
final Authentication runAsAuthentication = AuthenticationUtil.getRunAsAuthentication();
final Authentication fullAuthentication = AuthenticationUtil.getFullAuthentication();
if (runAsAuthentication != null && currentUserName.equals(runAsUser))
{
for (final GrantedAuthority authority : runAsAuthentication.getAuthorities())
{
authoritiesForUser.add(authority.getAuthority());
}
}
else if (fullAuthentication != null && currentUserName.equals(fullUser))
{
for (final GrantedAuthority authority : fullAuthentication.getAuthorities())
{
authoritiesForUser.add(authority.getAuthority());
}
}
return authoritiesForUser;
}
}

View File

@@ -0,0 +1,110 @@
/*
* Copyright 2019 - 2020 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.client;
import java.util.function.Consumer;
import de.acosix.alfresco.keycloak.repo.deps.keycloak.representations.idm.GroupRepresentation;
import de.acosix.alfresco.keycloak.repo.deps.keycloak.representations.idm.UserRepresentation;
/**
* Instances of this interface wrap the relevant Keycloak admin ReST API for the synchronisation of users and groups from a Keycloak
* instance.
*
* @author Axel Faust
*/
public interface IDMClient
{
/**
* Retrieves the number of users within the Keycloak IDM database.
*
* @return the count of users in the Keycloak database
*/
int countUsers();
/**
* Retrieves the number of groups within the Keycloak IDM database.
*
* @return the count of groups in the Keycloak database
*/
int countGroups();
/**
* Retrieves the details of one specific group from Keycloak.
*
* @param groupId
* the ID of the group in Keycloak
* @return the group details
*/
GroupRepresentation getGroup(String groupId);
/**
* Loads and processes a batch of users from Keycloak using an externally specified processor.
*
* @param offset
* the index of the first user to retrieve
* @param userBatchSize
* the number of users to load in one batch
* @param userProcessor
* the processor handling the loaded users
* @return the number of processed users
*/
int processUsers(int offset, int userBatchSize, Consumer<UserRepresentation> userProcessor);
/**
* Loads and processes a batch of groups of a specific user from Keycloak using an externally specified processor.
*
* @param userId
* the ID of user for which to process groups
* @param offset
* the index of the first group to retrieve
* @param groupBatchSize
* the number of groups to load in one batch
* @param groupProcessor
* the processor handling the loaded groups
* @return the number of processed groups
*/
int processUserGroups(String userId, int offset, int groupBatchSize, Consumer<GroupRepresentation> groupProcessor);
/**
* Loads and processes a batch of groups from Keycloak using an externally specified processor.
*
* @param offset
* the index of the first group to retrieve
* @param groupBatchSize
* the number of groups to load in one batch
* @param groupProcessor
* the processor handling the loaded groups
* @return the number of processed groups
*/
int processGroups(int offset, int groupBatchSize, Consumer<GroupRepresentation> groupProcessor);
/**
* Loads and processes a batch of users / members of a group from Keycloak using an externally specified processor.
*
* @param groupId
* the ID of group for which to process members
* @param offset
* the index of the first user to retrieve
* @param userBatchSize
* the number of users to load in one batch
* @param userProcessor
* the processor handling the loaded users
* @return the number of processed users
*/
int processMembers(String groupId, int offset, int userBatchSize, Consumer<UserRepresentation> userProcessor);
}

View File

@@ -0,0 +1,699 @@
/*
* Copyright 2019 - 2020 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.client;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.locks.ReentrantReadWriteLock;
import java.util.function.Consumer;
import org.alfresco.error.AlfrescoRuntimeException;
import org.alfresco.httpclient.HttpClientFactory.NonBlockingHttpParamsFactory;
import org.alfresco.repo.content.MimetypeMap;
import org.alfresco.util.ParameterCheck;
import org.alfresco.util.PropertyCheck;
import org.apache.commons.httpclient.params.DefaultHttpParams;
import org.apache.http.HttpEntity;
import org.apache.http.HttpResponse;
import org.apache.http.NameValuePair;
import org.apache.http.client.HttpClient;
import org.apache.http.client.entity.UrlEncodedFormEntity;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.message.BasicNameValuePair;
import org.apache.http.util.EntityUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.InitializingBean;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.MappingIterator;
import de.acosix.alfresco.keycloak.repo.deps.keycloak.OAuth2Constants;
import de.acosix.alfresco.keycloak.repo.deps.keycloak.adapters.KeycloakDeployment;
import de.acosix.alfresco.keycloak.repo.deps.keycloak.adapters.ServerRequest;
import de.acosix.alfresco.keycloak.repo.deps.keycloak.adapters.authentication.ClientCredentialsProviderUtils;
import de.acosix.alfresco.keycloak.repo.deps.keycloak.adapters.rotation.AdapterTokenVerifier;
import de.acosix.alfresco.keycloak.repo.deps.keycloak.common.VerificationException;
import de.acosix.alfresco.keycloak.repo.deps.keycloak.common.util.KeycloakUriBuilder;
import de.acosix.alfresco.keycloak.repo.deps.keycloak.common.util.Time;
import de.acosix.alfresco.keycloak.repo.deps.keycloak.constants.ServiceUrlConstants;
import de.acosix.alfresco.keycloak.repo.deps.keycloak.representations.AccessToken;
import de.acosix.alfresco.keycloak.repo.deps.keycloak.representations.AccessTokenResponse;
import de.acosix.alfresco.keycloak.repo.deps.keycloak.representations.idm.GroupRepresentation;
import de.acosix.alfresco.keycloak.repo.deps.keycloak.representations.idm.UserRepresentation;
import de.acosix.alfresco.keycloak.repo.deps.keycloak.util.JsonSerialization;
import de.acosix.alfresco.keycloak.repo.util.RefreshableAccessTokenHolder;
/**
* Implements the API for a client to the Keycloak admin ReST API specific to IDM structures.
*
* @author Axel Faust
*/
public class IDMClientImpl implements InitializingBean, IDMClient
{
private static final Logger LOGGER = LoggerFactory.getLogger(IDMClientImpl.class);
static
{
// use same Alfresco NonBlockingHttpParamsFactory as SolrQueryHTTPClient (indirectly) does
DefaultHttpParams.setHttpParamsFactory(new NonBlockingHttpParamsFactory());
}
protected final ReentrantReadWriteLock tokenLock = new ReentrantReadWriteLock(true);
protected KeycloakDeployment deployment;
protected String userName;
protected String password;
protected RefreshableAccessTokenHolder token;
/**
* {@inheritDoc}
*/
@Override
public void afterPropertiesSet()
{
PropertyCheck.mandatory(this, "keycloakDeployment", this.deployment);
}
/**
* @param deployment
* the deployment to set
*/
public void setDeployment(final KeycloakDeployment deployment)
{
this.deployment = deployment;
}
/**
* @param userName
* the userName to set
*/
public void setUserName(final String userName)
{
this.userName = userName;
}
/**
* @param password
* the password to set
*/
public void setPassword(final String password)
{
this.password = password;
}
/**
*
* {@inheritDoc}
*/
@Override
public int countUsers()
{
final URI uri = KeycloakUriBuilder.fromUri(this.deployment.getAuthServerBaseUrl()).path("/admin/realms/{realm}/users/count")
.build(this.deployment.getRealm());
final AtomicInteger count = new AtomicInteger(0);
this.processGenericGet(uri, root -> {
if (root.isInt())
{
count.set(root.intValue());
}
else
{
throw new AlfrescoRuntimeException("Keycloak admin API did not yield expected data for user count");
}
});
return count.get();
}
/**
*
* {@inheritDoc}
*/
@Override
public int countGroups()
{
final URI uri = KeycloakUriBuilder.fromUri(this.deployment.getAuthServerBaseUrl()).path("/admin/realms/{realm}/groups/count")
.build(this.deployment.getRealm());
final AtomicInteger count = new AtomicInteger(0);
this.processGenericGet(uri, root -> {
if (root.isObject() && root.has("count"))
{
count.set(root.get("count").intValue());
}
else
{
throw new AlfrescoRuntimeException("Keycloak admin API did not yield expected JSON data for group count");
}
});
return count.get();
}
/**
*
* {@inheritDoc}
*/
@Override
public GroupRepresentation getGroup(final String groupId)
{
ParameterCheck.mandatoryString("groupId", groupId);
final URI uri = KeycloakUriBuilder.fromUri(this.deployment.getAuthServerBaseUrl()).path("/admin/realms/{realm}/groups/{groupId}")
.build(this.deployment.getRealm(), groupId);
final GroupRepresentation group = this.processGenericGet(uri, GroupRepresentation.class);
return group;
}
/**
*
* {@inheritDoc}
*/
@Override
public int processUsers(final int offset, final int userBatchSize, final Consumer<UserRepresentation> userProcessor)
{
ParameterCheck.mandatory("userProcessor", userProcessor);
if (offset < 0)
{
throw new IllegalArgumentException("offset must be a non-negative integer");
}
if (userBatchSize <= 0)
{
throw new IllegalArgumentException("userBatchSize must be a positive integer");
}
final URI uri = KeycloakUriBuilder.fromUri(this.deployment.getAuthServerBaseUrl()).path("/admin/realms/{realm}/users")
.queryParam("first", offset).queryParam("max", userBatchSize).build(this.deployment.getRealm());
final int processedUsers = this.processEntityBatch(uri, userProcessor, UserRepresentation.class);
return processedUsers;
}
/**
*
* {@inheritDoc}
*/
@Override
public int processUserGroups(final String userId, final int offset, final int groupBatchSize,
final Consumer<GroupRepresentation> groupProcessor)
{
ParameterCheck.mandatoryString("userId", userId);
ParameterCheck.mandatory("groupProcessor", groupProcessor);
final URI uri = KeycloakUriBuilder.fromUri(this.deployment.getAuthServerBaseUrl()).path("/admin/realms/{realm}/users/{user}/groups")
.queryParam("first", offset).queryParam("max", groupBatchSize).build(this.deployment.getRealm(), userId);
if (offset < 0)
{
throw new IllegalArgumentException("offset must be a non-negative integer");
}
if (groupBatchSize <= 0)
{
throw new IllegalArgumentException("groupBatchSize must be a positive integer");
}
final int processedGroups = this.processEntityBatch(uri, groupProcessor, GroupRepresentation.class);
return processedGroups;
}
/**
*
* {@inheritDoc}
*/
@Override
public int processGroups(final int offset, final int groupBatchSize, final Consumer<GroupRepresentation> groupProcessor)
{
ParameterCheck.mandatory("groupProcessor", groupProcessor);
final URI uri = KeycloakUriBuilder.fromUri(this.deployment.getAuthServerBaseUrl()).path("/admin/realms/{realm}/groups")
.queryParam("first", offset).queryParam("max", groupBatchSize).build(this.deployment.getRealm());
if (offset < 0)
{
throw new IllegalArgumentException("offset must be a non-negative integer");
}
if (groupBatchSize <= 0)
{
throw new IllegalArgumentException("groupBatchSize must be a positive integer");
}
final int processedGroups = this.processEntityBatch(uri, groupProcessor, GroupRepresentation.class);
return processedGroups;
}
/**
*
* {@inheritDoc}
*/
@Override
public int processMembers(final String groupId, final int offset, final int userBatchSize,
final Consumer<UserRepresentation> userProcessor)
{
ParameterCheck.mandatoryString("groupId", groupId);
ParameterCheck.mandatory("userProcessor", userProcessor);
if (offset < 0)
{
throw new IllegalArgumentException("offset must be a non-negative integer");
}
if (userBatchSize <= 0)
{
throw new IllegalArgumentException("userBatchSize must be a positive integer");
}
final URI uri = KeycloakUriBuilder.fromUri(this.deployment.getAuthServerBaseUrl())
.path("/admin/realms/{realm}/groups/{groupId}/members").queryParam("first", offset).queryParam("max", userBatchSize)
.build(this.deployment.getRealm(), groupId);
final int processedUsers = this.processEntityBatch(uri, userProcessor, UserRepresentation.class);
return processedUsers;
}
/**
* Loads and processes a batch of generic entities from Keycloak.
*
* @param <T>
* the type of the response entities
* @param uri
* the URI to call
* @param entityProcessor
* the processor handling the loaded entities
* @param entityClass
* the type of the expected response entities
* @return the number of processed entities
*/
protected <T> int processEntityBatch(final URI uri, final Consumer<T> entityProcessor, final Class<T> entityClass)
{
final HttpGet get = new HttpGet(uri);
get.addHeader("Accept", MimetypeMap.MIMETYPE_JSON);
get.addHeader("Authorization", "Bearer " + this.getValidAccessTokenForRequest());
try
{
final HttpClient client = this.deployment.getClient();
final HttpResponse response = client.execute(get);
final int status = response.getStatusLine().getStatusCode();
final HttpEntity httpEntity = response.getEntity();
if (status != 200)
{
EntityUtils.consumeQuietly(httpEntity);
throw new IOException("Bad status: " + status);
}
if (httpEntity == null)
{
throw new IOException("Response does not contain a body");
}
final InputStream is = httpEntity.getContent();
try
{
final MappingIterator<T> iterator = JsonSerialization.mapper.readerFor(entityClass).readValues(is);
int entitiesProcessed = 0;
while (iterator.hasNextValue())
{
final T loadedEntity = iterator.nextValue();
entityProcessor.accept(loadedEntity);
entitiesProcessed++;
}
return entitiesProcessed;
}
finally
{
try
{
is.close();
}
catch (final IOException e)
{
LOGGER.trace("Error closing entity stream", e);
}
}
}
catch (final IOException ioex)
{
LOGGER.error("Failed to retrieve entities", ioex);
throw new AlfrescoRuntimeException("Failed to retrieve entities", ioex);
}
}
/**
* Executes a generic HTTP GET operation yielding a JSON response.
*
* @param uri
* the URI to call
* @param responseProcessor
* the processor handling the response JSON
*/
protected void processGenericGet(final URI uri, final Consumer<JsonNode> responseProcessor)
{
final HttpGet get = new HttpGet(uri);
get.addHeader("Accept", MimetypeMap.MIMETYPE_JSON);
get.addHeader("Authorization", "Bearer " + this.getValidAccessTokenForRequest());
try
{
final HttpClient client = this.deployment.getClient();
final HttpResponse response = client.execute(get);
final int status = response.getStatusLine().getStatusCode();
final HttpEntity httpEntity = response.getEntity();
if (status != 200)
{
EntityUtils.consumeQuietly(httpEntity);
throw new IOException("Bad status: " + status);
}
if (httpEntity == null)
{
throw new IOException("Response does not contain a body");
}
final InputStream is = httpEntity.getContent();
try
{
final JsonNode root = JsonSerialization.mapper.readTree(is);
responseProcessor.accept(root);
}
finally
{
try
{
is.close();
}
catch (final IOException e)
{
LOGGER.trace("Error closing entity stream", e);
}
}
}
catch (final IOException ioex)
{
LOGGER.error("Failed to retrieve entities", ioex);
throw new AlfrescoRuntimeException("Failed to retrieve entities", ioex);
}
}
/**
* Executes a generic HTTP GET operation yielding a mapped response entity.
*
* @param <T>
* the type of the response entity
* @param uri
* the URI to call
* @param responseType
* the class object for the type of the response entity
* @return the response entity
*
*/
protected <T> T processGenericGet(final URI uri, final Class<T> responseType)
{
final HttpGet get = new HttpGet(uri);
get.addHeader("Accept", MimetypeMap.MIMETYPE_JSON);
get.addHeader("Authorization", "Bearer " + this.getValidAccessTokenForRequest());
try
{
final HttpClient client = this.deployment.getClient();
final HttpResponse response = client.execute(get);
final int status = response.getStatusLine().getStatusCode();
final HttpEntity httpEntity = response.getEntity();
if (status != 200)
{
EntityUtils.consumeQuietly(httpEntity);
throw new IOException("Bad status: " + status);
}
if (httpEntity == null)
{
throw new IOException("Response does not contain a body");
}
final InputStream is = httpEntity.getContent();
try
{
final T responseEntity = JsonSerialization.mapper.readValue(is, responseType);
return responseEntity;
}
finally
{
try
{
is.close();
}
catch (final IOException e)
{
LOGGER.trace("Error closing entity stream", e);
}
}
}
catch (final IOException ioex)
{
LOGGER.error("Failed to retrieve entities", ioex);
throw new AlfrescoRuntimeException("Failed to retrieve entities", ioex);
}
}
/**
* Retrieves / determines a valid access token for a request to the admin ReST API.
*
* @return the valid access token to use in a request immediately following this operation
*/
protected String getValidAccessTokenForRequest()
{
String validToken = null;
this.tokenLock.readLock().lock();
try
{
if (this.token != null && (!this.token.canRefresh() || !this.token.shouldRefresh(this.deployment.getTokenMinimumTimeToLive())))
{
validToken = this.token.getToken();
}
}
finally
{
this.tokenLock.readLock().unlock();
}
if (validToken == null)
{
this.tokenLock.writeLock().lock();
try
{
if (this.token != null
&& (!this.token.canRefresh() || !this.token.shouldRefresh(this.deployment.getTokenMinimumTimeToLive())))
{
validToken = this.token.getToken();
}
if (validToken == null)
{
this.obtainOrRefreshAccessToken();
validToken = this.token.getToken();
}
}
finally
{
this.tokenLock.writeLock().unlock();
}
}
return validToken;
}
/**
* Retrieves or refreshes an access token, depending on the presence of valid refresh token for a previous access token.
*/
protected void obtainOrRefreshAccessToken()
{
AccessTokenResponse response;
try
{
if (this.token != null && this.token.canRefresh())
{
response = ServerRequest.invokeRefresh(this.deployment, this.token.getRefreshToken());
}
else
{
response = this.userName != null && !this.userName.isEmpty() ? this.getAccessToken(this.userName, this.password)
: this.getAccessToken();
}
}
catch (final IOException ioex)
{
LOGGER.error("Error retrieving / refreshing access token", ioex);
throw new AlfrescoRuntimeException("Error retrieving / refreshing access token", ioex);
}
catch (final ServerRequest.HttpFailure httpFailure)
{
LOGGER.error("Refreshing access token failed: {} {}", httpFailure.getStatus(), httpFailure.getError());
throw new AlfrescoRuntimeException("Failed to refresh access token: " + httpFailure.getStatus() + " " + httpFailure.getError());
}
final String tokenString = response.getToken();
final AdapterTokenVerifier.VerifiedTokens tokens;
try
{
tokens = AdapterTokenVerifier.verifyTokens(tokenString, response.getIdToken(), this.deployment);
}
catch (final VerificationException vex)
{
LOGGER.error("Token verification failed", vex);
throw new AlfrescoRuntimeException("Failed to verify token", vex);
}
final AccessToken accessToken = tokens.getAccessToken();
if ((accessToken.getExpiration() - this.deployment.getTokenMinimumTimeToLive()) <= Time.currentTime())
{
throw new AlfrescoRuntimeException("Failed to retrieve / refresh the access token with a longer time-to-live than the minimum");
}
this.tokenLock.writeLock().lock();
try
{
this.token = new RefreshableAccessTokenHolder(response, tokens);
}
finally
{
this.tokenLock.writeLock().unlock();
}
if (response.getNotBeforePolicy() > this.deployment.getNotBefore())
{
this.deployment.updateNotBefore(response.getNotBeforePolicy());
}
}
/**
* Retrieves an access token using client credentials.
*
* @return the access token
* @throws IOException
* when errors occur in the HTTP interaction
*/
protected AccessTokenResponse getAccessToken() throws IOException
{
LOGGER.debug("Retrieving access token with client credentrials");
final AccessTokenResponse tokenResponse = this.getAccessTokenImpl(formParams -> {
formParams.add(new BasicNameValuePair(OAuth2Constants.GRANT_TYPE, OAuth2Constants.CLIENT_CREDENTIALS));
});
return tokenResponse;
}
/**
* Retrieves an access token for a specified synchronisation user.
*
* @param userName
* the user to use for synchronisation access
* @param password
* the password of the user
* @return the access token
* @throws IOException
* when errors occur in the HTTP interaction
*/
protected AccessTokenResponse getAccessToken(final String userName, final String password) throws IOException
{
LOGGER.debug("Retrieving access token for user {}", userName);
final AccessTokenResponse tokenResponse = this.getAccessTokenImpl(formParams -> {
formParams.add(new BasicNameValuePair(OAuth2Constants.GRANT_TYPE, OAuth2Constants.PASSWORD));
formParams.add(new BasicNameValuePair("username", userName));
formParams.add(new BasicNameValuePair("password", password));
});
return tokenResponse;
}
/**
* Retrieves an OIDC access token with the specific token request parameter up to the caller to define via the provided consumer.
*
* @param postParamProvider
* a provider of HTTP POST parameters for the access token request
* @return the access token
* @throws IOException
* when errors occur in the HTTP interaction
*/
// implementing this method locally avoids having the dependency on Keycloak authz-client
// authz-client does not support refresh, so would be of limited value anyway
protected AccessTokenResponse getAccessTokenImpl(final Consumer<List<NameValuePair>> postParamProvider) throws IOException
{
AccessTokenResponse tokenResponse = null;
final HttpClient client = this.deployment.getClient();
final HttpPost post = new HttpPost(KeycloakUriBuilder.fromUri(this.deployment.getAuthServerBaseUrl())
.path(ServiceUrlConstants.TOKEN_PATH).build(this.deployment.getRealm()));
final List<NameValuePair> formParams = new ArrayList<>();
postParamProvider.accept(formParams);
ClientCredentialsProviderUtils.setClientCredentials(this.deployment, post, formParams);
final UrlEncodedFormEntity form = new UrlEncodedFormEntity(formParams, "UTF-8");
post.setEntity(form);
final HttpResponse response = client.execute(post);
final int status = response.getStatusLine().getStatusCode();
final HttpEntity entity = response.getEntity();
if (status != 200)
{
final String statusReason = response.getStatusLine().getReasonPhrase();
LOGGER.debug("Failed to retrieve access token due to HTTP {}: {}", status, statusReason);
EntityUtils.consumeQuietly(entity);
throw new AlfrescoRuntimeException("Failed to retrieve access token due to HTTP error " + status + ": " + statusReason);
}
if (entity == null)
{
throw new AlfrescoRuntimeException("Response to access token request did not contain a response body");
}
final InputStream is = entity.getContent();
try
{
tokenResponse = JsonSerialization.readValue(is, AccessTokenResponse.class);
}
finally
{
try
{
is.close();
}
catch (final IOException e)
{
LOGGER.trace("Error closing entity stream", e);
}
}
return tokenResponse;
}
}

View File

@@ -1,5 +1,5 @@
/* /*
* Copyright 2019 Acosix GmbH * Copyright 2019 - 2020 Acosix GmbH
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@@ -31,7 +31,6 @@ import java.util.Set;
import org.alfresco.error.AlfrescoRuntimeException; import org.alfresco.error.AlfrescoRuntimeException;
import org.alfresco.util.PropertyCheck; import org.alfresco.util.PropertyCheck;
import org.keycloak.representations.adapters.config.AdapterConfig;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.FactoryBean; import org.springframework.beans.factory.FactoryBean;
@@ -41,6 +40,8 @@ import org.springframework.util.PropertyPlaceholderHelper;
import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonProperty;
import de.acosix.alfresco.keycloak.repo.deps.keycloak.representations.adapters.config.AdapterConfig;
/** /**
* @author Axel Faust * @author Axel Faust
*/ */

View File

@@ -1,5 +1,5 @@
/* /*
* Copyright 2019 Acosix GmbH * Copyright 2019 - 2020 Acosix GmbH
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@@ -18,13 +18,14 @@ package de.acosix.alfresco.keycloak.repo.spring;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import org.alfresco.util.PropertyCheck; 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.FactoryBean;
import org.springframework.beans.factory.InitializingBean; import org.springframework.beans.factory.InitializingBean;
import de.acosix.alfresco.keycloak.repo.deps.keycloak.adapters.HttpClientBuilder;
import de.acosix.alfresco.keycloak.repo.deps.keycloak.adapters.KeycloakDeployment;
import de.acosix.alfresco.keycloak.repo.deps.keycloak.adapters.KeycloakDeploymentBuilder;
import de.acosix.alfresco.keycloak.repo.deps.keycloak.representations.adapters.config.AdapterConfig;
/** /**
* @author Axel Faust * @author Axel Faust
*/ */

View File

@@ -0,0 +1,128 @@
/*
* Copyright 2019 - 2020 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.sync;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.alfresco.repo.security.sync.NodeDescription;
import org.alfresco.service.namespace.NamespaceService;
import org.alfresco.service.namespace.QName;
import org.alfresco.util.PropertyCheck;
import org.springframework.beans.factory.InitializingBean;
/**
* This class provides common configuration properties and logic for mapping processor handling Keycloak authority attributes.
*
* @author Axel Faust
*/
public abstract class BaseAttributeProcessor implements InitializingBean
{
protected NamespaceService namespaceService;
protected Map<String, String> attributePropertyMappings;
protected Map<String, QName> attributePropertyQNameMappings;
/**
*
* {@inheritDoc}
*/
@Override
public void afterPropertiesSet()
{
PropertyCheck.mandatory(this, "namespaceService", this.namespaceService);
if (this.attributePropertyMappings != null && !this.attributePropertyMappings.isEmpty())
{
this.attributePropertyQNameMappings = new HashMap<>();
this.attributePropertyMappings
.forEach((k, v) -> this.attributePropertyQNameMappings.put(k, QName.resolveToQName(this.namespaceService, v)));
}
}
/**
* @param namespaceService
* the namespaceService to set
*/
public void setNamespaceService(final NamespaceService namespaceService)
{
this.namespaceService = namespaceService;
}
/**
* @param attributePropertyMappings
* the attributePropertyMappings to set
*/
public void setAttributePropertyMappings(final Map<String, String> attributePropertyMappings)
{
this.attributePropertyMappings = attributePropertyMappings;
}
/**
* Performs the general attribute to property mapping. This operation will not handle any value type conversions, relying instead on the
* underlying data types and registered type converters in Alfresco to handle conversion in the persistence layer. Any attribute which
* is only associated with a single value will be unwrapped from a list to the singular value, while all other attributes will be kept
* as-is. THis operation also does not perform any checks whether a configured target property actually supports a multi-valued
* property, again leaving that kind of processing to the Alfresco default functionality of integrity checking.
*
* @param attributes
* the list of attributes
* @param nodeDescription
* the node description to enhance
*/
protected void map(final Map<String, List<String>> attributes, final NodeDescription nodeDescription)
{
if (this.attributePropertyQNameMappings != null && attributes != null)
{
attributes.keySet().stream().filter(this.attributePropertyQNameMappings::containsKey)
.forEach(k -> this.mapAttribute(k, attributes, nodeDescription));
}
}
/**
* Maps an individual attribute to the correlating node property of the node description.
*
* @param attribute
* the name of the attribute to map
* @param attributes
* the list of attributes
* @param nodeDescription
* the node description to enhance
*/
protected void mapAttribute(final String attribute, final Map<String, List<String>> attributes, final NodeDescription nodeDescription)
{
final QName propertyQName = this.attributePropertyQNameMappings.get(attribute);
final List<String> values = attributes.get(attribute);
if (values != null)
{
Serializable value;
if (values.size() == 1)
{
value = values.get(0);
}
else
{
value = new ArrayList<>(values);
}
nodeDescription.getProperties().put(propertyQName, value);
}
}
}

View File

@@ -0,0 +1,192 @@
/*
* Copyright 2019 - 2020 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.sync;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.List;
import org.alfresco.util.ParameterCheck;
import org.alfresco.util.PropertyCheck;
import org.springframework.beans.factory.InitializingBean;
import de.acosix.alfresco.keycloak.repo.client.IDMClient;
/**
* This class provides common configuration and logic relevant for any filter based on authority group containments.
*
* @author Axel Faust
*/
public abstract class BaseGroupContainmentFilter implements InitializingBean
{
protected IDMClient idmClient;
protected List<String> groupPaths;
protected List<String> groupIds;
protected List<String> idResolvedGroupPaths;
protected boolean requireAll = false;
protected boolean allowTransitive = true;
protected int groupLoadBatchSize = 50;
/**
*
* {@inheritDoc}
*/
@Override
public void afterPropertiesSet()
{
PropertyCheck.mandatory(this, "idmClient", this.idmClient);
if (this.groupIds != null && !this.groupIds.isEmpty())
{
this.idResolvedGroupPaths = new ArrayList<>();
this.groupIds.stream().map(id -> this.idmClient.getGroup(id).getPath()).forEach(this.idResolvedGroupPaths::add);
}
}
/**
* @param idmClient
* the idmClient to set
*/
public void setIdmClient(final IDMClient idmClient)
{
this.idmClient = idmClient;
}
/**
* @param groupPaths
* the groupPaths to set as a comma-separated string of paths
*/
public void setGroupPaths(final String groupPaths)
{
this.groupPaths = groupPaths != null && !groupPaths.isEmpty() ? Arrays.asList(groupPaths.split(",")) : null;
}
/**
* @param groupIds
* the groupIds to set as a comma-separated string of paths
*/
public void setGroupIds(final String groupIds)
{
this.groupIds = groupIds != null && !groupIds.isEmpty() ? Arrays.asList(groupIds.split(",")) : null;
}
/**
* @param requireAll
* the requireAll to set
*/
public void setRequireAll(final boolean requireAll)
{
this.requireAll = requireAll;
}
/**
* @param allowTransitive
* the allowTransitive to set
*/
public void setAllowTransitive(final boolean allowTransitive)
{
this.allowTransitive = allowTransitive;
}
/**
* @param groupLoadBatchSize
* the groupLoadBatchSize to set
*/
public void setGroupLoadBatchSize(final int groupLoadBatchSize)
{
this.groupLoadBatchSize = groupLoadBatchSize;
}
/**
* Checks whether parent groups match the configured restrictions.
*
* @param parentGroupIds
* the list of parent group IDs for an authority
* @param parentGroupPaths
* the list of parent group paths for an authority
* @return {@code true} if the parent groups match the configured restrictions, {@code false} otherwise
*/
protected boolean parentGroupsMatch(final List<String> parentGroupIds, final List<String> parentGroupPaths)
{
ParameterCheck.mandatory("parentGroupIds", parentGroupIds);
ParameterCheck.mandatory("parentGroupPaths", parentGroupPaths);
boolean matches;
if (this.requireAll)
{
if (this.allowTransitive)
{
final boolean allPathsMatch = this.groupPaths == null
|| this.groupPaths.stream().allMatch(path -> this.groupPathOrTransitiveContained(path, parentGroupPaths));
final boolean allResolvedPathsMatch = this.idResolvedGroupPaths == null
|| this.idResolvedGroupPaths.stream().allMatch(path -> this.groupPathOrTransitiveContained(path, parentGroupPaths));
matches = allPathsMatch && allResolvedPathsMatch;
}
else
{
final boolean allPathsMatch = this.groupPaths == null || this.groupPaths.stream().allMatch(parentGroupPaths::contains);
// parentGroupIds might be empty if they cannot be efficiently retrieved or paths are sufficiently known
final boolean allIdsMatch = this.groupIds == null || this.groupIds.stream().allMatch(parentGroupIds::contains)
|| this.idResolvedGroupPaths.stream().allMatch(parentGroupPaths::contains);
matches = allPathsMatch && allIdsMatch;
}
}
else
{
if (this.allowTransitive)
{
matches = (this.groupPaths != null
&& this.groupPaths.stream().anyMatch(path -> this.groupPathOrTransitiveContained(path, parentGroupPaths)));
matches = matches || (this.idResolvedGroupPaths != null && this.idResolvedGroupPaths.stream()
.anyMatch(path -> this.groupPathOrTransitiveContained(path, parentGroupPaths)));
}
else
{
matches = (this.groupPaths != null && this.groupPaths.stream().anyMatch(parentGroupPaths::contains));
matches = matches || (this.groupIds != null && (this.groupIds.stream().anyMatch(parentGroupIds::contains)
|| this.idResolvedGroupPaths.stream().anyMatch(parentGroupPaths::contains)));
}
}
return matches;
}
/**
* Checks whether a specific group path matches any entry in a list of paths using either exact match or prefix matching.
*
* @param groupPath
* the path to check
* @param groupPaths
* the paths to check against
* @return {@code true} if the path matches one of the paths in exact match or prefix matching mode
*/
protected boolean groupPathOrTransitiveContained(final String groupPath, final Collection<String> groupPaths)
{
boolean contained = groupPaths.contains(groupPath);
final String groupPathPrefix = groupPath + "/";
contained = contained || groupPaths.stream().anyMatch(path -> path.startsWith(groupPathPrefix));
return contained;
}
}

View File

@@ -0,0 +1,59 @@
/*
* Copyright 2019 - 2020 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.sync;
import org.alfresco.model.ContentModel;
import org.alfresco.repo.security.sync.NodeDescription;
import org.alfresco.service.cmr.security.AuthorityType;
import org.alfresco.util.PropertyMap;
import de.acosix.alfresco.keycloak.repo.deps.keycloak.representations.idm.GroupRepresentation;
/**
* This user synchronisation mapping processor maps the default Alfresco authority container properties from a Keycloak group.
*
* @author Axel Faust
*/
public class DefaultGroupProcessor implements GroupProcessor
{
protected boolean enabled;
/**
* @param enabled
* the enabled to set
*/
public void setEnabled(final boolean enabled)
{
this.enabled = enabled;
}
/**
*
* {@inheritDoc}
*/
@Override
public void mapGroup(final GroupRepresentation group, final NodeDescription groupNode)
{
if (this.enabled)
{
final PropertyMap properties = groupNode.getProperties();
properties.put(ContentModel.PROP_AUTHORITY_NAME, AuthorityType.GROUP.getPrefixString() + group.getId());
properties.put(ContentModel.PROP_AUTHORITY_DISPLAY_NAME, group.getName());
}
}
}

View File

@@ -0,0 +1,168 @@
/*
* Copyright 2019 - 2020 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.sync;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import org.alfresco.model.ContentModel;
import org.alfresco.repo.security.sync.NodeDescription;
import org.alfresco.service.namespace.QName;
import org.alfresco.util.PropertyMap;
import de.acosix.alfresco.keycloak.repo.deps.keycloak.representations.idm.UserRepresentation;
/**
* This user synchronisation mapping processor maps the default Alfresco person properties from a Keycloak user.
*
* @author Axel Faust
*/
public class DefaultPersonProcessor implements UserProcessor
{
protected boolean enabled;
protected boolean mapNull;
protected boolean mapFirstName;
protected boolean mapLastName;
protected boolean mapEmail;
protected boolean mapEnabledState;
/**
* @param enabled
* the enabled to set
*/
public void setEnabled(final boolean enabled)
{
this.enabled = enabled;
}
/**
* @param mapNull
* the mapNull to set
*/
public void setMapNull(final boolean mapNull)
{
this.mapNull = mapNull;
}
/**
* @param mapFirstName
* the mapFirstName to set
*/
public void setMapFirstName(final boolean mapFirstName)
{
this.mapFirstName = mapFirstName;
}
/**
* @param mapLastName
* the mapLastName to set
*/
public void setMapLastName(final boolean mapLastName)
{
this.mapLastName = mapLastName;
}
/**
* @param mapEmail
* the mapEmail to set
*/
public void setMapEmail(final boolean mapEmail)
{
this.mapEmail = mapEmail;
}
/**
* @param mapEnabledState
* the mapEnabledState to set
*/
public void setMapEnabledState(final boolean mapEnabledState)
{
this.mapEnabledState = mapEnabledState;
}
/**
*
* {@inheritDoc}
*/
@Override
public void mapUser(final UserRepresentation user, final NodeDescription person)
{
if (this.enabled)
{
final PropertyMap properties = person.getProperties();
if ((this.mapNull || user.getFirstName() != null) && this.mapFirstName)
{
properties.put(ContentModel.PROP_FIRSTNAME, user.getFirstName());
}
if ((this.mapNull || user.getLastName() != null) && this.mapLastName)
{
properties.put(ContentModel.PROP_LASTNAME, user.getLastName());
}
if ((this.mapNull || user.getEmail() != null) && this.mapEmail)
{
properties.put(ContentModel.PROP_EMAIL, user.getEmail());
}
if ((this.mapNull || user.isEnabled() != null) && this.mapEnabledState)
{
properties.put(ContentModel.PROP_ENABLED, user.isEnabled());
}
}
}
/**
*
* {@inheritDoc}
*/
@Override
public Collection<QName> getMappedProperties()
{
Collection<QName> mappedProperties;
if (this.enabled)
{
mappedProperties = new ArrayList<>(4);
if (this.mapFirstName)
{
mappedProperties.add(ContentModel.PROP_FIRSTNAME);
}
if (this.mapLastName)
{
mappedProperties.add(ContentModel.PROP_LASTNAME);
}
if (this.mapEmail)
{
mappedProperties.add(ContentModel.PROP_EMAIL);
}
if (this.mapEnabledState)
{
mappedProperties.add(ContentModel.PROP_ENABLED);
}
}
else
{
mappedProperties = Collections.emptySet();
}
return mappedProperties;
}
}

View File

@@ -0,0 +1,78 @@
/*
* Copyright 2019 - 2020 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.sync;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import de.acosix.alfresco.keycloak.repo.deps.keycloak.representations.idm.GroupRepresentation;
/**
* This class provides filter capabilities for groups to be synchronised based on their parent group and whether they are contained in
* specific groups.
*
* @author Axel Faust
*/
public class GroupContainmentGroupFilter extends BaseGroupContainmentFilter
implements GroupFilter
{
private static final Logger LOGGER = LoggerFactory.getLogger(GroupContainmentGroupFilter.class);
/**
*
* {@inheritDoc}
*/
@Override
public boolean shouldIncludeGroup(final GroupRepresentation group)
{
boolean matches;
if ((this.groupPaths != null && !this.groupPaths.isEmpty()) || (this.groupIds != null && !this.groupIds.isEmpty()))
{
LOGGER.debug(
"Checking group {} ({}) for containment in groups with paths {} / IDs {}, using allowTransitive={} and requireAll={}",
group.getId(), group.getPath(), this.groupPaths, this.groupIds, this.allowTransitive, this.requireAll);
// no need to retrieve parent group ID as path should be sufficient
// Keycloak groups can only ever have one parent
final List<String> parentGroupIds = Collections.emptyList();
final List<String> parentGroupPaths = new ArrayList<>();
final String groupPath = group.getPath();
final String parentPath = groupPath.substring(0, groupPath.lastIndexOf('/'));
if (!parentPath.isEmpty())
{
parentGroupPaths.add(parentPath);
}
matches = this.parentGroupsMatch(parentGroupIds, parentGroupPaths);
LOGGER.debug("Group containment result for group {}: {}", group.getId(), matches);
}
else
{
matches = true;
}
return matches;
}
}

View File

@@ -0,0 +1,78 @@
/*
* Copyright 2019 - 2020 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.sync;
import java.util.ArrayList;
import java.util.List;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.InitializingBean;
import de.acosix.alfresco.keycloak.repo.deps.keycloak.representations.idm.UserRepresentation;
/**
* This class provides filter capabilities for users to be synchronised based on the groups they are a member of and whether they are
* contained in specific groups.
*
* @author Axel Faust
*/
public class GroupContainmentUserFilter extends BaseGroupContainmentFilter
implements UserFilter, InitializingBean
{
private static final Logger LOGGER = LoggerFactory.getLogger(GroupContainmentUserFilter.class);
/**
*
* {@inheritDoc}
*/
@Override
public boolean shouldIncludeUser(final UserRepresentation user)
{
boolean matches;
if ((this.groupPaths != null && !this.groupPaths.isEmpty()) || (this.groupIds != null && !this.groupIds.isEmpty()))
{
LOGGER.debug("Checking user {} for containment in groups with paths {} / IDs {}, using allowTransitive={} and requireAll={}",
user.getUsername(), this.groupPaths, this.groupIds, this.allowTransitive, this.requireAll);
final List<String> parentGroupIds = new ArrayList<>();
final List<String> parentGroupPaths = new ArrayList<>();
int offset = 0;
int processedGroups = 1;
while (processedGroups > 0)
{
processedGroups = this.idmClient.processUserGroups(user.getId(), offset, this.groupLoadBatchSize, group -> {
parentGroupIds.add(group.getId());
parentGroupPaths.add(group.getPath());
});
offset += processedGroups;
}
matches = this.parentGroupsMatch(parentGroupIds, parentGroupPaths);
LOGGER.debug("Group containment result for user {}: {}", user.getUsername(), matches);
}
else
{
matches = true;
}
return matches;
}
}

View File

@@ -0,0 +1,38 @@
/*
* Copyright 2019 - 2020 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.sync;
import de.acosix.alfresco.keycloak.repo.deps.keycloak.representations.idm.GroupRepresentation;
/**
* Instances of this interface are used to determine which groups should be synchronised. All instances of this interface in the Keycloak
* authentication subsystem will be consulted and only groups for which every filter returns {@code true} will be synchronised. If no filter
* instances have been defined, groups will always be synchronised without any filtering.
*
* @author Axel Faust
*/
public interface GroupFilter
{
/**
* Determines whether this group should be included in the synchronisation.
*
* @param group
* the group to consider
* @return {@code true} if the group should be synchronised, {@code false} if not
*/
boolean shouldIncludeGroup(GroupRepresentation group);
}

View File

@@ -0,0 +1,41 @@
/*
* Copyright 2019 - 2020 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.sync;
import org.alfresco.repo.security.sync.NodeDescription;
import de.acosix.alfresco.keycloak.repo.deps.keycloak.representations.idm.GroupRepresentation;
/**
* Instances of this interface are to map data from Keycloak groups to the Alfresco authority container node description. All instances of
* this interface in the Keycloak authentication subsystem will be consulted in the order the beans are defined in the Spring application
* context, resulting in an aggregated authority container node description.
*
* @author Axel Faust
*/
public interface GroupProcessor
{
/**
* Maps data from a Keycloak group representation to a description of an Alfresco node for the authority container.
*
* @param group
* the Keycloak group representation
* @param groupNodeDescription
* the Alfresco node description
*/
void mapGroup(GroupRepresentation group, NodeDescription groupNodeDescription);
}

View File

@@ -0,0 +1,531 @@
/*
* Copyright 2019 - 2020 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.sync;
import java.util.AbstractCollection;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.Date;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.NoSuchElementException;
import java.util.Set;
import java.util.function.Consumer;
import java.util.function.Function;
import org.alfresco.model.ContentModel;
import org.alfresco.repo.management.subsystems.ActivateableBean;
import org.alfresco.repo.security.sync.NodeDescription;
import org.alfresco.repo.security.sync.UserRegistry;
import org.alfresco.service.cmr.security.AuthorityType;
import org.alfresco.service.namespace.QName;
import org.alfresco.util.PropertyCheck;
import org.alfresco.util.PropertyMap;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
import de.acosix.alfresco.keycloak.repo.client.IDMClient;
import de.acosix.alfresco.keycloak.repo.client.IDMClientImpl;
import de.acosix.alfresco.keycloak.repo.deps.keycloak.representations.idm.GroupRepresentation;
import de.acosix.alfresco.keycloak.repo.deps.keycloak.representations.idm.UserRepresentation;
/**
* This class provides a Keycloak-based user registry to support synchronisation with Keycloak managed users and groups.
*
* @author Axel Faust
*/
public class KeycloakUserRegistry implements UserRegistry, InitializingBean, ActivateableBean, ApplicationContextAware
{
private static final Logger LOGGER = LoggerFactory.getLogger(KeycloakUserRegistry.class);
protected boolean active;
protected ApplicationContext applicationContext;
protected IDMClient idmClient;
protected Collection<UserFilter> userFilters;
protected Collection<GroupFilter> groupFilters;
protected Collection<UserProcessor> userProcessors;
protected Collection<GroupProcessor> groupProcessors;
protected int personLoadBatchSize = 50;
protected int groupLoadBatchSize = 50;
/**
* {@inheritDoc}
*/
@Override
public void afterPropertiesSet()
{
PropertyCheck.mandatory(this, "applicationContext", this.applicationContext);
PropertyCheck.mandatory(this, "idmClient", this.idmClient);
this.userFilters = Collections.unmodifiableList(
new ArrayList<>(this.applicationContext.getBeansOfType(UserFilter.class, false, true).values()));
this.groupFilters = Collections.unmodifiableList(
new ArrayList<>(this.applicationContext.getBeansOfType(GroupFilter.class, false, true).values()));
this.userProcessors = Collections.unmodifiableList(
new ArrayList<>(this.applicationContext.getBeansOfType(UserProcessor.class, false, true).values()));
this.groupProcessors = Collections.unmodifiableList(
new ArrayList<>(this.applicationContext.getBeansOfType(GroupProcessor.class, false, true).values()));
}
/**
* {@inheritDoc}
*/
@Override
public boolean isActive()
{
return this.active;
}
/**
* @param active
* the active to set
*/
public void setActive(final boolean active)
{
this.active = active;
}
/**
* {@inheritDoc}
*/
@Override
public void setApplicationContext(final ApplicationContext applicationContext)
{
this.applicationContext = applicationContext;
}
/**
* @param idmClient
* the idmClient to set
*/
public void setIdmClient(final IDMClientImpl idmClient)
{
this.idmClient = idmClient;
}
/**
* @param personLoadBatchSize
* the personLoadBatchSize to set
*/
public void setPersonLoadBatchSize(final int personLoadBatchSize)
{
this.personLoadBatchSize = personLoadBatchSize;
}
/**
* @param groupLoadBatchSize
* the groupLoadBatchSize to set
*/
public void setGroupLoadBatchSize(final int groupLoadBatchSize)
{
this.groupLoadBatchSize = groupLoadBatchSize;
}
/**
* {@inheritDoc}
*/
@Override
public Collection<NodeDescription> getPersons(final Date modifiedSince)
{
// Keycloak does not support any "modifiedSince" semantics
Collection<NodeDescription> people = Collections.emptyList();
if (this.active)
{
people = new UserCollection<>(this.personLoadBatchSize, this.idmClient.countUsers(), this::mapUser);
}
return people;
}
/**
* {@inheritDoc}
*/
@Override
public Collection<NodeDescription> getGroups(final Date modifiedSince)
{
// Keycloak does not support any "modifiedSince" semantics
Collection<NodeDescription> groups = Collections.emptySet();
if (this.active)
{
groups = new GroupCollection<>(this.groupLoadBatchSize, this.idmClient.countGroups(), this::mapGroup);
}
return groups;
}
/**
* {@inheritDoc}
*/
@Override
public Collection<String> getPersonNames()
{
Collection<String> personNames = Collections.emptySet();
if (this.active)
{
personNames = new UserCollection<>(this.personLoadBatchSize, this.idmClient.countUsers(), UserRepresentation::getUsername);
}
return personNames;
}
/**
* {@inheritDoc}
*/
@Override
public Collection<String> getGroupNames()
{
Collection<String> groupNames = Collections.emptySet();
if (this.active)
{
groupNames = new GroupCollection<>(this.groupLoadBatchSize, this.idmClient.countGroups(),
group -> AuthorityType.GROUP.getPrefixString() + group.getId());
}
return groupNames;
}
/**
* {@inheritDoc}
*/
@Override
public Set<QName> getPersonMappedProperties()
{
final Set<QName> mappedProperties = new HashSet<>();
this.userProcessors.stream().map(UserProcessor::getMappedProperties).forEach(mappedProperties::addAll);
return mappedProperties;
}
/**
* Maps a single user from the Keycloak representation into an abstract description of a person node.
*
* @param user
* the user to map
* @return the mapped person node description
*/
protected NodeDescription mapUser(final UserRepresentation user)
{
final NodeDescription person = new NodeDescription(user.getId());
final PropertyMap personProperties = person.getProperties();
LOGGER.debug("Mapping user {}", user.getUsername());
this.userProcessors.forEach(processor -> processor.mapUser(user, person));
// always wins against user-defined mappings for cm:userName
personProperties.put(ContentModel.PROP_USERNAME, user.getUsername());
return person;
}
/**
* Maps a single group from the Keycloak representation into an abstract description of a group node.
*
* @param group
* the group to map
* @return the mapped group node description
*/
protected NodeDescription mapGroup(final GroupRepresentation group)
{
// need to use group ID as unique name as Keycloak group name itself is non-unique
final NodeDescription groupD = new NodeDescription(group.getId());
final PropertyMap groupProperties = groupD.getProperties();
final String groupName = AuthorityType.GROUP.getPrefixString() + group.getId();
LOGGER.debug("Mapping group {}", groupName);
this.groupProcessors.forEach(processor -> processor.mapGroup(group, groupD));
// always wins against user-defined mappings for cm:authorityName
groupProperties.put(ContentModel.PROP_AUTHORITY_NAME, groupName);
final Set<String> childAssociations = groupD.getChildAssociations();
group.getSubGroups().stream()
.filter(subGroup -> !this.groupFilters.stream().anyMatch(filter -> !filter.shouldIncludeGroup(subGroup)))
.forEach(subGroup -> childAssociations.add(AuthorityType.GROUP.getPrefixString() + subGroup.getId()));
int offset = 0;
int processedMembers = 1;
while (processedMembers > 0)
{
processedMembers = this.idmClient.processMembers(group.getId(), offset, this.personLoadBatchSize, user -> {
final boolean skipSync = this.userFilters.stream().anyMatch(filter -> !filter.shouldIncludeUser(user));
if (!skipSync)
{
childAssociations.add(user.getUsername());
}
});
offset += processedMembers;
}
LOGGER.debug("Mapped members of group {}: {}", groupName, childAssociations);
return groupD;
}
/**
* This class provides common basic functionalities for a collection of Keycloak authority-based data elements, supporting basic batch
* load-based pagination / iterative traversal.
*
* @author Axel Faust
*/
protected abstract class KeycloakAuthorityCollection<T, AR> extends AbstractCollection<T>
{
protected final int batchSize;
protected final int totalUpperBound;
protected final Function<AR, T> mapper;
/**
* Constructs a new instance of this class.
*
* @param batchSize
* the size of batches to use for incrementally loading data elements in the iterator
* @param totalUpperBound
* the upper bound of the total number of elements to expect in this collection - this is just an estimation (without
* adjusting for any potential filtering) and will be used as the {@link #size() collection's size}.
* @param mapper
* the mapping handler to turn a low-level authority representation into the actual collection value representation
*/
protected KeycloakAuthorityCollection(final int batchSize, final int totalUpperBound, final Function<AR, T> mapper)
{
this.batchSize = batchSize;
this.totalUpperBound = totalUpperBound;
this.mapper = mapper;
}
/**
* {@inheritDoc}
*/
@Override
public int size()
{
return this.totalUpperBound;
}
/**
* {@inheritDoc}
*/
@Override
public Iterator<T> iterator()
{
return new KeycloakAuthorityIterator();
}
/**
* Loads the next batch of authority representations.
*
* @param offset
* the index of the first low-level authority to load
* @param batchSize
* the maximum number of low-level authorities to load from the backend
* @param authorityProcessor
* the processor to consume individual authority representations - the number of representations passed to this processor
* may be different than the number of authorities loaded from the backend due to filtering and potential pre-processing
* (e.g. splitting of groups and sub-groups)
* @return the number of low-level authorities loaded in this batch to properly adjust the offset for the next load operation
*/
protected abstract int loadNext(int offset, int batchSize, Consumer<AR> authorityProcessor);
/**
* Converts an authority representation into the type of object to be exposed as values of the collection.
*
* @param authorityRepresentation
* the authority representation to convert
* @return the converted value
*/
protected T convert(final AR authorityRepresentation)
{
return this.mapper.apply(authorityRepresentation);
}
protected class KeycloakAuthorityIterator implements Iterator<T>
{
private final List<T> buffer = new ArrayList<>();
private int offset;
private int index;
private boolean noMoreResults;
/**
* {@inheritDoc}
*/
@Override
public synchronized boolean hasNext()
{
this.checkAndFillBuffer();
final boolean hasNext = !this.buffer.isEmpty() && this.index < this.buffer.size();
return hasNext;
}
/**
* {@inheritDoc}
*/
@Override
public synchronized T next()
{
this.checkAndFillBuffer();
T next;
if (!this.buffer.isEmpty() && this.index < this.buffer.size())
{
next = this.buffer.get(this.index++);
}
else
{
throw new NoSuchElementException();
}
return next;
}
protected synchronized void checkAndFillBuffer()
{
if ((this.buffer.isEmpty() || this.index >= this.buffer.size()) && !this.noMoreResults)
{
this.buffer.clear();
this.index = 0;
this.offset += KeycloakAuthorityCollection.this.loadNext(this.offset, KeycloakAuthorityCollection.this.batchSize,
authority -> this.buffer.add(KeycloakAuthorityCollection.this.convert(authority)));
this.noMoreResults = this.buffer.isEmpty();
}
}
}
}
/**
* This class provides the basis for all user-related collections.
*
* @author Axel Faust
*/
protected class UserCollection<T> extends KeycloakAuthorityCollection<T, UserRepresentation>
{
/**
* Constructs a new instance of this class.
*
* @param batchSize
* the size of batches to use for incrementally loading data elements in the iterator
* @param totalUpperBound
* the upper bound of the total number of elements to expect in this collection - this is just an estimation (without
* adjusting for any potential filtering) and will be used as the {@link #size() collection's size}.
* @param mapper
* the mapping handler to turn a low-level authority representation into the actual collection value representation
*/
public UserCollection(final int batchSize, final int totalUpperBound, final Function<UserRepresentation, T> mapper)
{
super(batchSize, totalUpperBound, mapper);
}
/**
* {@inheritDoc}
*/
@Override
protected int loadNext(final int offset, final int batchSize, final Consumer<UserRepresentation> authorityProcessor)
{
// TODO Evaluate other iteration approaches, e.g. crawling from a configured root group
// How to count totals in advance though?
return KeycloakUserRegistry.this.idmClient.processUsers(offset, batchSize, user -> {
final boolean skipSync = KeycloakUserRegistry.this.userFilters.stream().anyMatch(filter -> !filter.shouldIncludeUser(user));
if (!skipSync)
{
authorityProcessor.accept(user);
}
});
}
}
/**
* This class provides the basis for all group-related collections.
*
* @author Axel Faust
*/
protected class GroupCollection<T> extends KeycloakAuthorityCollection<T, GroupRepresentation>
{
/**
* Constructs a new instance of this class.
*
* @param batchSize
* the size of batches to use for incrementally loading data elements in the iterator
* @param totalUpperBound
* the upper bound of the total number of elements to expect in this collection - this is just an estimation (without
* adjusting for any potential filtering) and will be used as the {@link #size() collection's size}.
* @param mapper
* the mapping handler to turn a low-level authority representation into the actual collection value representation
*/
public GroupCollection(final int batchSize, final int totalUpperBound, final Function<GroupRepresentation, T> mapper)
{
super(batchSize, totalUpperBound, mapper);
}
/**
* {@inheritDoc}
*/
@Override
protected int loadNext(final int offset, final int batchSize, final Consumer<GroupRepresentation> authorityProcessor)
{
// TODO Evaluate other iteration approaches, e.g. crawling from a configured root group
// How to count totals in advance though?
return KeycloakUserRegistry.this.idmClient.processGroups(offset, batchSize, group -> {
this.processGroupsRecursively(group, authorityProcessor);
});
}
protected void processGroupsRecursively(final GroupRepresentation group, final Consumer<GroupRepresentation> authorityProcessor)
{
final boolean skipSync = KeycloakUserRegistry.this.groupFilters.stream().anyMatch(filter -> !filter.shouldIncludeGroup(group));
if (!skipSync)
{
authorityProcessor.accept(group);
}
// any filtering applied above does not apply here as any sub-group will be individually checked for filtering by recursive
// processing
group.getSubGroups().forEach(subGroup -> this.processGroupsRecursively(subGroup, authorityProcessor));
}
}
}

View File

@@ -0,0 +1,55 @@
/*
* Copyright 2019 - 2020 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.sync;
import org.alfresco.repo.security.sync.NodeDescription;
import de.acosix.alfresco.keycloak.repo.deps.keycloak.representations.idm.GroupRepresentation;
/**
* Instances of this class perform simple mappings from Keycloak group attributes to authority container node description properties.
*
* @author Axel Faust
*/
public class SimpleGroupAttributeProcessor extends BaseAttributeProcessor
implements GroupProcessor
{
protected boolean enabled;
/**
* @param enabled
* the enabled to set
*/
public void setEnabled(final boolean enabled)
{
this.enabled = enabled;
}
/**
*
* {@inheritDoc}
*/
@Override
public void mapGroup(final GroupRepresentation group, final NodeDescription groupNode)
{
if (this.enabled)
{
this.map(group.getAttributes(), groupNode);
}
}
}

View File

@@ -0,0 +1,70 @@
/*
* Copyright 2019 - 2020 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.sync;
import java.util.Collection;
import java.util.Collections;
import java.util.HashSet;
import org.alfresco.repo.security.sync.NodeDescription;
import org.alfresco.service.namespace.QName;
import de.acosix.alfresco.keycloak.repo.deps.keycloak.representations.idm.UserRepresentation;
/**
* Instances of this class perform simple mappings from Keycloak user attributes to person node description properties.
*
* @author Axel Faust
*/
public class SimpleUserAttributeProcessor extends BaseAttributeProcessor
implements UserProcessor
{
protected boolean enabled;
/**
* @param enabled
* the enabled to set
*/
public void setEnabled(final boolean enabled)
{
this.enabled = enabled;
}
/**
*
* {@inheritDoc}
*/
@Override
public void mapUser(final UserRepresentation user, final NodeDescription person)
{
if (this.enabled)
{
this.map(user.getAttributes(), person);
}
}
/**
*
* {@inheritDoc}
*/
@Override
public Collection<QName> getMappedProperties()
{
return this.enabled ? new HashSet<>(this.attributePropertyQNameMappings.values()) : Collections.emptySet();
}
}

View File

@@ -0,0 +1,38 @@
/*
* Copyright 2019 - 2020 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.sync;
import de.acosix.alfresco.keycloak.repo.deps.keycloak.representations.idm.UserRepresentation;
/**
* Instances of this interface are used to determine which users should be synchronised. All instances of this interface in the Keycloak
* authentication subsystem will be consulted and only users for which every filter returns {@code true} will be synchronised. If no filter
* instances have been defined, users will always be synchronised without any filtering.
*
* @author Axel Faust
*/
public interface UserFilter
{
/**
* Determines whether this user should be included in the synchronisation.
*
* @param user
* the user to consider
* @return {@code true} if the user should be synchronised, {@code false} if not
*/
boolean shouldIncludeUser(UserRepresentation user);
}

View File

@@ -0,0 +1,51 @@
/*
* Copyright 2019 - 2020 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.sync;
import java.util.Collection;
import org.alfresco.repo.security.sync.NodeDescription;
import org.alfresco.service.namespace.QName;
import de.acosix.alfresco.keycloak.repo.deps.keycloak.representations.idm.UserRepresentation;
/**
* Instances of this interface are used to map data from Keycloak users to the Alfresco person node description. All instances of this
* interface in the Keycloak authentication subsystem will be consulted in the order the beans are defined in the Spring application
* context, resulting in an aggregated person node description.
*
* @author Axel Faust
*/
public interface UserProcessor
{
/**
* Maps data from a Keycloak user representation to a description of an Alfresco node for the person.
*
* @param user
* the Keycloak user representation
* @param personNodeDescription
* the Alfresco node description
*/
void mapUser(UserRepresentation user, NodeDescription personNodeDescription);
/**
* Retrieves the set of properties mapped by this instance.
*
* @return the set of person node properties mapped by this instance
*/
Collection<QName> getMappedProperties();
}

View File

@@ -0,0 +1,60 @@
/*
* Copyright 2019 - 2020 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.util;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* This class bundles static utility operations meant to bridge slightly different APIs of Alfresco versions, so that the Keycloak module
* can run on
* in as many Alfresco versions as possible.
*
* @author Axel Faust
*/
public class AlfrescoCompatibilityUtil
{
private static final Logger LOGGER = LoggerFactory.getLogger(AlfrescoCompatibilityUtil.class);
/**
* Masks the user name except for the first two characters. This method has been ported from 6.1+ Alfresco AuthenticationUtil for use in
* any Alfresco version.
*
* @param userName
* the user name to mask
* @return the masked user name
*/
public static String maskUsername(final String userName)
{
if (userName != null)
{
try
{
if (userName.length() >= 2)
{
return userName.substring(0, 2) + new String(new char[(userName.length() - 2)]).replace("\0", "*");
}
}
catch (final Exception e)
{
LOGGER.debug("Failed to mask the username", e);
}
return userName;
}
return null;
}
}

View File

@@ -0,0 +1,167 @@
/*
* Copyright 2019 - 2020 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.util;
import java.io.Serializable;
import org.alfresco.util.ParameterCheck;
import de.acosix.alfresco.keycloak.repo.deps.keycloak.adapters.rotation.AdapterTokenVerifier.VerifiedTokens;
import de.acosix.alfresco.keycloak.repo.deps.keycloak.common.util.Time;
import de.acosix.alfresco.keycloak.repo.deps.keycloak.representations.AccessToken;
import de.acosix.alfresco.keycloak.repo.deps.keycloak.representations.AccessTokenResponse;
import de.acosix.alfresco.keycloak.repo.deps.keycloak.representations.IDToken;
/**
* Instances of this class encapsulate an access token with its associated refresh data.
*
* @author Axel Faust
*/
public class RefreshableAccessTokenHolder implements Serializable
{
private static final long serialVersionUID = -3230026569734591820L;
protected final AccessToken accessToken;
protected final IDToken idToken;
protected final String token;
protected final String refreshToken;
protected final int refreshExpiration;
/**
* Constructs a new instance of this class from an access token response, typically from an initial authentication or token refresh
*
* @param tokenResponse
* the response to a request for an access token
* @param verifiedTokens
* the token wrapper from the response verification step any client should do before constructing a new instance of this
* class
*/
public RefreshableAccessTokenHolder(final AccessTokenResponse tokenResponse, final VerifiedTokens verifiedTokens)
{
ParameterCheck.mandatory("tokenResponse", tokenResponse);
ParameterCheck.mandatory("verifiedTokens", verifiedTokens);
this.accessToken = verifiedTokens.getAccessToken();
this.idToken = verifiedTokens.getIdToken();
this.token = tokenResponse.getToken();
this.refreshToken = tokenResponse.getRefreshToken();
this.refreshExpiration = Time.currentTime() + (int) tokenResponse.getRefreshExpiresIn();
}
/**
* Constructs a new instance of this class from details exposed by Keycloak servlet adapter APIs. Since these APIs do not provide some
* access to token response details, this constructor assumes that the refresh token is valid for at least 1/100th the duration of the
* overall access token.
*
* @param accessToken
* the access token
* @param idToken
* the ID token
* @param token
* the textual representation of the access token
* @param refreshToken
* the textual representation of the refresh token
*/
public RefreshableAccessTokenHolder(final AccessToken accessToken, final IDToken idToken, final String token, final String refreshToken)
{
ParameterCheck.mandatory("accessToken", accessToken);
ParameterCheck.mandatory("idToken", idToken);
ParameterCheck.mandatoryString("token", token);
this.accessToken = accessToken;
this.idToken = idToken;
this.token = token;
this.refreshToken = refreshToken;
// no explicit refresh expiration, so assume validity period is 1/100th
this.refreshExpiration = Time.currentTime() - (accessToken.getExpiration() - Time.currentTime()) / 100;
}
/**
* Checks whether the encapsulated access token has expired.
*
* @return {@code true} if the access token as expired, {@code false} otherwise
*/
public boolean isExpired()
{
final boolean isExpired = this.accessToken.getExpiration() < Time.currentTime();
return isExpired;
}
/**
* Checks whether the encapsulated access token can be refreshed.
*
* @return {@code true} if the token can be refreshed, {@code false} otherwise
*/
public boolean canRefresh()
{
final boolean canRefresh = this.refreshToken != null && this.refreshExpiration > Time.currentTime();
return canRefresh;
}
/**
* Checks whether the encapsulated access token should be refreshed.
*
* @param minTokenTTL
* the minimum time-to-live remaining before a token needs to be refreshed
*
* @return {@code true} if the token should be refreshed, {@code false} otherwise
*/
public boolean shouldRefresh(final int minTokenTTL)
{
final boolean shouldRefresh = this.refreshToken != null && this.accessToken.getExpiration() - minTokenTTL < Time.currentTime();
return shouldRefresh;
}
/**
* @return the token
*/
public String getToken()
{
return this.token;
}
/**
* @return the refreshToken
*/
public String getRefreshToken()
{
return this.refreshToken;
}
/**
* @return the access token
*/
public AccessToken getAccessToken()
{
return this.accessToken;
}
/**
* @return the idToken
*/
public IDToken getIdToken()
{
return this.idToken;
}
}

View File

@@ -5,6 +5,7 @@ COPY maven ${docker.tests.repositoryWebappPath}
RUN echo "" >> ${docker.tests.repositoryWebappPath}/../../shared/classes/alfresco-global.properties \ RUN echo "" >> ${docker.tests.repositoryWebappPath}/../../shared/classes/alfresco-global.properties \
&& echo "#MergeGlobalProperties" >> ${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 \ && sed -i '/#MergeGlobalProperties/r ${docker.tests.repositoryWebappPath}/WEB-INF/classes/alfresco/extension/alfresco-global.addition.properties' ${docker.tests.repositoryWebappPath}/../../shared/classes/alfresco-global.properties \
&& sed -i 's/<secure>true<\/secure>/<secure>false<\/secure>/' $CATALINA_HOME/conf/web.xml \
&& mv ${docker.tests.repositoryWebappPath}/WEB-INF/classes/alfresco/extension/entrypoint.sh $CATALINA_HOME/bin/ \ && mv ${docker.tests.repositoryWebappPath}/WEB-INF/classes/alfresco/extension/entrypoint.sh $CATALINA_HOME/bin/ \
&& chmod +x $CATALINA_HOME/bin/entrypoint.sh && chmod +x $CATALINA_HOME/bin/entrypoint.sh

View File

@@ -1,5 +1,5 @@
# #
# Copyright 2019 Acosix GmbH # Copyright 2019 - 2020 Acosix GmbH
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@@ -22,4 +22,7 @@ keycloak.adapter.auth-server-url=http://${docker.tests.host.name}:${docker.tests
keycloak.adapter.realm=test keycloak.adapter.realm=test
keycloak.adapter.resource=alfresco keycloak.adapter.resource=alfresco
keycloak.adapter.credentials.provider=secret keycloak.adapter.credentials.provider=secret
keycloak.adapter.credentials.secret=6f70a28f-98cd-41ca-8f2f-368a8797d708 keycloak.adapter.credentials.secret=6f70a28f-98cd-41ca-8f2f-368a8797d708
keycloak.synchronization.userFilter.containedInGroup.property.groupPaths=/Test A
keycloak.synchronization.groupFilter.containedInGroup.property.groupPaths=/Test A

View File

@@ -1,5 +1,5 @@
# #
# Copyright 2019 Acosix GmbH # Copyright 2019 - 2020 Acosix GmbH
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.

View File

@@ -1,6 +1,6 @@
<?xml version='1.0' encoding='UTF-8'?> <?xml version='1.0' encoding='UTF-8'?>
<!-- <!--
Copyright 2019 Acosix GmbH Copyright 2019 - 2020 Acosix GmbH
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@@ -75,8 +75,7 @@
<dependencySet> <dependencySet>
<outputDirectory>WEB-INF/lib</outputDirectory> <outputDirectory>WEB-INF/lib</outputDirectory>
<includes> <includes>
<!-- everything else from Keycloak should already be bundled in Alfresco 6.x --> <include>${project.groupId}:${project.artifactId}.deps:*</include>
<include>org.keycloak:keycloak-servlet-filter-adapter:*</include>
</includes> </includes>
<scope>compile</scope> <scope>compile</scope>
</dependencySet> </dependencySet>

View File

@@ -1,98 +1,195 @@
{ {
"id": "test", "id": "test",
"realm": "test", "realm": "test",
"users": [ "groups": [{
{ "name": "Test A",
"id": "mustermann", "subGroups": [{
"username": "mmustermann", "name": "Test AA"
"enabled": true, }, {
"email": "max.mustermann@muster.com", "name": "Test AB"
"firstName": "Max", }]
"lastName": "Mustermann", }, {
"credentials": [ "name": "Test B",
{ "subGroups": [{
"type": "password", "name": "Test BA"
"value": "mmustermann" }]
} }],
"users": [{
"id": "service-account-alfresco",
"serviceAccountClientId": "alfresco",
"username": "service-account-alfresco",
"enabled": true,
"email": "service-account-alfresco@muster.com",
"realmRoles": [
"offline_access",
"uma_authorization"
],
"clientRoles": {
"account": [
"view-profile",
"manage-account"
], ],
"realmRoles": [ "realm-management": [
"user" "view-users"
], ]
"clientRoles": {
"account": [
"view-profile",
"manage-account"
]
}
} }
], }, {
"clients": [ "id": "mmustermann",
{ "username": "mmustermann",
"clientId": "alfresco", "enabled": true,
"name": "Alfresco Repository", "email": "max.mustermann@muster.com",
"rootUrl": "http://localhost:${docker.tests.repositoryPort}/alfresco", "firstName": "Max",
"adminUrl": "http://localhost:${docker.tests.repositoryPort}/alfresco/keycloak", "lastName": "Mustermann",
"baseUrl": "http://localhost:${docker.tests.repositoryPort}/alfresco", "credentials": [{
"surrogateAuthRequired": false, "type": "password",
"enabled": true, "value": "mmustermann"
"clientAuthenticatorType": "client-secret", }],
"secret" : "6f70a28f-98cd-41ca-8f2f-368a8797d708", "realmRoles": [
"redirectUris": [ "user"
"http://localhost:${docker.tests.repositoryPort}/alfresco/*" ],
"clientRoles": {
"account": [
"view-profile",
"manage-account"
]
},
"groups": [
"/Test A/Test AB",
"/Test B/Test BA"
]
}, {
"id": "jdoe",
"username": "jdoe",
"enabled": true,
"email": "john.doe@muster.com",
"firstName": "John",
"lastName": "Doe",
"credentials": [{
"type": "password",
"value": "jdoe"
}],
"realmRoles": [
"user"
],
"clientRoles": {
"account": [
"view-profile",
"manage-account"
]
}
}, {
"id": "ssuper",
"username": "ssuper",
"enabled": true,
"email": "suzy.super@muster.com",
"firstName": "Suzy",
"lastName": "Super",
"credentials": [{
"type": "password",
"value": "ssuper"
}],
"realmRoles": [
"user"
],
"clientRoles": {
"account": [
"view-profile",
"manage-account"
], ],
"webOrigins": [ "alfresco": [
"http://localhost:${docker.tests.repositoryPort}" "admin"
], ]
"notBefore": 0, }
"bearerOnly": false, }],
"consentRequired": false, "roles": {
"standardFlowEnabled": true, "client": {
"implicitFlowEnabled": false, "alfresco": [{
"directAccessGrantsEnabled": true, "id": "57944d14-7240-464b-925d-7778fa9b78e6",
"serviceAccountsEnabled": false, "name": "admin",
"publicClient": false, "composite": false,
"frontchannelLogout": false, "clientRole": true,
"attributes": {}
}]
}
},
"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": true,
"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,
"protocolMappers": [{
"name": "groups",
"protocol": "openid-connect", "protocol": "openid-connect",
"attributes": { "protocolMapper": "oidc-group-membership-mapper",
"saml.assertion.signature": "false", "consentRequired": false,
"saml.force.post.binding": "false", "config": {
"saml.multivalued.roles": "false", "full.path": "true",
"saml.encrypt": "false", "id.token.claim": "true",
"saml.server.signature": "false", "access.token.claim": "true",
"saml.server.signature.keyinfo.ext": "false", "claim.name": "groups",
"exclude.session.state.from.auth.response": "false", "userinfo.token.claim": "true"
"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
} }
}],
"defaultClientScopes": [
"web-origins",
"role_list",
"roles",
"profile",
"email"
],
"optionalClientScopes": [
"address",
"phone",
"offline_access",
"microprofile-jwt"
],
"access": {
"view": true,
"configure": true,
"manage": true
} }
], }],
"notBefore": 0, "notBefore": 0,
"revokeRefreshToken": false, "revokeRefreshToken": false,
"refreshTokenMaxReuse": 0, "refreshTokenMaxReuse": 0,
@@ -145,16 +242,13 @@
"FreeOTP", "FreeOTP",
"Google Authenticator" "Google Authenticator"
], ],
"scopeMappings": [ "scopeMappings": [{
{ "clientScope": "offline_access",
"clientScope": "offline_access", "roles": [
"roles": [ "offline_access"
"offline_access" ]
] }],
} "clientScopes": [{
],
"clientScopes": [
{
"id": "0ee41513-079a-4156-9eab-5709b18be2a6", "id": "0ee41513-079a-4156-9eab-5709b18be2a6",
"name": "address", "name": "address",
"description": "OpenID Connect built-in scope: address", "description": "OpenID Connect built-in scope: address",
@@ -164,26 +258,24 @@
"display.on.consent.screen": "true", "display.on.consent.screen": "true",
"consent.screen.text": "${addressScopeConsentText}" "consent.screen.text": "${addressScopeConsentText}"
}, },
"protocolMappers": [ "protocolMappers": [{
{ "id": "1218801d-c159-4ddd-901c-2fc5afa06170",
"id": "1218801d-c159-4ddd-901c-2fc5afa06170", "name": "address",
"name": "address", "protocol": "openid-connect",
"protocol": "openid-connect", "protocolMapper": "oidc-address-mapper",
"protocolMapper": "oidc-address-mapper", "consentRequired": false,
"consentRequired": false, "config": {
"config": { "user.attribute.formatted": "formatted",
"user.attribute.formatted": "formatted", "user.attribute.country": "country",
"user.attribute.country": "country", "user.attribute.postal_code": "postal_code",
"user.attribute.postal_code": "postal_code", "userinfo.token.claim": "true",
"userinfo.token.claim": "true", "user.attribute.street": "street",
"user.attribute.street": "street", "id.token.claim": "true",
"id.token.claim": "true", "user.attribute.region": "region",
"user.attribute.region": "region", "access.token.claim": "true",
"access.token.claim": "true", "user.attribute.locality": "locality"
"user.attribute.locality": "locality"
}
} }
] }]
}, },
{ {
"id": "bac1ffb3-92bf-481d-b6f8-8dec9bbe7291", "id": "bac1ffb3-92bf-481d-b6f8-8dec9bbe7291",
@@ -195,8 +287,7 @@
"display.on.consent.screen": "true", "display.on.consent.screen": "true",
"consent.screen.text": "${emailScopeConsentText}" "consent.screen.text": "${emailScopeConsentText}"
}, },
"protocolMappers": [ "protocolMappers": [{
{
"id": "4f28826f-46c6-4fe9-b499-611b66d9bc6f", "id": "4f28826f-46c6-4fe9-b499-611b66d9bc6f",
"name": "email", "name": "email",
"protocol": "openid-connect", "protocol": "openid-connect",
@@ -237,8 +328,7 @@
"include.in.token.scope": "true", "include.in.token.scope": "true",
"display.on.consent.screen": "false" "display.on.consent.screen": "false"
}, },
"protocolMappers": [ "protocolMappers": [{
{
"id": "a69c70ca-70c0-465b-8faf-fffd427f90d9", "id": "a69c70ca-70c0-465b-8faf-fffd427f90d9",
"name": "groups", "name": "groups",
"protocol": "openid-connect", "protocol": "openid-connect",
@@ -291,8 +381,7 @@
"display.on.consent.screen": "true", "display.on.consent.screen": "true",
"consent.screen.text": "${phoneScopeConsentText}" "consent.screen.text": "${phoneScopeConsentText}"
}, },
"protocolMappers": [ "protocolMappers": [{
{
"id": "850fe82e-ea0b-4cda-a0bc-dcc9d355c5a8", "id": "850fe82e-ea0b-4cda-a0bc-dcc9d355c5a8",
"name": "phone number verified", "name": "phone number verified",
"protocol": "openid-connect", "protocol": "openid-connect",
@@ -334,8 +423,7 @@
"display.on.consent.screen": "true", "display.on.consent.screen": "true",
"consent.screen.text": "${profileScopeConsentText}" "consent.screen.text": "${profileScopeConsentText}"
}, },
"protocolMappers": [ "protocolMappers": [{
{
"id": "da5905e2-00ee-4ed0-ab7d-cdac7ead6206", "id": "da5905e2-00ee-4ed0-ab7d-cdac7ead6206",
"name": "zoneinfo", "name": "zoneinfo",
"protocol": "openid-connect", "protocol": "openid-connect",
@@ -553,20 +641,18 @@
"consent.screen.text": "${samlRoleListScopeConsentText}", "consent.screen.text": "${samlRoleListScopeConsentText}",
"display.on.consent.screen": "true" "display.on.consent.screen": "true"
}, },
"protocolMappers": [ "protocolMappers": [{
{ "id": "51899037-a3df-4924-bd73-81f07cfb3aa9",
"id": "51899037-a3df-4924-bd73-81f07cfb3aa9", "name": "role list",
"name": "role list", "protocol": "saml",
"protocol": "saml", "protocolMapper": "saml-role-list-mapper",
"protocolMapper": "saml-role-list-mapper", "consentRequired": false,
"consentRequired": false, "config": {
"config": { "single": "false",
"single": "false", "attribute.nameformat": "Basic",
"attribute.nameformat": "Basic", "attribute.name": "Role"
"attribute.name": "Role"
}
} }
] }]
}, },
{ {
"id": "4c15f94e-f490-4541-8c14-7b4734d6999b", "id": "4c15f94e-f490-4541-8c14-7b4734d6999b",
@@ -578,15 +664,14 @@
"display.on.consent.screen": "true", "display.on.consent.screen": "true",
"consent.screen.text": "${rolesScopeConsentText}" "consent.screen.text": "${rolesScopeConsentText}"
}, },
"protocolMappers": [ "protocolMappers": [{
{
"id": "1908b63f-1be6-4f87-a947-30ee66721d05", "id": "1908b63f-1be6-4f87-a947-30ee66721d05",
"name": "audience resolve", "name": "audience resolve",
"protocol": "openid-connect", "protocol": "openid-connect",
"protocolMapper": "oidc-audience-resolve-mapper", "protocolMapper": "oidc-audience-resolve-mapper",
"consentRequired": false, "consentRequired": false,
"config": { "config": {
} }
}, },
{ {
@@ -629,18 +714,16 @@
"display.on.consent.screen": "false", "display.on.consent.screen": "false",
"consent.screen.text": "" "consent.screen.text": ""
}, },
"protocolMappers": [ "protocolMappers": [{
{ "id": "4652b835-1693-45ae-bad5-b61b534610de",
"id": "4652b835-1693-45ae-bad5-b61b534610de", "name": "allowed web origins",
"name": "allowed web origins", "protocol": "openid-connect",
"protocol": "openid-connect", "protocolMapper": "oidc-allowed-origins-mapper",
"protocolMapper": "oidc-allowed-origins-mapper", "consentRequired": false,
"consentRequired": false, "config": {
"config": {
}
} }
] }]
} }
], ],
"defaultDefaultClientScopes": [ "defaultDefaultClientScopes": [
@@ -666,7 +749,7 @@
"strictTransportSecurity": "max-age=31536000; includeSubDomains" "strictTransportSecurity": "max-age=31536000; includeSubDomains"
}, },
"smtpServer": { "smtpServer": {
}, },
"eventsEnabled": false, "eventsEnabled": false,
"eventsListeners": [ "eventsListeners": [
@@ -676,14 +759,13 @@
"adminEventsEnabled": false, "adminEventsEnabled": false,
"adminEventsDetailsEnabled": false, "adminEventsDetailsEnabled": false,
"components": { "components": {
"org.keycloak.services.clientregistration.policy.ClientRegistrationPolicy": [ "org.keycloak.services.clientregistration.policy.ClientRegistrationPolicy": [{
{
"id": "43a864c4-c4fa-4057-bf87-2ca409cde736", "id": "43a864c4-c4fa-4057-bf87-2ca409cde736",
"name": "Max Clients Limit", "name": "Max Clients Limit",
"providerId": "max-clients", "providerId": "max-clients",
"subType": "anonymous", "subType": "anonymous",
"subComponents": { "subComponents": {
}, },
"config": { "config": {
"max-clients": [ "max-clients": [
@@ -697,10 +779,10 @@
"providerId": "consent-required", "providerId": "consent-required",
"subType": "anonymous", "subType": "anonymous",
"subComponents": { "subComponents": {
}, },
"config": { "config": {
} }
}, },
{ {
@@ -709,7 +791,7 @@
"providerId": "allowed-client-templates", "providerId": "allowed-client-templates",
"subType": "authenticated", "subType": "authenticated",
"subComponents": { "subComponents": {
}, },
"config": { "config": {
"allow-default-scopes": [ "allow-default-scopes": [
@@ -723,7 +805,7 @@
"providerId": "allowed-protocol-mappers", "providerId": "allowed-protocol-mappers",
"subType": "authenticated", "subType": "authenticated",
"subComponents": { "subComponents": {
}, },
"config": { "config": {
"allowed-protocol-mapper-types": [ "allowed-protocol-mapper-types": [
@@ -744,10 +826,10 @@
"providerId": "scope", "providerId": "scope",
"subType": "anonymous", "subType": "anonymous",
"subComponents": { "subComponents": {
}, },
"config": { "config": {
} }
}, },
{ {
@@ -756,7 +838,7 @@
"providerId": "allowed-protocol-mappers", "providerId": "allowed-protocol-mappers",
"subType": "anonymous", "subType": "anonymous",
"subComponents": { "subComponents": {
}, },
"config": { "config": {
"allowed-protocol-mapper-types": [ "allowed-protocol-mapper-types": [
@@ -777,7 +859,7 @@
"providerId": "trusted-hosts", "providerId": "trusted-hosts",
"subType": "anonymous", "subType": "anonymous",
"subComponents": { "subComponents": {
}, },
"config": { "config": {
"host-sending-registration-request-must-match": [ "host-sending-registration-request-must-match": [
@@ -794,7 +876,7 @@
"providerId": "allowed-client-templates", "providerId": "allowed-client-templates",
"subType": "anonymous", "subType": "anonymous",
"subComponents": { "subComponents": {
}, },
"config": { "config": {
"allow-default-scopes": [ "allow-default-scopes": [
@@ -803,13 +885,12 @@
} }
} }
], ],
"org.keycloak.keys.KeyProvider": [ "org.keycloak.keys.KeyProvider": [{
{
"id": "005993b6-dcb3-4ebf-b19c-7287c740bb79", "id": "005993b6-dcb3-4ebf-b19c-7287c740bb79",
"name": "rsa-generated", "name": "rsa-generated",
"providerId": "rsa-generated", "providerId": "rsa-generated",
"subComponents": { "subComponents": {
}, },
"config": { "config": {
"priority": [ "priority": [
@@ -822,7 +903,7 @@
"name": "aes-generated", "name": "aes-generated",
"providerId": "aes-generated", "providerId": "aes-generated",
"subComponents": { "subComponents": {
}, },
"config": { "config": {
"priority": [ "priority": [
@@ -835,7 +916,7 @@
"name": "hmac-generated", "name": "hmac-generated",
"providerId": "hmac-generated", "providerId": "hmac-generated",
"subComponents": { "subComponents": {
}, },
"config": { "config": {
"priority": [ "priority": [
@@ -850,16 +931,14 @@
}, },
"internationalizationEnabled": false, "internationalizationEnabled": false,
"supportedLocales": [], "supportedLocales": [],
"authenticationFlows": [ "authenticationFlows": [{
{
"id": "50eda798-0ed6-4fd1-9071-977ca22b032f", "id": "50eda798-0ed6-4fd1-9071-977ca22b032f",
"alias": "Handle Existing Account", "alias": "Handle Existing Account",
"description": "Handle what to do if there is existing account with same email/username like authenticated identity provider", "description": "Handle what to do if there is existing account with same email/username like authenticated identity provider",
"providerId": "basic-flow", "providerId": "basic-flow",
"topLevel": false, "topLevel": false,
"builtIn": true, "builtIn": true,
"authenticationExecutions": [ "authenticationExecutions": [{
{
"authenticator": "idp-confirm-link", "authenticator": "idp-confirm-link",
"requirement": "REQUIRED", "requirement": "REQUIRED",
"priority": 10, "priority": 10,
@@ -889,8 +968,7 @@
"providerId": "basic-flow", "providerId": "basic-flow",
"topLevel": false, "topLevel": false,
"builtIn": true, "builtIn": true,
"authenticationExecutions": [ "authenticationExecutions": [{
{
"authenticator": "idp-username-password-form", "authenticator": "idp-username-password-form",
"requirement": "REQUIRED", "requirement": "REQUIRED",
"priority": 10, "priority": 10,
@@ -913,8 +991,7 @@
"providerId": "basic-flow", "providerId": "basic-flow",
"topLevel": true, "topLevel": true,
"builtIn": true, "builtIn": true,
"authenticationExecutions": [ "authenticationExecutions": [{
{
"authenticator": "auth-cookie", "authenticator": "auth-cookie",
"requirement": "ALTERNATIVE", "requirement": "ALTERNATIVE",
"priority": 10, "priority": 10,
@@ -951,8 +1028,7 @@
"providerId": "client-flow", "providerId": "client-flow",
"topLevel": true, "topLevel": true,
"builtIn": true, "builtIn": true,
"authenticationExecutions": [ "authenticationExecutions": [{
{
"authenticator": "client-secret", "authenticator": "client-secret",
"requirement": "ALTERNATIVE", "requirement": "ALTERNATIVE",
"priority": 10, "priority": 10,
@@ -989,8 +1065,7 @@
"providerId": "basic-flow", "providerId": "basic-flow",
"topLevel": true, "topLevel": true,
"builtIn": true, "builtIn": true,
"authenticationExecutions": [ "authenticationExecutions": [{
{
"authenticator": "direct-grant-validate-username", "authenticator": "direct-grant-validate-username",
"requirement": "REQUIRED", "requirement": "REQUIRED",
"priority": 10, "priority": 10,
@@ -1020,15 +1095,13 @@
"providerId": "basic-flow", "providerId": "basic-flow",
"topLevel": true, "topLevel": true,
"builtIn": true, "builtIn": true,
"authenticationExecutions": [ "authenticationExecutions": [{
{ "authenticator": "docker-http-basic-authenticator",
"authenticator": "docker-http-basic-authenticator", "requirement": "REQUIRED",
"requirement": "REQUIRED", "priority": 10,
"priority": 10, "userSetupAllowed": false,
"userSetupAllowed": false, "autheticatorFlow": false
"autheticatorFlow": false }]
}
]
}, },
{ {
"id": "eb15d55b-a601-493c-9f49-069695624ada", "id": "eb15d55b-a601-493c-9f49-069695624ada",
@@ -1037,8 +1110,7 @@
"providerId": "basic-flow", "providerId": "basic-flow",
"topLevel": true, "topLevel": true,
"builtIn": true, "builtIn": true,
"authenticationExecutions": [ "authenticationExecutions": [{
{
"authenticatorConfig": "review profile config", "authenticatorConfig": "review profile config",
"authenticator": "idp-review-profile", "authenticator": "idp-review-profile",
"requirement": "REQUIRED", "requirement": "REQUIRED",
@@ -1070,8 +1142,7 @@
"providerId": "basic-flow", "providerId": "basic-flow",
"topLevel": false, "topLevel": false,
"builtIn": true, "builtIn": true,
"authenticationExecutions": [ "authenticationExecutions": [{
{
"authenticator": "auth-username-password-form", "authenticator": "auth-username-password-form",
"requirement": "REQUIRED", "requirement": "REQUIRED",
"priority": 10, "priority": 10,
@@ -1094,8 +1165,7 @@
"providerId": "basic-flow", "providerId": "basic-flow",
"topLevel": true, "topLevel": true,
"builtIn": true, "builtIn": true,
"authenticationExecutions": [ "authenticationExecutions": [{
{
"authenticator": "no-cookie-redirect", "authenticator": "no-cookie-redirect",
"requirement": "REQUIRED", "requirement": "REQUIRED",
"priority": 10, "priority": 10,
@@ -1132,16 +1202,14 @@
"providerId": "basic-flow", "providerId": "basic-flow",
"topLevel": true, "topLevel": true,
"builtIn": true, "builtIn": true,
"authenticationExecutions": [ "authenticationExecutions": [{
{ "authenticator": "registration-page-form",
"authenticator": "registration-page-form", "requirement": "REQUIRED",
"requirement": "REQUIRED", "priority": 10,
"priority": 10, "flowAlias": "registration form",
"flowAlias": "registration form", "userSetupAllowed": false,
"userSetupAllowed": false, "autheticatorFlow": true
"autheticatorFlow": true }]
}
]
}, },
{ {
"id": "088ede2e-2dce-466d-b25f-62cc07c12bee", "id": "088ede2e-2dce-466d-b25f-62cc07c12bee",
@@ -1150,8 +1218,7 @@
"providerId": "form-flow", "providerId": "form-flow",
"topLevel": false, "topLevel": false,
"builtIn": true, "builtIn": true,
"authenticationExecutions": [ "authenticationExecutions": [{
{
"authenticator": "registration-user-creation", "authenticator": "registration-user-creation",
"requirement": "REQUIRED", "requirement": "REQUIRED",
"priority": 20, "priority": 20,
@@ -1188,8 +1255,7 @@
"providerId": "basic-flow", "providerId": "basic-flow",
"topLevel": true, "topLevel": true,
"builtIn": true, "builtIn": true,
"authenticationExecutions": [ "authenticationExecutions": [{
{
"authenticator": "reset-credentials-choose-user", "authenticator": "reset-credentials-choose-user",
"requirement": "REQUIRED", "requirement": "REQUIRED",
"priority": 10, "priority": 10,
@@ -1226,19 +1292,16 @@
"providerId": "basic-flow", "providerId": "basic-flow",
"topLevel": true, "topLevel": true,
"builtIn": true, "builtIn": true,
"authenticationExecutions": [ "authenticationExecutions": [{
{ "authenticator": "http-basic-authenticator",
"authenticator": "http-basic-authenticator", "requirement": "REQUIRED",
"requirement": "REQUIRED", "priority": 10,
"priority": 10, "userSetupAllowed": false,
"userSetupAllowed": false, "autheticatorFlow": false
"autheticatorFlow": false }]
}
]
} }
], ],
"authenticatorConfig": [ "authenticatorConfig": [{
{
"id": "1593e111-7e5e-4ee2-b33d-f459834e4e3b", "id": "1593e111-7e5e-4ee2-b33d-f459834e4e3b",
"alias": "create unique user config", "alias": "create unique user config",
"config": { "config": {
@@ -1253,8 +1316,7 @@
} }
} }
], ],
"requiredActions": [ "requiredActions": [{
{
"alias": "CONFIGURE_TOTP", "alias": "CONFIGURE_TOTP",
"name": "Configure OTP", "name": "Configure OTP",
"providerId": "CONFIGURE_TOTP", "providerId": "CONFIGURE_TOTP",
@@ -1262,7 +1324,7 @@
"defaultAction": false, "defaultAction": false,
"priority": 10, "priority": 10,
"config": { "config": {
} }
}, },
{ {
@@ -1273,7 +1335,7 @@
"defaultAction": false, "defaultAction": false,
"priority": 20, "priority": 20,
"config": { "config": {
} }
}, },
{ {
@@ -1284,7 +1346,7 @@
"defaultAction": false, "defaultAction": false,
"priority": 30, "priority": 30,
"config": { "config": {
} }
}, },
{ {
@@ -1295,7 +1357,7 @@
"defaultAction": false, "defaultAction": false,
"priority": 40, "priority": 40,
"config": { "config": {
} }
}, },
{ {
@@ -1306,7 +1368,7 @@
"defaultAction": false, "defaultAction": false,
"priority": 50, "priority": 50,
"config": { "config": {
} }
} }
], ],

View File

@@ -0,0 +1,53 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
<parent>
<artifactId>de.acosix.alfresco.keycloak.parent</artifactId>
<groupId>de.acosix.alfresco.keycloak</groupId>
<version>1.1.0-SNAPSHOT</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>de.acosix.alfresco.keycloak.share.deps</artifactId>
<name>Acosix Alfresco Keycloak - Share Dependencies Module</name>
<description>Aggregate (Uber-)JAR of all dependencies for the Acosix Alfresco Keycloak Share Module</description>
<build>
<pluginManagement>
<plugins>
<plugin>
<artifactId>maven-shade-plugin</artifactId>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
<configuration>
<relocations>
<relocation>
<pattern>org.keycloak</pattern>
<shadedPattern>de.acosix.alfresco.keycloak.share.deps.keycloak</shadedPattern>
</relocation>
<relocation>
<pattern>org.jboss.logging</pattern>
<shadedPattern>de.acosix.alfresco.keycloak.share.deps.jboss.logging</shadedPattern>
</relocation>
</relocations>
<transformers>
<transformer />
<transformer />
<transformer>
<addHeader>false</addHeader>
</transformer>
</transformers>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</pluginManagement>
<plugins>
<plugin>
<artifactId>maven-shade-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>

128
share-dependencies/pom.xml Normal file
View File

@@ -0,0 +1,128 @@
<?xml version='1.0' encoding='UTF-8'?>
<!--
Copyright 2019 - 2020 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.
-->
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>de.acosix.alfresco.keycloak</groupId>
<artifactId>de.acosix.alfresco.keycloak.parent</artifactId>
<version>1.1.0-SNAPSHOT</version>
</parent>
<artifactId>de.acosix.alfresco.keycloak.share.deps</artifactId>
<name>Acosix Alfresco Keycloak - Share Dependencies Module</name>
<description>Aggregate (Uber-)JAR of all dependencies for the Acosix Alfresco Keycloak Share Module</description>
<dependencies>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-adapter-core</artifactId>
<exclusions>
<exclusion>
<groupId>org.bouncycastle</groupId>
<artifactId>*</artifactId>
</exclusion>
<exclusion>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>*</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-servlet-adapter-spi</artifactId>
<exclusions>
<exclusion>
<groupId>org.bouncycastle</groupId>
<artifactId>*</artifactId>
</exclusion>
<exclusion>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>*</artifactId>
</exclusion>
</exclusions>
</dependency>
<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>org.keycloak</groupId>
<artifactId>keycloak-authz-client</artifactId>
</dependency>
</dependencies>
<build>
<pluginManagement>
<plugins>
<plugin>
<artifactId>maven-shade-plugin</artifactId>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
<configuration>
<relocations>
<relocation>
<pattern>org.keycloak</pattern>
<shadedPattern>de.acosix.alfresco.keycloak.share.deps.keycloak</shadedPattern>
</relocation>
<relocation>
<pattern>org.jboss.logging</pattern>
<shadedPattern>de.acosix.alfresco.keycloak.share.deps.jboss.logging</shadedPattern>
</relocation>
</relocations>
<transformers>
<transformer
implementation="org.apache.maven.plugins.shade.resource.ServicesResourceTransformer" />
<transformer
implementation="org.apache.maven.plugins.shade.resource.ApacheLicenseResourceTransformer" />
<transformer
implementation="org.apache.maven.plugins.shade.resource.ApacheNoticeResourceTransformer">
<addHeader>false</addHeader>
</transformer>
</transformers>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</pluginManagement>
<plugins>
<plugin>
<artifactId>maven-shade-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>

View File

@@ -1,6 +1,6 @@
<?xml version='1.0' encoding='UTF-8'?> <?xml version='1.0' encoding='UTF-8'?>
<!-- <!--
Copyright 2019 Acosix GmbH Copyright 2019 - 2020 Acosix GmbH
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@@ -127,11 +127,92 @@
</dependencies> </dependencies>
<build> <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 -->
</image>
<image>
<!-- no change to Search image -->
</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> <plugins>
<plugin> <plugin>
<groupId>net.alchim31.maven</groupId> <groupId>net.alchim31.maven</groupId>
<artifactId>yuicompressor-maven-plugin</artifactId> <artifactId>yuicompressor-maven-plugin</artifactId>
</plugin> </plugin>
<plugin>
<groupId>io.fabric8</groupId>
<artifactId>docker-maven-plugin</artifactId>
</plugin>
</plugins> </plugins>
</build> </build>

View File

@@ -1,6 +1,6 @@
<?xml version='1.0' encoding='UTF-8'?> <?xml version='1.0' encoding='UTF-8'?>
<!-- <!--
Copyright 2019 Acosix GmbH Copyright 2019 - 2020 Acosix GmbH
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.

View File

@@ -1,6 +1,6 @@
<?xml version='1.0' encoding='UTF-8' ?> <?xml version='1.0' encoding='UTF-8' ?>
<!-- <!--
Copyright 2019 Acosix GmbH Copyright 2019 - 2020 Acosix GmbH
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.

View File

@@ -1,6 +1,6 @@
<?xml version='1.0' encoding='UTF-8' ?> <?xml version='1.0' encoding='UTF-8' ?>
<!-- <!--
Copyright 2019 Acosix GmbH Copyright 2019 - 2020 Acosix GmbH
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.

View File

@@ -1,6 +1,6 @@
<?xml version='1.0' encoding='UTF-8'?> <?xml version='1.0' encoding='UTF-8'?>
<!-- <!--
Copyright 2019 Acosix GmbH Copyright 2019 - 2020 Acosix GmbH
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.

View File

@@ -1,6 +1,6 @@
<?xml version='1.0' encoding='UTF-8' ?> <?xml version='1.0' encoding='UTF-8' ?>
<!-- <!--
Copyright 2019 Acosix GmbH Copyright 2019 - 2020 Acosix GmbH
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.

View File

@@ -1,5 +1,5 @@
/* /*
* Copyright 2019 Acosix GmbH * Copyright 2019 - 2020 Acosix GmbH
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.

View File

@@ -1,5 +1,5 @@
/* /*
* Copyright 2019 Acosix GmbH * Copyright 2019 - 2020 Acosix GmbH
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.

View File

@@ -1,5 +1,5 @@
/* /*
* Copyright 2019 Acosix GmbH * Copyright 2019 - 2020 Acosix GmbH
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.

View File

@@ -1,5 +1,5 @@
/* /*
* Copyright 2019 Acosix GmbH * Copyright 2019 - 2020 Acosix GmbH
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.

View File

@@ -1,5 +1,5 @@
/* /*
* Copyright 2019 Acosix GmbH * Copyright 2019 - 2020 Acosix GmbH
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.

View File

@@ -1,5 +1,5 @@
/* /*
* Copyright 2019 Acosix GmbH * Copyright 2019 - 2020 Acosix GmbH
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.

View File

@@ -1,5 +1,5 @@
/* /*
* Copyright 2019 Acosix GmbH * Copyright 2019 - 2020 Acosix GmbH
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.

View File

@@ -1,5 +1,5 @@
/* /*
* Copyright 2019 Acosix GmbH * Copyright 2019 - 2020 Acosix GmbH
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.

View File

@@ -1,5 +1,5 @@
/* /*
* Copyright 2019 Acosix GmbH * Copyright 2019 - 2020 Acosix GmbH
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.

View File

@@ -1,5 +1,5 @@
/* /*
* Copyright 2019 Acosix GmbH * Copyright 2019 - 2020 Acosix GmbH
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.

View File

@@ -1,5 +1,5 @@
/* /*
* Copyright 2019 Acosix GmbH * Copyright 2019 - 2020 Acosix GmbH
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.

View File

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8" ?> <?xml version="1.0" encoding="UTF-8" ?>
<!-- <!--
Copyright 2019 Acosix GmbH Copyright 2019 - 2020 Acosix GmbH
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.

View File

@@ -1,5 +1,5 @@
<#-- <#--
Copyright 2019 Acosix GmbH Copyright 2019 - 2020 Acosix GmbH
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.

View File

@@ -1,5 +1,5 @@
/* /*
* Copyright 2019 Acosix GmbH * Copyright 2019 - 2020 Acosix GmbH
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.

View File

@@ -0,0 +1,12 @@
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 \
&& sed -i 's/<secure>true<\/secure>/<secure>false<\/secure>/' $CATALINA_HOME/conf/web.xml \
&& 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,28 @@
#
# Copyright 2019 - 2020 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
keycloak.synchronization.userFilter.containedInGroup.property.groupPaths=/Test A
keycloak.synchronization.groupFilter.containedInGroup.property.groupPaths=/Test A

View File

@@ -0,0 +1,25 @@
#
# Copyright 2019 - 2020 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

@@ -1,6 +1,6 @@
<?xml version='1.0' encoding='UTF-8' ?> <?xml version='1.0' encoding='UTF-8' ?>
<!-- <!--
Copyright 2019 Acosix GmbH Copyright 2019 - 2020 Acosix GmbH
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.

View File

@@ -1,6 +1,6 @@
<?xml version='1.0' encoding='UTF-8'?> <?xml version='1.0' encoding='UTF-8'?>
<!-- <!--
Copyright 2019 Acosix GmbH Copyright 2019 - 2020 Acosix GmbH
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@@ -23,18 +23,43 @@
<format>dir</format> <format>dir</format>
</formats> </formats>
<includeBaseDirectory>false</includeBaseDirectory> <includeBaseDirectory>false</includeBaseDirectory>
<dependencySets> <fileSets>
<!-- <fileSet>
<dependencySet> <directory>${project.basedir}/src/test/docker/alfresco</directory>
<outputDirectory>WEB-INF/lib</outputDirectory> <outputDirectory>WEB-INF/classes/alfresco</outputDirectory>
<includes> <includes>
<include>*</include>
<include>**/*</include>
</includes> </includes>
<scope>compile</scope> <excludes>
</dependencySet> <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> <dependencySet>
<outputDirectory>WEB-INF/lib</outputDirectory> <outputDirectory>WEB-INF/lib</outputDirectory>
<includes> <includes>
<include>${project.groupId}:de.acosix.alfresco.keycloak.repo.deps:*</include>
<include>de.acosix.alfresco.utility:de.acosix.alfresco.utility.common:*</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.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.quartz2:*</include>

View File

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

View File

@@ -1,6 +1,6 @@
<?xml version='1.0' encoding='UTF-8'?> <?xml version='1.0' encoding='UTF-8'?>
<!-- <!--
Copyright 2019 Acosix GmbH Copyright 2019 - 2020 Acosix GmbH
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@@ -23,6 +23,13 @@
<format>dir</format> <format>dir</format>
</formats> </formats>
<includeBaseDirectory>false</includeBaseDirectory> <includeBaseDirectory>false</includeBaseDirectory>
<files>
<file>
<source>${project.basedir}/src/test/docker/share-log4j.properties</source>
<outputDirectory>WEB-INF/classes</outputDirectory>
<destName>log4j.properties</destName>
</file>
</files>
<fileSets> <fileSets>
<fileSet> <fileSet>
<directory>${project.build.directory}</directory> <directory>${project.build.directory}</directory>
@@ -32,24 +39,41 @@
</includes> </includes>
</fileSet> </fileSet>
<fileSet> <fileSet>
<directory>${project.basedir}/src/test/resources</directory> <directory>${project.basedir}/src/test/docker/alfresco</directory>
<outputDirectory>WEB-INF/classes</outputDirectory> <outputDirectory>WEB-INF/classes/alfresco</outputDirectory>
<includes> <includes>
<include>**/*.properties</include> <include>*</include>
<include>**/*.xml</include> <include>**/*</include>
</includes> </includes>
<excludes>
<exclude>*.js</exclude>
<exclude>**/*.js</exclude>
<exclude>*.ftl</exclude>
<exclude>**/*.ftl</exclude>
<exclude>*.keystore</exclude>
<exclude>**/*.keystore</exclude>
</excludes>
<filtered>true</filtered> <filtered>true</filtered>
<lineEnding>lf</lineEnding> <lineEnding>lf</lineEnding>
</fileSet> </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> </fileSets>
<dependencySets> <dependencySets>
<dependencySet> <dependencySet>
<outputDirectory>WEB-INF/lib</outputDirectory> <outputDirectory>WEB-INF/lib</outputDirectory>
<includes> <includes>
<include>org.keycloak:*</include> <include>${project.groupId}:de.acosix.alfresco.keycloak.share.deps:*</include>
<include>org.jboss.logging:*</include>
<include>org.bouncycastle:*</include>
<include>com.fasterxml.jackson.core:*</include>
</includes> </includes>
<scope>compile</scope> <scope>compile</scope>
</dependencySet> </dependencySet>

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
/* /*
* Copyright 2019 Acosix GmbH * Copyright 2019 - 2020 Acosix GmbH
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.

View File

@@ -1,6 +1,6 @@
<?xml version='1.0' encoding='UTF-8' ?> <?xml version='1.0' encoding='UTF-8' ?>
<!-- <!--
Copyright 2019 Acosix GmbH Copyright 2019 - 2020 Acosix GmbH
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.

View File

@@ -1,6 +1,6 @@
<?xml version='1.0' encoding='UTF-8' ?> <?xml version='1.0' encoding='UTF-8' ?>
<!-- <!--
Copyright 2019 Acosix GmbH Copyright 2019 - 2020 Acosix GmbH
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.