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