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 4a4ba8a..f781622 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 @@ -49,8 +49,6 @@ - - @@ -189,6 +187,11 @@ + + + + + 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 1dff46a..cfc78c0 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 @@ -15,26 +15,13 @@ */ 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.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.KeycloakDeployment; import org.keycloak.representations.AccessToken; @@ -48,12 +35,7 @@ import org.springframework.context.ApplicationContextAware; import de.acosix.alfresco.keycloak.repo.token.AccessTokenClient; import de.acosix.alfresco.keycloak.repo.token.AccessTokenException; import de.acosix.alfresco.keycloak.repo.token.AccessTokenRefreshException; -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; /** * This component provides Keycloak-integrated user/password authentication support to an Alfresco instance. @@ -80,17 +62,11 @@ public class KeycloakAuthenticationComponent extends AbstractAuthenticationCompo protected boolean allowGuestLogin; - protected boolean mapAuthorities; - - protected boolean mapPersonPropertiesOnLogin; - protected KeycloakDeployment deployment; protected AccessTokenClient accessTokenClient; - - protected Collection authorityExtractors; - - protected Collection userProcessors; + + protected List tokenProcessors; /** * @@ -103,10 +79,10 @@ public class KeycloakAuthenticationComponent extends AbstractAuthenticationCompo PropertyCheck.mandatory(this, "keycloakDeployment", this.deployment); this.accessTokenClient = new AccessTokenClient(this.deployment); - 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())); + + List tokenProcessors = new ArrayList<>(this.applicationContext.getBeansOfType(TokenProcessor.class, false, true).values()); + Collections.sort(tokenProcessors); + this.tokenProcessors = Collections.unmodifiableList(tokenProcessors); } /** @@ -174,24 +150,6 @@ public class KeycloakAuthenticationComponent extends AbstractAuthenticationCompo this.setAllowGuestLogin(Boolean.TRUE.equals(allowGuestLogin)); } - /** - * @param mapAuthorities - * the mapAuthorities to set - */ - public void setMapAuthorities(final boolean mapAuthorities) - { - this.mapAuthorities = mapAuthorities; - } - - /** - * @param mapPersonPropertiesOnLogin - * the mapPersonPropertiesOnLogin to set - */ - public void setMapPersonPropertiesOnLogin(final boolean mapPersonPropertiesOnLogin) - { - this.mapPersonPropertiesOnLogin = mapPersonPropertiesOnLogin; - } - /** * @param deployment * the deployment to set @@ -323,89 +281,11 @@ public class KeycloakAuthenticationComponent extends AbstractAuthenticationCompo */ public void handleUserTokens(final AccessToken accessToken, final IDToken idToken, final boolean freshLogin) { - if (this.mapAuthorities) - { - 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 = this.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; - this.getTransactionService().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 = 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()) - { - // 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); - } + for (TokenProcessor processor : this.tokenProcessors) + { + LOGGER.debug("Processing token with {}", processor.getName()); + processor.handleUserTokens(this, 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 new file mode 100644 index 0000000..060e154 --- /dev/null +++ b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/authentication/KeycloakTokenAuthorityMapper.java @@ -0,0 +1,171 @@ +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/TokenProcessor.java b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/authentication/TokenProcessor.java new file mode 100644 index 0000000..dd94cd3 --- /dev/null +++ b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/authentication/TokenProcessor.java @@ -0,0 +1,51 @@ +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. + * + * @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; + } + + /** + * Handles access tokens from Keycloak. + * + * @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 freshLogin + * {@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()); + } + +}