diff --git a/pom.xml b/pom.xml index 8ec973d..8bdab3b 100644 --- a/pom.xml +++ b/pom.xml @@ -26,7 +26,7 @@ de.acosix.alfresco.keycloak de.acosix.alfresco.keycloak.parent - 1.1.0-rc7 + 1.1.0-rc8-SNAPSHOT pom Acosix Alfresco Keycloak - Parent diff --git a/repository/pom.xml b/repository/pom.xml index 2ceee02..9d253e8 100644 --- a/repository/pom.xml +++ b/repository/pom.xml @@ -21,7 +21,7 @@ de.acosix.alfresco.keycloak de.acosix.alfresco.keycloak.parent - 1.1.0-rc7 + 1.1.0-rc8-SNAPSHOT de.acosix.alfresco.keycloak.repo diff --git a/repository/src/main/globalConfig/subsystems/Authentication/keycloak/keycloak-authentication-context.xml b/repository/src/main/globalConfig/subsystems/Authentication/keycloak/keycloak-authentication-context.xml index 46c438a..c8284f1 100644 --- a/repository/src/main/globalConfig/subsystems/Authentication/keycloak/keycloak-authentication-context.xml +++ b/repository/src/main/globalConfig/subsystems/Authentication/keycloak/keycloak-authentication-context.xml @@ -23,6 +23,16 @@ + + + + + + + + + + @@ -187,9 +197,15 @@ - + - + + + + + + + @@ -201,10 +217,11 @@ - - - - + + + + + @@ -261,7 +278,7 @@ userToken - + groupMapper - + resourceFilter - + \ 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 8753126..71ac31f 100644 --- a/repository/src/main/globalConfig/subsystems/Authentication/keycloak/keycloak-authentication.properties +++ b/repository/src/main/globalConfig/subsystems/Authentication/keycloak/keycloak-authentication.properties @@ -13,9 +13,9 @@ keycloak.authentication.mapPersonPropertiesOnLogin=true keycloak.authentication.authenticateFTP=true keycloak.authentication.silentRemoteUserValidationFailure=true -# Authority Sync plugin -keycloak.authentication.syncAuthoritiesOnLogin=false -keycloak.authentication.syncAuthorityMembershipOnLogin=false +# Group Sync processor by Brian Long +keycloak.authentication.createMissingGroupsOnLogin=false +keycloak.authentication.syncGroupMembershipOnLogin=false keycloak.authentication.bodyBufferLimit=10485760 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 cfc78c0..a504808 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 @@ -65,7 +65,7 @@ public class KeycloakAuthenticationComponent extends AbstractAuthenticationCompo protected KeycloakDeployment deployment; protected AccessTokenClient accessTokenClient; - + protected List tokenProcessors; /** @@ -79,10 +79,10 @@ public class KeycloakAuthenticationComponent extends AbstractAuthenticationCompo PropertyCheck.mandatory(this, "keycloakDeployment", this.deployment); this.accessTokenClient = new AccessTokenClient(this.deployment); - - List tokenProcessors = new ArrayList<>(this.applicationContext.getBeansOfType(TokenProcessor.class, false, true).values()); - Collections.sort(tokenProcessors); - this.tokenProcessors = Collections.unmodifiableList(tokenProcessors); + + this.tokenProcessors = new ArrayList<>(this.applicationContext.getBeansOfType(TokenProcessor.class, false, true).values()); + Collections.sort(this.tokenProcessors); + this.tokenProcessors = Collections.unmodifiableList(this.tokenProcessors); } /** @@ -267,25 +267,23 @@ public class KeycloakAuthenticationComponent extends AbstractAuthenticationCompo } /** - * Processes tokens for authenticated users, mapping them to Alfresco person properties or granted authorities as configured for this - * instance. + * Handles user tokens after authentication (initial or refresh) by delegating them to {@link TokenProcessor token processors} defined + * in the application context. * * @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 + * {@code true} if the tokens are fresh, that is have just been obtained from an initial login, {@code false} otherwise */ public void handleUserTokens(final AccessToken accessToken, final IDToken idToken, final boolean freshLogin) { - for (TokenProcessor processor : this.tokenProcessors) - { + for (final TokenProcessor processor : this.tokenProcessors) + { LOGGER.debug("Processing token with {}", processor.getName()); - processor.handleUserTokens(this, accessToken, idToken, freshLogin); - } + processor.handleUserTokens(accessToken, idToken, freshLogin); + } } /** diff --git a/repository/src/main/java/de/acosix/alfresco/keycloak/repo/authentication/KeycloakTokenAuthorityMapper.java b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/authentication/KeycloakTokenAuthorityMapper.java deleted file mode 100644 index 060e154..0000000 --- a/repository/src/main/java/de/acosix/alfresco/keycloak/repo/authentication/KeycloakTokenAuthorityMapper.java +++ /dev/null @@ -1,171 +0,0 @@ -package de.acosix.alfresco.keycloak.repo.authentication; - -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.Set; -import java.util.stream.Collectors; - -import org.alfresco.repo.security.authentication.AbstractAuthenticationComponent; -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.keycloak.representations.AccessToken; -import org.keycloak.representations.IDToken; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.beans.BeansException; -import org.springframework.beans.factory.InitializingBean; -import org.springframework.context.ApplicationContext; -import org.springframework.context.ApplicationContextAware; - -import de.acosix.alfresco.keycloak.repo.util.AlfrescoCompatibilityUtil; -import net.sf.acegisecurity.Authentication; -import net.sf.acegisecurity.GrantedAuthority; -import net.sf.acegisecurity.GrantedAuthorityImpl; -import net.sf.acegisecurity.providers.UsernamePasswordAuthenticationToken; - -public class KeycloakTokenAuthorityMapper implements TokenProcessor, InitializingBean, ApplicationContextAware { - - private static final Logger LOGGER = LoggerFactory.getLogger(KeycloakTokenAuthorityMapper.class); - - private static final String NAME = "AuthorityMapper"; - - protected ApplicationContext applicationContext; - - protected boolean enabled; - - protected boolean mapPersonPropertiesOnLogin; - - protected Collection authorityExtractors; - - protected Collection userProcessors; - - @Override - public String getName() { - return NAME; - } - - public void setEnabled(boolean enabled) { - this.enabled = enabled; - } - - /** - * @param mapPersonPropertiesOnLogin - * the mapPersonPropertiesOnLogin to set - */ - public void setMapPersonPropertiesOnLogin(final boolean mapPersonPropertiesOnLogin) { - this.mapPersonPropertiesOnLogin = mapPersonPropertiesOnLogin; - } - - @Override - public void setApplicationContext(ApplicationContext applicationContext) { - this.applicationContext = applicationContext; - } - - @Override - public void afterPropertiesSet() throws BeansException { - 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())); - } - - @Override - public void handleUserTokens(AbstractAuthenticationComponent authComponent, AccessToken accessToken, - IDToken idToken, boolean freshLogin) { - if (!this.enabled) - return; - - LOGGER.debug("Mapping Keycloak access token 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 access token: {}", mappedAuthorities); - - if (!mappedAuthorities.isEmpty()) - { - final Authentication currentAuthentication = authComponent.getCurrentAuthentication(); - if (currentAuthentication instanceof UsernamePasswordAuthenticationToken) - { - GrantedAuthority[] grantedAuthorities = currentAuthentication.getAuthorities(); - - final List grantedAuthoritiesL = mappedAuthorities.stream().map(GrantedAuthorityImpl::new) - .collect(Collectors.toList()); - grantedAuthoritiesL.addAll(Arrays.asList(grantedAuthorities)); - - grantedAuthorities = grantedAuthoritiesL.toArray(new GrantedAuthority[0]); - ((UsernamePasswordAuthenticationToken) currentAuthentication).setAuthorities(grantedAuthorities); - } - else - { - LOGGER.warn( - "Authentication for user is not of the expected type {} - Keycloak access token cannot be mapped to granted authorities", - UsernamePasswordAuthenticationToken.class); - } - } - - if (freshLogin && this.mapPersonPropertiesOnLogin) - { - final boolean requiresNew = AlfrescoTransactionSupport.getTransactionReadState() == TxnReadState.TXN_READ_ONLY; - authComponent.getTransactionService().getRetryingTransactionHelper().doInTransaction(() -> { - this.updatePerson(authComponent, accessToken, idToken); - return null; - }, false, requiresNew); - } - } - - /** - * 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(AbstractAuthenticationComponent authComponent, - final AccessToken accessToken, final IDToken idToken) - { - final String userName = authComponent.getCurrentUserName(); - - LOGGER.debug("Mapping person property updates for user {}", AlfrescoCompatibilityUtil.maskUsername(userName)); - - final NodeRef person = authComponent.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 = authComponent.getNodeService(); - final Map currentProperties = nodeService.getProperties(person); - - propertiesToRemove.retainAll(currentProperties.keySet()); - if (!propertiesToRemove.isEmpty()) - { - // 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); - } - } - -} diff --git a/repository/src/main/java/de/acosix/alfresco/keycloak/repo/authentication/KeycloakTokenAuthoritySync.java b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/authentication/KeycloakTokenAuthoritySync.java deleted file mode 100644 index c6a6c2b..0000000 --- a/repository/src/main/java/de/acosix/alfresco/keycloak/repo/authentication/KeycloakTokenAuthoritySync.java +++ /dev/null @@ -1,249 +0,0 @@ -package de.acosix.alfresco.keycloak.repo.authentication; - -import java.util.Arrays; -import java.util.Collection; -import java.util.HashSet; -import java.util.Set; - -import org.alfresco.repo.security.authentication.AbstractAuthenticationComponent; -import org.alfresco.repo.security.authentication.AuthenticationUtil; -import org.alfresco.repo.security.authentication.AuthenticationUtil.RunAsWork; -import org.alfresco.repo.transaction.AlfrescoTransactionSupport; -import org.alfresco.repo.transaction.AlfrescoTransactionSupport.TxnReadState; -import org.alfresco.service.cmr.repository.DuplicateChildNodeNameException; -import org.alfresco.service.cmr.security.AuthorityService; -import org.alfresco.service.cmr.security.AuthorityType; -import org.keycloak.representations.AccessToken; -import org.keycloak.representations.IDToken; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import de.acosix.alfresco.keycloak.repo.util.AlfrescoCompatibilityUtil; -import net.sf.acegisecurity.GrantedAuthority; - -public class KeycloakTokenAuthoritySync implements TokenProcessor { - - private static final Logger LOGGER = LoggerFactory.getLogger(KeycloakTokenAuthoritySync.class); - - private static final String NAME = "AuthoritySync"; - - protected boolean enabled; - - protected boolean syncAuthorityMembershipOnLogin; - - protected AuthorityService authorityService; - - public void setEnabled(boolean enabled) { - this.enabled = enabled; - } - - /** - * @param syncAuthorityMembershipOnLogin - * the syncAuthorityMembershipOnLogin to set - */ - public void setSyncAuthorityMembershipOnLogin(final boolean syncAuthorityMembershipOnLogin) { - this.syncAuthorityMembershipOnLogin = syncAuthorityMembershipOnLogin; - } - - public void setAuthorityService(AuthorityService authorityService) { - this.authorityService = authorityService; - } - - @Override - public String getName() { - return NAME; - } - - @Override - public int getPriority() { - return 16; - } - - @Override - public void handleUserTokens(AbstractAuthenticationComponent authComponent, AccessToken accessToken, - IDToken idToken, boolean freshLogin) { - if (!this.enabled) - return; - - if (freshLogin) { - boolean requiresNew = AlfrescoTransactionSupport.getTransactionReadState() == TxnReadState.TXN_READ_ONLY; - authComponent.getTransactionService().getRetryingTransactionHelper().doInTransaction(() -> { - GrantedAuthority[] authorities = authComponent.getCurrentAuthentication().getAuthorities(); - this.syncAuthorities(Arrays.asList(authorities), accessToken, idToken); - return null; - }, false, requiresNew); - - if (this.syncAuthorityMembershipOnLogin) - { - authComponent.getTransactionService().getRetryingTransactionHelper().doInTransaction(() -> { - String userName = authComponent.getCurrentUserName(); - GrantedAuthority[] authorities = authComponent.getCurrentAuthentication().getAuthorities(); - this.syncAuthorityMemberships(userName, Arrays.asList(authorities), accessToken, idToken); - return null; - }, false, requiresNew); - } - } - } - - /** - * Synchronizes the groups of the current user, but not the membership, as Alfresco user groups. - * @param authorities - * the Alfresco authorities to persist as user groups - * @param accessToken - * the access token - * @param idToken - * the ID token - */ - protected void syncAuthorities(final Collection authorities, final AccessToken accessToken, final IDToken idToken) - { - LOGGER.debug("Synchronizing user groups {}", authorities); - - AuthenticationUtil.runAsSystem(new RunAsLoggableWork() { - @Override - public Void doLoggedWork() { - for (GrantedAuthority authority : authorities) - { - String authorityId = authority.getAuthority(); - - if (AuthorityType.GROUP.equals(AuthorityType.getAuthorityType(authorityId))) - { - // we only persist groups (not roles) in Alfresco - - // if it didn't exist, then we need to associate the user to the group - if (!authorityService.authorityExists(authorityId)) - { - // group does not yet exist; create one - - if (LOGGER.isDebugEnabled()) - LOGGER.debug("Creating authority {}", authorityId); - - String authorityShortName = authorityService.getShortName(authorityId); - authorityShortName = normalizeAuthority(authorityShortName); - - if (LOGGER.isDebugEnabled()) - LOGGER.debug("Creating group {}", authorityShortName); - - try { - authorityService.createAuthority(AuthorityType.GROUP, authorityShortName); - } catch (DuplicateChildNodeNameException dcnne) { - LOGGER.debug("Group {} already created; race condition?", authorityShortName); - } - } - } - } - - return null; - } - }); - } - - /** - * Synchronizes the membership of the current user with the authority, as an Alfresco user group. - * @param authorities - * the Alfresco authorities to persist as user groups - * @param accessToken - * the access token - * @param idToken - * the ID token - */ - protected void syncAuthorityMemberships(String userName, final Collection authorities, final AccessToken accessToken, final IDToken idToken) - { - if (LOGGER.isDebugEnabled()) - LOGGER.debug("Synchronizing user group membership for user {}", AlfrescoCompatibilityUtil.maskUsername(userName)); - - String userAuthorityId = this.authorityService.getName(AuthorityType.USER, userName); - Set persistedAuthorityIds = new HashSet<>(this.authorityService.getAuthoritiesForUser(userName)); - - if (LOGGER.isDebugEnabled()) - LOGGER.debug("Current authorities for user {}: {}", AlfrescoCompatibilityUtil.maskUsername(userName), persistedAuthorityIds); - - AuthenticationUtil.runAsSystem(new RunAsLoggableWork() { - @Override - public Void doLoggedWork() throws Exception { - for (GrantedAuthority authority : authorities) - { - String authorityId = authority.getAuthority(); - LOGGER.trace("Inspecting authority '{}' to grant membership", authorityId); - - if (AuthorityType.GROUP.equals(AuthorityType.getAuthorityType(authorityId))) - { - // we only persist groups in Alfresco - - // remove if it exists; any remaining GROUP authorities will be unlinked from user later in this method - persistedAuthorityIds.remove(authorityId); - // we cannot assume persistedAuthorityIds has only registered groups; it includes authorities from keycloak - - authorityId = normalizeAuthority(authorityId); - - if (LOGGER.isDebugEnabled()) - LOGGER.debug("Adding user {} to group {}", AlfrescoCompatibilityUtil.maskUsername(userName), authorityId); - - try { - // add the user to the existing group - authorityService.addAuthority(authorityId, userAuthorityId); - } catch (DuplicateChildNodeNameException dcnne) { - if (LOGGER.isTraceEnabled()) - LOGGER.trace("User {} is already a member of group {}", AlfrescoCompatibilityUtil.maskUsername(userName), authorityId); - } - } - } - - if (LOGGER.isDebugEnabled()) - LOGGER.debug("Removing user {} from authorities: {}", AlfrescoCompatibilityUtil.maskUsername(userName), persistedAuthorityIds); - - // revoke user from groups - for (String persistedAuthorityId : persistedAuthorityIds) - { - LOGGER.trace("Inspecting authority '{}' to revoke membership", persistedAuthorityId); - - if (AuthorityType.GROUP.equals(AuthorityType.getAuthorityType(persistedAuthorityId))) - { - // disassociate persisted groups only - persistedAuthorityId = normalizeAuthority(persistedAuthorityId); - - if (LOGGER.isDebugEnabled()) - LOGGER.debug("Removing user {} from group {}", AlfrescoCompatibilityUtil.maskUsername(userName), persistedAuthorityId); - - authorityService.removeAuthority(persistedAuthorityId, userAuthorityId); - } - } - - return null; - } - }); - } - - private String normalizeAuthority(String authorityIdOrShortName) { - // in case we need to filter out special characters - return authorityIdOrShortName; - } - - - - private interface RunAsLoggableWork extends RunAsWork { - - default T doWork() throws Exception { - long time = System.currentTimeMillis(); - LOGGER.trace("doWork()"); - - T result; - try { - result = this.doLoggedWork(); - } catch (Exception e) { - LOGGER.error("An unhandled exception occurred", e); - throw e; - } catch (Throwable t) { - LOGGER.error("An unhandled error occurred", t); - throw t; - } - - LOGGER.trace("doWork(): completed in {} ms", System.currentTimeMillis() - time); - - return result; - } - - T doLoggedWork() throws Exception; - - } - -} diff --git a/repository/src/main/java/de/acosix/alfresco/keycloak/repo/authentication/KeycloakTokenGrantedAuthorityProcessor.java b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/authentication/KeycloakTokenGrantedAuthorityProcessor.java new file mode 100644 index 0000000..be3fa69 --- /dev/null +++ b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/authentication/KeycloakTokenGrantedAuthorityProcessor.java @@ -0,0 +1,150 @@ +/* + * Copyright 2019 - 2021 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.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +import org.alfresco.repo.security.authentication.AuthenticationUtil; +import org.alfresco.service.cmr.security.AuthorityService; +import org.keycloak.representations.AccessToken; +import org.keycloak.representations.IDToken; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationContextAware; + +import de.acosix.alfresco.keycloak.repo.authority.GrantedAuthorityAwareAuthorityServiceImpl; +import net.sf.acegisecurity.Authentication; +import net.sf.acegisecurity.GrantedAuthority; +import net.sf.acegisecurity.GrantedAuthorityImpl; +import net.sf.acegisecurity.providers.UsernamePasswordAuthenticationToken; + +/** + * This token processor maps authorities found in a Keycloak access token to {@link GrantedAuthority granted authorities} of the + * authenticated user. Such mapping does not + * make the authenticated user a permanent / persisted member of any group represented by an authority, and as such operations like + * {@link AuthorityService#getContainingAuthorities(org.alfresco.service.cmr.security.AuthorityType, String, boolean) + * getContainingAuthorities} will not return any of the mapped authorities if the user is not a member based on Alfresco's internal + * authority data. A {@link GrantedAuthorityAwareAuthorityServiceImpl granted-authority-aware AuthorityService} provided by this module + * adapts the {@link AuthorityService#getAuthoritiesForUser(String) getAuthoritiesForUser} operation in such a way that the result includes + * any authorities for the authenticated users specified via granted authorities. This ensures authorities are properly included in any + * permission / access checking performed by Alfresco. + * + * @author Axel Faust + */ +public class KeycloakTokenGrantedAuthorityProcessor implements TokenProcessor, InitializingBean, ApplicationContextAware +{ + + private static final Logger LOGGER = LoggerFactory.getLogger(KeycloakTokenGrantedAuthorityProcessor.class); + + private static final String NAME = "GrantedAuthorityProcessor"; + + protected ApplicationContext applicationContext; + + protected boolean enabled; + + protected Collection authorityExtractors; + + /** + * + * {@inheritDoc} + */ + @Override + public String getName() + { + return NAME; + } + + /** + * @param enabled + * the enabled to set + */ + public void setEnabled(final boolean enabled) + { + this.enabled = enabled; + } + + /** + * + * {@inheritDoc} + */ + @Override + public void setApplicationContext(final ApplicationContext applicationContext) + { + this.applicationContext = applicationContext; + } + + /** + * + * {@inheritDoc} + */ + @Override + public void afterPropertiesSet() throws BeansException + { + this.authorityExtractors = Collections + .unmodifiableList(new ArrayList<>(this.applicationContext.getBeansOfType(AuthorityExtractor.class, false, true).values())); + } + + /** + * + * {@inheritDoc} + */ + @Override + public void handleUserTokens(final AccessToken accessToken, final IDToken idToken, final boolean freshLogin) + { + if (this.enabled) + { + LOGGER.debug("Mapping Keycloak access token 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 access token: {}", mappedAuthorities); + + if (!mappedAuthorities.isEmpty()) + { + final Authentication currentAuthentication = AuthenticationUtil.getFullAuthentication(); + if (currentAuthentication instanceof UsernamePasswordAuthenticationToken) + { + GrantedAuthority[] grantedAuthorities = currentAuthentication.getAuthorities(); + + final List grantedAuthoritiesL = mappedAuthorities.stream().map(GrantedAuthorityImpl::new) + .collect(Collectors.toList()); + grantedAuthoritiesL.addAll(Arrays.asList(grantedAuthorities)); + + grantedAuthorities = grantedAuthoritiesL.toArray(new GrantedAuthority[0]); + ((UsernamePasswordAuthenticationToken) currentAuthentication).setAuthorities(grantedAuthorities); + } + else + { + LOGGER.warn( + "Authentication for user is not of the expected type {} - Keycloak access token cannot be mapped to granted authorities", + UsernamePasswordAuthenticationToken.class); + } + } + } + } +} diff --git a/repository/src/main/java/de/acosix/alfresco/keycloak/repo/authentication/KeycloakTokenGroupSyncProcessor.java b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/authentication/KeycloakTokenGroupSyncProcessor.java new file mode 100644 index 0000000..c2cff17 --- /dev/null +++ b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/authentication/KeycloakTokenGroupSyncProcessor.java @@ -0,0 +1,253 @@ +/* + * Copyright 2019 - 2021 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.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; + +import org.alfresco.repo.security.authentication.AuthenticationUtil; +import org.alfresco.repo.transaction.AlfrescoTransactionSupport; +import org.alfresco.repo.transaction.AlfrescoTransactionSupport.TxnReadState; +import org.alfresco.service.cmr.repository.DuplicateChildNodeNameException; +import org.alfresco.service.cmr.security.AuthorityService; +import org.alfresco.service.cmr.security.AuthorityType; +import org.alfresco.service.cmr.security.PermissionService; +import org.alfresco.service.transaction.TransactionService; +import org.alfresco.util.PropertyCheck; +import org.keycloak.representations.AccessToken; +import org.keycloak.representations.IDToken; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationContextAware; + +import de.acosix.alfresco.keycloak.repo.util.AlfrescoCompatibilityUtil; + +/** + * This token processor handles group authorities found in a Keylcoak access token, optionally creating any groups not already existing in + * Alfresco and/or synchronising the group membership of the current user with the set of groups specified in the token. + * + * @author Brian Long + */ +public class KeycloakTokenGroupSyncProcessor implements TokenProcessor, InitializingBean, ApplicationContextAware +{ + + private static final Logger LOGGER = LoggerFactory.getLogger(KeycloakTokenGroupSyncProcessor.class); + + private static final String NAME = "GroupSyncProcessor"; + + protected ApplicationContext applicationContext; + + protected boolean createMissingGroupsOnLogin; + + protected boolean syncGroupMembershipOnLogin; + + protected TransactionService transactionService; + + protected AuthorityService authorityService; + + protected Collection authorityExtractors; + + /** + * + * {@inheritDoc} + */ + @Override + public String getName() + { + return NAME; + } + + /** + * + * {@inheritDoc} + */ + @Override + public void setApplicationContext(final ApplicationContext applicationContext) + { + this.applicationContext = applicationContext; + } + + /** + * @param createMissingGroupsOnLogin + * the createMissingGroupsOnLogin to set + */ + public void setCreateMissingGroupsOnLogin(final boolean createMissingGroupsOnLogin) + { + this.createMissingGroupsOnLogin = createMissingGroupsOnLogin; + } + + /** + * @param syncGroupMembershipOnLogin + * the syncAuthorityMembershipOnLogin to set + */ + public void setSyncGroupMembershipOnLogin(final boolean syncGroupMembershipOnLogin) + { + this.syncGroupMembershipOnLogin = syncGroupMembershipOnLogin; + } + + /** + * @param transactionService + * the transactionService to set + */ + public void setTransactionService(final TransactionService transactionService) + { + this.transactionService = transactionService; + } + + /** + * @param authorityService + * the authorityService to set + */ + public void setAuthorityService(final AuthorityService authorityService) + { + this.authorityService = authorityService; + } + + /** + * + * {@inheritDoc} + */ + @Override + public void afterPropertiesSet() throws BeansException + { + PropertyCheck.mandatory(this, "transactionService", this.transactionService); + PropertyCheck.mandatory(this, "authorityService", this.authorityService); + this.authorityExtractors = Collections + .unmodifiableList(new ArrayList<>(this.applicationContext.getBeansOfType(AuthorityExtractor.class, false, true).values())); + } + + /** + * + * {@inheritDoc} + */ + @Override + public void handleUserTokens(final AccessToken accessToken, final IDToken idToken, final boolean freshLogin) + { + if (freshLogin && (this.createMissingGroupsOnLogin || this.syncGroupMembershipOnLogin)) + { + final Collection groups = this.extractGroups(accessToken); + + final boolean requiresNew = AlfrescoTransactionSupport.getTransactionReadState() == TxnReadState.TXN_READ_ONLY; + + if (this.createMissingGroupsOnLogin) + { + AuthenticationUtil.runAsSystem(() -> this.transactionService.getRetryingTransactionHelper().doInTransaction(() -> { + this.syncGroups(groups); + return null; + }, false, requiresNew)); + } + + if (this.syncGroupMembershipOnLogin) + { + AuthenticationUtil.runAsSystem(() -> this.transactionService.getRetryingTransactionHelper().doInTransaction(() -> { + this.syncGroupMemberships(groups); + return null; + }, false, requiresNew)); + } + } + } + + /** + * Extracts groups from the provided access token. + * + * @param accessToken + * the Keycloak access token for the authenticated user + * @return the (mutable) collection of extracted group authority names + */ + protected Collection extractGroups(final AccessToken accessToken) + { + LOGGER.debug("Mapping Keycloak access token to user group authorities"); + + final Set groups = new HashSet<>(); + this.authorityExtractors.stream().map(extractor -> extractor.extractAuthorities(accessToken)).forEach(groups::addAll); + groups.removeIf(a -> AuthorityType.getAuthorityType(a) != AuthorityType.GROUP); + // in case some extractor mapped this pseudo-group + groups.remove(PermissionService.ALL_AUTHORITIES); + + LOGGER.debug("Mapped user group authorities from access token: {}", groups); + + return groups; + } + + /** + * Synchronises the groups of the current user, but not the membership, as Alfresco user groups. + * + * @param groups + * the names of the user's groups extracted from the Keycloak access token + */ + protected void syncGroups(final Collection groups) + { + LOGGER.debug("Synchronizing user groups {}", groups); + + for (final String group : groups) + { + if (!this.authorityService.authorityExists(group)) + { + LOGGER.debug("Creating group {}", group); + final String groupShortName = this.authorityService.getShortName(group); + + try + { + this.authorityService.createAuthority(AuthorityType.GROUP, groupShortName); + } + catch (final DuplicateChildNodeNameException dcnne) + { + LOGGER.debug("Group {} already created; race condition?", groupShortName); + } + } + } + } + + /** + * Synchronises the membership of the current user in Alfresco user groups. + * + * @param groups + * the Alfresco group authorities as determined from the Keycloak access token for the current user + */ + protected void syncGroupMemberships(final Collection groups) + { + final String userName = AuthenticationUtil.getFullyAuthenticatedUser(); + final String maskedUsername = AlfrescoCompatibilityUtil.maskUsername(userName); + + LOGGER.debug("Synchronising group membership for user {} and token extracted groups {}", maskedUsername, groups); + + final Set existingUnprocessedGroups = this.authorityService.getContainingAuthorities(AuthorityType.GROUP, userName, true); + + LOGGER.debug("User {} is currently in the groups {}", maskedUsername, existingUnprocessedGroups); + + for (final String group : groups) + { + // !remove(group) ensures we only add if not already a member + if (!existingUnprocessedGroups.remove(group) && this.authorityService.authorityExists(group)) + { + LOGGER.debug("Adding user {} to group {}", maskedUsername, group); + this.authorityService.addAuthority(group, userName); + } + } + + for (final String group : existingUnprocessedGroups) + { + LOGGER.debug("Removing user {} from group {}", maskedUsername, group); + this.authorityService.removeAuthority(group, userName); + } + } +} \ No newline at end of file diff --git a/repository/src/main/java/de/acosix/alfresco/keycloak/repo/authentication/KeycloakTokenPersonProcessor.java b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/authentication/KeycloakTokenPersonProcessor.java new file mode 100644 index 0000000..5c11b6b --- /dev/null +++ b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/authentication/KeycloakTokenPersonProcessor.java @@ -0,0 +1,198 @@ +/* + * Copyright 2019 - 2021 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.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import org.alfresco.model.ContentModel; +import org.alfresco.repo.security.authentication.AuthenticationUtil; +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.cmr.security.PersonService; +import org.alfresco.service.namespace.QName; +import org.alfresco.service.transaction.TransactionService; +import org.alfresco.util.PropertyCheck; +import org.keycloak.representations.AccessToken; +import org.keycloak.representations.IDToken; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationContextAware; + +import de.acosix.alfresco.keycloak.repo.util.AlfrescoCompatibilityUtil; + +/** + * This token processor maps profile data from the token of an authenticated user into the {@link ContentModel#TYPE_PERSON person + * properties} for that user upon login. + * + * @author Axel Faust + */ +public class KeycloakTokenPersonProcessor implements TokenProcessor, InitializingBean, ApplicationContextAware +{ + + private static final Logger LOGGER = LoggerFactory.getLogger(KeycloakTokenPersonProcessor.class); + + private static final String NAME = "PersonProcessor"; + + protected ApplicationContext applicationContext; + + protected boolean enabled; + + protected TransactionService transactionService; + + protected NodeService nodeService; + + protected PersonService personService; + + protected Collection userProcessors; + + /** + * + * {@inheritDoc} + */ + @Override + public String getName() + { + return NAME; + } + + public void setEnabled(final boolean enabled) + { + this.enabled = enabled; + } + + /** + * + * {@inheritDoc} + */ + @Override + public void setApplicationContext(final ApplicationContext applicationContext) + { + this.applicationContext = applicationContext; + } + + /** + * @param transactionService + * the transactionService to set + */ + public void setTransactionService(final TransactionService transactionService) + { + this.transactionService = transactionService; + } + + /** + * @param nodeService + * the nodeService to set + */ + public void setNodeService(final NodeService nodeService) + { + this.nodeService = nodeService; + } + + /** + * @param personService + * the personService to set + */ + public void setPersonService(final PersonService personService) + { + this.personService = personService; + } + + /** + * + * {@inheritDoc} + */ + @Override + public void afterPropertiesSet() throws BeansException + { + PropertyCheck.mandatory(this, "transactionService", this.transactionService); + PropertyCheck.mandatory(this, "nodeService", this.nodeService); + PropertyCheck.mandatory(this, "personService", this.personService); + + this.userProcessors = Collections + .unmodifiableList(new ArrayList<>(this.applicationContext.getBeansOfType(UserProcessor.class, false, true).values())); + } + + /** + * + * {@inheritDoc} + */ + @Override + public void handleUserTokens(final AccessToken accessToken, final IDToken idToken, final boolean freshLogin) + { + if (freshLogin && this.enabled) + { + final boolean requiresNew = AlfrescoTransactionSupport.getTransactionReadState() == TxnReadState.TXN_READ_ONLY; + this.transactionService.getRetryingTransactionHelper().doInTransaction(() -> { + this.updatePerson(accessToken, idToken); + return null; + }, false, requiresNew); + } + } + + /** + * 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 = AuthenticationUtil.getFullyAuthenticatedUser(); + + LOGGER.debug("Mapping person property updates for user {}", AlfrescoCompatibilityUtil.maskUsername(userName)); + + final NodeRef person = this.personService.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 Map currentProperties = this.nodeService.getProperties(person); + + propertiesToRemove.retainAll(currentProperties.keySet()); + if (!propertiesToRemove.isEmpty()) + { + // 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); + this.nodeService.setProperties(person, newProperties); + } + else if (!updates.isEmpty()) + { + this.nodeService.addProperties(person, updates); + } + } + +} \ No newline at end of file diff --git a/repository/src/main/java/de/acosix/alfresco/keycloak/repo/authentication/TokenProcessor.java b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/authentication/TokenProcessor.java index dd94cd3..028a21c 100644 --- a/repository/src/main/java/de/acosix/alfresco/keycloak/repo/authentication/TokenProcessor.java +++ b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/authentication/TokenProcessor.java @@ -1,51 +1,75 @@ +/* + * Copyright 2019 - 2021 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.security.authentication.AbstractAuthenticationComponent; import org.keycloak.representations.AccessToken; import org.keycloak.representations.IDToken; /** * Instances of this interface are used to process access tokens from Keycloak authenticated users. All instances of this - * interface in the Keycloak authentication subsystem will be consulted in the order of the priority field, followed by the - * order the beans are defined in the Spring application context. + * interface in the Keycloak authentication subsystem will be consulted in the order defined by the {@link #getPriority() priority} or the + * order the beans are defined in the Spring application context in case of identical priorities. * * @author Brian Long */ -public interface TokenProcessor extends Comparable { - - /** - * A name of the processor for logging and reference purposes. - * @return A processor name. - */ - String getName(); - - /** - * A priority for sorting beans for execution order. - * @return - */ - default int getPriority() { - return 0; - } +public interface TokenProcessor extends Comparable +{ + + /** + * The default priority value of a token processor if {@link #getPriority() getPriority} is not overridden. + */ + int DEFAULT_PRIORITY = 0; + + /** + * Retrieves the name of this processor, typically for logging and reference purposes. + * + * @return the name of this processor + */ + String getName(); + + /** + * A priority for sorting beans for execution order. + * + * @return the priority - the lower the earlier this bean is processed + */ + default int getPriority() + { + return DEFAULT_PRIORITY; + } /** * Handles access tokens from Keycloak. * * @param accessToken - * the Keycloak access token for the authenticated user + * 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 + * the Keycloak ID token for the authenticated user - may be {@code null} if not contained in the authentication response * @param freshLogin - * {@code true} if the tokens are fresh, that is have just been obtained from an initial login, {@code false} otherwise + * {@code true} if the tokens are fresh, that is have just been obtained from an initial login, {@code false} otherwise */ - void handleUserTokens( - final AbstractAuthenticationComponent authComponent, - final AccessToken accessToken, - final IDToken idToken, - final boolean freshLogin); - - @Override - default int compareTo(TokenProcessor o) { - return Integer.compare(this.getPriority(), o.getPriority()); - } + void handleUserTokens(AccessToken accessToken, IDToken idToken, boolean freshLogin); + + /** + * + * {@inheritDoc} + */ + @Override + default int compareTo(final TokenProcessor o) + { + return Integer.compare(this.getPriority(), o.getPriority()); + } } 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 a8e6a9b..9e9efd5 100644 --- a/repository/src/test/docker/alfresco/extension/alfresco-global.addition.properties +++ b/repository/src/test/docker/alfresco/extension/alfresco-global.addition.properties @@ -16,7 +16,8 @@ # note: this file is not named alfresco-global.properties to not override the default file in the image # instead it relies on Dockerfile post-processing to merge with the default file -authentication.chain=keycloak1:keycloak,alfrescoNtlm1:alfrescoNtlm +# alfrescoNtlm first to have local users take priority over remote and avoid errors in Keycloak for i.e. "admin" user +authentication.chain=alfrescoNtlm1:alfrescoNtlm,keycloak1:keycloak keycloak.adapter.auth-server-url=http://localhost:${docker.tests.keycloakPort}/auth keycloak.adapter.realm=test @@ -29,6 +30,11 @@ keycloak.adapter.proxy-url=http://keycloak:8080 keycloak.roles.requiredClientScopes=alfresco-role-service +keycloak.roles.resourceMapper.default.static.property.nameMappings.map.acme-group=GROUP_ACME_MEMBER + +keycloak.authentication.createMissingGroupsOnLogin=true +keycloak.authentication.syncGroupMembershipOnLogin=true + keycloak.synchronization.userFilter.containedInGroup.property.groupPaths=/Test A keycloak.synchronization.groupFilter.containedInGroup.property.groupPaths=/Test A diff --git a/repository/src/test/docker/test-realm.json b/repository/src/test/docker/test-realm.json index df72400..9609330 100644 --- a/repository/src/test/docker/test-realm.json +++ b/repository/src/test/docker/test-realm.json @@ -591,7 +591,11 @@ { "clientScope": "alfresco", "roles": [ - "admin" + "admin", + "search-admin", + "model-admin", + "site-admin", + "acme-group" ] } ] @@ -1091,6 +1095,22 @@ { "name": "admin", "clientRole": true + }, + { + "name": "model-admin", + "clientRole": true + }, + { + "name": "search-admin", + "clientRole": true + }, + { + "name": "site-admin", + "clientRole": true + }, + { + "name": "acme-group", + "clientRole": true } ] } @@ -1157,6 +1177,11 @@ "realmRoles": [ "default-roles-test" ], + "clientRoles": { + "alfresco": [ + "acme-group" + ] + }, "groups": [ "/Test A/Test AB", "/Test B/Test BA" diff --git a/share/pom.xml b/share/pom.xml index 3e5ed86..5da6c5b 100644 --- a/share/pom.xml +++ b/share/pom.xml @@ -21,7 +21,7 @@ de.acosix.alfresco.keycloak de.acosix.alfresco.keycloak.parent - 1.1.0-rc7 + 1.1.0-rc8-SNAPSHOT de.acosix.alfresco.keycloak.share diff --git a/share/src/test/docker/alfresco/extension/alfresco-global.addition.properties b/share/src/test/docker/alfresco/extension/alfresco-global.addition.properties index 4313109..d59c96c 100644 --- a/share/src/test/docker/alfresco/extension/alfresco-global.addition.properties +++ b/share/src/test/docker/alfresco/extension/alfresco-global.addition.properties @@ -29,6 +29,11 @@ keycloak.adapter.forced-route-url=http://keycloak:8080 keycloak.roles.requiredClientScopes=alfresco-role-service +keycloak.roles.resourceMapper.default.static.property.nameMappings.map.acme-group=GROUP_ACME_MEMBER + +keycloak.authentication.createMissingGroupsOnLogin=true +keycloak.authentication.syncGroupMembershipOnLogin=true + keycloak.synchronization.userFilter.containedInGroup.property.groupPaths=/Test A keycloak.synchronization.groupFilter.containedInGroup.property.groupPaths=/Test A diff --git a/share/src/test/docker/test-realm.json b/share/src/test/docker/test-realm.json index a97a9ef..4b63646 100644 --- a/share/src/test/docker/test-realm.json +++ b/share/src/test/docker/test-realm.json @@ -626,7 +626,11 @@ { "clientScope": "alfresco", "roles": [ - "admin" + "admin", + "search-admin", + "model-admin", + "site-admin", + "acme-group" ] } ] @@ -1161,6 +1165,22 @@ { "name": "admin", "clientRole": true + }, + { + "name": "model-admin", + "clientRole": true + }, + { + "name": "search-admin", + "clientRole": true + }, + { + "name": "site-admin", + "clientRole": true + }, + { + "name": "acme-group", + "clientRole": true } ] } @@ -1227,6 +1247,11 @@ "realmRoles": [ "default-roles-test" ], + "clientRoles": { + "alfresco": [ + "acme-group" + ] + }, "groups": [ "/Test A/Test AB", "/Test B/Test BA"