diff --git a/LICENSE b/LICENSE
index ace4d69..e7233b7 100644
--- a/LICENSE
+++ b/LICENSE
@@ -186,7 +186,7 @@
same "printed page" as the copyright notice for easier
identification within third-party archives.
- Copyright 2019 Acosix GmbH
+ 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.
diff --git a/pom.xml b/pom.xml
index 25020d6..4de5717 100644
--- a/pom.xml
+++ b/pom.xml
@@ -1,6 +1,6 @@
1.8
1.8
+ 3.2.1
+
6.0.1
-
+ 3.6.3.Final
+
4.5.1
4.4.3
@@ -125,6 +127,30 @@
keycloak-authz-client
${keycloak.version}
+
+
+ org.keycloak
+ keycloak-admin-client
+ ${keycloak.version}
+
+
+
+ org.jboss.resteasy
+ resteasy-client
+ ${resteasy.version}
+
+
+
+ org.jboss.resteasy
+ resteasy-multipart-provider
+ ${resteasy.version}
+
+
+
+ org.jboss.resteasy
+ resteasy-jackson2-provider
+ ${resteasy.version}
+
@@ -205,11 +231,20 @@
-
+
+
+
+ maven-shade-plugin
+ ${maven.shade.version}
+
+
+
+ repository-dependencies
repository
+ share-dependencies
share
\ No newline at end of file
diff --git a/repository-dependencies/dependency-reduced-pom.xml b/repository-dependencies/dependency-reduced-pom.xml
new file mode 100644
index 0000000..61d14a4
--- /dev/null
+++ b/repository-dependencies/dependency-reduced-pom.xml
@@ -0,0 +1,53 @@
+
+
+
+ de.acosix.alfresco.keycloak.parent
+ de.acosix.alfresco.keycloak
+ 1.1.0-SNAPSHOT
+
+ 4.0.0
+ de.acosix.alfresco.keycloak.repo.deps
+ Acosix Alfresco Keycloak - Repository Dependencies Module
+ Aggregate (Uber-)JAR of all dependencies for the Acosix Alfresco Keycloak Repository Module
+
+
+
+
+ maven-shade-plugin
+
+
+ package
+
+ shade
+
+
+
+
+ org.keycloak
+ de.acosix.alfresco.keycloak.repo.deps.keycloak
+
+
+ org.jboss.logging
+ de.acosix.alfresco.keycloak.repo.deps.jboss.logging
+
+
+
+
+
+
+ false
+
+
+
+
+
+
+
+
+
+
+ maven-shade-plugin
+
+
+
+
diff --git a/repository-dependencies/pom.xml b/repository-dependencies/pom.xml
new file mode 100644
index 0000000..998bb60
--- /dev/null
+++ b/repository-dependencies/pom.xml
@@ -0,0 +1,127 @@
+
+
+
+ 4.0.0
+
+
+ de.acosix.alfresco.keycloak
+ de.acosix.alfresco.keycloak.parent
+ 1.1.0-SNAPSHOT
+
+
+ de.acosix.alfresco.keycloak.repo.deps
+ Acosix Alfresco Keycloak - Repository Dependencies Module
+ Aggregate (Uber-)JAR of all dependencies for the Acosix Alfresco Keycloak Repository Module
+
+
+
+ org.keycloak
+ keycloak-adapter-core
+
+
+ org.bouncycastle
+ *
+
+
+ com.fasterxml.jackson.core
+ *
+
+
+
+
+
+ org.keycloak
+ keycloak-servlet-adapter-spi
+
+
+ org.bouncycastle
+ *
+
+
+ com.fasterxml.jackson.core
+ *
+
+
+
+
+
+ org.keycloak
+ keycloak-servlet-filter-adapter
+
+
+ org.bouncycastle
+ *
+
+
+ com.fasterxml.jackson.core
+ *
+
+
+ org.apache.httpcomponents
+ *
+
+
+
+
+
+
+
+
+
+ maven-shade-plugin
+
+
+ package
+
+ shade
+
+
+
+
+ org.keycloak
+ de.acosix.alfresco.keycloak.repo.deps.keycloak
+
+
+ org.jboss.logging
+ de.acosix.alfresco.keycloak.repo.deps.jboss.logging
+
+
+
+
+
+
+ false
+
+
+
+
+
+
+
+
+
+
+
+ maven-shade-plugin
+
+
+
+
\ No newline at end of file
diff --git a/repository/pom.xml b/repository/pom.xml
index b090479..fdc3c35 100644
--- a/repository/pom.xml
+++ b/repository/pom.xml
@@ -1,6 +1,6 @@
-
- 4.6.0.Final
8380
@@ -39,6 +36,12 @@
org.alfresco
alfresco-remote-api
+
+
+ org.keycloak
+ *
+
+
@@ -46,17 +49,17 @@
javax.servlet-api
-
- org.keycloak
- keycloak-servlet-filter-adapter
+ ${project.groupId}
+ de.acosix.alfresco.keycloak.repo.deps
+ ${project.version}
- org.bouncycastle
+ org.keycloak
*
- com.fasterxml.jackson.core
+ org.jboss.resteasy
*
@@ -115,7 +118,7 @@
-
+
jboss/keycloak
diff --git a/repository/src/main/asembly/amp.xml b/repository/src/main/asembly/amp.xml
new file mode 100644
index 0000000..4daec04
--- /dev/null
+++ b/repository/src/main/asembly/amp.xml
@@ -0,0 +1,52 @@
+
+
+
+ amp
+
+ amp
+
+ false
+
+ assemblies/amp-lib-component.xml
+ assemblies/amp-config-component.xml
+ assemblies/amp-messages-component.xml
+ assemblies/amp-repo-webscript-component.xml
+ assemblies/amp-surf-webscript-component.xml
+ assemblies/amp-templates-component.xml
+ assemblies/amp-webapp-component.xml
+
+
+
+ ${project.basedir}
+
+
+ *.properties
+
+ true
+ crlf
+
+
+
+
+ lib
+
+ ${project.groupId}:${project.artifactId}.deps:*
+
+
+
+
diff --git a/repository/src/main/config/alfresco-global.properties b/repository/src/main/config/alfresco-global.properties
index e7dd9ec..ad180f0 100644
--- a/repository/src/main/config/alfresco-global.properties
+++ b/repository/src/main/config/alfresco-global.properties
@@ -1,3 +1,5 @@
+${moduleId}.authorityServiceEnhancement.enabled=true
+
cache.${moduleId}.ssoToSessionCache.maxItems=10000
cache.${moduleId}.ssoToSessionCache.timeToLiveSeconds=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
cache.${moduleId}.sessionToPrincipalCache.clearable=false
# replicate, not distribute
-cache.${moduleId}.sessionToPrincipalCache.ignite.cache.type=replicated
\ No newline at end of file
+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
\ No newline at end of file
diff --git a/repository/src/main/config/log4j.properties b/repository/src/main/config/log4j.properties
index 18249c2..3733c5c 100644
--- a/repository/src/main/config/log4j.properties
+++ b/repository/src/main/config/log4j.properties
@@ -1 +1,2 @@
-log4j.logger.${project.artifactId}=INFO
\ No newline at end of file
+log4j.logger.${project.artifactId}=INFO
+log4j.logger.${project.artifactId}.deps=ERROR
\ No newline at end of file
diff --git a/repository/src/main/config/module-context.xml b/repository/src/main/config/module-context.xml
index 2051032..d18a7e8 100644
--- a/repository/src/main/config/module-context.xml
+++ b/repository/src/main/config/module-context.xml
@@ -1,6 +1,6 @@
@@ -72,7 +73,7 @@
-
+
@@ -81,6 +82,7 @@
+
@@ -101,7 +103,7 @@
-
+
@@ -133,5 +135,77 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ userAuthority
+ userToken
+
+
+
+
+
+
+
+
+
+
+ userFilter
+ userMapper
+ groupFilter
+ groupMapper
+
+
+
\ No newline at end of file
diff --git a/repository/src/main/globalConfig/subsystems/Authentication/keycloak/keycloak-authentication.properties b/repository/src/main/globalConfig/subsystems/Authentication/keycloak/keycloak-authentication.properties
index 906b81f..7f7b55d 100644
--- a/repository/src/main/globalConfig/subsystems/Authentication/keycloak/keycloak-authentication.properties
+++ b/repository/src/main/globalConfig/subsystems/Authentication/keycloak/keycloak-authentication.properties
@@ -4,9 +4,12 @@ keycloak.authentication.defaultAdministratorUserNames=
keycloak.authentication.allowTicketLogons=true
keycloak.authentication.allowLocalBasicLogon=true
keycloak.authentication.allowUserNamePasswordLogin=true
+keycloak.authentication.failExpiredUserNamePasswordLoginTokens=false
keycloak.authentication.allowGuestLogin=true
+keycloak.authentication.mapRoles=true
+keycloak.authentication.mapPersonPropertiesOnLogin=true
keycloak.authentication.authenticateFTP=true
-keycloak.authentication.silentValidationFailure=true
+keycloak.authentication.silentRemoteUserValidationFailure=true
keycloak.authentication.connectionTimeout=-1
keycloak.authentication.socketTimeout=-1
@@ -22,4 +25,75 @@ keycloak.adapter.public-client=false
keycloak.adapter.credentials.provider=secret
keycloak.adapter.credentials.secret=
-# TODO default settings (identical to AdapterConfig defaults) to better align with default Alfresco subsystem property handling
\ No newline at end of file
+# 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
\ No newline at end of file
diff --git a/repository/src/main/java/de/acosix/alfresco/keycloak/repo/authentication/AuthorityExtractor.java b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/authentication/AuthorityExtractor.java
new file mode 100644
index 0000000..64b2e33
--- /dev/null
+++ b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/authentication/AuthorityExtractor.java
@@ -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 extractAuthorities(AccessToken accessToken);
+}
diff --git a/repository/src/main/java/de/acosix/alfresco/keycloak/repo/authentication/DefaultAuthorityExtractor.java b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/authentication/DefaultAuthorityExtractor.java
new file mode 100644
index 0000000..75189c1
--- /dev/null
+++ b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/authentication/DefaultAuthorityExtractor.java
@@ -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 realmAccessExplicitMappings;
+
+ protected Map 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 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 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 realmAccessExplicitMappings)
+ {
+ this.realmAccessExplicitMappings = realmAccessExplicitMappings;
+ }
+
+ /**
+ * @param resourceAccessExplicitMappings
+ * the resourceAccessExplicitMappings to set
+ */
+ public void setResourceAccessExplicitMappings(final Map 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 extractAuthorities(final AccessToken accessToken)
+ {
+ Set 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 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 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 processAccess(final Access access, final Map explicitMappings, final AuthorityType authorityType,
+ final String prefix, final boolean capitalisation, final Locale capitalisationLocale)
+ {
+ final Set authorities;
+
+ final Set roles = access.getRoles();
+ if (roles != null && !roles.isEmpty())
+ {
+ LOGGER.debug("Access representation contains roles {}", roles);
+
+ Stream 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 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;
+ }
+}
diff --git a/repository/src/main/java/de/acosix/alfresco/keycloak/repo/authentication/DefaultPersonProcessor.java b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/authentication/DefaultPersonProcessor.java
new file mode 100644
index 0000000..3110314
--- /dev/null
+++ b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/authentication/DefaultPersonProcessor.java
@@ -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 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());
+ }
+ }
+ }
+}
diff --git a/repository/src/main/java/de/acosix/alfresco/keycloak/repo/authentication/KeycloakAuthenticationComponent.java b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/authentication/KeycloakAuthenticationComponent.java
index b04ad65..62c3e60 100644
--- a/repository/src/main/java/de/acosix/alfresco/keycloak/repo/authentication/KeycloakAuthenticationComponent.java
+++ b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/authentication/KeycloakAuthenticationComponent.java
@@ -1,5 +1,5 @@
/*
- * Copyright 2019 Acosix GmbH
+ * 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.
@@ -15,46 +15,95 @@
*/
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.HashSet;
+import java.util.List;
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.security.authentication.AbstractAuthenticationComponent;
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.keycloak.adapters.HttpClientBuilder;
-import org.keycloak.authorization.client.AuthzClient;
-import org.keycloak.authorization.client.Configuration;
-import org.keycloak.authorization.client.util.HttpResponseException;
-import org.keycloak.representations.adapters.config.AdapterConfig;
+import org.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.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 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
*/
-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);
+ protected final ThreadLocal lastTokenResponseStoreEnabled = new ThreadLocal<>();
+
+ protected final ThreadLocal lastTokenResponse = new ThreadLocal<>();
+
protected boolean active;
+ protected ApplicationContext applicationContext;
+
protected boolean allowUserNamePasswordLogin;
+ protected boolean failExpiredTicketTokens;
+
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 authorityExtractors;
- protected AuthzClient authzClient;
+ protected Collection userProcessors;
/**
*
@@ -63,54 +112,22 @@ public class KeycloakAuthenticationComponent extends AbstractAuthenticationCompo
@Override
public void afterPropertiesSet()
{
- PropertyCheck.mandatory(this, "adapterConfig", this.adapterConfig);
+ PropertyCheck.mandatory(this, "applicationContext", this.applicationContext);
+ PropertyCheck.mandatory(this, "keycloakDeployment", this.deployment);
- if (this.allowUserNamePasswordLogin)
- {
- Map credentials = this.adapterConfig.getCredentials();
- if (credentials != null)
- {
- credentials = new HashMap<>(credentials);
- }
+ this.authorityExtractors = Collections
+ .unmodifiableList(new ArrayList<>(this.applicationContext.getBeansOfType(AuthorityExtractor.class, false, true).values()));
+ this.userProcessors = Collections
+ .unmodifiableList(new ArrayList<>(this.applicationContext.getBeansOfType(UserProcessor.class, false, true).values()));
+ }
- if (credentials == null || ((!credentials.containsKey("provider") || "secret".equals(credentials.get("provider")))
- && !credentials.containsKey("secret")))
- {
- if (credentials == null)
- {
- credentials = new HashMap<>();
- }
- credentials.put("secret", "");
- }
-
- HttpClientBuilder httpClientBuilder = new HttpClientBuilder();
- if (this.connectionTimeout > 0)
- {
- httpClientBuilder = httpClientBuilder.establishConnectionTimeout(this.connectionTimeout, TimeUnit.MILLISECONDS);
- }
- if (this.socketTimeout > 0)
- {
- httpClientBuilder = httpClientBuilder.socketTimeout(this.socketTimeout, TimeUnit.MILLISECONDS);
- }
-
- this.config = new Configuration(this.adapterConfig.getAuthServerUrl(), this.adapterConfig.getRealm(),
- this.adapterConfig.getResource(), credentials, httpClientBuilder.build(this.adapterConfig));
- try
- {
- this.authzClient = AuthzClient.create(this.config);
- }
- catch (final RuntimeException e)
- {
- if (LOGGER.isDebugEnabled())
- {
- LOGGER.debug("Failed to pre-instantiate Keycloak authz client", e);
- }
- else
- {
- LOGGER.warn("Failed to pre-instantiate Keycloak authz client: {}", e.getMessage());
- }
- }
- }
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public boolean isActive()
+ {
+ return this.active;
}
/**
@@ -122,6 +139,15 @@ public class KeycloakAuthenticationComponent extends AbstractAuthenticationCompo
this.active = active;
}
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public void setApplicationContext(final ApplicationContext applicationContext)
+ {
+ this.applicationContext = applicationContext;
+ }
+
/**
* @param allowUserNamePasswordLogin
* the allowUserNamePasswordLogin to set
@@ -131,6 +157,15 @@ public class KeycloakAuthenticationComponent extends AbstractAuthenticationCompo
this.allowUserNamePasswordLogin = allowUserNamePasswordLogin;
}
+ /**
+ * @param failExpiredTicketTokens
+ * the failExpiredTicketTokens to set
+ */
+ public void setFailExpiredTicketTokens(final boolean failExpiredTicketTokens)
+ {
+ this.failExpiredTicketTokens = failExpiredTicketTokens;
+ }
+
/**
* @param allowGuestLogin
* the allowGuestLogin to set
@@ -138,42 +173,130 @@ public class KeycloakAuthenticationComponent extends AbstractAuthenticationCompo
public void setAllowGuestLogin(final boolean allowGuestLogin)
{
this.allowGuestLogin = allowGuestLogin;
+ super.setAllowGuestLogin(Boolean.valueOf(allowGuestLogin));
}
/**
- * @param adapterConfig
- * the adapterConfig to set
- */
- public void setAdapterConfig(final AdapterConfig adapterConfig)
- {
- this.adapterConfig = adapterConfig;
- }
-
- /**
- * @param connectionTimeout
- * the connectionTimeout to set
- */
- public void setConnectionTimeout(final int connectionTimeout)
- {
- this.connectionTimeout = connectionTimeout;
- }
-
- /**
- * @param socketTimeout
- * the socketTimeout to set
- */
- public void setSocketTimeout(final int socketTimeout)
- {
- this.socketTimeout = socketTimeout;
- }
-
- /**
- * {@inheritDoc}
+ * @param allowGuestLogin
+ * the allowGuestLogin to set
*/
@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");
}
- 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 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);
- throw new AuthenticationException("Keycloak authentication cannot be performed", e);
+ final Authentication currentAuthentication = this.getCurrentAuthentication();
+ if (currentAuthentication instanceof UsernamePasswordAuthenticationToken)
+ {
+ GrantedAuthority[] grantedAuthorities = currentAuthentication.getAuthorities();
+ final List 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));
- this.setCurrentUser(userName);
+ final boolean requiresNew = AlfrescoTransactionSupport.getTransactionReadState() == TxnReadState.TXN_READ_ONLY;
+ 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 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 propertiesToRemove = updates.keySet().stream().filter(k -> updates.get(k) == null).collect(Collectors.toSet());
+ updates.keySet().removeAll(propertiesToRemove);
+
+ final NodeService nodeService = this.getNodeService();
+ final Map 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());
- throw new AuthenticationException("Failed to authenticate user against Keycloak.", e);
+ // there is no bulk-remove, so we need to use setProperties to achieve a single update event
+ final Map 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;
}
+
+ /**
+ * 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 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;
+ }
}
diff --git a/repository/src/main/java/de/acosix/alfresco/keycloak/repo/authentication/KeycloakAuthenticationFilter.java b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/authentication/KeycloakAuthenticationFilter.java
index 0973392..bcac2f8 100644
--- a/repository/src/main/java/de/acosix/alfresco/keycloak/repo/authentication/KeycloakAuthenticationFilter.java
+++ b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/authentication/KeycloakAuthenticationFilter.java
@@ -1,5 +1,5 @@
/*
- * Copyright 2019 Acosix GmbH
+ * 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.
@@ -33,9 +33,9 @@ import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import org.alfresco.repo.SessionUser;
+import org.alfresco.repo.cache.SimpleCache;
import org.alfresco.repo.management.subsystems.ActivateableBean;
import org.alfresco.repo.security.authentication.AuthenticationException;
-import org.alfresco.repo.security.authentication.AuthenticationUtil;
import org.alfresco.repo.security.authentication.Authorization;
import org.alfresco.repo.web.auth.BasicAuthCredentials;
import org.alfresco.repo.web.auth.TicketCredentials;
@@ -48,25 +48,29 @@ import org.alfresco.util.PropertyCheck;
import org.apache.commons.codec.binary.Base64;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
-import org.keycloak.KeycloakSecurityContext;
-import org.keycloak.adapters.AdapterDeploymentContext;
-import org.keycloak.adapters.AuthenticatedActionsHandler;
-import org.keycloak.adapters.KeycloakDeployment;
-import org.keycloak.adapters.OidcKeycloakAccount;
-import org.keycloak.adapters.PreAuthActionsHandler;
-import org.keycloak.adapters.servlet.FilterRequestAuthenticator;
-import org.keycloak.adapters.servlet.OIDCFilterSessionStore;
-import org.keycloak.adapters.servlet.OIDCServletHttpFacade;
-import org.keycloak.adapters.spi.AuthOutcome;
-import org.keycloak.adapters.spi.AuthenticationError;
-import org.keycloak.adapters.spi.KeycloakAccount;
-import org.keycloak.adapters.spi.SessionIdMapper;
-import org.keycloak.adapters.spi.UserSessionManagement;
-import org.keycloak.representations.AccessToken;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.InitializingBean;
+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.
*
@@ -108,6 +112,10 @@ public class KeycloakAuthenticationFilter extends BaseAuthenticationFilter
protected AdapterDeploymentContext deploymentContext;
+ protected KeycloakAuthenticationComponent keycloakAuthenticationComponent;
+
+ protected SimpleCache keycloakTicketTokenCache;
+
/**
* {@inheritDoc}
*/
@@ -116,6 +124,8 @@ public class KeycloakAuthenticationFilter extends BaseAuthenticationFilter
{
PropertyCheck.mandatory(this, "keycloakDeployment", this.keycloakDeployment);
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
PropertyCheck.mandatory(this, "authenticationService", this.authenticationService);
@@ -209,6 +219,24 @@ public class KeycloakAuthenticationFilter extends BaseAuthenticationFilter
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 keycloakTicketTokenCache)
+ {
+ this.keycloakTicketTokenCache = keycloakTicketTokenCache;
+ }
+
/**
*
* {@inheritDoc}
@@ -289,7 +317,7 @@ public class KeycloakAuthenticationFilter extends BaseAuthenticationFilter
this.authenticationService.getCurrentTicket(), false);
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));
@@ -310,7 +338,7 @@ public class KeycloakAuthenticationFilter extends BaseAuthenticationFilter
this.authenticationService.getCurrentTicket(), false);
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));
@@ -451,14 +479,28 @@ public class KeycloakAuthenticationFilter extends BaseAuthenticationFilter
final AccessToken accessToken = keycloakSecurityContext.getToken();
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);
+ this.keycloakAuthenticationComponent.handleUserTokens(accessToken, keycloakSecurityContext.getIdToken(), true);
+
// need different attribute name than default for integration with web scripts framework
// default attribute name seems to be no longer used
session.setAttribute(AuthenticationDriver.AUTHENTICATION_USER, sessionUser);
this.authenticationListener.userAuthenticated(new KeycloakCredentials(accessToken));
+
+ // 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())
@@ -555,7 +597,7 @@ public class KeycloakAuthenticationFilter extends BaseAuthenticationFilter
&& !this.sessionIdMapper.hasSession(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);
session = req.getSession(false);
}
@@ -600,7 +642,7 @@ public class KeycloakAuthenticationFilter extends BaseAuthenticationFilter
final KeycloakAccount keycloakAccount = (KeycloakAccount) session.getAttribute(KeycloakAccount.class.getName());
if (keycloakAccount != null)
{
- skip = this.validateAndRefreshKeycloakAuthentication(req, res, sessionUser.getUserName(), keycloakAccount);
+ skip = this.validateAndRefreshKeycloakAuthentication(req, res, sessionUser.getUserName());
}
else
{
@@ -623,18 +665,40 @@ public class KeycloakAuthenticationFilter extends BaseAuthenticationFilter
* the HTTP servlet response
* @param userId
* the ID of the authenticated user
- * @param keycloakAccount
- * the Keycloak account object
* @return {@code true} if processing of the {@link #doFilter(ServletContext, ServletRequest, ServletResponse, FilterChain) filter
* operation} can be skipped as the account represents a valid and still active authentication, {@code false} otherwise
*/
protected boolean validateAndRefreshKeycloakAuthentication(final HttpServletRequest req, final HttpServletResponse res,
- final String userId, final KeycloakAccount keycloakAccount)
+ final String userId)
{
final OIDCServletHttpFacade facade = new OIDCServletHttpFacade(req, res);
final OIDCFilterSessionStore tokenStore = new OIDCFilterSessionStore(req, facade,
- this.bodyBufferLimit > 0 ? this.bodyBufferLimit : DEFAULT_BODY_BUFFER_LIMIT, this.keycloakDeployment, null);
+ 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();
@@ -645,6 +709,15 @@ public class KeycloakAuthenticationFilter extends BaseAuthenticationFilter
boolean skip = false;
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");
skip = true;
}
@@ -652,7 +725,7 @@ public class KeycloakAuthenticationFilter extends BaseAuthenticationFilter
{
this.sessionIdMapper.removeSession(oldSessionId);
LOGGER.debug("Keycloak-authenticated session for user {} was invalidated after token expiration",
- AuthenticationUtil.maskUsername(userId));
+ AlfrescoCompatibilityUtil.maskUsername(userId));
}
return skip;
}
@@ -704,7 +777,7 @@ public class KeycloakAuthenticationFilter extends BaseAuthenticationFilter
this.authenticationService.getCurrentTicket(), true);
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));
}
diff --git a/repository/src/main/java/de/acosix/alfresco/keycloak/repo/authentication/KeycloakAuthenticationServiceImpl.java b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/authentication/KeycloakAuthenticationServiceImpl.java
new file mode 100644
index 0000000..a8d287c
--- /dev/null
+++ b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/authentication/KeycloakAuthenticationServiceImpl.java
@@ -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 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 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;
+ }
+ }
+ }
+}
diff --git a/repository/src/main/java/de/acosix/alfresco/keycloak/repo/authentication/KeycloakCredentials.java b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/authentication/KeycloakCredentials.java
index eaed9c1..f1791b0 100644
--- a/repository/src/main/java/de/acosix/alfresco/keycloak/repo/authentication/KeycloakCredentials.java
+++ b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/authentication/KeycloakCredentials.java
@@ -1,5 +1,5 @@
/*
- * Copyright 2019 Acosix GmbH
+ * 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.
@@ -17,7 +17,8 @@ package de.acosix.alfresco.keycloak.repo.authentication;
import org.alfresco.repo.web.auth.WebCredentials;
import org.alfresco.util.ParameterCheck;
-import org.keycloak.representations.AccessToken;
+
+import de.acosix.alfresco.keycloak.repo.deps.keycloak.representations.AccessToken;
/**
* @author Axel Faust
diff --git a/repository/src/main/java/de/acosix/alfresco/keycloak/repo/authentication/KeycloakRemoteUserMapper.java b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/authentication/KeycloakRemoteUserMapper.java
index 3a7cf8f..231f011 100644
--- a/repository/src/main/java/de/acosix/alfresco/keycloak/repo/authentication/KeycloakRemoteUserMapper.java
+++ b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/authentication/KeycloakRemoteUserMapper.java
@@ -1,5 +1,5 @@
/*
- * Copyright 2019 Acosix GmbH
+ * 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.
@@ -25,13 +25,14 @@ import org.alfresco.repo.security.authentication.AuthenticationUtil;
import org.alfresco.repo.security.authentication.external.RemoteUserMapper;
import org.alfresco.service.cmr.security.PersonService;
import org.alfresco.util.PropertyCheck;
-import org.keycloak.adapters.BearerTokenRequestAuthenticator;
-import org.keycloak.adapters.KeycloakDeployment;
-import org.keycloak.adapters.spi.AuthOutcome;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.InitializingBean;
+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
*/
diff --git a/repository/src/main/java/de/acosix/alfresco/keycloak/repo/authentication/ResponseHeaderCookieCaptureServletHttpFacade.java b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/authentication/ResponseHeaderCookieCaptureServletHttpFacade.java
index 23a674e..79029e9 100644
--- a/repository/src/main/java/de/acosix/alfresco/keycloak/repo/authentication/ResponseHeaderCookieCaptureServletHttpFacade.java
+++ b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/authentication/ResponseHeaderCookieCaptureServletHttpFacade.java
@@ -1,5 +1,5 @@
/*
- * Copyright 2019 Acosix GmbH
+ * 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.
@@ -26,8 +26,9 @@ import java.util.Map;
import javax.servlet.http.HttpServletRequest;
import org.alfresco.util.Pair;
-import org.keycloak.adapters.servlet.ServletHttpFacade;
-import org.keycloak.adapters.spi.HttpFacade;
+
+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
diff --git a/repository/src/main/java/de/acosix/alfresco/keycloak/repo/authentication/SimpleCacheBackedSessionIdMapper.java b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/authentication/SimpleCacheBackedSessionIdMapper.java
index 0af6c74..9ffb8eb 100644
--- a/repository/src/main/java/de/acosix/alfresco/keycloak/repo/authentication/SimpleCacheBackedSessionIdMapper.java
+++ b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/authentication/SimpleCacheBackedSessionIdMapper.java
@@ -1,5 +1,5 @@
/*
- * Copyright 2019 Acosix GmbH
+ * 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.
@@ -21,9 +21,10 @@ import java.util.Set;
import org.alfresco.repo.cache.SimpleCache;
import org.alfresco.util.PropertyCheck;
-import org.keycloak.adapters.spi.SessionIdMapper;
import org.springframework.beans.factory.InitializingBean;
+import de.acosix.alfresco.keycloak.repo.deps.keycloak.adapters.spi.SessionIdMapper;
+
/**
* @author Axel Faust
*/
diff --git a/repository/src/main/java/de/acosix/alfresco/keycloak/repo/authentication/UserProcessor.java b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/authentication/UserProcessor.java
new file mode 100644
index 0000000..ea89fb9
--- /dev/null
+++ b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/authentication/UserProcessor.java
@@ -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 personNodeProperties);
+}
diff --git a/repository/src/main/java/de/acosix/alfresco/keycloak/repo/authority/GrantedAuthorityAwareAuthorityServiceImpl.java b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/authority/GrantedAuthorityAwareAuthorityServiceImpl.java
new file mode 100644
index 0000000..c0cf0ae
--- /dev/null
+++ b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/authority/GrantedAuthorityAwareAuthorityServiceImpl.java
@@ -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 getAuthoritiesForUser(final String currentUserName)
+ {
+ final Set 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;
+ }
+
+}
diff --git a/repository/src/main/java/de/acosix/alfresco/keycloak/repo/client/IDMClient.java b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/client/IDMClient.java
new file mode 100644
index 0000000..2676294
--- /dev/null
+++ b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/client/IDMClient.java
@@ -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 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 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 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 userProcessor);
+}
diff --git a/repository/src/main/java/de/acosix/alfresco/keycloak/repo/client/IDMClientImpl.java b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/client/IDMClientImpl.java
new file mode 100644
index 0000000..ffcc1f4
--- /dev/null
+++ b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/client/IDMClientImpl.java
@@ -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 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 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 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 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
+ * 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 int processEntityBatch(final URI uri, final Consumer entityProcessor, final Class 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 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 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
+ * 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 processGenericGet(final URI uri, final Class 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> 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 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;
+ }
+}
diff --git a/repository/src/main/java/de/acosix/alfresco/keycloak/repo/spring/KeycloakAdapterConfigBeanFactory.java b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/spring/KeycloakAdapterConfigBeanFactory.java
index db72960..50c0e10 100644
--- a/repository/src/main/java/de/acosix/alfresco/keycloak/repo/spring/KeycloakAdapterConfigBeanFactory.java
+++ b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/spring/KeycloakAdapterConfigBeanFactory.java
@@ -1,5 +1,5 @@
/*
- * Copyright 2019 Acosix GmbH
+ * 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.
@@ -31,7 +31,6 @@ import java.util.Set;
import org.alfresco.error.AlfrescoRuntimeException;
import org.alfresco.util.PropertyCheck;
-import org.keycloak.representations.adapters.config.AdapterConfig;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.FactoryBean;
@@ -41,6 +40,8 @@ import org.springframework.util.PropertyPlaceholderHelper;
import com.fasterxml.jackson.annotation.JsonProperty;
+import de.acosix.alfresco.keycloak.repo.deps.keycloak.representations.adapters.config.AdapterConfig;
+
/**
* @author Axel Faust
*/
diff --git a/repository/src/main/java/de/acosix/alfresco/keycloak/repo/spring/KeycloakDeploymentBeanFactory.java b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/spring/KeycloakDeploymentBeanFactory.java
index 943529a..e21bf8e 100644
--- a/repository/src/main/java/de/acosix/alfresco/keycloak/repo/spring/KeycloakDeploymentBeanFactory.java
+++ b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/spring/KeycloakDeploymentBeanFactory.java
@@ -1,5 +1,5 @@
/*
- * Copyright 2019 Acosix GmbH
+ * 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.
@@ -18,13 +18,14 @@ package de.acosix.alfresco.keycloak.repo.spring;
import java.util.concurrent.TimeUnit;
import org.alfresco.util.PropertyCheck;
-import org.keycloak.adapters.HttpClientBuilder;
-import org.keycloak.adapters.KeycloakDeployment;
-import org.keycloak.adapters.KeycloakDeploymentBuilder;
-import org.keycloak.representations.adapters.config.AdapterConfig;
import org.springframework.beans.factory.FactoryBean;
import org.springframework.beans.factory.InitializingBean;
+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
*/
diff --git a/repository/src/main/java/de/acosix/alfresco/keycloak/repo/sync/BaseAttributeProcessor.java b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/sync/BaseAttributeProcessor.java
new file mode 100644
index 0000000..3d7d522
--- /dev/null
+++ b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/sync/BaseAttributeProcessor.java
@@ -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 attributePropertyMappings;
+
+ protected Map 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 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> 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> attributes, final NodeDescription nodeDescription)
+ {
+ final QName propertyQName = this.attributePropertyQNameMappings.get(attribute);
+ final List 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);
+ }
+ }
+}
diff --git a/repository/src/main/java/de/acosix/alfresco/keycloak/repo/sync/BaseGroupContainmentFilter.java b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/sync/BaseGroupContainmentFilter.java
new file mode 100644
index 0000000..dfe0ce7
--- /dev/null
+++ b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/sync/BaseGroupContainmentFilter.java
@@ -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 groupPaths;
+
+ protected List groupIds;
+
+ protected List 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 parentGroupIds, final List 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 groupPaths)
+ {
+ boolean contained = groupPaths.contains(groupPath);
+ final String groupPathPrefix = groupPath + "/";
+ contained = contained || groupPaths.stream().anyMatch(path -> path.startsWith(groupPathPrefix));
+ return contained;
+ }
+}
diff --git a/repository/src/main/java/de/acosix/alfresco/keycloak/repo/sync/DefaultGroupProcessor.java b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/sync/DefaultGroupProcessor.java
new file mode 100644
index 0000000..282e6d8
--- /dev/null
+++ b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/sync/DefaultGroupProcessor.java
@@ -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());
+ }
+ }
+}
diff --git a/repository/src/main/java/de/acosix/alfresco/keycloak/repo/sync/DefaultPersonProcessor.java b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/sync/DefaultPersonProcessor.java
new file mode 100644
index 0000000..d92cca3
--- /dev/null
+++ b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/sync/DefaultPersonProcessor.java
@@ -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 getMappedProperties()
+ {
+ Collection 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;
+ }
+}
diff --git a/repository/src/main/java/de/acosix/alfresco/keycloak/repo/sync/GroupContainmentGroupFilter.java b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/sync/GroupContainmentGroupFilter.java
new file mode 100644
index 0000000..db565d4
--- /dev/null
+++ b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/sync/GroupContainmentGroupFilter.java
@@ -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 parentGroupIds = Collections.emptyList();
+ final List 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;
+ }
+}
diff --git a/repository/src/main/java/de/acosix/alfresco/keycloak/repo/sync/GroupContainmentUserFilter.java b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/sync/GroupContainmentUserFilter.java
new file mode 100644
index 0000000..1a0f282
--- /dev/null
+++ b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/sync/GroupContainmentUserFilter.java
@@ -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 parentGroupIds = new ArrayList<>();
+ final List 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;
+ }
+}
diff --git a/repository/src/main/java/de/acosix/alfresco/keycloak/repo/sync/GroupFilter.java b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/sync/GroupFilter.java
new file mode 100644
index 0000000..615092e
--- /dev/null
+++ b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/sync/GroupFilter.java
@@ -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);
+}
diff --git a/repository/src/main/java/de/acosix/alfresco/keycloak/repo/sync/GroupProcessor.java b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/sync/GroupProcessor.java
new file mode 100644
index 0000000..738f32a
--- /dev/null
+++ b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/sync/GroupProcessor.java
@@ -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);
+}
diff --git a/repository/src/main/java/de/acosix/alfresco/keycloak/repo/sync/KeycloakUserRegistry.java b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/sync/KeycloakUserRegistry.java
new file mode 100644
index 0000000..1105f19
--- /dev/null
+++ b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/sync/KeycloakUserRegistry.java
@@ -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 userFilters;
+
+ protected Collection groupFilters;
+
+ protected Collection userProcessors;
+
+ protected Collection 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 getPersons(final Date modifiedSince)
+ {
+ // Keycloak does not support any "modifiedSince" semantics
+
+ Collection people = Collections.emptyList();
+
+ if (this.active)
+ {
+ people = new UserCollection<>(this.personLoadBatchSize, this.idmClient.countUsers(), this::mapUser);
+ }
+
+ return people;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public Collection getGroups(final Date modifiedSince)
+ {
+ // Keycloak does not support any "modifiedSince" semantics
+
+ Collection groups = Collections.emptySet();
+
+ if (this.active)
+ {
+ groups = new GroupCollection<>(this.groupLoadBatchSize, this.idmClient.countGroups(), this::mapGroup);
+ }
+
+ return groups;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public Collection getPersonNames()
+ {
+ Collection personNames = Collections.emptySet();
+
+ if (this.active)
+ {
+ personNames = new UserCollection<>(this.personLoadBatchSize, this.idmClient.countUsers(), UserRepresentation::getUsername);
+ }
+
+ return personNames;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public Collection getGroupNames()
+ {
+ Collection 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 getPersonMappedProperties()
+ {
+ final Set 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 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 extends AbstractCollection
+ {
+
+ protected final int batchSize;
+
+ protected final int totalUpperBound;
+
+ protected final Function 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 mapper)
+ {
+ this.batchSize = batchSize;
+ this.totalUpperBound = totalUpperBound;
+ this.mapper = mapper;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public int size()
+ {
+ return this.totalUpperBound;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public Iterator 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 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
+ {
+
+ private final List 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 extends KeycloakAuthorityCollection
+ {
+
+ /**
+ * 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 mapper)
+ {
+ super(batchSize, totalUpperBound, mapper);
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ protected int loadNext(final int offset, final int batchSize, final Consumer 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 extends KeycloakAuthorityCollection
+ {
+
+ /**
+ * 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 mapper)
+ {
+ super(batchSize, totalUpperBound, mapper);
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ protected int loadNext(final int offset, final int batchSize, final Consumer 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 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));
+ }
+ }
+}
diff --git a/repository/src/main/java/de/acosix/alfresco/keycloak/repo/sync/SimpleGroupAttributeProcessor.java b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/sync/SimpleGroupAttributeProcessor.java
new file mode 100644
index 0000000..72e1088
--- /dev/null
+++ b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/sync/SimpleGroupAttributeProcessor.java
@@ -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);
+ }
+ }
+
+}
diff --git a/repository/src/main/java/de/acosix/alfresco/keycloak/repo/sync/SimpleUserAttributeProcessor.java b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/sync/SimpleUserAttributeProcessor.java
new file mode 100644
index 0000000..91ec43d
--- /dev/null
+++ b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/sync/SimpleUserAttributeProcessor.java
@@ -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 getMappedProperties()
+ {
+ return this.enabled ? new HashSet<>(this.attributePropertyQNameMappings.values()) : Collections.emptySet();
+ }
+
+}
diff --git a/repository/src/main/java/de/acosix/alfresco/keycloak/repo/sync/UserFilter.java b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/sync/UserFilter.java
new file mode 100644
index 0000000..066dea9
--- /dev/null
+++ b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/sync/UserFilter.java
@@ -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);
+}
diff --git a/repository/src/main/java/de/acosix/alfresco/keycloak/repo/sync/UserProcessor.java b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/sync/UserProcessor.java
new file mode 100644
index 0000000..17b2510
--- /dev/null
+++ b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/sync/UserProcessor.java
@@ -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 getMappedProperties();
+}
diff --git a/repository/src/main/java/de/acosix/alfresco/keycloak/repo/util/AlfrescoCompatibilityUtil.java b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/util/AlfrescoCompatibilityUtil.java
new file mode 100644
index 0000000..2938729
--- /dev/null
+++ b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/util/AlfrescoCompatibilityUtil.java
@@ -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;
+ }
+}
diff --git a/repository/src/main/java/de/acosix/alfresco/keycloak/repo/util/RefreshableAccessTokenHolder.java b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/util/RefreshableAccessTokenHolder.java
new file mode 100644
index 0000000..cddc090
--- /dev/null
+++ b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/util/RefreshableAccessTokenHolder.java
@@ -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;
+ }
+
+}
diff --git a/repository/src/test/docker/Repository-Dockerfile b/repository/src/test/docker/Repository-Dockerfile
index f533f5e..c634fd6 100644
--- a/repository/src/test/docker/Repository-Dockerfile
+++ b/repository/src/test/docker/Repository-Dockerfile
@@ -5,6 +5,7 @@ COPY maven ${docker.tests.repositoryWebappPath}
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/true<\/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
diff --git a/repository/src/test/docker/alfresco/extension/alfresco-global.addition.properties b/repository/src/test/docker/alfresco/extension/alfresco-global.addition.properties
index 4d98028..12f06d8 100644
--- a/repository/src/test/docker/alfresco/extension/alfresco-global.addition.properties
+++ b/repository/src/test/docker/alfresco/extension/alfresco-global.addition.properties
@@ -1,5 +1,5 @@
#
-# Copyright 2019 Acosix GmbH
+# 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.
@@ -22,4 +22,7 @@ keycloak.adapter.auth-server-url=http://${docker.tests.host.name}:${docker.tests
keycloak.adapter.realm=test
keycloak.adapter.resource=alfresco
keycloak.adapter.credentials.provider=secret
-keycloak.adapter.credentials.secret=6f70a28f-98cd-41ca-8f2f-368a8797d708
\ No newline at end of file
+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
diff --git a/repository/src/test/docker/alfresco/extension/dev-log4j.properties b/repository/src/test/docker/alfresco/extension/dev-log4j.properties
index 2f921d2..c7fbd09 100644
--- a/repository/src/test/docker/alfresco/extension/dev-log4j.properties
+++ b/repository/src/test/docker/alfresco/extension/dev-log4j.properties
@@ -1,5 +1,5 @@
#
-# Copyright 2019 Acosix GmbH
+# 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.
diff --git a/repository/src/test/docker/repository-it.xml b/repository/src/test/docker/repository-it.xml
index 57016ee..9bf5ec4 100644
--- a/repository/src/test/docker/repository-it.xml
+++ b/repository/src/test/docker/repository-it.xml
@@ -1,6 +1,6 @@
- org.keycloak:keycloak-servlet-filter-adapter:*
+ ${project.groupId}:${project.artifactId}.deps:*
compile
diff --git a/repository/src/test/docker/test-realm.json b/repository/src/test/docker/test-realm.json
index fda30ca..6a68685 100644
--- a/repository/src/test/docker/test-realm.json
+++ b/repository/src/test/docker/test-realm.json
@@ -1,98 +1,195 @@
{
"id": "test",
"realm": "test",
- "users": [
- {
- "id": "mustermann",
- "username": "mmustermann",
- "enabled": true,
- "email": "max.mustermann@muster.com",
- "firstName": "Max",
- "lastName": "Mustermann",
- "credentials": [
- {
- "type": "password",
- "value": "mmustermann"
- }
+ "groups": [{
+ "name": "Test A",
+ "subGroups": [{
+ "name": "Test AA"
+ }, {
+ "name": "Test AB"
+ }]
+ }, {
+ "name": "Test B",
+ "subGroups": [{
+ "name": "Test BA"
+ }]
+ }],
+ "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": [
- "user"
- ],
- "clientRoles": {
- "account": [
- "view-profile",
- "manage-account"
- ]
- }
+ "realm-management": [
+ "view-users"
+ ]
}
- ],
- "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/*"
+ }, {
+ "id": "mmustermann",
+ "username": "mmustermann",
+ "enabled": true,
+ "email": "max.mustermann@muster.com",
+ "firstName": "Max",
+ "lastName": "Mustermann",
+ "credentials": [{
+ "type": "password",
+ "value": "mmustermann"
+ }],
+ "realmRoles": [
+ "user"
+ ],
+ "clientRoles": {
+ "account": [
+ "view-profile",
+ "manage-account"
+ ]
+ },
+ "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": [
- "http://localhost:${docker.tests.repositoryPort}"
- ],
- "notBefore": 0,
- "bearerOnly": false,
- "consentRequired": false,
- "standardFlowEnabled": true,
- "implicitFlowEnabled": false,
- "directAccessGrantsEnabled": true,
- "serviceAccountsEnabled": false,
- "publicClient": false,
- "frontchannelLogout": false,
+ "alfresco": [
+ "admin"
+ ]
+ }
+ }],
+ "roles": {
+ "client": {
+ "alfresco": [{
+ "id": "57944d14-7240-464b-925d-7778fa9b78e6",
+ "name": "admin",
+ "composite": 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",
- "attributes": {
- "saml.assertion.signature": "false",
- "saml.force.post.binding": "false",
- "saml.multivalued.roles": "false",
- "saml.encrypt": "false",
- "saml.server.signature": "false",
- "saml.server.signature.keyinfo.ext": "false",
- "exclude.session.state.from.auth.response": "false",
- "saml_force_name_id_format": "false",
- "saml.client.signature": "false",
- "tls.client.certificate.bound.access.tokens": "false",
- "saml.authnstatement": "false",
- "display.on.consent.screen": "false",
- "saml.onetimeuse.condition": "false"
- },
- "authenticationFlowBindingOverrides": {
-
- },
- "fullScopeAllowed": true,
- "nodeReRegistrationTimeout": -1,
- "defaultClientScopes": [
- "web-origins",
- "role_list",
- "roles",
- "profile",
- "email"
- ],
- "optionalClientScopes": [
- "address",
- "phone",
- "offline_access",
- "microprofile-jwt"
- ],
- "access": {
- "view": true,
- "configure": true,
- "manage": true
+ "protocolMapper": "oidc-group-membership-mapper",
+ "consentRequired": false,
+ "config": {
+ "full.path": "true",
+ "id.token.claim": "true",
+ "access.token.claim": "true",
+ "claim.name": "groups",
+ "userinfo.token.claim": "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,
"revokeRefreshToken": false,
"refreshTokenMaxReuse": 0,
@@ -145,16 +242,13 @@
"FreeOTP",
"Google Authenticator"
],
- "scopeMappings": [
- {
- "clientScope": "offline_access",
- "roles": [
- "offline_access"
- ]
- }
- ],
- "clientScopes": [
- {
+ "scopeMappings": [{
+ "clientScope": "offline_access",
+ "roles": [
+ "offline_access"
+ ]
+ }],
+ "clientScopes": [{
"id": "0ee41513-079a-4156-9eab-5709b18be2a6",
"name": "address",
"description": "OpenID Connect built-in scope: address",
@@ -164,26 +258,24 @@
"display.on.consent.screen": "true",
"consent.screen.text": "${addressScopeConsentText}"
},
- "protocolMappers": [
- {
- "id": "1218801d-c159-4ddd-901c-2fc5afa06170",
- "name": "address",
- "protocol": "openid-connect",
- "protocolMapper": "oidc-address-mapper",
- "consentRequired": false,
- "config": {
- "user.attribute.formatted": "formatted",
- "user.attribute.country": "country",
- "user.attribute.postal_code": "postal_code",
- "userinfo.token.claim": "true",
- "user.attribute.street": "street",
- "id.token.claim": "true",
- "user.attribute.region": "region",
- "access.token.claim": "true",
- "user.attribute.locality": "locality"
- }
+ "protocolMappers": [{
+ "id": "1218801d-c159-4ddd-901c-2fc5afa06170",
+ "name": "address",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-address-mapper",
+ "consentRequired": false,
+ "config": {
+ "user.attribute.formatted": "formatted",
+ "user.attribute.country": "country",
+ "user.attribute.postal_code": "postal_code",
+ "userinfo.token.claim": "true",
+ "user.attribute.street": "street",
+ "id.token.claim": "true",
+ "user.attribute.region": "region",
+ "access.token.claim": "true",
+ "user.attribute.locality": "locality"
}
- ]
+ }]
},
{
"id": "bac1ffb3-92bf-481d-b6f8-8dec9bbe7291",
@@ -195,8 +287,7 @@
"display.on.consent.screen": "true",
"consent.screen.text": "${emailScopeConsentText}"
},
- "protocolMappers": [
- {
+ "protocolMappers": [{
"id": "4f28826f-46c6-4fe9-b499-611b66d9bc6f",
"name": "email",
"protocol": "openid-connect",
@@ -237,8 +328,7 @@
"include.in.token.scope": "true",
"display.on.consent.screen": "false"
},
- "protocolMappers": [
- {
+ "protocolMappers": [{
"id": "a69c70ca-70c0-465b-8faf-fffd427f90d9",
"name": "groups",
"protocol": "openid-connect",
@@ -291,8 +381,7 @@
"display.on.consent.screen": "true",
"consent.screen.text": "${phoneScopeConsentText}"
},
- "protocolMappers": [
- {
+ "protocolMappers": [{
"id": "850fe82e-ea0b-4cda-a0bc-dcc9d355c5a8",
"name": "phone number verified",
"protocol": "openid-connect",
@@ -334,8 +423,7 @@
"display.on.consent.screen": "true",
"consent.screen.text": "${profileScopeConsentText}"
},
- "protocolMappers": [
- {
+ "protocolMappers": [{
"id": "da5905e2-00ee-4ed0-ab7d-cdac7ead6206",
"name": "zoneinfo",
"protocol": "openid-connect",
@@ -553,20 +641,18 @@
"consent.screen.text": "${samlRoleListScopeConsentText}",
"display.on.consent.screen": "true"
},
- "protocolMappers": [
- {
- "id": "51899037-a3df-4924-bd73-81f07cfb3aa9",
- "name": "role list",
- "protocol": "saml",
- "protocolMapper": "saml-role-list-mapper",
- "consentRequired": false,
- "config": {
- "single": "false",
- "attribute.nameformat": "Basic",
- "attribute.name": "Role"
- }
+ "protocolMappers": [{
+ "id": "51899037-a3df-4924-bd73-81f07cfb3aa9",
+ "name": "role list",
+ "protocol": "saml",
+ "protocolMapper": "saml-role-list-mapper",
+ "consentRequired": false,
+ "config": {
+ "single": "false",
+ "attribute.nameformat": "Basic",
+ "attribute.name": "Role"
}
- ]
+ }]
},
{
"id": "4c15f94e-f490-4541-8c14-7b4734d6999b",
@@ -578,15 +664,14 @@
"display.on.consent.screen": "true",
"consent.screen.text": "${rolesScopeConsentText}"
},
- "protocolMappers": [
- {
+ "protocolMappers": [{
"id": "1908b63f-1be6-4f87-a947-30ee66721d05",
"name": "audience resolve",
"protocol": "openid-connect",
"protocolMapper": "oidc-audience-resolve-mapper",
"consentRequired": false,
"config": {
-
+
}
},
{
@@ -629,18 +714,16 @@
"display.on.consent.screen": "false",
"consent.screen.text": ""
},
- "protocolMappers": [
- {
- "id": "4652b835-1693-45ae-bad5-b61b534610de",
- "name": "allowed web origins",
- "protocol": "openid-connect",
- "protocolMapper": "oidc-allowed-origins-mapper",
- "consentRequired": false,
- "config": {
-
- }
+ "protocolMappers": [{
+ "id": "4652b835-1693-45ae-bad5-b61b534610de",
+ "name": "allowed web origins",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-allowed-origins-mapper",
+ "consentRequired": false,
+ "config": {
+
}
- ]
+ }]
}
],
"defaultDefaultClientScopes": [
@@ -666,7 +749,7 @@
"strictTransportSecurity": "max-age=31536000; includeSubDomains"
},
"smtpServer": {
-
+
},
"eventsEnabled": false,
"eventsListeners": [
@@ -676,14 +759,13 @@
"adminEventsEnabled": false,
"adminEventsDetailsEnabled": false,
"components": {
- "org.keycloak.services.clientregistration.policy.ClientRegistrationPolicy": [
- {
+ "org.keycloak.services.clientregistration.policy.ClientRegistrationPolicy": [{
"id": "43a864c4-c4fa-4057-bf87-2ca409cde736",
"name": "Max Clients Limit",
"providerId": "max-clients",
"subType": "anonymous",
"subComponents": {
-
+
},
"config": {
"max-clients": [
@@ -697,10 +779,10 @@
"providerId": "consent-required",
"subType": "anonymous",
"subComponents": {
-
+
},
"config": {
-
+
}
},
{
@@ -709,7 +791,7 @@
"providerId": "allowed-client-templates",
"subType": "authenticated",
"subComponents": {
-
+
},
"config": {
"allow-default-scopes": [
@@ -723,7 +805,7 @@
"providerId": "allowed-protocol-mappers",
"subType": "authenticated",
"subComponents": {
-
+
},
"config": {
"allowed-protocol-mapper-types": [
@@ -744,10 +826,10 @@
"providerId": "scope",
"subType": "anonymous",
"subComponents": {
-
+
},
"config": {
-
+
}
},
{
@@ -756,7 +838,7 @@
"providerId": "allowed-protocol-mappers",
"subType": "anonymous",
"subComponents": {
-
+
},
"config": {
"allowed-protocol-mapper-types": [
@@ -777,7 +859,7 @@
"providerId": "trusted-hosts",
"subType": "anonymous",
"subComponents": {
-
+
},
"config": {
"host-sending-registration-request-must-match": [
@@ -794,7 +876,7 @@
"providerId": "allowed-client-templates",
"subType": "anonymous",
"subComponents": {
-
+
},
"config": {
"allow-default-scopes": [
@@ -803,13 +885,12 @@
}
}
],
- "org.keycloak.keys.KeyProvider": [
- {
+ "org.keycloak.keys.KeyProvider": [{
"id": "005993b6-dcb3-4ebf-b19c-7287c740bb79",
"name": "rsa-generated",
"providerId": "rsa-generated",
"subComponents": {
-
+
},
"config": {
"priority": [
@@ -822,7 +903,7 @@
"name": "aes-generated",
"providerId": "aes-generated",
"subComponents": {
-
+
},
"config": {
"priority": [
@@ -835,7 +916,7 @@
"name": "hmac-generated",
"providerId": "hmac-generated",
"subComponents": {
-
+
},
"config": {
"priority": [
@@ -850,16 +931,14 @@
},
"internationalizationEnabled": false,
"supportedLocales": [],
- "authenticationFlows": [
- {
+ "authenticationFlows": [{
"id": "50eda798-0ed6-4fd1-9071-977ca22b032f",
"alias": "Handle Existing Account",
"description": "Handle what to do if there is existing account with same email/username like authenticated identity provider",
"providerId": "basic-flow",
"topLevel": false,
"builtIn": true,
- "authenticationExecutions": [
- {
+ "authenticationExecutions": [{
"authenticator": "idp-confirm-link",
"requirement": "REQUIRED",
"priority": 10,
@@ -889,8 +968,7 @@
"providerId": "basic-flow",
"topLevel": false,
"builtIn": true,
- "authenticationExecutions": [
- {
+ "authenticationExecutions": [{
"authenticator": "idp-username-password-form",
"requirement": "REQUIRED",
"priority": 10,
@@ -913,8 +991,7 @@
"providerId": "basic-flow",
"topLevel": true,
"builtIn": true,
- "authenticationExecutions": [
- {
+ "authenticationExecutions": [{
"authenticator": "auth-cookie",
"requirement": "ALTERNATIVE",
"priority": 10,
@@ -951,8 +1028,7 @@
"providerId": "client-flow",
"topLevel": true,
"builtIn": true,
- "authenticationExecutions": [
- {
+ "authenticationExecutions": [{
"authenticator": "client-secret",
"requirement": "ALTERNATIVE",
"priority": 10,
@@ -989,8 +1065,7 @@
"providerId": "basic-flow",
"topLevel": true,
"builtIn": true,
- "authenticationExecutions": [
- {
+ "authenticationExecutions": [{
"authenticator": "direct-grant-validate-username",
"requirement": "REQUIRED",
"priority": 10,
@@ -1020,15 +1095,13 @@
"providerId": "basic-flow",
"topLevel": true,
"builtIn": true,
- "authenticationExecutions": [
- {
- "authenticator": "docker-http-basic-authenticator",
- "requirement": "REQUIRED",
- "priority": 10,
- "userSetupAllowed": false,
- "autheticatorFlow": false
- }
- ]
+ "authenticationExecutions": [{
+ "authenticator": "docker-http-basic-authenticator",
+ "requirement": "REQUIRED",
+ "priority": 10,
+ "userSetupAllowed": false,
+ "autheticatorFlow": false
+ }]
},
{
"id": "eb15d55b-a601-493c-9f49-069695624ada",
@@ -1037,8 +1110,7 @@
"providerId": "basic-flow",
"topLevel": true,
"builtIn": true,
- "authenticationExecutions": [
- {
+ "authenticationExecutions": [{
"authenticatorConfig": "review profile config",
"authenticator": "idp-review-profile",
"requirement": "REQUIRED",
@@ -1070,8 +1142,7 @@
"providerId": "basic-flow",
"topLevel": false,
"builtIn": true,
- "authenticationExecutions": [
- {
+ "authenticationExecutions": [{
"authenticator": "auth-username-password-form",
"requirement": "REQUIRED",
"priority": 10,
@@ -1094,8 +1165,7 @@
"providerId": "basic-flow",
"topLevel": true,
"builtIn": true,
- "authenticationExecutions": [
- {
+ "authenticationExecutions": [{
"authenticator": "no-cookie-redirect",
"requirement": "REQUIRED",
"priority": 10,
@@ -1132,16 +1202,14 @@
"providerId": "basic-flow",
"topLevel": true,
"builtIn": true,
- "authenticationExecutions": [
- {
- "authenticator": "registration-page-form",
- "requirement": "REQUIRED",
- "priority": 10,
- "flowAlias": "registration form",
- "userSetupAllowed": false,
- "autheticatorFlow": true
- }
- ]
+ "authenticationExecutions": [{
+ "authenticator": "registration-page-form",
+ "requirement": "REQUIRED",
+ "priority": 10,
+ "flowAlias": "registration form",
+ "userSetupAllowed": false,
+ "autheticatorFlow": true
+ }]
},
{
"id": "088ede2e-2dce-466d-b25f-62cc07c12bee",
@@ -1150,8 +1218,7 @@
"providerId": "form-flow",
"topLevel": false,
"builtIn": true,
- "authenticationExecutions": [
- {
+ "authenticationExecutions": [{
"authenticator": "registration-user-creation",
"requirement": "REQUIRED",
"priority": 20,
@@ -1188,8 +1255,7 @@
"providerId": "basic-flow",
"topLevel": true,
"builtIn": true,
- "authenticationExecutions": [
- {
+ "authenticationExecutions": [{
"authenticator": "reset-credentials-choose-user",
"requirement": "REQUIRED",
"priority": 10,
@@ -1226,19 +1292,16 @@
"providerId": "basic-flow",
"topLevel": true,
"builtIn": true,
- "authenticationExecutions": [
- {
- "authenticator": "http-basic-authenticator",
- "requirement": "REQUIRED",
- "priority": 10,
- "userSetupAllowed": false,
- "autheticatorFlow": false
- }
- ]
+ "authenticationExecutions": [{
+ "authenticator": "http-basic-authenticator",
+ "requirement": "REQUIRED",
+ "priority": 10,
+ "userSetupAllowed": false,
+ "autheticatorFlow": false
+ }]
}
],
- "authenticatorConfig": [
- {
+ "authenticatorConfig": [{
"id": "1593e111-7e5e-4ee2-b33d-f459834e4e3b",
"alias": "create unique user config",
"config": {
@@ -1253,8 +1316,7 @@
}
}
],
- "requiredActions": [
- {
+ "requiredActions": [{
"alias": "CONFIGURE_TOTP",
"name": "Configure OTP",
"providerId": "CONFIGURE_TOTP",
@@ -1262,7 +1324,7 @@
"defaultAction": false,
"priority": 10,
"config": {
-
+
}
},
{
@@ -1273,7 +1335,7 @@
"defaultAction": false,
"priority": 20,
"config": {
-
+
}
},
{
@@ -1284,7 +1346,7 @@
"defaultAction": false,
"priority": 30,
"config": {
-
+
}
},
{
@@ -1295,7 +1357,7 @@
"defaultAction": false,
"priority": 40,
"config": {
-
+
}
},
{
@@ -1306,7 +1368,7 @@
"defaultAction": false,
"priority": 50,
"config": {
-
+
}
}
],
diff --git a/share-dependencies/dependency-reduced-pom.xml b/share-dependencies/dependency-reduced-pom.xml
new file mode 100644
index 0000000..80fe345
--- /dev/null
+++ b/share-dependencies/dependency-reduced-pom.xml
@@ -0,0 +1,53 @@
+
+
+
+ de.acosix.alfresco.keycloak.parent
+ de.acosix.alfresco.keycloak
+ 1.1.0-SNAPSHOT
+
+ 4.0.0
+ de.acosix.alfresco.keycloak.share.deps
+ Acosix Alfresco Keycloak - Share Dependencies Module
+ Aggregate (Uber-)JAR of all dependencies for the Acosix Alfresco Keycloak Share Module
+
+
+
+
+ maven-shade-plugin
+
+
+ package
+
+ shade
+
+
+
+
+ org.keycloak
+ de.acosix.alfresco.keycloak.share.deps.keycloak
+
+
+ org.jboss.logging
+ de.acosix.alfresco.keycloak.share.deps.jboss.logging
+
+
+
+
+
+
+ false
+
+
+
+
+
+
+
+
+
+
+ maven-shade-plugin
+
+
+
+
diff --git a/share-dependencies/pom.xml b/share-dependencies/pom.xml
new file mode 100644
index 0000000..55519a5
--- /dev/null
+++ b/share-dependencies/pom.xml
@@ -0,0 +1,128 @@
+
+
+
+ 4.0.0
+
+
+ de.acosix.alfresco.keycloak
+ de.acosix.alfresco.keycloak.parent
+ 1.1.0-SNAPSHOT
+
+
+ de.acosix.alfresco.keycloak.share.deps
+ Acosix Alfresco Keycloak - Share Dependencies Module
+ Aggregate (Uber-)JAR of all dependencies for the Acosix Alfresco Keycloak Share Module
+
+
+
+ org.keycloak
+ keycloak-adapter-core
+
+
+ org.bouncycastle
+ *
+
+
+ com.fasterxml.jackson.core
+ *
+
+
+
+
+
+ org.keycloak
+ keycloak-servlet-adapter-spi
+
+
+ org.bouncycastle
+ *
+
+
+ com.fasterxml.jackson.core
+ *
+
+
+
+
+
+ org.keycloak
+ keycloak-servlet-filter-adapter
+
+
+ org.bouncycastle
+ *
+
+
+ com.fasterxml.jackson.core
+ *
+
+
+
+
+
+ org.keycloak
+ keycloak-authz-client
+
+
+
+
+
+
+
+ maven-shade-plugin
+
+
+ package
+
+ shade
+
+
+
+
+ org.keycloak
+ de.acosix.alfresco.keycloak.share.deps.keycloak
+
+
+ org.jboss.logging
+ de.acosix.alfresco.keycloak.share.deps.jboss.logging
+
+
+
+
+
+
+ false
+
+
+
+
+
+
+
+
+
+
+
+ maven-shade-plugin
+
+
+
+
\ No newline at end of file
diff --git a/share/pom.xml b/share/pom.xml
index 07b5248..71f5d58 100644
--- a/share/pom.xml
+++ b/share/pom.xml
@@ -1,6 +1,6 @@
+
+
+
+ io.fabric8
+ docker-maven-plugin
+
+
+
+
+
+
+
+
+
+ ${docker.tests.host.name}
+
+
+
+
+
+
+ ${moduleId}-repository-test-contentstore:/usr/local/tomcat/alf_data
+ ${project.build.directory}/docker/repository-logs:/usr/local/tomcat/logs
+
+
+
+ postgres
+ keycloak
+
+
+
+
+
+
+
+
+
+
+ jboss/keycloak
+ keycloak
+
+ keycloak
+
+ admin
+ admin
+ /tmp/test-realm.json
+ h2
+
+
+ ${docker.tests.keycloakPort}:8080
+
+
+ custom
+ ${moduleId}-test
+ keycloak
+
+
+
+ ${project.build.directory}/docker/test-realm.json:/tmp/test-realm.json
+
+
+
+
+
+
+
+
+
+
+
+
net.alchim31.maven
yuicompressor-maven-plugin
+
+
+ io.fabric8
+ docker-maven-plugin
+
+
diff --git a/share/src/main/assembly/amp.xml b/share/src/main/assembly/amp.xml
index 2e014f8..a4a92a2 100644
--- a/share/src/main/assembly/amp.xml
+++ b/share/src/main/assembly/amp.xml
@@ -1,6 +1,6 @@
+
+ *.js
+ **/*.js
+ *.ftl
+ **/*.ftl
+ *.keystore
+ **/*.keystore
+
+ true
+ lf
+
+
+ ${project.basedir}/src/test/docker/alfresco
+ WEB-INF/classes/alfresco
+
+ *.js
+ **/*.js
+ *.ftl
+ **/*.ftl
+ *.keystore
+ **/*.keystore
+
+
+
+
WEB-INF/lib
+ ${project.groupId}:de.acosix.alfresco.keycloak.repo.deps:*
de.acosix.alfresco.utility:de.acosix.alfresco.utility.common:*
de.acosix.alfresco.utility:de.acosix.alfresco.utility.core.repo.quartz1:*
de.acosix.alfresco.utility:de.acosix.alfresco.utility.core.repo.quartz2:*
diff --git a/share/src/test/docker/repository-logs/dummy.properties b/share/src/test/docker/repository-logs/dummy.properties
new file mode 100644
index 0000000..5d13f33
--- /dev/null
+++ b/share/src/test/docker/repository-logs/dummy.properties
@@ -0,0 +1 @@
+# only exists to ensure Maven creates path in project ./target
\ No newline at end of file
diff --git a/share/src/test/docker/share-it.xml b/share/src/test/docker/share-it.xml
index 216b083..29fa83a 100644
--- a/share/src/test/docker/share-it.xml
+++ b/share/src/test/docker/share-it.xml
@@ -1,6 +1,6 @@