initial checkin

This commit is contained in:
2021-07-30 15:37:05 -04:00
commit 5db42ccbc8
10 changed files with 675 additions and 0 deletions

11
.gitignore vendored Normal file
View File

@@ -0,0 +1,11 @@
# Maven
target
pom.xml.versionsBackup
# Eclipse
.project
.classpath
.settings
# Visual Studio Code
.factorypath

65
pom.xml Normal file
View File

@@ -0,0 +1,65 @@
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.inteligr8.activiti</groupId>
<artifactId>oidc-activiti-app-ext</artifactId>
<version>1.0-SNAPSHOT</version>
<name>Keycloak Authentication &amp; Authorization for APS</name>
<properties>
<maven.compiler.source>11</maven.compiler.source>
<maven.compiler.target>11</maven.compiler.target>
<maven.compiler.release>11</maven.compiler.release>
<aps.version>1.11.1.1</aps.version>
<keycloak.version>6.0.1</keycloak.version>
<spring-security-oauth2.version>2.0.17.RELEASE</spring-security-oauth2.version>
<slf4j.version>1.7.26</slf4j.version>
</properties>
<dependencies>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
<version>${slf4j.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.springframework.security.oauth</groupId>
<artifactId>spring-security-oauth2</artifactId>
<version>${spring-security-oauth2.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-spring-security-adapter</artifactId>
<version>${keycloak.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>com.activiti</groupId>
<artifactId>activiti-app</artifactId>
<version>${aps.version}</version>
<classifier>classes</classifier>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>com.activiti</groupId>
<artifactId>activiti-app-logic</artifactId>
<version>${aps.version}</version>
<scope>provided</scope>
</dependency>
</dependencies>
<repositories>
<repository>
<id>alfresco-public</id>
<url>https://artifacts.alfresco.com/nexus/content/repositories/public</url>
</repository>
<repository>
<id>activiti-releases</id>
<url>https://artifacts.alfresco.com/nexus/content/repositories/activiti-enterprise-releases</url>
</repository>
</repositories>
</project>

View File

@@ -0,0 +1,10 @@
package com.activiti.extension.conf;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
@Configuration
@ComponentScan(basePackages = {"com.inteligr8.activiti.oidc"})
public class OidcExtSpringComponentScanner {
}

View File

@@ -0,0 +1,54 @@
package com.inteligr8.activiti.ais;
import java.util.Collection;
import java.util.HashSet;
import java.util.Set;
import org.keycloak.KeycloakPrincipal;
import org.keycloak.KeycloakSecurityContext;
import org.keycloak.adapters.OidcKeycloakAccount;
import org.keycloak.adapters.springsecurity.token.KeycloakAuthenticationToken;
import org.keycloak.representations.AccessToken;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import com.activiti.security.identity.service.authentication.provider.IdentityServiceAuthenticationToken;
public abstract class AbstractIdentityServiceActivitiAuthenticator implements Authenticator {
private final Logger logger = LoggerFactory.getLogger(this.getClass());
protected AccessToken getOidcAccessToken(Authentication auth) {
KeycloakSecurityContext ksc = this.getKeycloakSecurityContext(auth);
return ksc.getToken();
}
@SuppressWarnings("unchecked")
protected KeycloakSecurityContext getKeycloakSecurityContext(Authentication auth) {
if (auth instanceof KeycloakAuthenticationToken) {
this.logger.debug("Fetching KeycloakSecurityContext from KeycloakAuthenticationToken");
if (auth.getPrincipal() instanceof KeycloakPrincipal) {
return ((KeycloakPrincipal<? extends KeycloakSecurityContext>)auth.getPrincipal()).getKeycloakSecurityContext();
} else {
return null;
}
} else if (auth instanceof IdentityServiceAuthenticationToken) {
this.logger.debug("Fetching KeycloakSecurityContext from IdentityServiceAuthenticationToken");
OidcKeycloakAccount account = ((IdentityServiceAuthenticationToken)auth).getAccount();
return account == null ? null : account.getKeycloakSecurityContext();
} else {
return null;
}
}
protected Set<String> toSet(Collection<? extends GrantedAuthority> grantedAuthorities) {
Set<String> authorities = new HashSet<>(grantedAuthorities.size());
for (GrantedAuthority grantedAuthority : grantedAuthorities)
authorities.add(grantedAuthority.getAuthority());
return authorities;
}
}

View File

@@ -0,0 +1,14 @@
package com.inteligr8.activiti.ais;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
public interface Authenticator {
default void preAuthenticate(Authentication authentication) throws AuthenticationException {
}
default void postAuthenticate(Authentication authentication) throws AuthenticationException {
}
}

View File

@@ -0,0 +1,211 @@
package com.inteligr8.activiti.ais;
import java.util.Date;
import java.util.List;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.apache.commons.lang3.StringUtils;
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;
import org.springframework.stereotype.Component;
import com.activiti.domain.idm.Group;
import com.activiti.domain.idm.Tenant;
import com.activiti.domain.idm.User;
import com.activiti.service.api.GroupService;
import com.activiti.service.api.UserService;
import com.activiti.service.idm.TenantService;
import com.activiti.service.license.LicenseService;
/**
* This class/bean implements an Open ID Connect authenticator for Alfresco
* Process Services that supports the creation of missing users and groups and
* synchronizes user/group membership. This is configurable using several
* Spring property values starting with the `keycloak-ext.` prefix.
*
* This implements an internal Authenticator so other authenticators could be
* created in the future.
*
* FIXME This implements is not good for multi-tenancy.
*
* @author brian.long@yudrio.com
*/
@Component("activiti-app.authenticator")
@Lazy
public class IdentityServiceActivitiAppAuthenticator extends AbstractIdentityServiceActivitiAuthenticator implements Authenticator {
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;
@Autowired
private TenantService tenantService;
@Autowired
private UserService userService;
@Autowired
private GroupService groupService;
/**
* This method validates that the user exists, if not, it creates the
* missing user. Without this functionality, SSO straight up fails in APS.
*/
@Override
public void preAuthenticate(Authentication auth) throws AuthenticationException {
Long tenantId = this.findDefaultTenantId();
this.logger.trace("Tenant ID: {}", tenantId);
User user = this.findUser(auth, tenantId);
if (user == null) {
if (this.createMissingUser) {
this.logger.debug("User does not yet exist; creating the user: {}", auth.getName());
user = this.createUser(auth, tenantId);
this.logger.debug("Created user: {} => {}", user.getId(), user.getExternalId());
if (this.clearNewUserGroups) {
this.logger.debug("Clearing groups: {}", user.getId());
// fetch and remove default groups
user = this.userService.findUserByEmailFetchGroups(user.getEmail());
for (Group group : user.getGroups())
this.groupService.deleteUserFromGroup(group, user);
}
} else {
this.logger.info("User does not exist; user creation is disabled: {}", auth.getName());
}
}
}
/**
* This method validates that the groups exist, if not, it creates the
* missing ones. Without this functionality, SSO works, but the user's
* authorities are not synchronized.
*/
@Override
public void postAuthenticate(Authentication auth) throws AuthenticationException {
Long tenantId = this.findDefaultTenantId();
User user = this.findUser(auth, tenantId);
this.logger.debug("Inspecting user: {} => {}", user.getId(), user.getExternalId());
this.syncUserAuthorities(user, auth, tenantId);
}
private Long findDefaultTenantId() {
String defaultTenantName = this.licenseService.getDefaultTenantName();
this.logger.trace("Default Tenant: {}", defaultTenantName);
List<Tenant> 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 User findUser(Authentication auth, Long tenantId) {
String email = auth.getName();
User user = this.userService.findUserByEmailAndTenantId(email, tenantId);
if (user == null) {
this.logger.debug("User does not exist in tenant; trying tenant-less lookup: {}", email);
user = this.userService.findUserByEmail(email);
} else {
this.logger.trace("Found user: {}", user.getId());
}
return user;
}
private User createUser(Authentication auth, Long tenantId) {
AccessToken atoken = this.getOidcAccessToken(auth);
if (atoken == null) {
this.logger.debug("The OIDC access token could not be found; using email to determine names: {}", auth.getName());
Matcher emailNamesMatcher = this.emailNamesPattern.matcher(auth.getName());
if (!emailNamesMatcher.matches()) {
this.logger.warn("The email address could not be parsed for names: {}", auth.getName());
return this.userService.createNewUserFromExternalStore(auth.getName(), "Unknown", "User", tenantId, auth.getName(), this.externalIdmSource, new Date());
} else {
String firstName = StringUtils.capitalize(emailNamesMatcher.group(1));
String lastName = StringUtils.capitalize(emailNamesMatcher.group(2));
return this.userService.createNewUserFromExternalStore(auth.getName(), firstName, lastName, tenantId, auth.getName(), this.externalIdmSource, new Date());
}
} else {
return this.userService.createNewUserFromExternalStore(auth.getName(), atoken.getGivenName(), atoken.getFamilyName(), tenantId, auth.getName(), this.externalIdmSource, new Date());
}
}
private void syncUserAuthorities(User user, Authentication auth, Long tenantId) {
Set<String> authorities = this.toSet(auth.getAuthorities());
this.logger.debug("OIDC authorities: {}", authorities);
// check Activiti groups
User userWithGroups = this.userService.findUserByEmailFetchGroups(user.getEmail());
for (Group group : userWithGroups.getGroups()) {
this.logger.trace("Inspecting group: {} => ", group.getId(), group.getExternalId());
if (authorities.remove(group.getExternalId())) {
// all good
} else {
if (this.syncGroupRemove) {
this.logger.trace("Removing user '{}' from group '{}'", user.getExternalId(), group.getExternalId());
this.groupService.deleteUserFromGroup(group, userWithGroups);
} else {
this.logger.debug("User/group membership sync disabled; not removing user from group: {} => {}", user.getExternalId(), group.getExternalId());
}
}
}
// add remaining authorities into Activiti
for (String authority : authorities) {
this.logger.trace("Syncing group membership: {}", authority);
Group group = this.groupService.getGroupByExternalId(authority);
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());
} else {
this.logger.debug("Group does not exist; group creation is disabled: {}", authority);
}
}
if (group != null && this.syncGroupAdd) {
this.logger.trace("Adding user '{}' from group '{}'", user.getExternalId(), group.getExternalId());
this.groupService.addUserToGroup(group, userWithGroups);
} else {
this.logger.debug("User/group membership sync disabled; not adding user to group: {} => {}", user.getExternalId(), group.getExternalId());
}
}
}
}

View File

@@ -0,0 +1,159 @@
package com.inteligr8.activiti.ais;
import java.util.List;
import java.util.Set;
import org.activiti.engine.IdentityService;
import org.activiti.engine.identity.Group;
import org.activiti.engine.identity.User;
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;
import org.springframework.stereotype.Component;
/**
* This is an unused implementation for non-APS installation. It is not tested
* and probably pointless.
*
* @author brian.long@yudrio.com
*/
@Component("activiti.authenticator")
@Lazy
public class IdentityServiceActivitiEngineAuthenticator extends AbstractIdentityServiceActivitiAuthenticator implements Authenticator {
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;
/**
* This method validates that the user exists, if not, it creates the
* missing user. Without this functionality, SSO straight up fails.
*/
@Override
public void preAuthenticate(Authentication auth) throws AuthenticationException {
User user = this.findUser(auth);
if (user == null) {
if (this.createMissingUser) {
this.logger.debug("User does not yet exist; creating the user: {}", auth.getName());
user = this.createUser(auth);
this.logger.debug("Created user: {} => {}", user.getId(), user.getEmail());
if (this.clearNewUserGroups) {
this.logger.debug("Clearing groups: {}", user.getId());
List<Group> groups = this.identityService.createGroupQuery()
.groupMember(user.getId())
.list();
for (Group group : groups)
this.identityService.deleteMembership(user.getId(), group.getId());
}
} else {
this.logger.info("User does not exist; user creation is disabled: {}", auth.getName());
}
}
}
/**
* This method validates that the groups exist, if not, it creates the
* missing ones. Without this functionality, SSO works, but the user's
* authorities are not synchronized.
*/
@Override
public void postAuthenticate(Authentication auth) throws AuthenticationException {
User user = this.findUser(auth);
this.logger.debug("Inspecting user: {} => {}", user.getId(), user.getEmail());
this.syncUserAuthorities(user, auth);
}
private User findUser(Authentication auth) {
String email = auth.getName();
User user = this.identityService.createUserQuery()
.userEmail(email)
.singleResult();
return user;
}
private User createUser(Authentication auth) {
User user = this.identityService.newUser(auth.getName());
user.setEmail(auth.getName());
this.identityService.saveUser(user);
return user;
}
private void syncUserAuthorities(User user, Authentication auth) {
Set<String> authorities = this.toSet(auth.getAuthorities());
this.logger.debug("OIDC authorities: {}", authorities);
// check Activiti groups
List<Group> groups = this.identityService.createGroupQuery()
.groupMember(user.getEmail())
.list();
this.logger.debug("User is currently a member of {} groups", groups.size());
for (Group group : groups) {
this.logger.trace("Inspecting group: {} => {} ({})", group.getId(), group.getName(), group.getType());
if (authorities.remove(group.getName())) {
this.logger.trace("Group and membership already exist: {} => {}", user.getEmail(), group.getName());
// already a member of the group
} else {
if (this.syncGroupRemove) {
this.logger.trace("Group membership not in OIDC token; removing from group: {} => {}", user.getEmail(), group.getName());
this.identityService.deleteMembership(user.getId(), group.getId());
} else {
this.logger.debug("User/group membership sync disabled; not removing user from group: {} => {}", user.getId(), group.getId());
}
}
}
this.logger.debug("Unaddressed OIDC authorities: {}", authorities);
// check remainder/unaddressed authorities
for (String authority : authorities) {
this.logger.trace("Inspecting authority: {}", authority);
Group group = this.identityService.createGroupQuery()
.groupName(authority)
.singleResult();
if (group == null) {
if (this.createMissingGroup) {
this.logger.trace("Group does not exist; creating one");
group = this.identityService.newGroup(authority);
group.setName(authority);
this.identityService.saveGroup(group);
} else {
this.logger.info("User does not exist; user creation is disabled: {}", auth.getName());
}
}
if (group != null && this.syncGroupAdd) {
this.logger.trace("Group membership not in Activiti; adding to group: {} => {}", user.getEmail(), group.getName());
this.identityService.createMembership(user.getId(), group.getId());
} else {
this.logger.debug("User/group membership sync disabled; not adding user to group: {} => {}", user.getId(), group.getId());
}
}
}
}

View File

@@ -0,0 +1,38 @@
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;
/**
* 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);
}
}

View File

@@ -0,0 +1,66 @@
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.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.activiti.api.msmt.MsmtTenantResolver;
import com.activiti.api.security.AlfrescoSecurityConfigOverride;
import com.activiti.conf.MsmtProperties;
/**
* This class/bean overrides the AIS authentication provider, enabling a more
* complete integration with AIS.
*
* FIXME This is not optimal, but with AIS enabled, we cannot use the proper
* override.
*
* @author brian.long@yudrio.com
* @see IdentityServiceAuthenticationProviderAdapter
*/
@Component
public class IdentityServiceSecurityConfigurationAdapter implements AlfrescoSecurityConfigOverride {
private final Logger logger = LoggerFactory.getLogger(this.getClass());
@Value("${keycloak.ext.odic.enabled:true}")
private boolean enabled;
@Autowired
protected MsmtProperties msmtProperties;
@Autowired(required = false) // Only when multi-schema multi-tenant is enabled
protected MsmtTenantResolver tenantResolver;
@Autowired
@Qualifier("activiti-app.authenticator")
private Authenticator authenticator;
protected Authenticator getAuthenticator() {
return this.authenticator;
}
@Override
public void configureGlobal(AuthenticationManagerBuilder authmanBuilder, 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);
}
}
}

View File

@@ -0,0 +1,47 @@
package com.inteligr8.activiti.ais;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import com.activiti.security.identity.service.authentication.provider.IdentityServiceAuthenticationProvider;
/**
* 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.
*
* @author brian.long@yudrio.com
*/
public class InterceptingIdentityServiceAuthenticationProvider extends IdentityServiceAuthenticationProvider {
private final Logger logger = LoggerFactory.getLogger(this.getClass());
private final Authenticator authenticator;
public InterceptingIdentityServiceAuthenticationProvider(Authenticator authenticator) {
this.authenticator = authenticator;
}
@Override
public Authentication authenticate(Authentication auth) throws AuthenticationException {
this.logger.trace("authenticate({})", auth.getName());
this.authenticator.preAuthenticate(auth);
this.logger.debug("Pre-authenticated user: {}", auth.getName());
auth = super.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());
return auth;
}
}