diff --git a/src/main/java/com/activiti/conf/ActivitiOotbSecurityConfigurationAdapter.java b/src/main/java/com/activiti/conf/ActivitiOotbSecurityConfigurationAdapter.java new file mode 100644 index 0000000..d90e7c8 --- /dev/null +++ b/src/main/java/com/activiti/conf/ActivitiOotbSecurityConfigurationAdapter.java @@ -0,0 +1,57 @@ +package com.activiti.conf; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.stereotype.Component; + +import com.inteligr8.activiti.ActivitiSecurityConfigAdapter; + +/** + * This class/bean executes the OOTB security configuration without the + * override, so you can still use its OOTB features. This will allow you to + * enable/disable features, chain them, and uset he OOTB features as a + * fallback or failsafe. + * + * This class must be in the com.activiti.conf package so it can use protected + * fields and methods of the OOTB class instance. + * + * @author brian@inteligr8.com + * @see com.activiti.conf.SecurityConfiguration + */ +@Component +public class ActivitiOotbSecurityConfigurationAdapter implements ActivitiSecurityConfigAdapter { + + private final Logger logger = LoggerFactory.getLogger(this.getClass()); + + @Value("${keycloak-ext.ootbSecurityConfig.enabled:true}") + private boolean enabled; + + @Autowired + private SecurityConfiguration ootbSecurityConfig; + + @Override + public boolean isEnabled() { + return this.enabled; + } + + public int getPriority() { + return 0; + } + + @Override + public void configureGlobal(AuthenticationManagerBuilder authmanBuilder, UserDetailsService userDetailsService) { + this.logger.trace("configureGlobal()"); + + this.logger.info("Using OOTB authentication"); + + // unset override (which has already been called in order to get here) + this.ootbSecurityConfig.securityConfigOverride = null; + + this.ootbSecurityConfig.configureGlobal(authmanBuilder); + } + +} diff --git a/src/main/java/com/activiti/extension/conf/KeycloakExtSpringComponentScanner.java b/src/main/java/com/activiti/extension/conf/KeycloakExtSpringComponentScanner.java index 2ee46b1..62dceef 100644 --- a/src/main/java/com/activiti/extension/conf/KeycloakExtSpringComponentScanner.java +++ b/src/main/java/com/activiti/extension/conf/KeycloakExtSpringComponentScanner.java @@ -4,7 +4,7 @@ import org.springframework.context.annotation.ComponentScan; import org.springframework.context.annotation.Configuration; @Configuration -@ComponentScan(basePackages = {"com.inteligr8.activiti.ais"}) +@ComponentScan(basePackages = {"com.inteligr8.activiti"}) public class KeycloakExtSpringComponentScanner { } diff --git a/src/main/java/com/inteligr8/activiti/ActivitiSecurityConfigAdapter.java b/src/main/java/com/inteligr8/activiti/ActivitiSecurityConfigAdapter.java new file mode 100644 index 0000000..87122da --- /dev/null +++ b/src/main/java/com/inteligr8/activiti/ActivitiSecurityConfigAdapter.java @@ -0,0 +1,38 @@ +package com.inteligr8.activiti; + +import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; +import org.springframework.security.core.userdetails.UserDetailsService; + +/** + * @author brian@inteligr8.com + */ +public interface ActivitiSecurityConfigAdapter extends Comparable { + + /** + * Is the adapter enabled? This allows for configurable enablement. + * + * @return true if enabled; false otherwise + */ + boolean isEnabled(); + + /** + * The lower the value, the higher the priority. The OOTB security + * configuration uses priority 0. Use negative values to supersede it. + * Anything with equal priorities should be considered unordered and may + * execute in a random order. + * + * @return A priority; may be negative or positive + */ + int getPriority(); + + /** + * @see com.activiti.api.security.AlfrescoSecurityConfigOverride + */ + void configureGlobal(AuthenticationManagerBuilder authmanBuilder, UserDetailsService userDetailsService); + + @Override + default int compareTo(ActivitiSecurityConfigAdapter adapter) { + return Integer.compare(this.getPriority(), adapter.getPriority()); + } + +} diff --git a/src/main/java/com/inteligr8/activiti/Inteligr8SecurityConfigurationRegistry.java b/src/main/java/com/inteligr8/activiti/Inteligr8SecurityConfigurationRegistry.java new file mode 100644 index 0000000..020b442 --- /dev/null +++ b/src/main/java/com/inteligr8/activiti/Inteligr8SecurityConfigurationRegistry.java @@ -0,0 +1,100 @@ +package com.inteligr8.activiti; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.stereotype.Component; + +import com.activiti.api.security.AlfrescoSecurityConfigOverride; +import com.activiti.domain.idm.Group; +import com.activiti.domain.idm.Tenant; +import com.activiti.service.api.GroupService; +import com.activiti.service.idm.TenantService; +import com.activiti.service.license.LicenseService; + +/** + * This class/bean overrides the APS security configuration with a collection + * of implementations. The OOTB extension only provides one override. This + * uses that extension point, but delegates it out to multiple possible + * implementations. + * + * Order cannot be controlled, so it should not be assumed in any adapter + * implementation. + * + * @author brian@inteligr8.com + */ +@Component +public class Inteligr8SecurityConfigurationRegistry implements AlfrescoSecurityConfigOverride { + + private final Logger logger = LoggerFactory.getLogger(this.getClass()); + + @Autowired + private List adapters; + + @Autowired(required = false) + private LicenseService licenseService; + + @Autowired(required = false) + private TenantService tenantService; + + @Autowired(required = false) + private GroupService groupService; + + @Override + public void configureGlobal(AuthenticationManagerBuilder authmanBuilder, UserDetailsService userDetailsService) { + this.logger.trace("configureGlobal()"); + + Collections.sort(this.adapters); + + if (this.logger.isTraceEnabled()) + this.logGroups(); + + for (ActivitiSecurityConfigAdapter adapter : this.adapters) { + if (adapter.isEnabled()) { + this.logger.info("Security adapter enabled: {}", adapter.getClass()); + adapter.configureGlobal(authmanBuilder, userDetailsService); + break; + } else { + this.logger.info("Security adapter disabled: {}", adapter.getClass()); + } + } + } + + private void logGroups() { + Long tenantId = this.findDefaultTenantId(); + if (tenantId != null) { + // not first boot + this.logger.trace("Functional groups: {}", this.toGroupNames(this.groupService.getFunctionalGroups(tenantId))); + this.logger.trace("System groups: {}", this.toGroupNames(this.groupService.getSystemGroups(tenantId))); + } + } + + private Long findDefaultTenantId() { + String defaultTenantName = this.licenseService.getDefaultTenantName(); + this.logger.trace("Default Tenant: {}", defaultTenantName); + + List tenants = this.tenantService.findTenantsByName(defaultTenantName); + if (tenants == null || tenants.isEmpty()) { + this.logger.warn("Default tenant not found"); + return null; + } + + Tenant tenant = tenants.iterator().next(); + return tenant.getId(); + } + + private Collection toGroupNames(Collection groups) { + List groupNames = new ArrayList<>(groups.size()); + for (Group group : groups) + groupNames.add(group.getName() + " [" + group.getExternalId() + "]"); + return groupNames; + } + +} diff --git a/src/main/java/com/inteligr8/activiti/ais/AbstractIdentityServiceActivitiAuthenticator.java b/src/main/java/com/inteligr8/activiti/ais/AbstractIdentityServiceActivitiAuthenticator.java deleted file mode 100644 index ccb3d0f..0000000 --- a/src/main/java/com/inteligr8/activiti/ais/AbstractIdentityServiceActivitiAuthenticator.java +++ /dev/null @@ -1,99 +0,0 @@ -package com.inteligr8.activiti.ais; - -import java.util.Collection; -import java.util.HashSet; -import java.util.Map.Entry; -import java.util.Set; - -import org.apache.commons.lang3.StringUtils; -import org.keycloak.KeycloakPrincipal; -import org.keycloak.KeycloakSecurityContext; -import org.keycloak.adapters.springsecurity.token.KeycloakAuthenticationToken; -import org.keycloak.representations.AccessToken; -import org.keycloak.representations.AccessToken.Access; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.GrantedAuthority; - -import com.inteligr8.activiti.Authenticator; - -public abstract class AbstractIdentityServiceActivitiAuthenticator implements Authenticator { - - private final Logger logger = LoggerFactory.getLogger(this.getClass()); - - - - protected Set getRoles(Authentication auth) { - Set authorities = this.toSet(auth.getAuthorities()); - this.logger.debug("Auto-parsed authorities: {}", authorities); - - if (authorities.isEmpty()) { - AccessToken atoken = this.getKeycloakAccessToken(auth); - if (atoken == null) { - this.logger.debug("Access token not available"); - return null; - } else if (atoken.getRealmAccess() == null && atoken.getResourceAccess().isEmpty()) { - this.logger.debug("Access token has no role information"); - return null; - } else { - if (atoken.getRealmAccess() != null) { - this.logger.debug("Access token realm roles: {}", atoken.getRealmAccess().getRoles()); - authorities.addAll(atoken.getRealmAccess().getRoles()); - } - - for (Entry resourceAccess : atoken.getResourceAccess().entrySet()) { - this.logger.debug("Access token resources '{}' roles: {}", resourceAccess.getKey(), resourceAccess.getValue().getRoles()); - authorities.addAll(resourceAccess.getValue().getRoles()); - } - - this.logger.debug("Access token authorities: {}", authorities); - } - } - - return authorities; - } - - protected AccessToken getKeycloakAccessToken(Authentication auth) { - KeycloakSecurityContext ksc = this.getKeycloakSecurityContext(auth); - return ksc == null ? null : ksc.getToken(); - } - - @SuppressWarnings("unchecked") - protected KeycloakSecurityContext getKeycloakSecurityContext(Authentication auth) { - if (auth.getCredentials() instanceof KeycloakSecurityContext) { - this.logger.debug("Found keycloak context in credentials"); - return (KeycloakSecurityContext)auth.getCredentials(); - } else if (auth.getPrincipal() instanceof KeycloakPrincipal) { - this.logger.debug("Found keycloak context in principal: {}", auth.getPrincipal()); - return ((KeycloakPrincipal)auth.getPrincipal()).getKeycloakSecurityContext(); - } else if (!(auth instanceof KeycloakAuthenticationToken)) { - this.logger.warn("Unexpected token: {}", auth.getClass()); - return null; - } - - KeycloakAuthenticationToken ktoken = (KeycloakAuthenticationToken)auth; - if (ktoken.getAccount() != null) { - this.logger.debug("Found keycloak context in account: {}", ktoken.getAccount().getPrincipal() == null ? null : ktoken.getAccount().getPrincipal().getName()); - return ktoken.getAccount().getKeycloakSecurityContext(); - } else { - this.logger.warn("Unable to find keycloak security context"); - this.logger.debug("Principal: {}", auth.getPrincipal()); - this.logger.debug("Account: {}", ktoken.getAccount()); - if (auth.getPrincipal() != null) - this.logger.debug("Principal type: {}", auth.getPrincipal().getClass()); - return null; - } - } - - protected Set toSet(Collection grantedAuthorities) { - Set authorities = new HashSet<>(Math.max(grantedAuthorities.size(), 16)); - for (GrantedAuthority grantedAuthority : grantedAuthorities) { - String authority = StringUtils.trimToNull(grantedAuthority.getAuthority()); - if (authority == null) - this.logger.warn("The granted authorities include an empty authority!?: '{}'", grantedAuthority.getAuthority()); - authorities.add(authority); - } - return authorities; - } -} diff --git a/src/main/java/com/inteligr8/activiti/ais/IdentityServiceAuthenticationProviderAdapter.java b/src/main/java/com/inteligr8/activiti/ais/IdentityServiceAuthenticationProviderAdapter.java deleted file mode 100644 index f74ef19..0000000 --- a/src/main/java/com/inteligr8/activiti/ais/IdentityServiceAuthenticationProviderAdapter.java +++ /dev/null @@ -1,39 +0,0 @@ -package com.inteligr8.activiti.ais; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.beans.factory.annotation.Qualifier; -import org.springframework.security.authentication.AuthenticationProvider; - -import com.activiti.api.security.AlfrescoAuthenticationProviderOverride; -import com.inteligr8.activiti.Authenticator; - -/** - * FIXME This would be nice, but with AIS enabled, it is never called. The use - * of this requires a fix from the Alfresco/Activiti team. Their AIS - * authentication logic appears to have been hastily added, breaking this - * override possibility. We are instead using the heavier weight - * `OidcSecurityConfigurationAdapter` and re-implementing the authentication - * logic discovered in the `activiti-app` project - * `com.activiti.conf.SecurityConfiguration` class. - * - * @author brian.long@yudrio.com - * @see IdentityServiceSecurityConfigurationAdapter - */ -//@Component -public class IdentityServiceAuthenticationProviderAdapter implements AlfrescoAuthenticationProviderOverride { - - private final Logger logger = LoggerFactory.getLogger(this.getClass()); - - @Autowired - @Qualifier("activiti-app.authenticator") - private Authenticator authenticator; - - @Override - public AuthenticationProvider createAuthenticationProvider() { - this.logger.trace("createAuthenticationProvider()"); - return new InterceptingIdentityServiceAuthenticationProvider(this.authenticator); - } - -} diff --git a/src/main/java/com/inteligr8/activiti/ais/IdentityServiceSecurityConfigurationAdapter.java b/src/main/java/com/inteligr8/activiti/ais/IdentityServiceSecurityConfigurationAdapter.java index f729886..9e89944 100644 --- a/src/main/java/com/inteligr8/activiti/ais/IdentityServiceSecurityConfigurationAdapter.java +++ b/src/main/java/com/inteligr8/activiti/ais/IdentityServiceSecurityConfigurationAdapter.java @@ -1,3 +1,4 @@ + package com.inteligr8.activiti.ais; import org.slf4j.Logger; @@ -11,28 +12,31 @@ import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.stereotype.Component; import com.activiti.api.msmt.MsmtTenantResolver; -import com.activiti.api.security.AlfrescoSecurityConfigOverride; import com.activiti.conf.MsmtProperties; -import com.inteligr8.activiti.Authenticator; +import com.activiti.security.identity.service.authentication.provider.IdentityServiceAuthenticationProvider; +import com.inteligr8.activiti.ActivitiSecurityConfigAdapter; +import com.inteligr8.activiti.auth.Authenticator; +import com.inteligr8.activiti.auth.InterceptingAuthenticationProvider; /** - * This class/bean overrides the AIS authentication provider, enabling a more - * complete integration with AIS. + * This class/bean injects a custom AIS authentication provider into the + * security configuration. * - * FIXME This is not optimal, but with AIS enabled, we cannot use the proper - * override. - * - * @author brian.long@yudrio.com - * @see IdentityServiceAuthenticationProviderAdapter + * @author brian@inteligr8.com + * @see com.activiti.security.identity.service.authentication.provider.IdentityServiceAuthenticationProvider */ @Component -public class IdentityServiceSecurityConfigurationAdapter implements AlfrescoSecurityConfigOverride { +public class IdentityServiceSecurityConfigurationAdapter implements ActivitiSecurityConfigAdapter { private final Logger logger = LoggerFactory.getLogger(this.getClass()); - @Value("${keycloak.ext.odic.enabled:true}") + @Value("${keycloak-ext.ais.enabled:false}") private boolean enabled; + // this assures execution before the OOTB impl (-10 < 0) + @Value("${keycloak-ext.ais.priority:-10}") + private int priority; + @Autowired protected MsmtProperties msmtProperties; @@ -40,28 +44,36 @@ public class IdentityServiceSecurityConfigurationAdapter implements AlfrescoSecu protected MsmtTenantResolver tenantResolver; @Autowired - @Qualifier("activiti-app.authenticator") + @Qualifier("keycloak-ext.activiti-app.authenticator") private Authenticator authenticator; protected Authenticator getAuthenticator() { return this.authenticator; } + + @Override + public boolean isEnabled() { + return this.enabled; + } + + @Override + public int getPriority() { + return this.priority; + } @Override - public void configureGlobal(AuthenticationManagerBuilder authmanBuilder, UserDetailsService userDetailsService) { + public void configureGlobal(AuthenticationManagerBuilder auth, UserDetailsService userDetailsService) { this.logger.trace("configureGlobal()"); - if (this.enabled) { - this.logger.info("Using Keycloak authentication extension, featuring creation of missing users and authority synchronization"); - - InterceptingIdentityServiceAuthenticationProvider provider = new InterceptingIdentityServiceAuthenticationProvider(this.getAuthenticator()); - if (this.msmtProperties.isMultiSchemaMultiTenantEnabled()) - provider.setTenantResolver(this.tenantResolver); - provider.setUserDetailsService(userDetailsService); - provider.setGrantedAuthoritiesMapper(new SimpleAuthorityMapper()); - - authmanBuilder.authenticationProvider(provider); - } + this.logger.info("Using AIS authentication extension, featuring creation of missing users and authority synchronization"); + + IdentityServiceAuthenticationProvider provider = new IdentityServiceAuthenticationProvider(); + if (this.msmtProperties.isMultiSchemaMultiTenantEnabled()) + provider.setTenantResolver(this.tenantResolver); + provider.setUserDetailsService(userDetailsService); + provider.setGrantedAuthoritiesMapper(new SimpleAuthorityMapper()); + + auth.authenticationProvider(new InterceptingAuthenticationProvider(provider, this.getAuthenticator())); } } diff --git a/src/main/java/com/inteligr8/activiti/Authenticator.java b/src/main/java/com/inteligr8/activiti/auth/Authenticator.java similarity index 91% rename from src/main/java/com/inteligr8/activiti/Authenticator.java rename to src/main/java/com/inteligr8/activiti/auth/Authenticator.java index 6ef403d..821f42c 100644 --- a/src/main/java/com/inteligr8/activiti/Authenticator.java +++ b/src/main/java/com/inteligr8/activiti/auth/Authenticator.java @@ -1,4 +1,4 @@ -package com.inteligr8.activiti; +package com.inteligr8.activiti.auth; import org.springframework.security.core.Authentication; import org.springframework.security.core.AuthenticationException; diff --git a/src/main/java/com/inteligr8/activiti/ais/InterceptingIdentityServiceAuthenticationProvider.java b/src/main/java/com/inteligr8/activiti/auth/InterceptingAuthenticationProvider.java similarity index 51% rename from src/main/java/com/inteligr8/activiti/ais/InterceptingIdentityServiceAuthenticationProvider.java rename to src/main/java/com/inteligr8/activiti/auth/InterceptingAuthenticationProvider.java index bde2786..f49b7d2 100644 --- a/src/main/java/com/inteligr8/activiti/ais/InterceptingIdentityServiceAuthenticationProvider.java +++ b/src/main/java/com/inteligr8/activiti/auth/InterceptingAuthenticationProvider.java @@ -1,30 +1,35 @@ -package com.inteligr8.activiti.ais; +package com.inteligr8.activiti.auth; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.security.authentication.AuthenticationProvider; import org.springframework.security.core.Authentication; import org.springframework.security.core.AuthenticationException; -import com.activiti.security.identity.service.authentication.provider.IdentityServiceAuthenticationProvider; -import com.inteligr8.activiti.Authenticator; - /** - * This class/bean extends the APS AIS OOTB authentication provider. It uses - * an `Authenticator` to pre/post authenticate. The pre-authentication allows - * us to circumvent the problem with AIS and missing users. The - * post-authentication allow us to synchronize groups/authorities. + * This class/bean provides a pre/post authentication capability to the + * Spring AuthenticationProvider. The pre-authentication hook allows us to + * circumvent the problem with authenticating missing users. The + * post-authentication hook allow us to synchronize groups/authorities. * - * @author brian.long@yudrio.com + * @author brian@inteligr8.com */ -public class InterceptingIdentityServiceAuthenticationProvider extends IdentityServiceAuthenticationProvider { - +public class InterceptingAuthenticationProvider implements AuthenticationProvider { + private final Logger logger = LoggerFactory.getLogger(this.getClass()); + private final AuthenticationProvider provider; private final Authenticator authenticator; - public InterceptingIdentityServiceAuthenticationProvider(Authenticator authenticator) { + public InterceptingAuthenticationProvider(AuthenticationProvider provider, Authenticator authenticator) { + this.provider = provider; this.authenticator = authenticator; } + @Override + public boolean supports(Class authClass) { + return this.provider.supports(authClass); + } + @Override public Authentication authenticate(Authentication auth) throws AuthenticationException { this.logger.trace("authenticate({})", auth.getName()); @@ -32,13 +37,9 @@ public class InterceptingIdentityServiceAuthenticationProvider extends IdentityS this.authenticator.preAuthenticate(auth); this.logger.debug("Pre-authenticated user: {}", auth.getName()); - auth = super.authenticate(auth); + auth = this.provider.authenticate(auth); this.logger.debug("Authenticated user '{}' with authorities: {}", auth.getName(), auth.getAuthorities()); - // FIXME temporary for debugging - if (auth.getName().equals("admin@app.activiti.com")) - return auth; - this.authenticator.postAuthenticate(auth); this.logger.debug("Post-authenticated user: {}", auth.getName()); diff --git a/src/main/java/com/inteligr8/activiti/keycloak/AbstractKeycloakActivitiAuthenticator.java b/src/main/java/com/inteligr8/activiti/keycloak/AbstractKeycloakActivitiAuthenticator.java new file mode 100644 index 0000000..1667d3d --- /dev/null +++ b/src/main/java/com/inteligr8/activiti/keycloak/AbstractKeycloakActivitiAuthenticator.java @@ -0,0 +1,247 @@ +package com.inteligr8.activiti.keycloak; + +import java.util.Collection; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.apache.commons.lang3.StringUtils; +import org.keycloak.KeycloakPrincipal; +import org.keycloak.KeycloakSecurityContext; +import org.keycloak.adapters.springsecurity.token.KeycloakAuthenticationToken; +import org.keycloak.representations.AccessToken; +import org.keycloak.representations.AccessToken.Access; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.util.Pair; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; + +import com.inteligr8.activiti.auth.Authenticator; + +public abstract class AbstractKeycloakActivitiAuthenticator implements Authenticator, InitializingBean { + + private final Logger logger = LoggerFactory.getLogger(this.getClass()); + + @Value("${keycloak-ext.createMissingUser:true}") + protected boolean createMissingUser; + + @Value("${keycloak-ext.clearNewUserGroups:true}") + protected boolean clearNewUserGroups; + + @Value("${keycloak-ext.createMissingGroup:true}") + protected boolean createMissingGroup; + + @Value("${keycloak-ext.syncGroupAdd:true}") + protected boolean syncGroupAdd; + + @Value("${keycloak-ext.syncGroupRemove:true}") + protected boolean syncGroupRemove; + + @Value("${keycloak-ext.resource.include.regex.patterns:#{null}}") + protected String resourceRegexIncludes; + + @Value("${keycloak-ext.group.format.regex.patterns:#{null}}") + protected String regexPatterns; + + @Value("${keycloak-ext.group.format.regex.replacements:#{null}}") + protected String regexReplacements; + + @Value("${keycloak-ext.group.include.regex.patterns:#{null}}") + protected String regexIncludes; + + @Value("${keycloak-ext.group.exclude.regex.patterns:#{null}}") + protected String regexExcludes; + + protected final List> groupFormatters = new LinkedList<>(); + protected final Set resourceIncludes = new HashSet<>(); + protected final Set groupIncludes = new HashSet<>(); + protected final Set groupExcludes = new HashSet<>(); + + @Override + public void afterPropertiesSet() { + if (this.regexPatterns != null) { + String[] regexPatternStrs = StringUtils.split(this.regexPatterns, ','); + String[] regexReplaceStrs = this.regexReplacements == null ? new String[0] : StringUtils.split(this.regexReplacements, ","); + for (int i = 0; i < regexPatternStrs.length; i++) { + Pattern regexPattern = Pattern.compile(regexPatternStrs[i]); + String regexReplace = (i < regexReplaceStrs.length) ? regexReplaceStrs[i] : ""; + this.groupFormatters.add(Pair.of(regexPattern, regexReplace)); + } + } + + if (this.resourceRegexIncludes != null) { + String[] regexPatternStrs = StringUtils.split(this.resourceRegexIncludes, ','); + for (int i = 0; i < regexPatternStrs.length; i++) + this.resourceIncludes.add(Pattern.compile(regexPatternStrs[i])); + } + + if (this.regexIncludes != null) { + String[] regexPatternStrs = StringUtils.split(this.regexIncludes, ','); + for (int i = 0; i < regexPatternStrs.length; i++) + this.groupIncludes.add(Pattern.compile(regexPatternStrs[i])); + } + + if (this.regexExcludes != null) { + String[] regexPatternStrs = StringUtils.split(this.regexExcludes, ','); + for (int i = 0; i < regexPatternStrs.length; i++) + this.groupExcludes.add(Pattern.compile(regexPatternStrs[i])); + } + } + + + + protected Map getRoles(Authentication auth) { + Map authorities = new HashMap<>(); + + AccessToken atoken = this.getKeycloakAccessToken(auth); + if (atoken == null) { + this.logger.debug("Access token not available"); + return null; + } else if (atoken.getRealmAccess() == null && atoken.getResourceAccess().isEmpty()) { + this.logger.debug("Access token has no role information"); + return null; + } else { + if (atoken.getRealmAccess() != null) { + this.logger.debug("Access token realm roles: {}", atoken.getRealmAccess().getRoles()); + Collection roles = this.filterRoles(atoken.getRealmAccess().getRoles()); + Map mappedRoles = this.formatRoles(roles); + authorities.putAll(mappedRoles); + } + + for (Entry resourceAccess : atoken.getResourceAccess().entrySet()) { + if (this.includeResource(resourceAccess.getKey())) { + this.logger.debug("Access token resources '{}' roles: {}", resourceAccess.getKey(), resourceAccess.getValue().getRoles()); + Collection roles = this.filterRoles(resourceAccess.getValue().getRoles()); + Map mappedRoles = this.formatRoles(roles); + authorities.putAll(mappedRoles); + } + } + + this.logger.debug("Access token authorities: {}", authorities); + } + + return authorities; + } + + private Collection filterRoles(Collection unfilteredRoles) { + if (this.groupIncludes.isEmpty() && this.groupExcludes.isEmpty()) + return unfilteredRoles; + + Set filteredRoles = new HashSet<>(unfilteredRoles.size()); + + for (String role : unfilteredRoles) { + boolean doInclude = this.groupIncludes.isEmpty(); + for (Pattern regex : this.groupIncludes) { + Matcher matcher = regex.matcher(role); + if (matcher.matches()) { + this.logger.debug("Role matched inclusion filter: {}", role); + doInclude = true; + break; + } + } + + if (doInclude) { + for (Pattern regex : this.groupExcludes) { + Matcher matcher = regex.matcher(role); + if (matcher.matches()) { + this.logger.debug("Role matched exclusion filter: {}", role); + doInclude = false; + break; + } + } + + if (doInclude) + filteredRoles.add(role); + } + } + + return filteredRoles; + } + + private Map formatRoles(Collection unformattedRoles) { + Map formattedRoles = new HashMap<>(unformattedRoles.size()); + + for (String unformattedRole : unformattedRoles) { + String formattedRole = null; + + for (Pair regex : this.groupFormatters) { + Matcher matcher = regex.getFirst().matcher(unformattedRole); + if (matcher.matches()) { + this.logger.trace("Role matched formatter: {}", unformattedRole); + formattedRole = matcher.replaceFirst(regex.getSecond()); + this.logger.debug("Role formatted: {}", formattedRole); + break; + } + } + + formattedRoles.put(unformattedRole, formattedRole == null ? unformattedRole : formattedRole); + } + + return formattedRoles; + } + + private boolean includeResource(String resource) { + if (this.resourceIncludes.isEmpty()) + return true; + + for (Pattern resourceInclude : this.resourceIncludes) { + Matcher matcher = resourceInclude.matcher(resource); + if (matcher.matches()) + return true; + } + + return false; + } + + protected AccessToken getKeycloakAccessToken(Authentication auth) { + KeycloakSecurityContext ksc = this.getKeycloakSecurityContext(auth); + return ksc == null ? null : ksc.getToken(); + } + + @SuppressWarnings("unchecked") + protected KeycloakSecurityContext getKeycloakSecurityContext(Authentication auth) { + if (auth.getCredentials() instanceof KeycloakSecurityContext) { + this.logger.debug("Found keycloak context in credentials"); + return (KeycloakSecurityContext)auth.getCredentials(); + } else if (auth.getPrincipal() instanceof KeycloakPrincipal) { + this.logger.debug("Found keycloak context in principal: {}", auth.getPrincipal()); + return ((KeycloakPrincipal)auth.getPrincipal()).getKeycloakSecurityContext(); + } else if (!(auth instanceof KeycloakAuthenticationToken)) { + this.logger.warn("Unexpected token: {}", auth.getClass()); + return null; + } + + KeycloakAuthenticationToken ktoken = (KeycloakAuthenticationToken)auth; + if (ktoken.getAccount() != null) { + this.logger.debug("Found keycloak context in account: {}", ktoken.getAccount().getPrincipal() == null ? null : ktoken.getAccount().getPrincipal().getName()); + return ktoken.getAccount().getKeycloakSecurityContext(); + } else { + this.logger.warn("Unable to find keycloak security context"); + this.logger.debug("Principal: {}", auth.getPrincipal()); + this.logger.debug("Account: {}", ktoken.getAccount()); + if (auth.getPrincipal() != null) + this.logger.debug("Principal type: {}", auth.getPrincipal().getClass()); + return null; + } + } + + protected Set toSet(Collection grantedAuthorities) { + Set authorities = new HashSet<>(Math.max(grantedAuthorities.size(), 16)); + for (GrantedAuthority grantedAuthority : grantedAuthorities) { + String authority = StringUtils.trimToNull(grantedAuthority.getAuthority()); + if (authority == null) + this.logger.warn("The granted authorities include an empty authority!?: '{}'", grantedAuthority.getAuthority()); + authorities.add(authority); + } + return authorities; + } +} diff --git a/src/main/java/com/inteligr8/activiti/ais/IdentityServiceActivitiAppAuthenticator.java b/src/main/java/com/inteligr8/activiti/keycloak/KeycloakActivitiAppAuthenticator.java similarity index 78% rename from src/main/java/com/inteligr8/activiti/ais/IdentityServiceActivitiAppAuthenticator.java rename to src/main/java/com/inteligr8/activiti/keycloak/KeycloakActivitiAppAuthenticator.java index 3c250ad..282d4e2 100644 --- a/src/main/java/com/inteligr8/activiti/ais/IdentityServiceActivitiAppAuthenticator.java +++ b/src/main/java/com/inteligr8/activiti/keycloak/KeycloakActivitiAppAuthenticator.java @@ -1,8 +1,9 @@ -package com.inteligr8.activiti.ais; +package com.inteligr8.activiti.keycloak; import java.util.Date; import java.util.List; -import java.util.Set; +import java.util.Map; +import java.util.Map.Entry; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -11,7 +12,6 @@ import org.keycloak.representations.AccessToken; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Lazy; import org.springframework.security.core.Authentication; import org.springframework.security.core.AuthenticationException; @@ -24,7 +24,6 @@ import com.activiti.service.api.GroupService; import com.activiti.service.api.UserService; import com.activiti.service.idm.TenantService; import com.activiti.service.license.LicenseService; -import com.inteligr8.activiti.Authenticator; /** * This class/bean implements an Open ID Connect authenticator for Alfresco @@ -39,29 +38,14 @@ import com.inteligr8.activiti.Authenticator; * * @author brian.long@yudrio.com */ -@Component("activiti-app.authenticator") +@Component("keycloak-ext.activiti-app.authenticator") @Lazy -public class IdentityServiceActivitiAppAuthenticator extends AbstractIdentityServiceActivitiAuthenticator implements Authenticator { +public class KeycloakActivitiAppAuthenticator extends AbstractKeycloakActivitiAuthenticator { private final Logger logger = LoggerFactory.getLogger(this.getClass()); private final Pattern emailNamesPattern = Pattern.compile("([A-Za-z]+)[A-Za-z0-9]*\\.([A-Za-z]+)[A-Za-z0-9]*@.*"); private final String externalIdmSource = "ais"; - - @Value("${keycloak-ext.createMissingUser:true}") - private boolean createMissingUser; - - @Value("${keycloak-ext.clearNewUserGroups:true}") - private boolean clearNewUserGroups; - - @Value("${keycloak-ext.createMissingGroup:true}") - private boolean createMissingGroup; - - @Value("${keycloak-ext.syncGroupAdd:true}") - private boolean syncGroupAdd; - - @Value("${keycloak-ext.syncGroupRemove:true}") - private boolean syncGroupRemove; @Autowired private LicenseService licenseService; @@ -116,7 +100,7 @@ public class IdentityServiceActivitiAppAuthenticator extends AbstractIdentitySer User user = this.findUser(auth, tenantId); this.logger.debug("Inspecting user: {} => {}", user.getId(), user.getExternalId()); - this.syncUserAuthorities(user, auth, tenantId); + this.syncUserRoles(user, auth, tenantId); } private Long findDefaultTenantId() { @@ -165,50 +149,51 @@ public class IdentityServiceActivitiAppAuthenticator extends AbstractIdentitySer } } - private void syncUserAuthorities(User user, Authentication auth, Long tenantId) { - Set authorities = this.getRoles(auth); - if (authorities == null) { - this.logger.debug("The user authorities could not be determined; skipping sync: {}", user.getEmail()); + private void syncUserRoles(User user, Authentication auth, Long tenantId) { + Map roles = this.getRoles(auth); + if (roles == null) { + this.logger.debug("The user roles could not be determined; skipping sync: {}", user.getEmail()); return; } // check Activiti groups User userWithGroups = this.userService.findUserByEmailFetchGroups(user.getEmail()); for (Group group : userWithGroups.getGroups()) { - this.logger.trace("Inspecting group: {} => ", group.getId(), group.getExternalId()); + this.logger.trace("Inspecting group: {} => ", group.getId(), group.getName()); - if (authorities.remove(group.getExternalId())) { + if (group.getExternalId() == null) { + // skip APS system groups + } else if (roles.remove(group.getExternalId()) != null) { // all good } else { if (this.syncGroupRemove) { - this.logger.trace("Removing user '{}' from group '{}'", user.getExternalId(), group.getExternalId()); + this.logger.trace("Removing user '{}' from group '{}'", user.getExternalId(), group.getName()); this.groupService.deleteUserFromGroup(group, userWithGroups); } else { - this.logger.debug("User/group membership sync disabled; not removing user from group: {} => {}", user.getExternalId(), group.getExternalId()); + this.logger.debug("User/group membership sync disabled; not removing user from group: {} => {}", user.getExternalId(), group.getName()); } } } // add remaining authorities into Activiti - for (String authority : authorities) { - this.logger.trace("Syncing group membership: {}", authority); + for (Entry role : roles.entrySet()) { + this.logger.trace("Syncing group membership: {}", role); - Group group = this.groupService.getGroupByExternalId(authority); + Group group = this.groupService.getGroupByExternalId(role.getKey()); if (group == null) { if (this.createMissingGroup) { - this.logger.trace("Creating new group: {}", authority); - String shortAuthority = authority.replaceFirst("[A-Z]+_", ""); - group = this.groupService.createGroupFromExternalStore(shortAuthority, tenantId, Group.TYPE_SYSTEM_GROUP, null, authority, new Date()); + this.logger.trace("Creating new group: {}", role); + group = this.groupService.createGroupFromExternalStore(role.getValue(), tenantId, Group.TYPE_SYSTEM_GROUP, null, role.getKey(), new Date()); } else { - this.logger.debug("Group does not exist; group creation is disabled: {}", authority); + this.logger.debug("Group does not exist; group creation is disabled: {}", role); } } if (group != null && this.syncGroupAdd) { - this.logger.trace("Adding user '{}' from group '{}'", user.getExternalId(), group.getExternalId()); + this.logger.trace("Adding user '{}' to group '{}'", user.getExternalId(), group.getName()); this.groupService.addUserToGroup(group, userWithGroups); } else { - this.logger.debug("User/group membership sync disabled; not adding user to group: {} => {}", user.getExternalId(), group.getExternalId()); + this.logger.debug("User/group membership sync disabled; not adding user to group: {} => {}", user.getExternalId(), group.getName()); } } } diff --git a/src/main/java/com/inteligr8/activiti/ais/IdentityServiceActivitiEngineAuthenticator.java b/src/main/java/com/inteligr8/activiti/keycloak/KeycloakActivitiEngineAuthenticator.java similarity index 76% rename from src/main/java/com/inteligr8/activiti/ais/IdentityServiceActivitiEngineAuthenticator.java rename to src/main/java/com/inteligr8/activiti/keycloak/KeycloakActivitiEngineAuthenticator.java index fcfffdd..b58cf2a 100644 --- a/src/main/java/com/inteligr8/activiti/ais/IdentityServiceActivitiEngineAuthenticator.java +++ b/src/main/java/com/inteligr8/activiti/keycloak/KeycloakActivitiEngineAuthenticator.java @@ -1,7 +1,8 @@ -package com.inteligr8.activiti.ais; +package com.inteligr8.activiti.keycloak; import java.util.List; -import java.util.Set; +import java.util.Map; +import java.util.Map.Entry; import org.activiti.engine.IdentityService; import org.activiti.engine.identity.Group; @@ -15,37 +16,23 @@ import org.springframework.security.core.Authentication; import org.springframework.security.core.AuthenticationException; import org.springframework.stereotype.Component; -import com.inteligr8.activiti.Authenticator; - /** * This is an unused implementation for non-APS installation. It is not tested * and probably pointless. * * @author brian.long@yudrio.com */ -@Component("activiti.authenticator") +@Component("keycloak-ext.activiti-engine.authenticator") @Lazy -public class IdentityServiceActivitiEngineAuthenticator extends AbstractIdentityServiceActivitiAuthenticator implements Authenticator { +public class KeycloakActivitiEngineAuthenticator extends AbstractKeycloakActivitiAuthenticator { private final Logger logger = LoggerFactory.getLogger(this.getClass()); - @Value("${keycloak-ext.createMissingUser:true}") - private boolean createMissingUser; - - @Value("${keycloak-ext.clearNewUserGroups:true}") - private boolean clearNewUserGroups; - - @Value("${keycloak-ext.createMissingGroup:true}") - private boolean createMissingGroup; - - @Value("${keycloak-ext.syncGroupAdd:true}") - private boolean syncGroupAdd; - - @Value("${keycloak-ext.syncGroupRemove:true}") - private boolean syncGroupRemove; - @Autowired private IdentityService identityService; + + @Value("${keycloak-ext.group.prefix:KEYCLOAK_}") + private String groupPrefix; /** * This method validates that the user exists, if not, it creates the @@ -85,7 +72,7 @@ public class IdentityServiceActivitiEngineAuthenticator extends AbstractIdentity User user = this.findUser(auth); this.logger.debug("Inspecting user: {} => {}", user.getId(), user.getEmail()); - this.syncUserAuthorities(user, auth); + this.syncUserRoles(user, auth); } private User findUser(Authentication auth) { @@ -105,10 +92,10 @@ public class IdentityServiceActivitiEngineAuthenticator extends AbstractIdentity return user; } - private void syncUserAuthorities(User user, Authentication auth) { - Set authorities = this.getRoles(auth); - if (authorities == null) { - this.logger.debug("The user authorities could not be determined; skipping sync: {}", user.getEmail()); + private void syncUserRoles(User user, Authentication auth) { + Map roles = this.getRoles(auth); + if (roles == null) { + this.logger.debug("The user roles could not be determined; skipping sync: {}", user.getEmail()); return; } @@ -118,8 +105,11 @@ public class IdentityServiceActivitiEngineAuthenticator extends AbstractIdentity .list(); this.logger.debug("User is currently a member of {} groups", groups.size()); for (Group group : groups) { + if (!group.getId().startsWith(this.groupPrefix)) + continue; + this.logger.trace("Inspecting group: {} => {} ({})", group.getId(), group.getName(), group.getType()); - if (authorities.remove(group.getName())) { + if (roles.remove(group.getId().substring(this.groupPrefix.length())) != null) { this.logger.trace("Group and membership already exist: {} => {}", user.getEmail(), group.getName()); // already a member of the group } else { @@ -132,20 +122,20 @@ public class IdentityServiceActivitiEngineAuthenticator extends AbstractIdentity } } - this.logger.debug("Unaddressed OIDC authorities: {}", authorities); + this.logger.debug("Unaddressed OIDC roles: {}", roles); - // check remainder/unaddressed authorities - for (String authority : authorities) { - this.logger.trace("Inspecting authority: {}", authority); + // check remainder/unaddressed roles + for (Entry role : roles.entrySet()) { + this.logger.trace("Inspecting role: {}", role); Group group = this.identityService.createGroupQuery() - .groupName(authority) + .groupId(this.groupPrefix + role.getKey()) .singleResult(); if (group == null) { if (this.createMissingGroup) { this.logger.trace("Group does not exist; creating one"); - group = this.identityService.newGroup(authority); - group.setName(authority); + group = this.identityService.newGroup(this.groupPrefix + role.getKey()); + group.setName(role.getValue()); this.identityService.saveGroup(group); } else { this.logger.info("User does not exist; user creation is disabled: {}", auth.getName()); diff --git a/src/main/java/com/inteligr8/activiti/keycloak/KeycloakSecurityConfigurationAdapter.java b/src/main/java/com/inteligr8/activiti/keycloak/KeycloakSecurityConfigurationAdapter.java new file mode 100644 index 0000000..f3e065b --- /dev/null +++ b/src/main/java/com/inteligr8/activiti/keycloak/KeycloakSecurityConfigurationAdapter.java @@ -0,0 +1,67 @@ +package com.inteligr8.activiti.keycloak; + +import org.keycloak.adapters.springsecurity.authentication.KeycloakAuthenticationProvider; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; +import org.springframework.security.core.authority.mapping.SimpleAuthorityMapper; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.stereotype.Component; + +import com.inteligr8.activiti.ActivitiSecurityConfigAdapter; +import com.inteligr8.activiti.auth.Authenticator; +import com.inteligr8.activiti.auth.InterceptingAuthenticationProvider; + +/** + * This class/bean injects a custom keycloak authentication provider into the + * security configuration. + * + * @author brian@inteligr8.com + * @see org.keycloak.adapters.springsecurity.authentication.KeycloakAuthenticationProvider + */ +@Component +public class KeycloakSecurityConfigurationAdapter implements ActivitiSecurityConfigAdapter { + + private final Logger logger = LoggerFactory.getLogger(this.getClass()); + + @Value("${keycloak-ext.keycloak.enabled:false}") + private boolean enabled; + + // this assures execution before the OOTB impl (-10 < 0) + @Value("${keycloak-ext.keycloak.priority:-5}") + private int priority; + + @Autowired + @Qualifier("keycloak-ext.activiti-app.authenticator") + private Authenticator authenticator; + + protected Authenticator getAuthenticator() { + return this.authenticator; + } + + @Override + public boolean isEnabled() { + return this.enabled; + } + + @Override + public int getPriority() { + return this.priority; + } + + @Override + public void configureGlobal(AuthenticationManagerBuilder auth, UserDetailsService userDetailsService) { + this.logger.trace("configureGlobal()"); + + this.logger.info("Using Keycloak authentication extension, featuring creation of missing users and authority synchronization"); + + KeycloakAuthenticationProvider provider = new KeycloakAuthenticationProvider(); + provider.setGrantedAuthoritiesMapper(new SimpleAuthorityMapper()); + + auth.authenticationProvider(new InterceptingAuthenticationProvider(provider, this.getAuthenticator())); + } + +}