Compare commits
20 Commits
Author | SHA1 | Date | |
---|---|---|---|
a3cb17e402 | |||
c6d0977b2f | |||
a55d1c32d0 | |||
2405a8a313 | |||
e22fbc30a5 | |||
a0cc13dc02 | |||
173bfed44f | |||
76066f01dd | |||
dc5a7dad39 | |||
03c6e5aaa2 | |||
10ed99b0a2 | |||
4e4a6aca8d | |||
34feb28a18 | |||
44d0bf533d | |||
a856ab7d3d | |||
5563b055e7 | |||
807294881b | |||
a42c754a09 | |||
916f371297 | |||
bbef37a222 |
67
README.md
Normal file
67
README.md
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
# Keycloak Extension for Activiti
|
||||||
|
|
||||||
|
This library was created to expand the functionality of keycloak integration within the APS (Activiti App) application. It includes a similar implementation for core Activiti (Activiti Engine), but the core functional is not delivered with that OOTB application at this time.
|
||||||
|
|
||||||
|
The Activiti App delivers SSO capability and that is about it. The user must already exist and group synchronization may only happen outside of the context of authentication. Namely over another protocol (LDAP).
|
||||||
|
|
||||||
|
This module expands SSO to include user creation and group synchronization. Group synchronization uses the standard access token for Open ID Connect. These groups are termed "roles".
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
The installation is simple. Just include the JAR in the classpath of your Activiti App application. This is best done by not chaning the `activiti-app.war` file, but instead including it within the classpath using your web container configuration. For Apache Tomcat, you would add or modify the following context file: `conf/Catalina/localhost/activiti-app.xml`. Its related contents would be:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<Context>
|
||||||
|
<Resources>
|
||||||
|
<PostResources base="${catalina.base}/ext" className="org.apache.catalina.webresources.DirResourceSet" webAppMount="/WEB-INF/lib" readOnly="true" />
|
||||||
|
</Resources>
|
||||||
|
</Context>
|
||||||
|
```
|
||||||
|
|
||||||
|
Notice the use of `PostResources` instead of `PreResources`. This library needs to be loaded after the web application. This is the best way to load any other extensions or customization to the Activiti App, including `JavaDelegate` implementations.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
The library is highly configurable. You configure it with properties specified in the `activiti-app.properties` file, which exists somewhere in the root of the classpath. That is typically in the `lib` folder. The properties to configure are enumerated in the table below.
|
||||||
|
|
||||||
|
### Common
|
||||||
|
|
||||||
|
| Property | Default | Description |
|
||||||
|
| ---------------------------------------------- | --------- | ----------- |
|
||||||
|
| `keycloak-ext.ais.enabled` | `false` | Enable AIS integration, overriding and extending the OOTB AIS provider. |
|
||||||
|
| `keycloak-ext.ootbSecurityConfig.enabled` | `true` | Enable OOTB functionality as if this module were not installed. This adapter operates at priority `0`. This means it only works if other adapters are disabled (default). |
|
||||||
|
| `keycloak-ext.default.admins.users` | | A default set of administrators to add to the administration role on application startup. |
|
||||||
|
| `keycloak-ext.clearNewUserDefaultGroups` | `true` | When creating a new user, clear any default groups added to that user. This will not impact existing users. |
|
||||||
|
| `keycloak-ext.resource.include.regex.patterns` | | OIDC provides roles in the realm and all permitted clients/resources. By default all resources are included. You can limit it with regular expressions with this property. |
|
||||||
|
| `keycloak-ext.group.format.regex.patterns` | | Reformat roles that match the specified regular expressions. The replacements are specified in another property. Multiple expressions may be specified by using commas. Whitespace is not stripped. |
|
||||||
|
| `keycloak-ext.group.format.regex.replacements` | | Reformat roles with the specified replacement expressions. The regular expressions are specified in another property. Multiple expressions may be specified by using commas. Whitespace is not stripped. |
|
||||||
|
| `keycloak-ext.group.include.regex.patterns` | | If specified, only the roles that match the specified regular expressions will be considered; otherwise all roles are included. |
|
||||||
|
| `keycloak-ext.group.exclude.regex.patterns` | | If specified, the roles that match the specified regular expressions will be ignored. This overrides any role explicitly included. |
|
||||||
|
| `keycloak-ext.syncInternalGroup` | `false` | If an internal group with the same name already exists, use that group instead of creating a new one with the same name. Also register that internal group as external. |
|
||||||
|
|
||||||
|
### For Activiti App Only
|
||||||
|
|
||||||
|
| Property | Default | Description |
|
||||||
|
| ----------------------------------------- | -------------- | ----------- |
|
||||||
|
| `keycloak-ext.syncGroupAs` | `organization` | When creating a new group, should it be a functional (`organization`) group or a system (`capability`) group? |
|
||||||
|
| `keycloak-ext.external.id` | `ais` | When creating a new group or registering an internal group as external, use this ID as a prefix to the external group ID. |
|
||||||
|
|
||||||
|
### Rare
|
||||||
|
|
||||||
|
| Property | Default | Description |
|
||||||
|
| ----------------------------------------- | --------------- | ----------- |
|
||||||
|
| `keycloak-ext.ais.priority` | `-10` | The order of configurable adapters to use with the application. Only the lowest priority enabled adapter will be used. Values of `1`+ will only load if the OOTB adapter is disabled. |
|
||||||
|
| `keycloak-ext.group.admins.validate` | `false` | Whether or not to validate the existence and capabilities of an administrators group on appliation startup. This is only applicable for when one is accidently removed and no one has the rights to create one. |
|
||||||
|
| `keycloak-ext.group.admins.name` | `admins` | The name of an administrators group to potentially add and default users on application startup. |
|
||||||
|
| `keycloak-ext.group.admins.externalId` | `admins` | The name of an administrators group to potentially add and default users on application startup. |
|
||||||
|
| `keycloak-ext.createMissingUser` | `true` | Before authentication, check to make sure the user exists as an APS user; if they don't, create the user. |
|
||||||
|
| `keycloak-ext.createMissingGroup` | `true` | Before authorization, check to make sure groups exist for the roles the user claims; if they don't, create the groups. |
|
||||||
|
| `keycloak-ext.syncGroupAdd` | `true` | If the user belongs to a role but not its corresponding group, add the user to the group. |
|
||||||
|
| `keycloak-ext.syncGroupRemove` | `true` | If the user belongs to a group but does not have the corresponding role, remove the user from the group. |
|
||||||
|
|
||||||
|
### Untested
|
||||||
|
|
||||||
|
| Property | Default | Description |
|
||||||
|
| ----------------------------------------- | --------------- | ----------- |
|
||||||
|
| `keycloak-ext.keycloak.enabled` | `false` | Enable Keycloak integration, overriding and extending the OOTB Keycloak provider (*untested*). |
|
||||||
|
| `keycloak-ext.keycloak.priority` | `-5` | The order of configurable adapters to use with the application. Only the lowest priority enabled adapter will be used. Values of `1`+ will only load if the OOTB adapter is disabled. |
|
21
pom.xml
21
pom.xml
@@ -4,7 +4,7 @@
|
|||||||
<modelVersion>4.0.0</modelVersion>
|
<modelVersion>4.0.0</modelVersion>
|
||||||
<groupId>com.inteligr8.activiti</groupId>
|
<groupId>com.inteligr8.activiti</groupId>
|
||||||
<artifactId>keycloak-activiti-app-ext</artifactId>
|
<artifactId>keycloak-activiti-app-ext</artifactId>
|
||||||
<version>1.0.0</version>
|
<version>1.1.3</version>
|
||||||
<name>Keycloak Authentication & Authorization for APS</name>
|
<name>Keycloak Authentication & Authorization for APS</name>
|
||||||
|
|
||||||
<properties>
|
<properties>
|
||||||
@@ -78,12 +78,21 @@
|
|||||||
<id>activiti-releases</id>
|
<id>activiti-releases</id>
|
||||||
<url>https://artifacts.alfresco.com/nexus/content/repositories/activiti-enterprise-releases</url>
|
<url>https://artifacts.alfresco.com/nexus/content/repositories/activiti-enterprise-releases</url>
|
||||||
</repository>
|
</repository>
|
||||||
|
<repository>
|
||||||
|
<id>inteligr8-releases</id>
|
||||||
|
<url>https://repos.inteligr8.com/nexus/repository/inteligr8-private</url>
|
||||||
|
</repository>
|
||||||
</repositories>
|
</repositories>
|
||||||
|
|
||||||
<pluginRepositories>
|
<distributionManagement>
|
||||||
<pluginRepository>
|
<repository>
|
||||||
<id>inteligr8-releases</id>
|
<id>inteligr8-releases</id>
|
||||||
<url>https://repos.inteligr8.com/nexus/repository/inteligr8-public</url>
|
<url>https://repos.inteligr8.com/nexus/repository/inteligr8-private</url>
|
||||||
</pluginRepository>
|
</repository>
|
||||||
</pluginRepositories>
|
<snapshotRepository>
|
||||||
|
<id>inteligr8-snapshots</id>
|
||||||
|
<url>https://repos.inteligr8.com/nexus/repository/inteligr8-snapshots</url>
|
||||||
|
</snapshotRepository>
|
||||||
|
</distributionManagement>
|
||||||
|
|
||||||
</project>
|
</project>
|
@@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@@ -4,7 +4,7 @@ import org.springframework.context.annotation.ComponentScan;
|
|||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
@Configuration
|
@Configuration
|
||||||
@ComponentScan(basePackages = {"com.inteligr8.activiti.oidc"})
|
@ComponentScan(basePackages = {"com.inteligr8.activiti"})
|
||||||
public class OidcExtSpringComponentScanner {
|
public class KeycloakExtSpringComponentScanner {
|
||||||
|
|
||||||
}
|
}
|
@@ -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<ActivitiSecurityConfigAdapter> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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());
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@@ -0,0 +1,192 @@
|
|||||||
|
package com.inteligr8.activiti;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.Date;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
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.activiti.api.security.AlfrescoSecurityConfigOverride;
|
||||||
|
import com.activiti.domain.idm.Group;
|
||||||
|
import com.activiti.domain.idm.GroupCapability;
|
||||||
|
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 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());
|
||||||
|
|
||||||
|
private final List<String> adminCapabilities = Arrays.asList(
|
||||||
|
"access-all-models-in-tenant",
|
||||||
|
"access-editor",
|
||||||
|
"access-reports",
|
||||||
|
"publish-app-to-dashboard",
|
||||||
|
"tenant-admin",
|
||||||
|
"tenant-admin-api",
|
||||||
|
"upload-license");
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private List<ActivitiSecurityConfigAdapter> adapters;
|
||||||
|
|
||||||
|
@Autowired(required = false)
|
||||||
|
private LicenseService licenseService;
|
||||||
|
|
||||||
|
@Autowired(required = false)
|
||||||
|
private TenantService tenantService;
|
||||||
|
|
||||||
|
@Autowired(required = false)
|
||||||
|
private UserService userService;
|
||||||
|
|
||||||
|
@Autowired(required = false)
|
||||||
|
private GroupService groupService;
|
||||||
|
|
||||||
|
@Value("${keycloak-ext.default.admins.users:#{null}}")
|
||||||
|
private String adminUserStrs;
|
||||||
|
|
||||||
|
@Value("${keycloak-ext.group.admins.name:admins}")
|
||||||
|
private String adminGroupName;
|
||||||
|
|
||||||
|
@Value("${keycloak-ext.group.admins.externalId:#{null}}")
|
||||||
|
private String adminGroupExternalId;
|
||||||
|
|
||||||
|
@Value("${keycloak-ext.group.admins.validate:false}")
|
||||||
|
private boolean validateAdministratorsGroup;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void configureGlobal(AuthenticationManagerBuilder authmanBuilder, UserDetailsService userDetailsService) {
|
||||||
|
this.logger.trace("configureGlobal()");
|
||||||
|
|
||||||
|
Collections.sort(this.adapters);
|
||||||
|
|
||||||
|
if (this.logger.isTraceEnabled())
|
||||||
|
this.logGroups();
|
||||||
|
if (this.validateAdministratorsGroup)
|
||||||
|
this.validateAdmins();
|
||||||
|
if (this.adminUserStrs != null && this.adminUserStrs.length() > 0)
|
||||||
|
this.associateAdmins();
|
||||||
|
|
||||||
|
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() {
|
||||||
|
if (this.groupService == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
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 void validateAdmins() {
|
||||||
|
if (this.groupService == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
Long tenantId = this.findDefaultTenantId();
|
||||||
|
Group group = this.groupService.getGroupByExternalIdAndTenantId(this.adminGroupExternalId, tenantId);
|
||||||
|
if (group == null) {
|
||||||
|
List<Group> groups = this.groupService.getGroupByNameAndTenantId(this.adminGroupName, tenantId);
|
||||||
|
if (!groups.isEmpty())
|
||||||
|
group = groups.iterator().next();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (group == null) {
|
||||||
|
this.logger.info("Creating group: {} ({})", this.adminGroupName, this.adminGroupExternalId);
|
||||||
|
if (this.adminGroupExternalId != null) {
|
||||||
|
group = this.groupService.createGroupFromExternalStore(
|
||||||
|
this.adminGroupExternalId, tenantId, Group.TYPE_SYSTEM_GROUP, null, this.adminGroupName, new Date());
|
||||||
|
} else {
|
||||||
|
group = this.groupService.createGroup(this.adminGroupName, tenantId, Group.TYPE_SYSTEM_GROUP, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.debug("Checking group capabilities: {}", group.getName());
|
||||||
|
Group groupWithCaps = this.groupService.getGroup(group.getId(), false, true, false, false);
|
||||||
|
Set<String> adminCaps = new HashSet<>(this.adminCapabilities);
|
||||||
|
for (GroupCapability cap : groupWithCaps.getCapabilities())
|
||||||
|
adminCaps.remove(cap.getName());
|
||||||
|
if (!adminCaps.isEmpty()) {
|
||||||
|
this.logger.info("Granting group '{}' capabilities: {}", group.getName(), adminCaps);
|
||||||
|
this.groupService.addCapabilitiesToGroup(group.getId(), new ArrayList<>(adminCaps));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void associateAdmins() {
|
||||||
|
if (this.userService == null || this.groupService == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
List<String> adminUsers = Arrays.asList(this.adminUserStrs.split(","));
|
||||||
|
if (adminUsers.isEmpty())
|
||||||
|
return;
|
||||||
|
|
||||||
|
Long tenantId = this.findDefaultTenantId();
|
||||||
|
List<Group> groups = this.groupService.getSystemGroupWithName("Administrators", tenantId);
|
||||||
|
|
||||||
|
for (String email : adminUsers) {
|
||||||
|
User user = this.userService.findUserByEmail(email);
|
||||||
|
|
||||||
|
this.logger.debug("Adding {} to {}", user.getEmail(), "Administrators");
|
||||||
|
for (Group group : groups)
|
||||||
|
this.groupService.addUserToGroup(group, user);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 Collection<String> toGroupNames(Collection<Group> groups) {
|
||||||
|
List<String> groupNames = new ArrayList<>(groups.size());
|
||||||
|
for (Group group : groups)
|
||||||
|
groupNames.add(group.getName() + " [" + group.getExternalId() + "]");
|
||||||
|
return groupNames;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@@ -1,54 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
@@ -1,38 +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;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
@@ -1,3 +1,4 @@
|
|||||||
|
|
||||||
package com.inteligr8.activiti.ais;
|
package com.inteligr8.activiti.ais;
|
||||||
|
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
@@ -11,27 +12,31 @@ import org.springframework.security.core.userdetails.UserDetailsService;
|
|||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
import com.activiti.api.msmt.MsmtTenantResolver;
|
import com.activiti.api.msmt.MsmtTenantResolver;
|
||||||
import com.activiti.api.security.AlfrescoSecurityConfigOverride;
|
|
||||||
import com.activiti.conf.MsmtProperties;
|
import com.activiti.conf.MsmtProperties;
|
||||||
|
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
|
* This class/bean injects a custom AIS authentication provider into the
|
||||||
* complete integration with AIS.
|
* security configuration.
|
||||||
*
|
*
|
||||||
* FIXME This is not optimal, but with AIS enabled, we cannot use the proper
|
* @author brian@inteligr8.com
|
||||||
* override.
|
* @see com.activiti.security.identity.service.authentication.provider.IdentityServiceAuthenticationProvider
|
||||||
*
|
|
||||||
* @author brian.long@yudrio.com
|
|
||||||
* @see IdentityServiceAuthenticationProviderAdapter
|
|
||||||
*/
|
*/
|
||||||
@Component
|
@Component
|
||||||
public class IdentityServiceSecurityConfigurationAdapter implements AlfrescoSecurityConfigOverride {
|
public class IdentityServiceSecurityConfigurationAdapter implements ActivitiSecurityConfigAdapter {
|
||||||
|
|
||||||
private final Logger logger = LoggerFactory.getLogger(this.getClass());
|
private final Logger logger = LoggerFactory.getLogger(this.getClass());
|
||||||
|
|
||||||
@Value("${keycloak.ext.odic.enabled:true}")
|
@Value("${keycloak-ext.ais.enabled:false}")
|
||||||
private boolean enabled;
|
private boolean enabled;
|
||||||
|
|
||||||
|
// this assures execution before the OOTB impl (-10 < 0)
|
||||||
|
@Value("${keycloak-ext.ais.priority:-10}")
|
||||||
|
private int priority;
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
protected MsmtProperties msmtProperties;
|
protected MsmtProperties msmtProperties;
|
||||||
|
|
||||||
@@ -39,28 +44,36 @@ public class IdentityServiceSecurityConfigurationAdapter implements AlfrescoSecu
|
|||||||
protected MsmtTenantResolver tenantResolver;
|
protected MsmtTenantResolver tenantResolver;
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
@Qualifier("activiti-app.authenticator")
|
@Qualifier("keycloak-ext.activiti-app.authenticator")
|
||||||
private Authenticator authenticator;
|
private Authenticator authenticator;
|
||||||
|
|
||||||
protected Authenticator getAuthenticator() {
|
protected Authenticator getAuthenticator() {
|
||||||
return this.authenticator;
|
return this.authenticator;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isEnabled() {
|
||||||
|
return this.enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int getPriority() {
|
||||||
|
return this.priority;
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void configureGlobal(AuthenticationManagerBuilder authmanBuilder, UserDetailsService userDetailsService) {
|
public void configureGlobal(AuthenticationManagerBuilder auth, UserDetailsService userDetailsService) {
|
||||||
this.logger.trace("configureGlobal()");
|
this.logger.trace("configureGlobal()");
|
||||||
|
|
||||||
if (this.enabled) {
|
this.logger.info("Using AIS authentication extension, featuring creation of missing users and authority synchronization");
|
||||||
this.logger.info("Using Keycloak authentication extension, featuring creation of missing users and authority synchronization");
|
|
||||||
|
IdentityServiceAuthenticationProvider provider = new IdentityServiceAuthenticationProvider();
|
||||||
InterceptingIdentityServiceAuthenticationProvider provider = new InterceptingIdentityServiceAuthenticationProvider(this.getAuthenticator());
|
if (this.msmtProperties.isMultiSchemaMultiTenantEnabled())
|
||||||
if (this.msmtProperties.isMultiSchemaMultiTenantEnabled())
|
provider.setTenantResolver(this.tenantResolver);
|
||||||
provider.setTenantResolver(this.tenantResolver);
|
provider.setUserDetailsService(userDetailsService);
|
||||||
provider.setUserDetailsService(userDetailsService);
|
provider.setGrantedAuthoritiesMapper(new SimpleAuthorityMapper());
|
||||||
provider.setGrantedAuthoritiesMapper(new SimpleAuthorityMapper());
|
|
||||||
|
auth.authenticationProvider(new InterceptingAuthenticationProvider(provider, this.getAuthenticator()));
|
||||||
authmanBuilder.authenticationProvider(provider);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@@ -1,4 +1,4 @@
|
|||||||
package com.inteligr8.activiti.ais;
|
package com.inteligr8.activiti.auth;
|
||||||
|
|
||||||
import org.springframework.security.core.Authentication;
|
import org.springframework.security.core.Authentication;
|
||||||
import org.springframework.security.core.AuthenticationException;
|
import org.springframework.security.core.AuthenticationException;
|
@@ -1,29 +1,35 @@
|
|||||||
package com.inteligr8.activiti.ais;
|
package com.inteligr8.activiti.auth;
|
||||||
|
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.security.authentication.AuthenticationProvider;
|
||||||
import org.springframework.security.core.Authentication;
|
import org.springframework.security.core.Authentication;
|
||||||
import org.springframework.security.core.AuthenticationException;
|
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
|
* This class/bean provides a pre/post authentication capability to the
|
||||||
* an `Authenticator` to pre/post authenticate. The pre-authentication allows
|
* Spring AuthenticationProvider. The pre-authentication hook allows us to
|
||||||
* us to circumvent the problem with AIS and missing users. The
|
* circumvent the problem with authenticating missing users. The
|
||||||
* post-authentication allow us to synchronize groups/authorities.
|
* 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 Logger logger = LoggerFactory.getLogger(this.getClass());
|
||||||
|
private final AuthenticationProvider provider;
|
||||||
private final Authenticator authenticator;
|
private final Authenticator authenticator;
|
||||||
|
|
||||||
public InterceptingIdentityServiceAuthenticationProvider(Authenticator authenticator) {
|
public InterceptingAuthenticationProvider(AuthenticationProvider provider, Authenticator authenticator) {
|
||||||
|
this.provider = provider;
|
||||||
this.authenticator = authenticator;
|
this.authenticator = authenticator;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean supports(Class<?> authClass) {
|
||||||
|
return this.provider.supports(authClass);
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Authentication authenticate(Authentication auth) throws AuthenticationException {
|
public Authentication authenticate(Authentication auth) throws AuthenticationException {
|
||||||
this.logger.trace("authenticate({})", auth.getName());
|
this.logger.trace("authenticate({})", auth.getName());
|
||||||
@@ -31,13 +37,9 @@ public class InterceptingIdentityServiceAuthenticationProvider extends IdentityS
|
|||||||
this.authenticator.preAuthenticate(auth);
|
this.authenticator.preAuthenticate(auth);
|
||||||
this.logger.debug("Pre-authenticated user: {}", auth.getName());
|
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());
|
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.authenticator.postAuthenticate(auth);
|
||||||
this.logger.debug("Post-authenticated user: {}", auth.getName());
|
this.logger.debug("Post-authenticated user: {}", auth.getName());
|
||||||
|
|
@@ -0,0 +1,269 @@
|
|||||||
|
package com.inteligr8.activiti.keycloak;
|
||||||
|
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.Iterator;
|
||||||
|
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.clearNewUserDefaultGroups:true}")
|
||||||
|
protected boolean clearNewUserDefaultGroups;
|
||||||
|
|
||||||
|
@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.syncInternalGroups:false}")
|
||||||
|
protected boolean syncInternalGroups;
|
||||||
|
|
||||||
|
@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<Pair<Pattern, String>> groupFormatters = new LinkedList<>();
|
||||||
|
protected final Set<Pattern> resourceIncludes = new HashSet<>();
|
||||||
|
protected final Set<Pattern> groupIncludes = new HashSet<>();
|
||||||
|
protected final Set<Pattern> 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<String, String> getKeycloakRoles(Authentication auth) {
|
||||||
|
Map<String, String> 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<String> roles = this.filterRoles(atoken.getRealmAccess().getRoles());
|
||||||
|
Map<String, String> mappedRoles = this.formatRoles(roles);
|
||||||
|
authorities.putAll(mappedRoles);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (Entry<String, Access> resourceAccess : atoken.getResourceAccess().entrySet()) {
|
||||||
|
if (this.includeResource(resourceAccess.getKey())) {
|
||||||
|
this.logger.debug("Access token resources '{}' roles: {}", resourceAccess.getKey(), resourceAccess.getValue().getRoles());
|
||||||
|
Collection<String> roles = this.filterRoles(resourceAccess.getValue().getRoles());
|
||||||
|
Map<String, String> mappedRoles = this.formatRoles(roles);
|
||||||
|
authorities.putAll(mappedRoles);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.debug("Access token authorities: {}", authorities);
|
||||||
|
}
|
||||||
|
|
||||||
|
return authorities;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Collection<String> filterRoles(Collection<String> unfilteredRoles) {
|
||||||
|
if (this.groupIncludes.isEmpty() && this.groupExcludes.isEmpty())
|
||||||
|
return unfilteredRoles;
|
||||||
|
|
||||||
|
Set<String> 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<String, String> formatRoles(Collection<String> unformattedRoles) {
|
||||||
|
Map<String, String> formattedRoles = new HashMap<>(unformattedRoles.size());
|
||||||
|
|
||||||
|
for (String unformattedRole : unformattedRoles) {
|
||||||
|
String formattedRole = null;
|
||||||
|
|
||||||
|
for (Pair<Pattern, String> 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<? extends KeycloakSecurityContext>)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 <K, V> boolean removeMapEntriesByValue(Map<K, V> map, V value) {
|
||||||
|
if (value == null)
|
||||||
|
throw new IllegalArgumentException();
|
||||||
|
|
||||||
|
int found = 0;
|
||||||
|
|
||||||
|
Iterator<Entry<K, V>> i = map.entrySet().iterator();
|
||||||
|
while (i.hasNext()) {
|
||||||
|
Entry<K, V> entry = i.next();
|
||||||
|
if (entry.getValue() != null && value.equals(entry.getValue())) {
|
||||||
|
i.remove();
|
||||||
|
found++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return found > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected Set<String> toSet(Collection<? extends GrantedAuthority> grantedAuthorities) {
|
||||||
|
Set<String> 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;
|
||||||
|
}
|
||||||
|
}
|
@@ -1,11 +1,14 @@
|
|||||||
package com.inteligr8.activiti.ais;
|
package com.inteligr8.activiti.keycloak;
|
||||||
|
|
||||||
import java.util.Date;
|
import java.util.Date;
|
||||||
import java.util.List;
|
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.Matcher;
|
||||||
import java.util.regex.Pattern;
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
import javax.persistence.NonUniqueResultException;
|
||||||
|
|
||||||
import org.apache.commons.lang3.StringUtils;
|
import org.apache.commons.lang3.StringUtils;
|
||||||
import org.keycloak.representations.AccessToken;
|
import org.keycloak.representations.AccessToken;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
@@ -38,29 +41,13 @@ import com.activiti.service.license.LicenseService;
|
|||||||
*
|
*
|
||||||
* @author brian.long@yudrio.com
|
* @author brian.long@yudrio.com
|
||||||
*/
|
*/
|
||||||
@Component("activiti-app.authenticator")
|
@Component("keycloak-ext.activiti-app.authenticator")
|
||||||
@Lazy
|
@Lazy
|
||||||
public class IdentityServiceActivitiAppAuthenticator extends AbstractIdentityServiceActivitiAuthenticator implements Authenticator {
|
public class KeycloakActivitiAppAuthenticator extends AbstractKeycloakActivitiAuthenticator {
|
||||||
|
|
||||||
private final Logger logger = LoggerFactory.getLogger(this.getClass());
|
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 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
|
@Autowired
|
||||||
private LicenseService licenseService;
|
private LicenseService licenseService;
|
||||||
@@ -74,6 +61,20 @@ public class IdentityServiceActivitiAppAuthenticator extends AbstractIdentitySer
|
|||||||
@Autowired
|
@Autowired
|
||||||
private GroupService groupService;
|
private GroupService groupService;
|
||||||
|
|
||||||
|
@Value("${keycloak-ext.external.id:ais}")
|
||||||
|
protected String externalIdmSource;
|
||||||
|
|
||||||
|
@Value("${keycloak-ext.syncGroupAs:organization}")
|
||||||
|
protected String syncGroupAs;
|
||||||
|
|
||||||
|
protected boolean syncGroupAsOrganization() {
|
||||||
|
return !this.syncGroupAsCapability();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected boolean syncGroupAsCapability() {
|
||||||
|
return this.syncGroupAs != null && this.syncGroupAs.toLowerCase().startsWith("cap");
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This method validates that the user exists, if not, it creates the
|
* This method validates that the user exists, if not, it creates the
|
||||||
* missing user. Without this functionality, SSO straight up fails in APS.
|
* missing user. Without this functionality, SSO straight up fails in APS.
|
||||||
@@ -91,16 +92,26 @@ public class IdentityServiceActivitiAppAuthenticator extends AbstractIdentitySer
|
|||||||
user = this.createUser(auth, tenantId);
|
user = this.createUser(auth, tenantId);
|
||||||
this.logger.debug("Created user: {} => {}", user.getId(), user.getExternalId());
|
this.logger.debug("Created user: {} => {}", user.getId(), user.getExternalId());
|
||||||
|
|
||||||
if (this.clearNewUserGroups) {
|
if (this.clearNewUserDefaultGroups) {
|
||||||
this.logger.debug("Clearing groups: {}", user.getId());
|
this.logger.debug("Clearing groups: {}", user.getId());
|
||||||
// fetch and remove default groups
|
// fetch and remove default groups
|
||||||
user = this.userService.findUserByEmailFetchGroups(user.getEmail());
|
user = this.userService.getUser(user.getId(), true);
|
||||||
for (Group group : user.getGroups())
|
for (Group group : user.getGroups())
|
||||||
this.groupService.deleteUserFromGroup(group, user);
|
this.groupService.deleteUserFromGroup(group, user);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
this.logger.info("User does not exist; user creation is disabled: {}", auth.getName());
|
this.logger.info("User does not exist; user creation is disabled: {}", auth.getName());
|
||||||
}
|
}
|
||||||
|
} else if (user.getExternalOriginalSrc() == null || user.getExternalOriginalSrc().length() == 0) {
|
||||||
|
this.logger.debug("User exists, but not created by an external source: {}", auth.getName());
|
||||||
|
this.logger.info("Linking user '{}' with external IDM '{}'", auth.getName(), this.externalIdmSource);
|
||||||
|
user.setExternalId(auth.getName());
|
||||||
|
user.setExternalOriginalSrc(this.externalIdmSource);
|
||||||
|
this.userService.save(user);
|
||||||
|
} else if (!this.externalIdmSource.equals(user.getExternalOriginalSrc())) {
|
||||||
|
this.logger.debug("User '{}' exists, but created by another source: {}", auth.getName(), user.getExternalOriginalSrc());
|
||||||
|
} else {
|
||||||
|
this.logger.trace("User already exists: {}", auth.getName());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,7 +126,7 @@ public class IdentityServiceActivitiAppAuthenticator extends AbstractIdentitySer
|
|||||||
User user = this.findUser(auth, tenantId);
|
User user = this.findUser(auth, tenantId);
|
||||||
this.logger.debug("Inspecting user: {} => {}", user.getId(), user.getExternalId());
|
this.logger.debug("Inspecting user: {} => {}", user.getId(), user.getExternalId());
|
||||||
|
|
||||||
this.syncUserAuthorities(user, auth, tenantId);
|
this.syncUserRoles(user, auth, tenantId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private Long findDefaultTenantId() {
|
private Long findDefaultTenantId() {
|
||||||
@@ -147,13 +158,13 @@ public class IdentityServiceActivitiAppAuthenticator extends AbstractIdentitySer
|
|||||||
}
|
}
|
||||||
|
|
||||||
private User createUser(Authentication auth, Long tenantId) {
|
private User createUser(Authentication auth, Long tenantId) {
|
||||||
AccessToken atoken = this.getOidcAccessToken(auth);
|
AccessToken atoken = this.getKeycloakAccessToken(auth);
|
||||||
if (atoken == null) {
|
if (atoken == null) {
|
||||||
this.logger.debug("The OIDC access token could not be found; using email to determine names: {}", auth.getName());
|
this.logger.debug("The keycloak access token could not be found; using email to determine names: {}", auth.getName());
|
||||||
Matcher emailNamesMatcher = this.emailNamesPattern.matcher(auth.getName());
|
Matcher emailNamesMatcher = this.emailNamesPattern.matcher(auth.getName());
|
||||||
if (!emailNamesMatcher.matches()) {
|
if (!emailNamesMatcher.matches()) {
|
||||||
this.logger.warn("The email address could not be parsed for names: {}", auth.getName());
|
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());
|
return this.userService.createNewUserFromExternalStore(auth.getName(), "Unknown", "Person", tenantId, auth.getName(), this.externalIdmSource, new Date());
|
||||||
} else {
|
} else {
|
||||||
String firstName = StringUtils.capitalize(emailNamesMatcher.group(1));
|
String firstName = StringUtils.capitalize(emailNamesMatcher.group(1));
|
||||||
String lastName = StringUtils.capitalize(emailNamesMatcher.group(2));
|
String lastName = StringUtils.capitalize(emailNamesMatcher.group(2));
|
||||||
@@ -164,48 +175,102 @@ public class IdentityServiceActivitiAppAuthenticator extends AbstractIdentitySer
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void syncUserAuthorities(User user, Authentication auth, Long tenantId) {
|
private void syncUserRoles(User user, Authentication auth, Long tenantId) {
|
||||||
Set<String> authorities = this.toSet(auth.getAuthorities());
|
Map<String, String> roles = this.getKeycloakRoles(auth);
|
||||||
this.logger.debug("OIDC authorities: {}", authorities);
|
if (roles == null) {
|
||||||
|
this.logger.debug("The user roles could not be determined; skipping sync: {}", user.getEmail());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean syncAsOrg = this.syncGroupAsOrganization();
|
||||||
|
|
||||||
// check Activiti groups
|
// check Activiti groups
|
||||||
User userWithGroups = this.userService.findUserByEmailFetchGroups(user.getEmail());
|
User userWithGroups = this.userService.getUser(user.getId(), true);
|
||||||
for (Group group : userWithGroups.getGroups()) {
|
for (Group group : userWithGroups.getGroups()) {
|
||||||
this.logger.trace("Inspecting group: {} => ", group.getId(), group.getExternalId());
|
if (group.getExternalId() == null && !this.syncInternalGroups)
|
||||||
|
continue;
|
||||||
|
|
||||||
if (authorities.remove(group.getExternalId())) {
|
this.logger.trace("Inspecting group: {} => {} ({})", group.getId(), group.getName(), group.getExternalId());
|
||||||
// all good
|
|
||||||
|
if (group.getExternalId() != null && this.removeMapEntriesByValue(roles, this.apsGroupExternalIdToKeycloakRole(group.getExternalId()))) {
|
||||||
|
// role already existed and the user is already a member
|
||||||
|
} else if (group.getExternalId() == null && roles.remove(this.apsGroupNameToKeycloakRole(group.getName())) != null) {
|
||||||
|
// internal role already existed and the user is already a member
|
||||||
} else {
|
} else {
|
||||||
|
// at this point, we have a group that the user does not have a corresponding role for
|
||||||
if (this.syncGroupRemove) {
|
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);
|
this.groupService.deleteUserFromGroup(group, userWithGroups);
|
||||||
} else {
|
} 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
|
// add remaining authorities into Activiti
|
||||||
for (String authority : authorities) {
|
for (Entry<String, String> role : roles.entrySet()) {
|
||||||
this.logger.trace("Syncing group membership: {}", authority);
|
this.logger.trace("Syncing group membership: {}", role);
|
||||||
|
|
||||||
|
Group group;
|
||||||
|
try {
|
||||||
|
group = this.groupService.getGroupByExternalIdAndTenantId(this.keycloakRoleToApsGroupExternalId(role.getKey()), tenantId);
|
||||||
|
} catch (NonUniqueResultException nure) {
|
||||||
|
this.logger.warn("There are multiple groups with the external ID; not adding user to group: {}", role.getKey());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (group == null && this.syncInternalGroups) {
|
||||||
|
List<Group> groups = this.groupService.getGroupByNameAndTenantId(this.keycloakRoleToApsGroupName(role.getValue()), tenantId);
|
||||||
|
if (groups.size() > 1) {
|
||||||
|
this.logger.warn("There are multiple groups with the same name; not adding user to group: {}", role.getValue());
|
||||||
|
continue;
|
||||||
|
} else if (groups.size() == 1) {
|
||||||
|
group = groups.iterator().next();
|
||||||
|
this.logger.debug("Found an internal group; registering as external: {}", group.getName());
|
||||||
|
group.setExternalId(this.keycloakRoleToApsGroupExternalId(role.getKey()));
|
||||||
|
group.setLastSyncTimeStamp(new Date());
|
||||||
|
group.setLastUpdate(new Date());
|
||||||
|
this.groupService.save(group);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Group group = this.groupService.getGroupByExternalId(authority);
|
|
||||||
if (group == null) {
|
if (group == null) {
|
||||||
if (this.createMissingGroup) {
|
if (this.createMissingGroup) {
|
||||||
this.logger.trace("Creating new group: {}", authority);
|
this.logger.trace("Creating new group for role: {}", role);
|
||||||
String shortAuthority = authority.replaceFirst("[A-Z]+_", "");
|
String name = this.keycloakRoleToApsGroupName(role.getValue());
|
||||||
group = this.groupService.createGroupFromExternalStore(shortAuthority, tenantId, Group.TYPE_SYSTEM_GROUP, null, authority, new Date());
|
String externalId = this.keycloakRoleToApsGroupExternalId(role.getKey());
|
||||||
|
int type = syncAsOrg ? Group.TYPE_FUNCTIONAL_GROUP : Group.TYPE_SYSTEM_GROUP;
|
||||||
|
this.logger.trace("Creating new group: {} ({}) [type: {}]", name, externalId, type);
|
||||||
|
group = this.groupService.createGroupFromExternalStore(name, tenantId, type, null, externalId, new Date());
|
||||||
} else {
|
} 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) {
|
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);
|
this.groupService.addUserToGroup(group, userWithGroups);
|
||||||
} else {
|
} 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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private String keycloakRoleToApsGroupExternalId(String role) {
|
||||||
|
return this.externalIdmSource + "_" + role;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String apsGroupExternalIdToKeycloakRole(String externalId) {
|
||||||
|
int underscorePos = externalId.indexOf('_');
|
||||||
|
return underscorePos < 0 ? externalId : externalId.substring(underscorePos + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String keycloakRoleToApsGroupName(String role) {
|
||||||
|
return role;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String apsGroupNameToKeycloakRole(String externalId) {
|
||||||
|
return externalId;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
@@ -1,7 +1,8 @@
|
|||||||
package com.inteligr8.activiti.ais;
|
package com.inteligr8.activiti.keycloak;
|
||||||
|
|
||||||
import java.util.List;
|
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.IdentityService;
|
||||||
import org.activiti.engine.identity.Group;
|
import org.activiti.engine.identity.Group;
|
||||||
@@ -21,29 +22,17 @@ import org.springframework.stereotype.Component;
|
|||||||
*
|
*
|
||||||
* @author brian.long@yudrio.com
|
* @author brian.long@yudrio.com
|
||||||
*/
|
*/
|
||||||
@Component("activiti.authenticator")
|
@Component("keycloak-ext.activiti-engine.authenticator")
|
||||||
@Lazy
|
@Lazy
|
||||||
public class IdentityServiceActivitiEngineAuthenticator extends AbstractIdentityServiceActivitiAuthenticator implements Authenticator {
|
public class KeycloakActivitiEngineAuthenticator extends AbstractKeycloakActivitiAuthenticator {
|
||||||
|
|
||||||
private final Logger logger = LoggerFactory.getLogger(this.getClass());
|
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
|
@Autowired
|
||||||
private IdentityService identityService;
|
private IdentityService identityService;
|
||||||
|
|
||||||
|
@Value("${keycloak-ext.group.prefix:KEYCLOAK_}")
|
||||||
|
private String groupPrefix;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This method validates that the user exists, if not, it creates the
|
* This method validates that the user exists, if not, it creates the
|
||||||
@@ -59,7 +48,7 @@ public class IdentityServiceActivitiEngineAuthenticator extends AbstractIdentity
|
|||||||
user = this.createUser(auth);
|
user = this.createUser(auth);
|
||||||
this.logger.debug("Created user: {} => {}", user.getId(), user.getEmail());
|
this.logger.debug("Created user: {} => {}", user.getId(), user.getEmail());
|
||||||
|
|
||||||
if (this.clearNewUserGroups) {
|
if (this.clearNewUserDefaultGroups) {
|
||||||
this.logger.debug("Clearing groups: {}", user.getId());
|
this.logger.debug("Clearing groups: {}", user.getId());
|
||||||
List<Group> groups = this.identityService.createGroupQuery()
|
List<Group> groups = this.identityService.createGroupQuery()
|
||||||
.groupMember(user.getId())
|
.groupMember(user.getId())
|
||||||
@@ -83,7 +72,7 @@ public class IdentityServiceActivitiEngineAuthenticator extends AbstractIdentity
|
|||||||
User user = this.findUser(auth);
|
User user = this.findUser(auth);
|
||||||
this.logger.debug("Inspecting user: {} => {}", user.getId(), user.getEmail());
|
this.logger.debug("Inspecting user: {} => {}", user.getId(), user.getEmail());
|
||||||
|
|
||||||
this.syncUserAuthorities(user, auth);
|
this.syncUserRoles(user, auth);
|
||||||
}
|
}
|
||||||
|
|
||||||
private User findUser(Authentication auth) {
|
private User findUser(Authentication auth) {
|
||||||
@@ -103,9 +92,12 @@ public class IdentityServiceActivitiEngineAuthenticator extends AbstractIdentity
|
|||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void syncUserAuthorities(User user, Authentication auth) {
|
private void syncUserRoles(User user, Authentication auth) {
|
||||||
Set<String> authorities = this.toSet(auth.getAuthorities());
|
Map<String, String> roles = this.getKeycloakRoles(auth);
|
||||||
this.logger.debug("OIDC authorities: {}", authorities);
|
if (roles == null) {
|
||||||
|
this.logger.debug("The user roles could not be determined; skipping sync: {}", user.getEmail());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// check Activiti groups
|
// check Activiti groups
|
||||||
List<Group> groups = this.identityService.createGroupQuery()
|
List<Group> groups = this.identityService.createGroupQuery()
|
||||||
@@ -113,8 +105,11 @@ public class IdentityServiceActivitiEngineAuthenticator extends AbstractIdentity
|
|||||||
.list();
|
.list();
|
||||||
this.logger.debug("User is currently a member of {} groups", groups.size());
|
this.logger.debug("User is currently a member of {} groups", groups.size());
|
||||||
for (Group group : groups) {
|
for (Group group : groups) {
|
||||||
|
if (!group.getId().startsWith(this.groupPrefix) && this.syncInternalGroups)
|
||||||
|
continue;
|
||||||
|
|
||||||
this.logger.trace("Inspecting group: {} => {} ({})", group.getId(), group.getName(), group.getType());
|
this.logger.trace("Inspecting group: {} => {} ({})", group.getId(), group.getName(), group.getType());
|
||||||
if (authorities.remove(group.getName())) {
|
if (roles.remove(this.activitiGroupIdToKeycloakRole(group.getId())) != null) {
|
||||||
this.logger.trace("Group and membership already exist: {} => {}", user.getEmail(), group.getName());
|
this.logger.trace("Group and membership already exist: {} => {}", user.getEmail(), group.getName());
|
||||||
// already a member of the group
|
// already a member of the group
|
||||||
} else {
|
} else {
|
||||||
@@ -127,23 +122,23 @@ public class IdentityServiceActivitiEngineAuthenticator extends AbstractIdentity
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.debug("Unaddressed OIDC authorities: {}", authorities);
|
this.logger.debug("Unaddressed OIDC roles: {}", roles);
|
||||||
|
|
||||||
// check remainder/unaddressed authorities
|
// check remainder/unaddressed roles
|
||||||
for (String authority : authorities) {
|
for (Entry<String, String> role : roles.entrySet()) {
|
||||||
this.logger.trace("Inspecting authority: {}", authority);
|
this.logger.trace("Inspecting role: {}", role);
|
||||||
|
|
||||||
Group group = this.identityService.createGroupQuery()
|
Group group = this.identityService.createGroupQuery()
|
||||||
.groupName(authority)
|
.groupId(this.keycloakRoleToActivitiGroupId(role.getKey()))
|
||||||
.singleResult();
|
.singleResult();
|
||||||
if (group == null) {
|
if (group == null) {
|
||||||
if (this.createMissingGroup) {
|
if (this.createMissingGroup) {
|
||||||
this.logger.trace("Group does not exist; creating one");
|
this.logger.trace("Group does not exist; creating one");
|
||||||
group = this.identityService.newGroup(authority);
|
group = this.identityService.newGroup(this.keycloakRoleToActivitiGroupId(role.getKey()));
|
||||||
group.setName(authority);
|
group.setName(role.getValue());
|
||||||
this.identityService.saveGroup(group);
|
this.identityService.saveGroup(group);
|
||||||
} else {
|
} else {
|
||||||
this.logger.info("User does not exist; user creation is disabled: {}", auth.getName());
|
this.logger.info("Group does not exist; group creation is disabled: {}", role.getKey());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,4 +151,12 @@ public class IdentityServiceActivitiEngineAuthenticator extends AbstractIdentity
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private String keycloakRoleToActivitiGroupId(String role) {
|
||||||
|
return this.groupPrefix + role;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String activitiGroupIdToKeycloakRole(String groupId) {
|
||||||
|
return groupId.startsWith(this.groupPrefix) ? groupId.substring(this.groupPrefix.length()) : groupId;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
@@ -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()));
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
Reference in New Issue
Block a user