Compare commits
30 Commits
Author | SHA1 | Date | |
---|---|---|---|
514ba6bae1 | |||
6c93637dbb | |||
65d80dc82d | |||
4859473938 | |||
99f5a7702c | |||
880da07a84 | |||
9412957e89 | |||
faba551a2d | |||
84d1b4ea2f | |||
187e558177 | |||
c38c1d28df | |||
9c67dad207 | |||
55619d6b4a | |||
d631cc5f12 | |||
0a10b06cc8 | |||
76ce7e42d4 | |||
0b032f1f7f | |||
790836194e | |||
dde6dbcdb0 | |||
3d3a7433c5 | |||
5dfdf8452d | |||
9c7641b858 | |||
0e34f589c3 | |||
dcd7e987f1 | |||
a73543d2a6 | |||
cd472b9269 | |||
e7f2e2ee0c | |||
bf848b009c | |||
52b86c0de4 | |||
b34093bb85 |
132
README.md
132
README.md
@@ -1,14 +1,19 @@
|
|||||||
# Keycloak Extension for Activiti
|
# Auth Extension for APS (Activiti App)
|
||||||
|
|
||||||
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.
|
This library was originally created to expand the functionality of Keycloak integration within the Alfresco Process Services (APS) application. It has expanded to support general OAuth, closing gaps that remain in the implementation provided by Alfresco. This is useless for the open source Activiti product.
|
||||||
|
|
||||||
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).
|
APS delivers SSO capability and that is about it. It has a few shortcomings:
|
||||||
|
|
||||||
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".
|
- The user must already exist in APS, which means they must be sync'd in from LDAP.
|
||||||
|
- The user roles are for their session only and not synchronized with APS Organizations. This prevents the user from being included in task candidate group assignments and other group features.
|
||||||
|
|
||||||
|
This extension aims to resolve those issues.
|
||||||
|
|
||||||
|
The older version of this extension was specific to Keycloak and was named `keycloak-activiti-app-ext`. See the [`develop-v1.4.x` branch](https://git.inteligr8.com/inteligr8/auth-activiti-app-ext/src/branch/develop-v1.4.x/) for details specific to it.
|
||||||
|
|
||||||
## Installation
|
## 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:
|
The installation is simple. Just include the JAR in the classpath of your APS 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
|
```xml
|
||||||
<Context>
|
<Context>
|
||||||
@@ -18,57 +23,94 @@ The installation is simple. Just include the JAR in the classpath of your Activ
|
|||||||
</Context>
|
</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.
|
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. If you use the `-security` switch, you will need to give this path permissions in the `catalina.policy` file:
|
||||||
|
|
||||||
|
```properties
|
||||||
|
grant codeBase "file:${catalina.base}/ext/-" {
|
||||||
|
permission java.security.AllPermissions
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dependencies
|
||||||
|
|
||||||
|
This extension requires the [`multiext-activiti-app-ext`](https://git.inteligr8.com/inteligr8/multiext-activiti-app-ext). Without it, APS will fail to startup. It is very small and requires no additional configuration.
|
||||||
|
|
||||||
## Support Matrix
|
## Support Matrix
|
||||||
|
|
||||||
| Keycloak Activiti App Extension | Activiti App |
|
| Activiti App Extension | Activiti App |
|
||||||
| ------------------------------- | --------------- |
|
| --------------------------------------- | --------------- |
|
||||||
| v1.0 - v1.2 | v1.11.x |
|
| `keycloak-activiti-app-ext` v1.0 - v1.2 | v1.11.x |
|
||||||
| v1.3+ | v1.11.x - v2.3+ |
|
| `keycloak-activiti-app-ext` v1.3 - v1.4 | v1.11.x - v2.x |
|
||||||
|
| `auth-activiti-app-ext` v2.0+ | v24.x+ |
|
||||||
|
|
||||||
## Configuration
|
## 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.
|
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. Or you could specify these options with `-D` switches on startup of the web container. The properties to configure are enumerated in the table below.
|
||||||
|
|
||||||
### Common
|
This will only work if OAuth is being used. That would be the case if `activiti.identity-service.enabled` or `security.oauth2.authentication.enabled` is `true`.
|
||||||
|
|
||||||
| Property | Default | Description |
|
The following properties are used across the functionalities of this extension.
|
||||||
| ---------------------------------------------- | --------- | ----------- |
|
|
||||||
| `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 |
|
||||||
|
| --------------------------------- | --------- | ----------- |
|
||||||
|
| `auth-ext.externalId` | `oauth` | This will serve as the external ID for users and as the prefix for the external ID of groups created or searched by this extension. Anything without an external ID is considered internal. So mismatched external IDs are never considered for anything by this extension. |
|
||||||
|
| `auth-ext.tenant` | | A preselected tenant for all operations in this extension. Only required if there are multiple tenants. |
|
||||||
|
| `auth-ext.sync.resyncInMillis` | `300000` | To prevent too many sync checks, how long between seeing the EXACT same token should we wait before doing another sync. The only time this matters is if the user is manually added to or removed from groups in APS by other means. Or those groups are deleted. |
|
||||||
|
|
||||||
| Property | Default | Description |
|
### OAuth Authentication/Authorization
|
||||||
| ---------------------------------------------- | ------- | ----------- |
|
|
||||||
| `keycloak-ext.group.capability.regex.patterns` | | When creating a new group, sync as an APS Organization, except when the specified pattern matches the role. In those cases, sync as an APS Capability. |
|
|
||||||
| `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
|
The following properties were added to increase the configurability of the built-in OAuth capabilities of APS. The default in this extension adds the `microprofile-jwt` scope, which is key to providing groups/roles/entitlements.
|
||||||
|
|
||||||
| Property | Default | Description |
|
| Property | Default |
|
||||||
| ----------------------------------------- | --------------- | ----------- |
|
| ---------------------------------------------- | --------- |
|
||||||
| `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. |
|
| `auth-ext.oauth.scopes` | `openid`, `profile`, `email`, `microprofile-jwt` |
|
||||||
| `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
|
### OAuth Synchronization
|
||||||
|
|
||||||
| Property | Default | Description |
|
The following properties provide the core functionality of this extension. That is role synchronization.
|
||||||
| ----------------------------------------- | --------------- | ----------- |
|
|
||||||
| `keycloak-ext.keycloak.enabled` | `false` | Enable Keycloak integration, overriding and extending the OOTB Keycloak provider (*untested*). |
|
| Property | Default | Description |
|
||||||
| `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. |
|
| --------------------------------------------- | ------------ | ----------- |
|
||||||
|
| `auth-ext.sync.user.createMissing` | `true` | If the user is authenticated, the user may be created in APS. |
|
||||||
|
| `auth-ext.sync.user.requireOidcGroup` | | This is only applicable when `createMissing` is `true`. If this is unset or the OAuth Authorization Server gives the user the specified group/role, then the user record will be created in APS. |
|
||||||
|
| `auth-ext.sync.user.clearNewUserGroups` | `true` | This is only applicable when `createMissing` is `true`. All default APS groups will be deleted from the new user record. |
|
||||||
|
| `auth-ext.sync.group.createMissing` | `true` | If a filtered and translated OIDC group has no corresponding APS group, a group will be created in APS. See `auth-ext.sync.group.capabilities.patterns` for whether that group will be an APS Organization or APS Capability. |
|
||||||
|
| `auth-ext.sync.group.additions` | `true` | If the user isn't in an APS group but OAuth claims the OIDC group, then add them to it. |
|
||||||
|
| `auth-ext.sync.group.removals` | `true` | If the user is in APS group but OAuth claims no OIDC group, then remove them from it. |
|
||||||
|
| `auth-ext.sync.group.internal` | `false` | When considering groups for creation or user membership, include internal groups. Internal groups are ones without an `externalId`. |
|
||||||
|
| `auth-ext.sync.group.internal.externalize` | `false` | This is only applicable when `internal` is `true`. If an internal group is encountered during the operation of this extension, make it external with the current `externalId`. |
|
||||||
|
| `auth-ext.sync.group.tenantize` | `false` | If a group without a tenant is encountered during the operation of this extension, make it part of the selected tenant. |
|
||||||
|
| `auth-ext.sync.group.include.patterns` | | A comma delimited set of regular expression patterns on what OIDC groups to include. This is processed before `translate` processing. A blank value matches everything. If anything is specified, then only matches could possibly be included. Any matches of the `exclude` property patterns always override though. |
|
||||||
|
| `auth-ext.sync.group.exclude.patterns` | | A comma delimited set of regular expression patterns on what OIDC groups to exclude. This is processed before `translate` processing. A blank value matches nothing (includes all that pass the `include` constraint). If anything is specified and `include` is empty, then all non-matches are included. If both are specified, `exclude` matches override `include` matches. |
|
||||||
|
| `auth-ext.sync.group.translate.patterns` | | A comma delimited set of regular expression patterns for the translation (reformatting) of OIDC groups to APS groups. This list corresponds to the `replacements` property and must have the same number of commas. |
|
||||||
|
| `auth-ext.sync.group.translate.replacements` | | A comma delimited set of regular expression replacement strings for the translation (reformatting) of OIDC groups to APS groups. This list corresponds to the `patterns` property and must have the same number of commas. |
|
||||||
|
| `auth-ext.sync.group.capability.patterns` | `Superusers` | A comma delimited set of regular expression patterns on what translated OIDC groups to associate with APS Capability Groups instead of APS Organization Groups (default). This is processed after `translate` processing. |
|
||||||
|
|
||||||
|
### Authentication Data Fixers
|
||||||
|
|
||||||
|
#### Administrator Password Fixer
|
||||||
|
|
||||||
|
This fixer does not execute unless the `auth-ext.reset.admin.password` property is explicitly set. It is meant to be set for a single run to fix scenarios when a non-OAuth administrator cannot access the system. This is useful when your OAuth configuration isn't working and you need to reset your non-OAuth administrator's password.
|
||||||
|
|
||||||
|
| Property | Default |
|
||||||
|
| --------------------------------- | ------------------------ |
|
||||||
|
| `auth-ext.reset.admin.username` | `admin@app.activiti.com` |
|
||||||
|
| `auth-ext.reset.admin.password` | |
|
||||||
|
|
||||||
|
#### Administrator Members Fixer
|
||||||
|
|
||||||
|
This fixer does not execute unless the `auth-ext.default.admin-users` property is explicitly set. It is meant to be set for a single run to add both non-OAuth and OAuth users to an administrator group. This is useful when OAuth is misconfigured and all administrators lose their administrator rights.
|
||||||
|
|
||||||
|
| Property | Default | Description |
|
||||||
|
| ------------------------------------ | ------------ | ----------- |
|
||||||
|
| `auth-ext.default.admins.users` | | A comma delimited list of user emails. |
|
||||||
|
| `auth-ext.group.admins.name` | `Superusers` | The APS Group (Capability or Organization) for the specified users to be members. |
|
||||||
|
|
||||||
|
#### Administrator Group Fixer
|
||||||
|
|
||||||
|
This fixer does not execute unless the `auth-ext.group.admins.validate` property is set to `true`. It is meant to be set for a single run to correct the specified group for administration access. This is useful if you accidentally delete the administrative APS Capability Group, typically called `Administrator` or `Superusers`.
|
||||||
|
|
||||||
|
| Property | Default | Description |
|
||||||
|
| ----------------------------------------- | ------------------------ | ----------- |
|
||||||
|
| `auth-ext.group.admins.validate` | `false` |
|
||||||
|
| `auth-ext.group.admins.name` | `Superusers` | The APS Capability Group of which to grant all capabilities. |
|
||||||
|
178
dependency-reduced-pom.xml
Normal file
178
dependency-reduced-pom.xml
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<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 http://maven.apache.org/maven-v4_0_0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
<groupId>com.inteligr8.activiti</groupId>
|
||||||
|
<artifactId>keycloak-activiti-app-ext</artifactId>
|
||||||
|
<name>Keycloak Authentication & Authorization for APS</name>
|
||||||
|
<version>1.4-SNAPSHOT</version>
|
||||||
|
<description>An Alfresco Process Service App extension providing improved Keycloak/AIS support.</description>
|
||||||
|
<url>https://bitbucket.org/inteligr8/keycloak-activiti-app-ext</url>
|
||||||
|
<developers>
|
||||||
|
<developer>
|
||||||
|
<id>brian.long</id>
|
||||||
|
<name>Brian Long</name>
|
||||||
|
<email>brian@inteligr8.com</email>
|
||||||
|
<url>https://twitter.com/brianmlong</url>
|
||||||
|
</developer>
|
||||||
|
</developers>
|
||||||
|
<licenses>
|
||||||
|
<license>
|
||||||
|
<name>GNU GENERAL PUBLIC LICENSE, Version 3, 29 June 2007</name>
|
||||||
|
<url>https://www.gnu.org/licenses/lgpl-3.0.txt</url>
|
||||||
|
</license>
|
||||||
|
</licenses>
|
||||||
|
<scm>
|
||||||
|
<connection>scm:git:https://bitbucket.org/inteligr8/keycloak-activiti-app-ext.git</connection>
|
||||||
|
<developerConnection>scm:git:git@bitbucket.org:inteligr8/keycloak-activiti-app-ext.git</developerConnection>
|
||||||
|
<url>https://bitbucket.org/inteligr8/keycloak-activiti-app-ext</url>
|
||||||
|
</scm>
|
||||||
|
<organization>
|
||||||
|
<name>Inteligr8</name>
|
||||||
|
<url>https://www.inteligr8.com</url>
|
||||||
|
</organization>
|
||||||
|
<build>
|
||||||
|
<plugins>
|
||||||
|
<plugin>
|
||||||
|
<artifactId>maven-shade-plugin</artifactId>
|
||||||
|
<version>3.6.0</version>
|
||||||
|
<executions>
|
||||||
|
<execution>
|
||||||
|
<id>shade-jar</id>
|
||||||
|
<goals>
|
||||||
|
<goal>shade</goal>
|
||||||
|
</goals>
|
||||||
|
<configuration>
|
||||||
|
<shadedArtifactAttached>true</shadedArtifactAttached>
|
||||||
|
<relocations>
|
||||||
|
<relocation>
|
||||||
|
<pattern />
|
||||||
|
<shadedPattern>shaded.keycloak.</shadedPattern>
|
||||||
|
<excludes>
|
||||||
|
<exclude>com.activiti.conf.*</exclude>
|
||||||
|
<exclude>com.activiti.extension.conf.*</exclude>
|
||||||
|
<exclude>com.inteligr8.activiti.**</exclude>
|
||||||
|
<exclude>META-INF/**/*</exclude>
|
||||||
|
</excludes>
|
||||||
|
</relocation>
|
||||||
|
</relocations>
|
||||||
|
</configuration>
|
||||||
|
</execution>
|
||||||
|
</executions>
|
||||||
|
</plugin>
|
||||||
|
</plugins>
|
||||||
|
</build>
|
||||||
|
<profiles>
|
||||||
|
<profile>
|
||||||
|
<id>ossrh-release</id>
|
||||||
|
<build>
|
||||||
|
<plugins>
|
||||||
|
<plugin>
|
||||||
|
<artifactId>maven-source-plugin</artifactId>
|
||||||
|
<executions>
|
||||||
|
<execution>
|
||||||
|
<id>source</id>
|
||||||
|
<phase>package</phase>
|
||||||
|
<goals>
|
||||||
|
<goal>jar-no-fork</goal>
|
||||||
|
</goals>
|
||||||
|
</execution>
|
||||||
|
</executions>
|
||||||
|
</plugin>
|
||||||
|
<plugin>
|
||||||
|
<artifactId>maven-javadoc-plugin</artifactId>
|
||||||
|
<executions>
|
||||||
|
<execution>
|
||||||
|
<id>javadoc</id>
|
||||||
|
<phase>package</phase>
|
||||||
|
<goals>
|
||||||
|
<goal>jar</goal>
|
||||||
|
</goals>
|
||||||
|
<configuration>
|
||||||
|
<show>public</show>
|
||||||
|
</configuration>
|
||||||
|
</execution>
|
||||||
|
</executions>
|
||||||
|
</plugin>
|
||||||
|
<plugin>
|
||||||
|
<artifactId>maven-gpg-plugin</artifactId>
|
||||||
|
<executions>
|
||||||
|
<execution>
|
||||||
|
<id>sign</id>
|
||||||
|
<phase>verify</phase>
|
||||||
|
<goals>
|
||||||
|
<goal>sign</goal>
|
||||||
|
</goals>
|
||||||
|
</execution>
|
||||||
|
</executions>
|
||||||
|
</plugin>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.sonatype.plugins</groupId>
|
||||||
|
<artifactId>nexus-staging-maven-plugin</artifactId>
|
||||||
|
<version>1.7.0</version>
|
||||||
|
<executions>
|
||||||
|
<execution>
|
||||||
|
<id>ossrh-deploy</id>
|
||||||
|
<phase>deploy</phase>
|
||||||
|
<goals>
|
||||||
|
<goal>deploy</goal>
|
||||||
|
</goals>
|
||||||
|
</execution>
|
||||||
|
</executions>
|
||||||
|
<configuration>
|
||||||
|
<serverId>ossrh</serverId>
|
||||||
|
<nexusUrl>https://s01.oss.sonatype.org/</nexusUrl>
|
||||||
|
<autoReleaseAfterClose>true</autoReleaseAfterClose>
|
||||||
|
</configuration>
|
||||||
|
</plugin>
|
||||||
|
</plugins>
|
||||||
|
</build>
|
||||||
|
<properties>
|
||||||
|
<maven.deploy.skip>true</maven.deploy.skip>
|
||||||
|
</properties>
|
||||||
|
</profile>
|
||||||
|
</profiles>
|
||||||
|
<repositories>
|
||||||
|
<repository>
|
||||||
|
<id>alfresco-private</id>
|
||||||
|
<url>https://artifacts.alfresco.com/nexus/content/groups/private</url>
|
||||||
|
</repository>
|
||||||
|
<repository>
|
||||||
|
<id>activiti-releases</id>
|
||||||
|
<url>https://artifacts.alfresco.com/nexus/content/repositories/activiti-enterprise-releases</url>
|
||||||
|
</repository>
|
||||||
|
</repositories>
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.security</groupId>
|
||||||
|
<artifactId>spring-security-oauth2-client</artifactId>
|
||||||
|
<version>6.3.2</version>
|
||||||
|
<scope>provided</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.activiti</groupId>
|
||||||
|
<artifactId>activiti-app</artifactId>
|
||||||
|
<version>24.3.0</version>
|
||||||
|
<classifier>classes</classifier>
|
||||||
|
<scope>provided</scope>
|
||||||
|
<exclusions>
|
||||||
|
<exclusion>
|
||||||
|
<artifactId>aspose-transformation</artifactId>
|
||||||
|
<groupId>com.activiti</groupId>
|
||||||
|
</exclusion>
|
||||||
|
<exclusion>
|
||||||
|
<artifactId>aoservices</artifactId>
|
||||||
|
<groupId>org.alfresco.officeservices</groupId>
|
||||||
|
</exclusion>
|
||||||
|
</exclusions>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
<properties>
|
||||||
|
<maven.compiler.release>17</maven.compiler.release>
|
||||||
|
<maven.compiler.source>17</maven.compiler.source>
|
||||||
|
<keycloak.version>23.0.7</keycloak.version>
|
||||||
|
<maven.compiler.target>17</maven.compiler.target>
|
||||||
|
<slf4j.version>1.7.36</slf4j.version>
|
||||||
|
<spring-security-oauth2.version>6.3.2</spring-security-oauth2.version>
|
||||||
|
<aps.version>24.3.0</aps.version>
|
||||||
|
</properties>
|
||||||
|
</project>
|
209
pom.xml
209
pom.xml
@@ -2,12 +2,14 @@
|
|||||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
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">
|
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>
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
|
||||||
<groupId>com.inteligr8.activiti</groupId>
|
<groupId>com.inteligr8.activiti</groupId>
|
||||||
<artifactId>keycloak-activiti-app-ext</artifactId>
|
<artifactId>auth-activiti-app-ext</artifactId>
|
||||||
<version>1.3.1</version>
|
<version>2.1.2</version>
|
||||||
<name>Keycloak Authentication & Authorization for APS</name>
|
|
||||||
<description>An Alfresco Process Service App extension providing improved Keycloak/AIS support.</description>
|
<name>Authentication & Authorization for APS</name>
|
||||||
<url>https://bitbucket.org/inteligr8/keycloak-activiti-app-ext</url>
|
<description>An Alfresco Process Service App extension providing improved authentication and authorization support.</description>
|
||||||
|
<url>https://git.inteligr8.com/inteligr8/auth-activiti-app-ext</url>
|
||||||
|
|
||||||
<licenses>
|
<licenses>
|
||||||
<license>
|
<license>
|
||||||
@@ -17,9 +19,9 @@
|
|||||||
</licenses>
|
</licenses>
|
||||||
|
|
||||||
<scm>
|
<scm>
|
||||||
<connection>scm:git:https://bitbucket.org/inteligr8/keycloak-activiti-app-ext.git</connection>
|
<connection>scm:git:https://git.inteligr8.com/inteligr8/auth-activiti-app-ext.git</connection>
|
||||||
<developerConnection>scm:git:git@bitbucket.org:inteligr8/keycloak-activiti-app-ext.git</developerConnection>
|
<developerConnection>scm:git:git@git.inteligr8.com:inteligr8/auth-activiti-app-ext.git</developerConnection>
|
||||||
<url>https://bitbucket.org/inteligr8/keycloak-activiti-app-ext</url>
|
<url>https://git.inteligr8.com/inteligr8/auth-activiti-app-ext</url>
|
||||||
</scm>
|
</scm>
|
||||||
<organization>
|
<organization>
|
||||||
<name>Inteligr8</name>
|
<name>Inteligr8</name>
|
||||||
@@ -39,31 +41,24 @@
|
|||||||
<maven.compiler.target>17</maven.compiler.target>
|
<maven.compiler.target>17</maven.compiler.target>
|
||||||
<maven.compiler.release>17</maven.compiler.release>
|
<maven.compiler.release>17</maven.compiler.release>
|
||||||
|
|
||||||
<aps.version>2.4.4</aps.version>
|
<aps.version>25.1.1</aps.version>
|
||||||
<keycloak.version>18.0.2</keycloak.version>
|
|
||||||
<spring-security-oauth2.version>5.8.5</spring-security-oauth2.version>
|
<!-- for RAD -->
|
||||||
<slf4j.version>1.7.36</slf4j.version>
|
<tomcat-rad.version>10-2.2</tomcat-rad.version>
|
||||||
|
<aps.hotswap.enabled>false</aps.hotswap.enabled>
|
||||||
|
<aps.tomcat.opts.base>-Dspring.main.allow-circular-references=true \
|
||||||
|
-Dhibernate.dialect=org.hibernate.dialect.PostgreSQLDialect \
|
||||||
|
-Dauth-ext.external.id=keycloak \
|
||||||
|
-Dauth-ext.sync.group.translate.patterns=aps-admin \
|
||||||
|
-Dauth-ext.sync.group.translate.replacements=Superusers \
|
||||||
|
-Dauth-ext.group.admins.validate=true</aps.tomcat.opts.base>
|
||||||
|
<aps.timeout>120000</aps.timeout>
|
||||||
|
<keycloak.realm>my-app</keycloak.realm>
|
||||||
|
<oauth.client.id>aps-app-public</oauth.client.id>
|
||||||
|
<oauth.client.secret></oauth.client.secret>
|
||||||
</properties>
|
</properties>
|
||||||
|
|
||||||
<dependencies>
|
<dependencies>
|
||||||
<dependency>
|
|
||||||
<groupId>org.slf4j</groupId>
|
|
||||||
<artifactId>slf4j-api</artifactId>
|
|
||||||
<version>${slf4j.version}</version>
|
|
||||||
<scope>provided</scope>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.springframework.security</groupId>
|
|
||||||
<artifactId>spring-security-oauth2-client</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>
|
|
||||||
<!-- Needed for Activiti App Identity Service inheritance/override -->
|
<!-- Needed for Activiti App Identity Service inheritance/override -->
|
||||||
<!-- includes activiti-app-logic for API -->
|
<!-- includes activiti-app-logic for API -->
|
||||||
<dependency>
|
<dependency>
|
||||||
@@ -73,6 +68,7 @@
|
|||||||
<classifier>classes</classifier>
|
<classifier>classes</classifier>
|
||||||
<scope>provided</scope>
|
<scope>provided</scope>
|
||||||
<exclusions>
|
<exclusions>
|
||||||
|
<!-- not necessary to download for building -->
|
||||||
<exclusion>
|
<exclusion>
|
||||||
<groupId>com.activiti</groupId>
|
<groupId>com.activiti</groupId>
|
||||||
<artifactId>aspose-transformation</artifactId>
|
<artifactId>aspose-transformation</artifactId>
|
||||||
@@ -81,11 +77,158 @@
|
|||||||
<groupId>org.alfresco.officeservices</groupId>
|
<groupId>org.alfresco.officeservices</groupId>
|
||||||
<artifactId>aoservices</artifactId>
|
<artifactId>aoservices</artifactId>
|
||||||
</exclusion>
|
</exclusion>
|
||||||
|
<!-- very old and overrides real spring version -->
|
||||||
|
<exclusion>
|
||||||
|
<groupId>com.ryantenney.metrics</groupId>
|
||||||
|
<artifactId>metrics-spring</artifactId>
|
||||||
|
</exclusion>
|
||||||
|
<exclusion>
|
||||||
|
<groupId>org.springframework.security.oauth</groupId>
|
||||||
|
<artifactId>spring-security-oauth2</artifactId>
|
||||||
|
</exclusion>
|
||||||
|
<exclusion>
|
||||||
|
<groupId>org.springframework.security.oauth.boot</groupId>
|
||||||
|
<artifactId>spring-security-oauth2-autoconfigure</artifactId>
|
||||||
|
</exclusion>
|
||||||
</exclusions>
|
</exclusions>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.inteligr8.activiti</groupId>
|
||||||
|
<artifactId>multiext-activiti-app-ext</artifactId>
|
||||||
|
<version>1.0.0</version>
|
||||||
|
<scope>runtime</scope>
|
||||||
|
</dependency>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
|
|
||||||
|
<build>
|
||||||
|
<plugins>
|
||||||
|
<plugin>
|
||||||
|
<groupId>io.repaint.maven</groupId>
|
||||||
|
<artifactId>tiles-maven-plugin</artifactId>
|
||||||
|
<version>2.40</version>
|
||||||
|
<extensions>true</extensions>
|
||||||
|
<configuration>
|
||||||
|
<tiles>
|
||||||
|
<!-- Documentation: https://bitbucket.org/inteligr8/ootbee-beedk/src/stable/beedk-aps-ext-rad-tile -->
|
||||||
|
<!--
|
||||||
|
<tile>com.inteligr8.ootbee:beedk-aps-ext-rad-tile:[1.1.0,2.0.0)</tile>
|
||||||
|
-->
|
||||||
|
<tile>com.inteligr8.ootbee:beedk-aps-ext-rad-tile:1.1-SNAPSHOT</tile>
|
||||||
|
</tiles>
|
||||||
|
</configuration>
|
||||||
|
</plugin>
|
||||||
|
</plugins>
|
||||||
|
</build>
|
||||||
|
|
||||||
<profiles>
|
<profiles>
|
||||||
|
<profile>
|
||||||
|
<id>activiti-oauth-confidential</id>
|
||||||
|
<activation>
|
||||||
|
<property>
|
||||||
|
<name>secret</name>
|
||||||
|
</property>
|
||||||
|
</activation>
|
||||||
|
<properties>
|
||||||
|
<oauth.client.id>aps-app-confidential</oauth.client.id>
|
||||||
|
<oauth.client.secret>a-secret</oauth.client.secret>
|
||||||
|
</properties>
|
||||||
|
</profile>
|
||||||
|
<profile>
|
||||||
|
<id>activiti-oauth-legacy</id>
|
||||||
|
<activation>
|
||||||
|
<property>
|
||||||
|
<name>rad</name>
|
||||||
|
<value>!spring</value>
|
||||||
|
</property>
|
||||||
|
</activation>
|
||||||
|
<properties>
|
||||||
|
<aps.tomcat.opts>${aps.tomcat.opts.base} \
|
||||||
|
-Dactiviti.identity-service.enabled=true \
|
||||||
|
-Dactiviti.identity-service.realm=${keycloak.realm} \
|
||||||
|
-Dactiviti.identity-service.auth-server-url=http://host.docker.internal:${keycloak.server.port} \
|
||||||
|
-Dactiviti.identity-service.resource=${oauth.client.id} \
|
||||||
|
-Dactiviti.identity-service.credentials.secret=${oauth.client.secret} \
|
||||||
|
-Dactiviti.use-browser-based-logout=true \
|
||||||
|
-Dalfresco.content.sso.redirect_uri=http://loalhost:8080/activiti-app/app/rest/integration/sso/confirm-auth-request</aps.tomcat.opts>
|
||||||
|
</properties>
|
||||||
|
</profile>
|
||||||
|
<profile>
|
||||||
|
<id>activiti-oauth-spring</id>
|
||||||
|
<activation>
|
||||||
|
<property>
|
||||||
|
<name>rad</name>
|
||||||
|
<value>spring</value>
|
||||||
|
</property>
|
||||||
|
</activation>
|
||||||
|
<properties>
|
||||||
|
<aps.tomcat.opts>${aps.tomcat.opts.base} \
|
||||||
|
-Dsecurity.oauth2.authentication.enabled=true \
|
||||||
|
-Dsecurity.oauth2.client.registration.my-app.client-id=${oauth.client.id} \
|
||||||
|
-Dsecurity.oauth2.client.registration.my-app.client-secret=${oauth.client.secret} \
|
||||||
|
-Dsecurity.oauth2.client.registration.my-app.provider=aps-app \
|
||||||
|
-Dsecurity.oauth2.client.provider.aps-app.issuer_uri=http://host.docker.internal:${keycloak.server.port}/realms/${keycloak.realm}</aps.tomcat.opts>
|
||||||
|
</properties>
|
||||||
|
</profile>
|
||||||
|
<profile>
|
||||||
|
<id>rad-keycloak</id>
|
||||||
|
<activation>
|
||||||
|
<property>
|
||||||
|
<name>rad</name>
|
||||||
|
</property>
|
||||||
|
</activation>
|
||||||
|
<properties>
|
||||||
|
<!-- Due to SSL restricitons in previous versions, testing against keyclaok is near impossible. -->
|
||||||
|
<!-- This module should still work against nearly all versions of Keycloak that support the OIDC standards -->
|
||||||
|
<keycloak.server.version>26.2</keycloak.server.version>
|
||||||
|
<keycloak.server.port>8081</keycloak.server.port>
|
||||||
|
</properties>
|
||||||
|
<build>
|
||||||
|
<plugins>
|
||||||
|
<plugin>
|
||||||
|
<groupId>io.fabric8</groupId>
|
||||||
|
<artifactId>docker-maven-plugin</artifactId>
|
||||||
|
<version>0.46.0</version>
|
||||||
|
<executions>
|
||||||
|
<execution>
|
||||||
|
<id>run-keycloak</id>
|
||||||
|
<phase>test-compile</phase>
|
||||||
|
<goals><goal>start</goal></goals>
|
||||||
|
<configuration>
|
||||||
|
<images>
|
||||||
|
<image>
|
||||||
|
<name>keycloak/keycloak:${keycloak.server.version}</name>
|
||||||
|
<alias>keycloak</alias>
|
||||||
|
<run>
|
||||||
|
<cmd>start-dev --import-realm</cmd>
|
||||||
|
<env>
|
||||||
|
<KC_BOOTSTRAP_ADMIN_USERNAME>admin</KC_BOOTSTRAP_ADMIN_USERNAME>
|
||||||
|
<KC_BOOTSTRAP_ADMIN_PASSWORD>admin</KC_BOOTSTRAP_ADMIN_PASSWORD>
|
||||||
|
</env>
|
||||||
|
<ports>
|
||||||
|
<port>${keycloak.server.port}:8080</port>
|
||||||
|
</ports>
|
||||||
|
<network>
|
||||||
|
<mode>custom</mode>
|
||||||
|
<name>${project.artifactId}</name>
|
||||||
|
</network>
|
||||||
|
<extraHosts>
|
||||||
|
<host>host.docker.internal:host-gateway</host>
|
||||||
|
</extraHosts>
|
||||||
|
<volumes>
|
||||||
|
<bind>
|
||||||
|
<volume>${project.basedir}/src/test/resources/keycloak-import:/opt/keycloak/data/import:ro</volume>
|
||||||
|
</bind>
|
||||||
|
</volumes>
|
||||||
|
</run>
|
||||||
|
</image>
|
||||||
|
</images>
|
||||||
|
</configuration>
|
||||||
|
</execution>
|
||||||
|
</executions>
|
||||||
|
</plugin>
|
||||||
|
</plugins>
|
||||||
|
</build>
|
||||||
|
</profile>
|
||||||
<profile>
|
<profile>
|
||||||
<id>ossrh-release</id>
|
<id>ossrh-release</id>
|
||||||
<properties>
|
<properties>
|
||||||
@@ -129,7 +272,7 @@
|
|||||||
<plugin>
|
<plugin>
|
||||||
<groupId>org.sonatype.plugins</groupId>
|
<groupId>org.sonatype.plugins</groupId>
|
||||||
<artifactId>nexus-staging-maven-plugin</artifactId>
|
<artifactId>nexus-staging-maven-plugin</artifactId>
|
||||||
<version>1.6.13</version>
|
<version>1.7.0</version>
|
||||||
<configuration>
|
<configuration>
|
||||||
<serverId>ossrh</serverId>
|
<serverId>ossrh</serverId>
|
||||||
<nexusUrl>https://s01.oss.sonatype.org/</nexusUrl>
|
<nexusUrl>https://s01.oss.sonatype.org/</nexusUrl>
|
||||||
@@ -149,10 +292,6 @@
|
|||||||
</profiles>
|
</profiles>
|
||||||
|
|
||||||
<repositories>
|
<repositories>
|
||||||
<repository>
|
|
||||||
<id>alfresco-private</id>
|
|
||||||
<url>https://artifacts.alfresco.com/nexus/content/groups/private</url>
|
|
||||||
</repository>
|
|
||||||
<repository>
|
<repository>
|
||||||
<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>
|
||||||
|
74
rad.ps1
Normal file
74
rad.ps1
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
|
||||||
|
function discoverArtifactId {
|
||||||
|
$script:ARTIFACT_ID=(mvn -q -Dexpression=project"."artifactId -DforceStdout help:evaluate)
|
||||||
|
}
|
||||||
|
|
||||||
|
function rebuild {
|
||||||
|
echo "Rebuilding project ..."
|
||||||
|
mvn process-classes
|
||||||
|
}
|
||||||
|
|
||||||
|
function start_ {
|
||||||
|
echo "Rebuilding project and starting Docker containers to support rapid application development ..."
|
||||||
|
mvn -Drad process-classes
|
||||||
|
}
|
||||||
|
|
||||||
|
function start_log {
|
||||||
|
echo "Rebuilding project and starting Docker containers to support rapid application development ..."
|
||||||
|
mvn -Drad "-Ddocker.showLogs" process-classes
|
||||||
|
}
|
||||||
|
|
||||||
|
function stop_ {
|
||||||
|
discoverArtifactId
|
||||||
|
echo "Stopping Docker containers that supported rapid application development ..."
|
||||||
|
docker container ls --filter name=${ARTIFACT_ID}-*
|
||||||
|
echo "Stopping containers ..."
|
||||||
|
docker container stop (docker container ls -q --filter name=${ARTIFACT_ID}-*)
|
||||||
|
echo "Removing containers ..."
|
||||||
|
docker container rm (docker container ls -aq --filter name=${ARTIFACT_ID}-*)
|
||||||
|
}
|
||||||
|
|
||||||
|
function tail_logs {
|
||||||
|
param (
|
||||||
|
$container
|
||||||
|
)
|
||||||
|
|
||||||
|
discoverArtifactId
|
||||||
|
docker container logs -f (docker container ls -q --filter name=${ARTIFACT_ID}-${container})
|
||||||
|
}
|
||||||
|
|
||||||
|
function list {
|
||||||
|
discoverArtifactId
|
||||||
|
docker container ls --filter name=${ARTIFACT_ID}-*
|
||||||
|
}
|
||||||
|
|
||||||
|
switch ($args[0]) {
|
||||||
|
"start" {
|
||||||
|
start_
|
||||||
|
}
|
||||||
|
"start_log" {
|
||||||
|
start_log
|
||||||
|
}
|
||||||
|
"stop" {
|
||||||
|
stop_
|
||||||
|
}
|
||||||
|
"restart" {
|
||||||
|
stop_
|
||||||
|
start_
|
||||||
|
}
|
||||||
|
"rebuild" {
|
||||||
|
rebuild
|
||||||
|
}
|
||||||
|
"tail" {
|
||||||
|
tail_logs $args[1]
|
||||||
|
}
|
||||||
|
"containers" {
|
||||||
|
list
|
||||||
|
}
|
||||||
|
default {
|
||||||
|
echo "Usage: .\rad.ps1 [ start | start_log | stop | restart | rebuild | tail {container} | containers ]"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "Completed!"
|
||||||
|
|
71
rad.sh
Normal file
71
rad.sh
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
discoverArtifactId() {
|
||||||
|
local ARTIFACT_ID=`mvn -q -Dexpression=project.artifactId -DforceStdout help:evaluate`
|
||||||
|
}
|
||||||
|
|
||||||
|
rebuild() {
|
||||||
|
echo "Rebuilding project ..."
|
||||||
|
mvn process-test-classes
|
||||||
|
}
|
||||||
|
|
||||||
|
start() {
|
||||||
|
echo "Rebuilding project and starting Docker containers to support rapid application development ..."
|
||||||
|
mvn -Drad process-test-resources
|
||||||
|
}
|
||||||
|
|
||||||
|
start_log() {
|
||||||
|
echo "Rebuilding project and starting Docker containers to support rapid application development ..."
|
||||||
|
mvn -Drad -Ddocker.showLogs process-test-classes
|
||||||
|
}
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
discoverArtifactId
|
||||||
|
echo "Stopping Docker containers that supported rapid application development ..."
|
||||||
|
docker container ls --filter name="^/${ARTIFACT_ID}"
|
||||||
|
echo "Stopping containers ..."
|
||||||
|
docker container stop `docker container ls -q --filter name="^/${ARTIFACT_ID}"`
|
||||||
|
echo "Removing containers ..."
|
||||||
|
docker container rm `docker container ls -aq --filter name="^/${ARTIFACT_ID}"`
|
||||||
|
}
|
||||||
|
|
||||||
|
tail_logs() {
|
||||||
|
discoverArtifactId
|
||||||
|
docker container logs -f `docker container ls -q --filter name="^/${ARTIFACT_ID}-$1$"`
|
||||||
|
}
|
||||||
|
|
||||||
|
list() {
|
||||||
|
discoverArtifactId
|
||||||
|
docker container ls --filter name="^/${ARTIFACT_ID}"
|
||||||
|
}
|
||||||
|
|
||||||
|
case "$1" in
|
||||||
|
start)
|
||||||
|
start
|
||||||
|
;;
|
||||||
|
start_log)
|
||||||
|
start_log
|
||||||
|
;;
|
||||||
|
stop)
|
||||||
|
stop
|
||||||
|
;;
|
||||||
|
restart)
|
||||||
|
stop
|
||||||
|
start
|
||||||
|
;;
|
||||||
|
rebuild)
|
||||||
|
rebuild
|
||||||
|
;;
|
||||||
|
tail)
|
||||||
|
tail_logs $2
|
||||||
|
;;
|
||||||
|
containers)
|
||||||
|
list
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Usage: ./rad.sh [ start | start_log | stop | restart | rebuild | tail {container} | containers ]"
|
||||||
|
exit 1
|
||||||
|
esac
|
||||||
|
|
||||||
|
echo "Completed!"
|
||||||
|
|
@@ -1,76 +0,0 @@
|
|||||||
/*
|
|
||||||
* This program is free software: you can redistribute it and/or modify it
|
|
||||||
* under the terms of the GNU Lesser General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or (at your
|
|
||||||
* option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful, but WITHOUT
|
|
||||||
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
|
|
||||||
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
|
|
||||||
* more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU General Public License along
|
|
||||||
* with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A priority for the execution order of adapters. The first enabled one will be used.
|
|
||||||
*
|
|
||||||
* @return A standard priority value; the lower the value, the higher the priority; 0 is the default
|
|
||||||
*/
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
@@ -16,6 +16,7 @@ package com.activiti.extension.conf;
|
|||||||
|
|
||||||
import org.springframework.context.annotation.ComponentScan;
|
import org.springframework.context.annotation.ComponentScan;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.context.annotation.FullyQualifiedAnnotationBeanNameGenerator;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A means for injecting packages to scan for the Spring context.
|
* A means for injecting packages to scan for the Spring context.
|
||||||
@@ -23,7 +24,12 @@ import org.springframework.context.annotation.Configuration;
|
|||||||
* @author brian@inteligr8.com
|
* @author brian@inteligr8.com
|
||||||
*/
|
*/
|
||||||
@Configuration
|
@Configuration
|
||||||
@ComponentScan(basePackages = {"com.inteligr8.activiti"})
|
@ComponentScan(
|
||||||
public class KeycloakExtSpringComponentScanner {
|
basePackages = {
|
||||||
|
"com.inteligr8.activiti.auth"
|
||||||
|
},
|
||||||
|
nameGenerator = FullyQualifiedAnnotationBeanNameGenerator.class
|
||||||
|
)
|
||||||
|
public class AuthExtSpringComponentScanner {
|
||||||
|
|
||||||
}
|
}
|
@@ -1,55 +0,0 @@
|
|||||||
/*
|
|
||||||
* This program is free software: you can redistribute it and/or modify it
|
|
||||||
* under the terms of the GNU Lesser General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or (at your
|
|
||||||
* option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful, but WITHOUT
|
|
||||||
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
|
|
||||||
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
|
|
||||||
* more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU General Public License along
|
|
||||||
* with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
package com.inteligr8.activiti;
|
|
||||||
|
|
||||||
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
|
|
||||||
import org.springframework.security.core.userdetails.UserDetailsService;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This provides a means to supporting multiple `SecurityConfigAdapter` options
|
|
||||||
* in one code base, while allowing only the first enabled one to be used.
|
|
||||||
*
|
|
||||||
* @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());
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
@@ -1,73 +0,0 @@
|
|||||||
/*
|
|
||||||
* This program is free software: you can redistribute it and/or modify it
|
|
||||||
* under the terms of the GNU Lesser General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or (at your
|
|
||||||
* option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful, but WITHOUT
|
|
||||||
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
|
|
||||||
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
|
|
||||||
* more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU General Public License along
|
|
||||||
* with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
package com.inteligr8.activiti;
|
|
||||||
|
|
||||||
import java.util.Collections;
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
import org.slf4j.Logger;
|
|
||||||
import org.slf4j.LoggerFactory;
|
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
|
||||||
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
|
|
||||||
import org.springframework.security.core.userdetails.UserDetailsService;
|
|
||||||
import org.springframework.stereotype.Component;
|
|
||||||
|
|
||||||
import com.activiti.api.security.AlfrescoSecurityConfigOverride;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This class/bean overrides the APS security configuration with a collection
|
|
||||||
* of implementations. The OOTB extension only provides one override. This
|
|
||||||
* uses that extension point, but delegates it out to multiple possible
|
|
||||||
* implementations.
|
|
||||||
*
|
|
||||||
* Order cannot be controlled, so it should not be assumed in any adapter
|
|
||||||
* implementation.
|
|
||||||
*
|
|
||||||
* @author brian@inteligr8.com
|
|
||||||
*/
|
|
||||||
@Component
|
|
||||||
public class Inteligr8SecurityConfigurationRegistry implements AlfrescoSecurityConfigOverride {
|
|
||||||
|
|
||||||
private final Logger logger = LoggerFactory.getLogger(this.getClass());
|
|
||||||
|
|
||||||
@Autowired
|
|
||||||
private List<ActivitiSecurityConfigAdapter> adapters;
|
|
||||||
|
|
||||||
@Autowired(required = false)
|
|
||||||
private List<DataFixer> fixers;
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void configureGlobal(AuthenticationManagerBuilder authmanBuilder, UserDetailsService userDetailsService) {
|
|
||||||
this.logger.trace("configureGlobal()");
|
|
||||||
|
|
||||||
Collections.sort(this.adapters);
|
|
||||||
|
|
||||||
if (this.fixers != null) {
|
|
||||||
for (DataFixer fixer : this.fixers)
|
|
||||||
fixer.fix();
|
|
||||||
}
|
|
||||||
|
|
||||||
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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
@@ -1,92 +0,0 @@
|
|||||||
/*
|
|
||||||
* This program is free software: you can redistribute it and/or modify it
|
|
||||||
* under the terms of the GNU Lesser General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or (at your
|
|
||||||
* option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful, but WITHOUT
|
|
||||||
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
|
|
||||||
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
|
|
||||||
* more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU General Public License along
|
|
||||||
* with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
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.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 injects a custom AIS authentication provider into the
|
|
||||||
* security configuration.
|
|
||||||
*
|
|
||||||
* @author brian@inteligr8.com
|
|
||||||
* @see com.activiti.security.identity.service.authentication.provider.IdentityServiceAuthenticationProvider
|
|
||||||
*/
|
|
||||||
@Component
|
|
||||||
public class IdentityServiceSecurityConfigurationAdapter implements ActivitiSecurityConfigAdapter {
|
|
||||||
|
|
||||||
private final Logger logger = LoggerFactory.getLogger(this.getClass());
|
|
||||||
|
|
||||||
@Value("${keycloak-ext.ais.enabled:false}")
|
|
||||||
private boolean enabled;
|
|
||||||
|
|
||||||
// this assures execution before the OOTB impl (-10 < 0)
|
|
||||||
@Value("${keycloak-ext.ais.priority:-10}")
|
|
||||||
private int priority;
|
|
||||||
|
|
||||||
@Autowired
|
|
||||||
protected MsmtProperties msmtProperties;
|
|
||||||
|
|
||||||
@Autowired(required = false) // Only when multi-schema multi-tenant is enabled
|
|
||||||
protected MsmtTenantResolver tenantResolver;
|
|
||||||
|
|
||||||
@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 AIS authentication extension, featuring creation of missing users and authority synchronization");
|
|
||||||
|
|
||||||
IdentityServiceAuthenticationProvider provider = new IdentityServiceAuthenticationProvider();
|
|
||||||
if (this.msmtProperties.isMultiSchemaMultiTenantEnabled())
|
|
||||||
provider.setTenantResolver(this.tenantResolver);
|
|
||||||
provider.setUserDetailsService(userDetailsService);
|
|
||||||
provider.setGrantedAuthoritiesMapper(new SimpleAuthorityMapper());
|
|
||||||
|
|
||||||
auth.authenticationProvider(new InterceptingAuthenticationProvider(provider, this.getAuthenticator()));
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
@@ -12,7 +12,7 @@
|
|||||||
* You should have received a copy of the GNU General Public License along
|
* You should have received a copy of the GNU General Public License along
|
||||||
* with this program. If not, see <https://www.gnu.org/licenses/>.
|
* with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
package com.inteligr8.activiti;
|
package com.inteligr8.activiti.auth;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
@@ -32,6 +32,7 @@ import com.activiti.domain.idm.Group;
|
|||||||
import com.activiti.domain.idm.GroupCapability;
|
import com.activiti.domain.idm.GroupCapability;
|
||||||
import com.activiti.domain.idm.Tenant;
|
import com.activiti.domain.idm.Tenant;
|
||||||
import com.activiti.service.api.GroupService;
|
import com.activiti.service.api.GroupService;
|
||||||
|
import com.inteligr8.activiti.auth.service.TenantFinderService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This class/bean attempts to fix the administrative group in APS. This may
|
* This class/bean attempts to fix the administrative group in APS. This may
|
||||||
@@ -41,7 +42,7 @@ import com.activiti.service.api.GroupService;
|
|||||||
* @author brian@inteligr8.com
|
* @author brian@inteligr8.com
|
||||||
*/
|
*/
|
||||||
@Component
|
@Component
|
||||||
public class ActivitiAppAdminGroupFixer implements DataFixer {
|
public class ActivitiAppAdministratorGroupFixer implements DataFixer {
|
||||||
|
|
||||||
private final Logger logger = LoggerFactory.getLogger(this.getClass());
|
private final Logger logger = LoggerFactory.getLogger(this.getClass());
|
||||||
|
|
||||||
@@ -60,61 +61,66 @@ public class ActivitiAppAdminGroupFixer implements DataFixer {
|
|||||||
@Autowired
|
@Autowired
|
||||||
private TenantFinderService tenantFinderService;
|
private TenantFinderService tenantFinderService;
|
||||||
|
|
||||||
@Value("${keycloak-ext.group.admins.name:admins}")
|
@Value("${auth-ext.group.admins.name:Superusers}")
|
||||||
private String adminGroupName;
|
private String adminGroupName;
|
||||||
|
|
||||||
@Value("${keycloak-ext.group.admins.externalId:#{null}}")
|
@Value("${auth-ext.externalId:oauth}")
|
||||||
private String adminGroupExternalId;
|
protected String externalIdmSource;
|
||||||
|
|
||||||
@Value("${keycloak-ext.group.admins.validate:false}")
|
@Value("${auth-ext.group.admins.validate:false}")
|
||||||
private boolean validateAdministratorsGroup;
|
private boolean validateAdministratorsGroup;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void fix() {
|
public void fix() {
|
||||||
this.logger.trace("fix()");
|
this.logger.trace("fix()");
|
||||||
|
|
||||||
|
if (this.groupService == null)
|
||||||
|
return;
|
||||||
|
|
||||||
if (this.logger.isTraceEnabled())
|
if (this.logger.isTraceEnabled())
|
||||||
this.logGroups();
|
this.logGroups();
|
||||||
|
|
||||||
if (this.validateAdministratorsGroup)
|
if (this.validateAdministratorsGroup)
|
||||||
this.validateAdmins();
|
this.validateAdmins(this.externalIdmSource + "_" + this.adminGroupName);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void logGroups() {
|
private void logGroups() {
|
||||||
if (this.groupService == null)
|
|
||||||
return;
|
|
||||||
|
|
||||||
Collection<Tenant> tenants = this.tenantFinderService.getTenants();
|
Collection<Tenant> tenants = this.tenantFinderService.getTenants();
|
||||||
for (Tenant tenant : tenants) {
|
for (Tenant tenant : tenants) {
|
||||||
this.logger.trace("Tenant: {} => {}", tenant.getId(), tenant.getName());
|
this.logger.trace("Tenant: {} => {}; functional groups: {}; system groups: {}",
|
||||||
this.logger.trace("Functional groups: {}", this.toGroupNames(this.groupService.getFunctionalGroups(tenant.getId())));
|
tenant.getId(),
|
||||||
this.logger.trace("System groups: {}", this.toGroupNames(this.groupService.getSystemGroups(tenant.getId())));
|
tenant.getName(),
|
||||||
|
this.toGroupNames(this.groupService.getFunctionalGroups(tenant.getId())),
|
||||||
|
this.toGroupNames(this.groupService.getSystemGroups(tenant.getId())));
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.trace("Tenant: null");
|
this.logger.trace("Tenant: null; functional groups: {}; system groups: {}",
|
||||||
this.logger.trace("Functional groups: {}", this.toGroupNames(this.groupService.getFunctionalGroups(null)));
|
this.toGroupNames(this.groupService.getFunctionalGroups(null)),
|
||||||
this.logger.trace("System groups: {}", this.toGroupNames(this.groupService.getSystemGroups(null)));
|
this.toGroupNames(this.groupService.getSystemGroups(null)));
|
||||||
}
|
}
|
||||||
|
|
||||||
private void validateAdmins() {
|
private void validateAdmins(String adminGroupExternalId) {
|
||||||
if (this.groupService == null)
|
|
||||||
return;
|
|
||||||
|
|
||||||
Long tenantId = this.tenantFinderService.findTenantId();
|
Long tenantId = this.tenantFinderService.findTenantId();
|
||||||
Group group = this.groupService.getGroupByExternalIdAndTenantId(this.adminGroupExternalId, tenantId);
|
|
||||||
|
this.logger.trace("Looking up group by external ID in tenant: {} [{}]", adminGroupExternalId, tenantId);
|
||||||
|
Group group = this.groupService.getGroupByExternalIdAndTenantId(adminGroupExternalId, tenantId);
|
||||||
|
|
||||||
if (group == null) {
|
if (group == null) {
|
||||||
|
this.logger.trace("Lookup up group by name in tenant: {} [{}]", this.adminGroupName, tenantId);
|
||||||
List<Group> groups = this.groupService.getGroupByNameAndTenantId(this.adminGroupName, tenantId);
|
List<Group> groups = this.groupService.getGroupByNameAndTenantId(this.adminGroupName, tenantId);
|
||||||
if (!groups.isEmpty())
|
if (!groups.isEmpty())
|
||||||
group = groups.iterator().next();
|
group = groups.iterator().next();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (group == null) {
|
if (group == null) {
|
||||||
this.logger.info("Creating group: {} ({})", this.adminGroupName, this.adminGroupExternalId);
|
this.logger.debug("Group not found; creating system/capabilities group: {} [{}]", this.adminGroupName, adminGroupExternalId);
|
||||||
if (this.adminGroupExternalId != null) {
|
group = this.groupService.createGroupFromExternalStore(
|
||||||
group = this.groupService.createGroupFromExternalStore(
|
this.adminGroupName, tenantId, Group.TYPE_SYSTEM_GROUP, null, adminGroupExternalId, new Date());
|
||||||
this.adminGroupExternalId, tenantId, Group.TYPE_SYSTEM_GROUP, null, this.adminGroupName, new Date());
|
this.logger.info("Created group: {}: {} [{}]", group.getId(), group.getName(), group.getExternalId());
|
||||||
} else {
|
} else if (group.getExternalId() == null) {
|
||||||
group = this.groupService.createGroup(this.adminGroupName, tenantId, Group.TYPE_SYSTEM_GROUP, null);
|
group.setExternalId(adminGroupExternalId);
|
||||||
}
|
this.groupService.save(group);
|
||||||
|
this.logger.info("Externalized group: {}: {} [{}]", group.getId(), group.getName(), group.getExternalId());
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.debug("Checking group capabilities: {}", group.getName());
|
this.logger.debug("Checking group capabilities: {}", group.getName());
|
||||||
@@ -123,7 +129,7 @@ public class ActivitiAppAdminGroupFixer implements DataFixer {
|
|||||||
for (GroupCapability cap : groupWithCaps.getCapabilities())
|
for (GroupCapability cap : groupWithCaps.getCapabilities())
|
||||||
adminCaps.remove(cap.getName());
|
adminCaps.remove(cap.getName());
|
||||||
if (!adminCaps.isEmpty()) {
|
if (!adminCaps.isEmpty()) {
|
||||||
this.logger.info("Granting group '{}' capabilities: {}", group.getName(), adminCaps);
|
this.logger.info("Granting group capabilities: {} => {}", group.getName(), adminCaps);
|
||||||
this.groupService.addCapabilitiesToGroup(group.getId(), new ArrayList<>(adminCaps));
|
this.groupService.addCapabilitiesToGroup(group.getId(), new ArrayList<>(adminCaps));
|
||||||
}
|
}
|
||||||
}
|
}
|
@@ -12,12 +12,12 @@
|
|||||||
* You should have received a copy of the GNU General Public License along
|
* You should have received a copy of the GNU General Public License along
|
||||||
* with this program. If not, see <https://www.gnu.org/licenses/>.
|
* with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
package com.inteligr8.activiti;
|
package com.inteligr8.activiti.auth;
|
||||||
|
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
import javax.persistence.NonUniqueResultException;
|
import jakarta.persistence.NonUniqueResultException;
|
||||||
|
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
@@ -29,6 +29,7 @@ import com.activiti.domain.idm.Group;
|
|||||||
import com.activiti.domain.idm.User;
|
import com.activiti.domain.idm.User;
|
||||||
import com.activiti.service.api.GroupService;
|
import com.activiti.service.api.GroupService;
|
||||||
import com.activiti.service.api.UserService;
|
import com.activiti.service.api.UserService;
|
||||||
|
import com.inteligr8.activiti.auth.service.TenantFinderService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This class/bean attempts to add administrators to the administrative group
|
* This class/bean attempts to add administrators to the administrative group
|
||||||
@@ -38,7 +39,7 @@ import com.activiti.service.api.UserService;
|
|||||||
* @author brian@inteligr8.com
|
* @author brian@inteligr8.com
|
||||||
*/
|
*/
|
||||||
@Component
|
@Component
|
||||||
public class ActivitiAppAdminMembersFixer implements DataFixer {
|
public class ActivitiAppAdministratorMembersFixer implements DataFixer {
|
||||||
|
|
||||||
private final Logger logger = LoggerFactory.getLogger(this.getClass());
|
private final Logger logger = LoggerFactory.getLogger(this.getClass());
|
||||||
|
|
||||||
@@ -51,53 +52,66 @@ public class ActivitiAppAdminMembersFixer implements DataFixer {
|
|||||||
@Autowired
|
@Autowired
|
||||||
private TenantFinderService tenantFinderService;
|
private TenantFinderService tenantFinderService;
|
||||||
|
|
||||||
@Value("${keycloak-ext.default.admins.users:#{null}}")
|
@Value("${auth-ext.default.admins.users:#{null}}")
|
||||||
private String adminUserStrs;
|
private String adminUserStrs;
|
||||||
|
|
||||||
@Value("${keycloak-ext.group.admins.name:admins}")
|
@Value("${auth-ext.group.admins.name:Superusers}")
|
||||||
private String adminGroupName;
|
private String adminGroupName;
|
||||||
|
|
||||||
@Value("${keycloak-ext.group.admins.externalId:#{null}}")
|
@Value("${auth-ext.externalId:oauth}")
|
||||||
private String adminGroupExternalId;
|
private String adminGroupExternalId;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void fix() {
|
public void fix() {
|
||||||
this.logger.trace("fix()");
|
this.logger.trace("fix()");
|
||||||
|
|
||||||
if (this.adminUserStrs != null && this.adminUserStrs.length() > 0)
|
if (this.userService == null || this.groupService == null)
|
||||||
this.associateAdmins();
|
return;
|
||||||
|
|
||||||
|
if (this.adminUserStrs == null || this.adminUserStrs.isEmpty())
|
||||||
|
return;
|
||||||
|
|
||||||
|
List<String> adminUsers = Arrays.asList(this.adminUserStrs.split(","));
|
||||||
|
if (adminUsers.isEmpty())
|
||||||
|
return;
|
||||||
|
|
||||||
|
this.associateAdmins(adminUsers);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void associateAdmins() {
|
private void associateAdmins(List<String> adminUsers) {
|
||||||
if (this.userService == null || this.groupService == null)
|
|
||||||
return;
|
|
||||||
|
|
||||||
List<String> adminUsers = Arrays.asList(this.adminUserStrs.split(","));
|
|
||||||
if (adminUsers.isEmpty())
|
|
||||||
return;
|
|
||||||
|
|
||||||
Long tenantId = this.tenantFinderService.findTenantId();
|
Long tenantId = this.tenantFinderService.findTenantId();
|
||||||
|
|
||||||
List<Group> groups = null;
|
List<Group> groups = null;
|
||||||
try {
|
try {
|
||||||
Group group1 = this.groupService.getGroupByExternalIdAndTenantId(this.adminGroupExternalId, tenantId);
|
this.logger.trace("Looking up group by external ID in tenant: {} [{}]", this.adminGroupExternalId, tenantId);
|
||||||
if (group1 != null)
|
Group agroup = this.groupService.getGroupByExternalIdAndTenantId(this.adminGroupExternalId, tenantId);
|
||||||
groups = Arrays.asList(group1);
|
if (agroup != null)
|
||||||
|
groups = Arrays.asList(agroup);
|
||||||
} catch (NonUniqueResultException nure) {
|
} catch (NonUniqueResultException nure) {
|
||||||
// suppress
|
// suppress
|
||||||
}
|
}
|
||||||
if (groups == null)
|
if (groups == null) {
|
||||||
|
this.logger.trace("Looking up group by name in tenant: {} [{}]", this.adminGroupName, tenantId);
|
||||||
groups = this.groupService.getGroupByNameAndTenantId(this.adminGroupName, tenantId);
|
groups = this.groupService.getGroupByNameAndTenantId(this.adminGroupName, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.trace("Considering configured admin users: {}", adminUsers);
|
||||||
this.logger.debug("Found {} admin group(s)", groups.size());
|
this.logger.debug("Found {} admin group(s)", groups.size());
|
||||||
|
|
||||||
for (String email : adminUsers) {
|
for (String email : adminUsers) {
|
||||||
|
this.logger.trace("Looking up configured admin user in tenant: {} [{}]", email, tenantId);
|
||||||
User user = this.userService.findUserByEmailAndTenantId(email, tenantId);
|
User user = this.userService.findUserByEmailAndTenantId(email, tenantId);
|
||||||
if (user == null) {
|
if (user == null) {
|
||||||
this.logger.info("The user with email '{}' does not exist, so they cannot be added as an administrator", email);
|
this.logger.warn("The user with email '{}' does not exist, so they cannot be added as an administrator", email);
|
||||||
} else {
|
} else {
|
||||||
this.logger.debug("Adding {} to admin group(s)", user.getEmail());
|
this.logger.debug("Adding {} to admin group(s)", user.getEmail());
|
||||||
for (Group group : groups)
|
for (Group group : groups) {
|
||||||
this.groupService.addUserToGroup(group, user);
|
if (this.groupService.addUserToGroup(group, user)) {
|
||||||
|
this.logger.info("Added {} [{}] to {} [{}]", user.getEmail(), user.getId(), group.getName(), group.getId());
|
||||||
|
} else {
|
||||||
|
this.logger.trace("User already in group: {} => {}", user.getEmail(), group.getName());
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
@@ -12,7 +12,7 @@
|
|||||||
* You should have received a copy of the GNU General Public License along
|
* You should have received a copy of the GNU General Public License along
|
||||||
* with this program. If not, see <https://www.gnu.org/licenses/>.
|
* with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
package com.inteligr8.activiti;
|
package com.inteligr8.activiti.auth;
|
||||||
|
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
@@ -22,6 +22,7 @@ import org.springframework.stereotype.Component;
|
|||||||
|
|
||||||
import com.activiti.domain.idm.User;
|
import com.activiti.domain.idm.User;
|
||||||
import com.activiti.service.api.UserService;
|
import com.activiti.service.api.UserService;
|
||||||
|
import com.inteligr8.activiti.auth.service.TenantFinderService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This class/bean attempts to reset the configured user's password.
|
* This class/bean attempts to reset the configured user's password.
|
||||||
@@ -29,7 +30,7 @@ import com.activiti.service.api.UserService;
|
|||||||
* @author brian@inteligr8.com
|
* @author brian@inteligr8.com
|
||||||
*/
|
*/
|
||||||
@Component
|
@Component
|
||||||
public class ActivitiAppAdminPasswordFixer implements DataFixer {
|
public class ActivitiAppAdministratorPasswordFixer implements DataFixer {
|
||||||
|
|
||||||
private final Logger logger = LoggerFactory.getLogger(this.getClass());
|
private final Logger logger = LoggerFactory.getLogger(this.getClass());
|
||||||
|
|
||||||
@@ -39,23 +40,29 @@ public class ActivitiAppAdminPasswordFixer implements DataFixer {
|
|||||||
@Autowired
|
@Autowired
|
||||||
private TenantFinderService tenantFinderService;
|
private TenantFinderService tenantFinderService;
|
||||||
|
|
||||||
@Value("${keycloak-ext.reset.admin.username:admin@app.activiti.com}")
|
@Value("${auth-ext.reset.admin.username:admin@app.activiti.com}")
|
||||||
private String adminUsername;
|
private String adminUsername;
|
||||||
|
|
||||||
@Value("${keycloak-ext.reset.admin.password:#{null}}")
|
@Value("${auth-ext.reset.admin.password:#{null}}")
|
||||||
private String adminPassword;
|
private String adminPassword;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void fix() {
|
public void fix() {
|
||||||
this.logger.trace("fix()");
|
this.logger.trace("fix()");
|
||||||
|
|
||||||
if (this.adminPassword != null) {
|
if (this.adminPassword == null)
|
||||||
this.logger.info("Resetting the password for admin user '{}'", this.adminUsername);
|
return;
|
||||||
|
if (this.userService == null)
|
||||||
|
return;
|
||||||
|
|
||||||
Long tenantId = this.tenantFinderService.findTenantId();
|
this.logger.debug("Considering admin username: {}", this.adminUsername);
|
||||||
User adminUser = this.userService.findUserByEmailAndTenantId(this.adminUsername, tenantId);
|
|
||||||
this.userService.changePassword(adminUser.getId(), this.adminPassword);
|
Long tenantId = this.tenantFinderService.findTenantId();
|
||||||
}
|
User adminUser = this.userService.findUserByEmailAndTenantId(this.adminUsername, tenantId);
|
||||||
|
this.logger.debug("Resolved admin username ID: {} => {}", this.adminUsername, adminUser.getId());
|
||||||
|
|
||||||
|
this.userService.changePassword(adminUser.getId(), this.adminPassword);
|
||||||
|
this.logger.info("Reset the password for admin user: {}", this.adminUsername);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
@@ -1,28 +0,0 @@
|
|||||||
/*
|
|
||||||
* This program is free software: you can redistribute it and/or modify it
|
|
||||||
* under the terms of the GNU Lesser General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or (at your
|
|
||||||
* option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful, but WITHOUT
|
|
||||||
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
|
|
||||||
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
|
|
||||||
* more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU General Public License along
|
|
||||||
* with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
package com.inteligr8.activiti.auth;
|
|
||||||
|
|
||||||
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 {
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
@@ -12,7 +12,11 @@
|
|||||||
* You should have received a copy of the GNU General Public License along
|
* You should have received a copy of the GNU General Public License along
|
||||||
* with this program. If not, see <https://www.gnu.org/licenses/>.
|
* with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
package com.inteligr8.activiti;
|
package com.inteligr8.activiti.auth;
|
||||||
|
|
||||||
|
import org.springframework.context.ApplicationContext;
|
||||||
|
|
||||||
|
import com.activiti.api.boot.BootstrapConfigurer;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This interface is for defining utilities that provide data-based fixes to
|
* This interface is for defining utilities that provide data-based fixes to
|
||||||
@@ -20,7 +24,7 @@ package com.inteligr8.activiti;
|
|||||||
*
|
*
|
||||||
* @author brian@inteligr8.com
|
* @author brian@inteligr8.com
|
||||||
*/
|
*/
|
||||||
public interface DataFixer {
|
public interface DataFixer extends BootstrapConfigurer {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The method called when the framework wants to execute the fix. This
|
* The method called when the framework wants to execute the fix. This
|
||||||
@@ -28,4 +32,9 @@ public interface DataFixer {
|
|||||||
*/
|
*/
|
||||||
void fix();
|
void fix();
|
||||||
|
|
||||||
|
@Override
|
||||||
|
default void applicationContextInitialized(ApplicationContext applicationContext) {
|
||||||
|
this.fix();
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
@@ -1,63 +0,0 @@
|
|||||||
/*
|
|
||||||
* This program is free software: you can redistribute it and/or modify it
|
|
||||||
* under the terms of the GNU Lesser General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or (at your
|
|
||||||
* option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful, but WITHOUT
|
|
||||||
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
|
|
||||||
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
|
|
||||||
* more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU General Public License along
|
|
||||||
* with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
package com.inteligr8.activiti.auth;
|
|
||||||
|
|
||||||
import org.slf4j.Logger;
|
|
||||||
import org.slf4j.LoggerFactory;
|
|
||||||
import org.springframework.security.authentication.AuthenticationProvider;
|
|
||||||
import org.springframework.security.core.Authentication;
|
|
||||||
import org.springframework.security.core.AuthenticationException;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This class/bean provides a pre/post authentication capability to the
|
|
||||||
* Spring AuthenticationProvider. The pre-authentication hook allows us to
|
|
||||||
* circumvent the problem with authenticating missing users. The
|
|
||||||
* post-authentication hook allow us to synchronize groups/authorities.
|
|
||||||
*
|
|
||||||
* @author brian@inteligr8.com
|
|
||||||
*/
|
|
||||||
public class InterceptingAuthenticationProvider implements AuthenticationProvider {
|
|
||||||
|
|
||||||
private final Logger logger = LoggerFactory.getLogger(this.getClass());
|
|
||||||
private final AuthenticationProvider provider;
|
|
||||||
private final Authenticator authenticator;
|
|
||||||
|
|
||||||
public InterceptingAuthenticationProvider(AuthenticationProvider provider, Authenticator authenticator) {
|
|
||||||
this.provider = provider;
|
|
||||||
this.authenticator = authenticator;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public boolean supports(Class<?> authClass) {
|
|
||||||
return this.provider.supports(authClass);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public Authentication authenticate(Authentication auth) throws AuthenticationException {
|
|
||||||
this.logger.trace("authenticate({})", auth.getName());
|
|
||||||
|
|
||||||
this.authenticator.preAuthenticate(auth);
|
|
||||||
this.logger.debug("Pre-authenticated user: {}", auth.getName());
|
|
||||||
|
|
||||||
auth = this.provider.authenticate(auth);
|
|
||||||
this.logger.debug("Authenticated user '{}' with authorities: {}", auth.getName(), auth.getAuthorities());
|
|
||||||
|
|
||||||
this.authenticator.postAuthenticate(auth);
|
|
||||||
this.logger.debug("Post-authenticated user: {}", auth.getName());
|
|
||||||
|
|
||||||
return auth;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
@@ -0,0 +1,146 @@
|
|||||||
|
package com.inteligr8.activiti.auth.oauth;
|
||||||
|
|
||||||
|
import static org.springframework.security.config.Customizer.withDefaults;
|
||||||
|
import static org.springframework.security.web.util.matcher.AntPathRequestMatcher.antMatcher;
|
||||||
|
|
||||||
|
import org.apache.commons.lang3.StringUtils;
|
||||||
|
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.ApplicationContext;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Conditional;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.context.annotation.Primary;
|
||||||
|
import org.springframework.core.annotation.Order;
|
||||||
|
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||||
|
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||||
|
import org.springframework.security.oauth2.client.registration.ClientRegistration;
|
||||||
|
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
|
||||||
|
import org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository;
|
||||||
|
import org.springframework.security.web.SecurityFilterChain;
|
||||||
|
import org.springframework.security.web.util.matcher.AndRequestMatcher;
|
||||||
|
import org.springframework.security.web.util.matcher.RequestHeaderRequestMatcher;
|
||||||
|
|
||||||
|
import com.activiti.domain.idm.Capabilities;
|
||||||
|
import com.activiti.security.ActivitiAppRequestHeaderService;
|
||||||
|
import com.activiti.security.ActivitiRestAuthorizationService;
|
||||||
|
import com.activiti.security.ProtectedPaths;
|
||||||
|
import com.activiti.security.identity.service.config.IdentityServiceEnabledCondition;
|
||||||
|
import com.inteligr8.activiti.auth.service.JwtAuthenticationProvider;
|
||||||
|
import com.nimbusds.oauth2.sdk.ParseException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The purpose of this class is to allow for the specification of a custom set
|
||||||
|
* of scopes by an APS system administrator.
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
@Conditional(IdentityServiceEnabledCondition.class)
|
||||||
|
public class IdentityServiceConfigurationOverride {
|
||||||
|
|
||||||
|
public static final String OOTB_CLIENT_REGISTRATION_BEANNAME = "clientRegistration";
|
||||||
|
public static final String OVERRIDE_CLIENT_REGISTRATION_BEANNAME = "inteligr8.clientRegistration";
|
||||||
|
|
||||||
|
private final Logger logger = LoggerFactory.getLogger(this.getClass());
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private ApplicationContext appContext;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private JwtAuthenticationProvider jwtAuthenticationProvider;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private ActivitiAppRequestHeaderService appRequestHeaderService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private ActivitiRestAuthorizationService restAuthorizationService;
|
||||||
|
|
||||||
|
@Bean("inteligr8.clientRegistrationRepository")
|
||||||
|
@Primary
|
||||||
|
public ClientRegistrationRepository clientRegistrationRepository() {
|
||||||
|
this.logger.trace("clientRegistrationRepository()");
|
||||||
|
ClientRegistration clientRegistration = this.appContext.getBean(OVERRIDE_CLIENT_REGISTRATION_BEANNAME, ClientRegistration.class);
|
||||||
|
|
||||||
|
this.logger.debug("Creating ClientRegistrationRepository with client registration: {}: {}", clientRegistration.getRegistrationId(), clientRegistration.getScopes());
|
||||||
|
return new InMemoryClientRegistrationRepository(clientRegistration);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// adding `microprofile-jwt` by default, unlike OOTB
|
||||||
|
@Value("${auth-ext.oauth.scopes:openid,profile,email,microprofile-jwt}")
|
||||||
|
protected String clientScopeStr;
|
||||||
|
|
||||||
|
@Bean(OVERRIDE_CLIENT_REGISTRATION_BEANNAME)
|
||||||
|
@Primary
|
||||||
|
public ClientRegistration clientRegistration() throws ParseException, InterruptedException {
|
||||||
|
this.logger.trace("clientRegistration()");
|
||||||
|
ClientRegistration clientRegistration = this.appContext.getBean(OOTB_CLIENT_REGISTRATION_BEANNAME, ClientRegistration.class);
|
||||||
|
|
||||||
|
this.logger.debug("Cloning OOTB ClientRegistration: {}", clientRegistration);
|
||||||
|
return ClientRegistration
|
||||||
|
.withClientRegistration(clientRegistration)
|
||||||
|
.scope(StringUtils.split(this.clientScopeStr, ", "))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Slightly higher priority than the one provided OOTB. This allows for
|
||||||
|
* the bean injection of the `JwtAuthenticationConverter`.
|
||||||
|
*
|
||||||
|
* This is basically a copy of what is provided OOTB, but:
|
||||||
|
*
|
||||||
|
* - The ability to configure the `JwtAuthenticationConverter`.
|
||||||
|
* - Allow non-UI access to `/app/rest/*`
|
||||||
|
*
|
||||||
|
* @see com.activiti.security.identity.service.config.IdentityServiceConfigurationApi#identityServiceApiWebSecurity
|
||||||
|
*/
|
||||||
|
@Bean("inteligr8.identityServiceApiWebSecurity")
|
||||||
|
@Order(-5)
|
||||||
|
public SecurityFilterChain identityServiceApiWebSecurity(HttpSecurity http) throws Exception {
|
||||||
|
http
|
||||||
|
.securityMatchers(matchers -> {
|
||||||
|
matchers.requestMatchers(
|
||||||
|
// same as OOTB
|
||||||
|
antMatcher(ProtectedPaths.API_URL_PATH + "/**"),
|
||||||
|
|
||||||
|
// want to also allow non-UI access to the the protected API
|
||||||
|
// we do this for anything with an `Authorization` header, as the UI uses session-based authorization
|
||||||
|
new AndRequestMatcher(new RequestHeaderRequestMatcher("Authorization"), antMatcher(ProtectedPaths.APP_URL_PATH + "/rest/**"))
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.csrf(csrf -> {
|
||||||
|
csrf.disable();
|
||||||
|
})
|
||||||
|
.cors(withDefaults())
|
||||||
|
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // Stores no Session for API calls
|
||||||
|
.oauth2ResourceServer(oauth2 ->
|
||||||
|
oauth2.jwt(jwtConfigurer -> {
|
||||||
|
// here is where we are injecting a Spring extendible `JwtAuthenticationConverter`.
|
||||||
|
jwtConfigurer.jwtAuthenticationConverter(this.jwtAuthenticationProvider.create());
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.authorizeHttpRequests(request ->
|
||||||
|
request
|
||||||
|
// same as OOTB
|
||||||
|
.requestMatchers(antMatcher(ProtectedPaths.API_URL_PATH + "/enterprise/**"))
|
||||||
|
.access(this.appRequestHeaderService)
|
||||||
|
.requestMatchers(antMatcher(ProtectedPaths.API_URL_PATH + "/**"))
|
||||||
|
.access(this.restAuthorizationService)
|
||||||
|
|
||||||
|
// borrowed from OOTB /app/rest security
|
||||||
|
.requestMatchers(antMatcher(ProtectedPaths.APP_URL_PATH + "/rest/reporting/**"))
|
||||||
|
.hasAuthority(Capabilities.ACCESS_REPORTS)
|
||||||
|
|
||||||
|
.requestMatchers(
|
||||||
|
antMatcher(ProtectedPaths.API_URL_PATH + "/**"),
|
||||||
|
antMatcher(ProtectedPaths.APP_URL_PATH + "/rest/**")
|
||||||
|
)
|
||||||
|
.authenticated()
|
||||||
|
);
|
||||||
|
|
||||||
|
return http.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@@ -0,0 +1,375 @@
|
|||||||
|
/*
|
||||||
|
* This program is free software: you can redistribute it and/or modify it
|
||||||
|
* under the terms of the GNU Lesser General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or (at your
|
||||||
|
* option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful, but WITHOUT
|
||||||
|
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
|
||||||
|
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
|
||||||
|
* more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License along
|
||||||
|
* with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
package com.inteligr8.activiti.auth.service;
|
||||||
|
|
||||||
|
import java.util.Date;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.LinkedList;
|
||||||
|
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.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.data.util.Pair;
|
||||||
|
import org.springframework.security.core.userdetails.UsernameNotFoundException;
|
||||||
|
import org.springframework.security.oauth2.core.ClaimAccessor;
|
||||||
|
import org.springframework.security.oauth2.core.oidc.StandardClaimNames;
|
||||||
|
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
|
||||||
|
import org.springframework.security.oauth2.jwt.Jwt;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import com.activiti.domain.idm.Group;
|
||||||
|
import com.activiti.domain.idm.User;
|
||||||
|
import com.activiti.service.api.GroupService;
|
||||||
|
import com.activiti.service.api.UserService;
|
||||||
|
|
||||||
|
import jakarta.annotation.PostConstruct;
|
||||||
|
import jakarta.persistence.NonUniqueResultException;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
@Lazy
|
||||||
|
public class GroupSyncService {
|
||||||
|
|
||||||
|
private final Logger logger = LoggerFactory.getLogger(this.getClass());
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private UserService userService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private GroupService groupService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private TenantFinderService tenantFinderService;
|
||||||
|
|
||||||
|
@Value("${auth-ext.externalId:oauth}")
|
||||||
|
protected String externalIdmSource;
|
||||||
|
|
||||||
|
@Value("${auth-ext.sync.group.createMissing:true}")
|
||||||
|
protected boolean createMissing;
|
||||||
|
|
||||||
|
@Value("${auth-ext.sync.group.additions:true}")
|
||||||
|
protected boolean syncAdditions;
|
||||||
|
|
||||||
|
@Value("${auth-ext.sync.group.removals:true}")
|
||||||
|
protected boolean syncRemovals;
|
||||||
|
|
||||||
|
@Value("${auth-ext.sync.group.internal:false}")
|
||||||
|
protected boolean syncInternalGroups;
|
||||||
|
|
||||||
|
@Value("${auth-ext.sync.group.internal.externalize:false}")
|
||||||
|
protected boolean externalizeMatchingInternalGroups;
|
||||||
|
|
||||||
|
@Value("${auth-ext.sync.group.tenantize:false}")
|
||||||
|
protected boolean retenantUntenantedGroups;
|
||||||
|
|
||||||
|
@Value("${auth-ext.sync.group.translate.patterns:#{null}}")
|
||||||
|
protected String translatePatterns;
|
||||||
|
|
||||||
|
@Value("${auth-ext.sync.group.translate.replacements:#{null}}")
|
||||||
|
protected String translateReplacements;
|
||||||
|
|
||||||
|
@Value("${auth-ext.sync.group.include.patterns:#{null}}")
|
||||||
|
protected String includePatterns;
|
||||||
|
protected final Set<Pattern> includes = new HashSet<>();
|
||||||
|
|
||||||
|
@Value("${auth-ext.sync.group.exclude.patterns:#{null}}")
|
||||||
|
protected String excludePatterns;
|
||||||
|
protected final Set<Pattern> excludes = new HashSet<>();
|
||||||
|
|
||||||
|
@Value("${auth-ext.sync.group.capability.patterns:Superusers}")
|
||||||
|
protected String capabilityPatterns;
|
||||||
|
protected final Set<Pattern> capabilities = new HashSet<>();
|
||||||
|
|
||||||
|
protected final List<Pair<Pattern, String>> translations = new LinkedList<>();
|
||||||
|
|
||||||
|
@PostConstruct
|
||||||
|
public void init() {
|
||||||
|
if (this.translatePatterns != null) {
|
||||||
|
String[] regexPatternStrs = StringUtils.split(this.translatePatterns, ',');
|
||||||
|
String[] regexReplaceStrs = this.translateReplacements == null ? new String[0] : StringUtils.split(this.translateReplacements, ",");
|
||||||
|
for (int i = 0; i < regexPatternStrs.length; i++) {
|
||||||
|
Pattern regexPattern = Pattern.compile(regexPatternStrs[i]);
|
||||||
|
String regexReplace = (i < regexReplaceStrs.length) ? regexReplaceStrs[i] : "";
|
||||||
|
this.translations.add(Pair.of(regexPattern, regexReplace));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.includePatterns != null) {
|
||||||
|
String[] regexPatternStrs = StringUtils.split(this.includePatterns, ',');
|
||||||
|
for (int i = 0; i < regexPatternStrs.length; i++)
|
||||||
|
this.includes.add(Pattern.compile(regexPatternStrs[i]));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.excludePatterns != null) {
|
||||||
|
String[] regexPatternStrs = StringUtils.split(this.excludePatterns, ',');
|
||||||
|
for (int i = 0; i < regexPatternStrs.length; i++)
|
||||||
|
this.excludes.add(Pattern.compile(regexPatternStrs[i]));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.capabilityPatterns != null) {
|
||||||
|
String[] regexPatternStrs = StringUtils.split(this.capabilityPatterns, ',');
|
||||||
|
for (int i = 0; i < regexPatternStrs.length; i++)
|
||||||
|
this.capabilities.add(Pattern.compile(regexPatternStrs[i]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void sync(OidcUser oidcUser) {
|
||||||
|
this.sync(oidcUser.getEmail(), oidcUser);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void sync(Jwt jwt) {
|
||||||
|
this.sync(jwt.getClaim(StandardClaimNames.EMAIL), jwt);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void sync(String email, ClaimAccessor claims) {
|
||||||
|
if (!claims.hasClaim("groups")) {
|
||||||
|
this.logger.warn("There is no 'groups' claim to synchronize: {}", email);
|
||||||
|
this.logger.debug("The claims available: {}", claims.getClaims().keySet());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Set<String> oidcGroups = new HashSet<>(claims.getClaimAsStringList("groups"));
|
||||||
|
this.logger.trace("Incoming OIDC groups: {}: {}", email, oidcGroups);
|
||||||
|
|
||||||
|
oidcGroups = this.filterGroups(oidcGroups);
|
||||||
|
Set<String> translatedGroups = this.translateGroups(oidcGroups);
|
||||||
|
|
||||||
|
this.logger.debug("Filtered/translated OIDC groups: {}: {}", email, translatedGroups);
|
||||||
|
|
||||||
|
long tenantId = this.tenantFinderService.findTenantId();
|
||||||
|
|
||||||
|
// check Activiti groups
|
||||||
|
User user = this.userService.findUserByEmailAndTenantId(email, tenantId);
|
||||||
|
if (user == null) {
|
||||||
|
user = this.userService.findUserByEmail(email);
|
||||||
|
if (user == null)
|
||||||
|
throw new UsernameNotFoundException("The user could not be found: " + email);
|
||||||
|
}
|
||||||
|
User userWithGroups = this.userService.getUser(user.getId(), true);
|
||||||
|
this.logger.debug("Discovered user belongs to {} APS groups: {}", userWithGroups.getGroups().size(), user.getExternalId());
|
||||||
|
|
||||||
|
// remove any OIDC groups that the user is already considered a member of
|
||||||
|
// this will leave only OIDC groups that need to be added
|
||||||
|
for (Group group : userWithGroups.getGroups()) {
|
||||||
|
if (group.getExternalId() == null && !this.syncInternalGroups) {
|
||||||
|
this.logger.trace("Ignoring internal APS group: {} => {}", group.getId(), group.getName());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.trace("Inspecting APS group: {} => {} ({})", group.getId(), group.getName(), group.getExternalId());
|
||||||
|
|
||||||
|
if (group.getExternalId() != null) {
|
||||||
|
String translatedGroup = this.apsGroupExternalIdToTranslatedOidcGroup(group.getExternalId());
|
||||||
|
|
||||||
|
if (this.retenantUntenantedGroups && group.getTenantId() == null) {
|
||||||
|
this.logger.warn("Moving tenant-less APS group to tenant: {} => {}", group.getName(), tenantId);
|
||||||
|
group.setTenantId(tenantId);
|
||||||
|
group.setLastUpdate(new Date());
|
||||||
|
this.groupService.save(group);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (translatedGroups.remove(translatedGroup)) {
|
||||||
|
this.logger.trace("User already belongs to APS group mapped to by (translated) OIDC group: {}: {} => {}", user.getExternalId(), translatedGroup, group.getName());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
String translatedGroup = this.apsGroupNameToTranslatedOidcGroup(group.getName());
|
||||||
|
|
||||||
|
if (translatedGroups.remove(translatedGroup)) {
|
||||||
|
this.logger.trace("User already belongs to APS group mapped to by (translated) OIDC group: {}: {} => {}", user.getExternalId(), translatedGroup, group.getName());
|
||||||
|
|
||||||
|
if (this.externalizeMatchingInternalGroups) {
|
||||||
|
this.logger.warn("Classifying internal APS group as external: {} => {}", group.getName(), this.externalIdmSource);
|
||||||
|
// register the group as external
|
||||||
|
group.setExternalId(this.translatedOidcGroupToApsGroupExternalId(translatedGroup));
|
||||||
|
group.setLastUpdate(new Date());
|
||||||
|
this.groupService.save(group);
|
||||||
|
// internal role already existed and the user is already a member
|
||||||
|
}
|
||||||
|
|
||||||
|
continue;
|
||||||
|
} else if (!this.syncInternalGroups) {
|
||||||
|
this.logger.trace("Internal APS group membership sync disabled; not considering removal of user from APS group: {} => {}", user.getExternalId(), group.getName());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// at this point, we have a group that the user does not have a corresponding OIDC group for
|
||||||
|
if (this.syncRemovals) {
|
||||||
|
this.logger.trace("Removing user from APS group: {} => {}", user.getExternalId(), group.getName());
|
||||||
|
this.groupService.deleteUserFromGroup(group, userWithGroups);
|
||||||
|
this.logger.info("Removed user from APS group: {} => {}", user.getExternalId(), group.getName());
|
||||||
|
} else {
|
||||||
|
this.logger.debug("User/group removal sync disabled; not removing user from APS group: {} => {}", user.getExternalId(), group.getName());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// the user needs to be added to the remaining authorities
|
||||||
|
for (String translatedGroup : translatedGroups) {
|
||||||
|
this.logger.trace("Inspecting unaccounted for (translated) OIDC group: {}", translatedGroup);
|
||||||
|
|
||||||
|
Group group;
|
||||||
|
try {
|
||||||
|
group = this.groupService.getGroupByExternalIdAndTenantId(this.translatedOidcGroupToApsGroupExternalId(translatedGroup), tenantId);
|
||||||
|
} catch (NonUniqueResultException nure) {
|
||||||
|
this.logger.warn("There are multiple groups matching the (translated) OIDC group for the external system: {} [{}]; skipping consideration of OIDC group", translatedGroup, this.externalIdmSource);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (group == null && this.syncInternalGroups) {
|
||||||
|
List<Group> groups = this.groupService.getGroupByNameAndTenantId(this.translatedOidcGroupToApsGroupName(translatedGroup), tenantId);
|
||||||
|
if (groups.size() > 1) {
|
||||||
|
this.logger.warn("There are multiple APS groups matching the (translated) OIDC group: {} [{}]; skipping consideration of OIDC group", translatedGroup, this.externalIdmSource);
|
||||||
|
continue;
|
||||||
|
} else if (groups.size() == 1) {
|
||||||
|
group = groups.iterator().next();
|
||||||
|
|
||||||
|
if (this.externalizeMatchingInternalGroups) {
|
||||||
|
this.logger.debug("Found an internal APS group; registering as external: {}", group.getName());
|
||||||
|
|
||||||
|
group.setExternalId(this.translatedOidcGroupToApsGroupExternalId(translatedGroup));
|
||||||
|
group.setLastSyncTimeStamp(new Date());
|
||||||
|
group.setLastUpdate(new Date());
|
||||||
|
this.groupService.save(group);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (group == null) {
|
||||||
|
if (!this.createMissing) {
|
||||||
|
this.logger.debug("APS Group does not exist for (translated) OIDC group; APS group creation is disabled; OIDC group will go unrecognized: {}", translatedGroup);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
group = this.createApsGroup(translatedGroup, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.syncAdditions) {
|
||||||
|
this.logger.trace("Adding user to APS group: {} => {}", user.getExternalId(), group.getName());
|
||||||
|
this.groupService.addUserToGroup(group, userWithGroups);
|
||||||
|
this.logger.info("Added user to APS group: {} => {}", user.getExternalId(), group.getName());
|
||||||
|
} else {
|
||||||
|
this.logger.debug("User/group addition sync disabled; not adding user to APS group: {} => {}", user.getExternalId(), group.getName());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected Group createApsGroup(String translatedGroup, long tenantId) {
|
||||||
|
this.logger.debug("APS Group does not exist for (translated) OIDC group; will attempt to create: {}", translatedGroup);
|
||||||
|
String name = this.translatedOidcGroupToApsGroupName(translatedGroup);
|
||||||
|
String externalId = this.translatedOidcGroupToApsGroupExternalId(translatedGroup);
|
||||||
|
|
||||||
|
boolean syncAsOrg = this.isTranslatedOidcGroupToBeOrganization(translatedGroup);
|
||||||
|
this.logger.trace("Creating new APS group as {}: {}", syncAsOrg ? "organization" : "capability", translatedGroup);
|
||||||
|
int type = syncAsOrg ? Group.TYPE_FUNCTIONAL_GROUP : Group.TYPE_SYSTEM_GROUP;
|
||||||
|
|
||||||
|
Group apsGroup = this.groupService.createGroupFromExternalStore(name, tenantId, type, null, externalId, new Date());
|
||||||
|
this.logger.info("Created new APS group: {} => {} [{}]", apsGroup.getId(), apsGroup.getName(), apsGroup.getExternalId());
|
||||||
|
return apsGroup;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Set<String> filterGroups(Set<String> unfilteredGroups) {
|
||||||
|
if (this.includes.isEmpty() && this.excludes.isEmpty())
|
||||||
|
return unfilteredGroups;
|
||||||
|
|
||||||
|
Set<String> filteredGroups = new HashSet<>();
|
||||||
|
|
||||||
|
for (String group : unfilteredGroups) {
|
||||||
|
boolean doInclude = this.includes.isEmpty();
|
||||||
|
for (Pattern regex : this.includes) {
|
||||||
|
Matcher matcher = regex.matcher(group);
|
||||||
|
if (matcher.matches()) {
|
||||||
|
this.logger.trace("OIDC group matched inclusion filter: {}", group);
|
||||||
|
doInclude = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (doInclude) {
|
||||||
|
for (Pattern regex : this.excludes) {
|
||||||
|
Matcher matcher = regex.matcher(group);
|
||||||
|
if (matcher.matches()) {
|
||||||
|
this.logger.trace("OIDC group matched exclusion filter: {}", group);
|
||||||
|
doInclude = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (doInclude)
|
||||||
|
filteredGroups.add(group);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return filteredGroups;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Set<String> translateGroups(Set<String> untranslatedGroups) {
|
||||||
|
Set<String> translatedGroups = new HashSet<>();
|
||||||
|
|
||||||
|
for (String untranslatedGroup : untranslatedGroups) {
|
||||||
|
String translatedGroup = null;
|
||||||
|
|
||||||
|
for (Pair<Pattern, String> regex : this.translations) {
|
||||||
|
Matcher matcher = regex.getFirst().matcher(untranslatedGroup);
|
||||||
|
if (matcher.matches()) {
|
||||||
|
translatedGroup = matcher.replaceFirst(regex.getSecond());
|
||||||
|
this.logger.trace("OIDC group formatted: {} => {}", untranslatedGroup, translatedGroup);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
translatedGroups.add(translatedGroup == null ? untranslatedGroup : translatedGroup);
|
||||||
|
}
|
||||||
|
|
||||||
|
return translatedGroups;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String translatedOidcGroupToApsGroupExternalId(String group) {
|
||||||
|
return this.externalIdmSource + "_" + group;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String apsGroupExternalIdToTranslatedOidcGroup(String externalId) {
|
||||||
|
int underscorePos = externalId.indexOf('_');
|
||||||
|
return underscorePos < 0 ? externalId : externalId.substring(underscorePos + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String translatedOidcGroupToApsGroupName(String group) {
|
||||||
|
return group;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String apsGroupNameToTranslatedOidcGroup(String externalId) {
|
||||||
|
return externalId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isTranslatedOidcGroupToBeOrganization(String translatedGroup) {
|
||||||
|
if (this.capabilities.isEmpty())
|
||||||
|
return true;
|
||||||
|
|
||||||
|
for (Pattern regex : this.capabilities) {
|
||||||
|
Matcher matcher = regex.matcher(translatedGroup);
|
||||||
|
if (matcher.matches())
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@@ -0,0 +1,11 @@
|
|||||||
|
package com.inteligr8.activiti.auth.service;
|
||||||
|
|
||||||
|
import org.springframework.core.convert.converter.Converter;
|
||||||
|
import org.springframework.security.authentication.AbstractAuthenticationToken;
|
||||||
|
import org.springframework.security.oauth2.jwt.Jwt;
|
||||||
|
|
||||||
|
public interface JwtAuthenticationProvider {
|
||||||
|
|
||||||
|
Converter<Jwt, AbstractAuthenticationToken> create();
|
||||||
|
|
||||||
|
}
|
@@ -0,0 +1,61 @@
|
|||||||
|
package com.inteligr8.activiti.auth.service;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.core.convert.converter.Converter;
|
||||||
|
import org.springframework.security.authentication.AbstractAuthenticationToken;
|
||||||
|
import org.springframework.security.core.userdetails.UserDetails;
|
||||||
|
import org.springframework.security.core.userdetails.UserDetailsService;
|
||||||
|
import org.springframework.security.oauth2.core.oidc.StandardClaimNames;
|
||||||
|
import org.springframework.security.oauth2.jwt.Jwt;
|
||||||
|
|
||||||
|
import com.activiti.security.identity.service.config.JwtAuthenticationToken;
|
||||||
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||||
|
|
||||||
|
public class SyncingJwtAuthenticationConverter implements Converter<Jwt, AbstractAuthenticationToken> {
|
||||||
|
|
||||||
|
private final Logger logger = LoggerFactory.getLogger(this.getClass());
|
||||||
|
private final UserDetailsService userDetailsService;
|
||||||
|
private final UserSyncService userSyncService;
|
||||||
|
private final GroupSyncService groupSyncService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private TokenRecaller tokenRecaller;
|
||||||
|
|
||||||
|
public SyncingJwtAuthenticationConverter(UserDetailsService userDetailsService, UserSyncService userSyncService, GroupSyncService groupSyncService) {
|
||||||
|
this.userDetailsService = userDetailsService;
|
||||||
|
this.userSyncService = userSyncService;
|
||||||
|
this.groupSyncService = groupSyncService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public AbstractAuthenticationToken convert(Jwt source) {
|
||||||
|
if (this.logger.isTraceEnabled()) {
|
||||||
|
this.logger.trace("convert({}, {})", source.getId(), source.getClaimAsString(StandardClaimNames.EMAIL));
|
||||||
|
try {
|
||||||
|
this.logger.trace("jwt: {}", new ObjectMapper().registerModule(new JavaTimeModule()).writeValueAsString(source));
|
||||||
|
} catch (JsonProcessingException jpe) {
|
||||||
|
this.logger.error("error", jpe);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.tokenRecaller.recall(source.getTokenValue())) {
|
||||||
|
this.logger.trace("Skipping sync for '{}' as the same token was already recently sync'd", source.getClaimAsString(StandardClaimNames.EMAIL));
|
||||||
|
} else {
|
||||||
|
this.userSyncService.sync(source);
|
||||||
|
this.groupSyncService.sync(source);
|
||||||
|
this.tokenRecaller.add(source.getTokenValue());
|
||||||
|
}
|
||||||
|
|
||||||
|
UserDetails springUser = this.userDetailsService.loadUserByUsername(source.getClaimAsString(StandardClaimNames.EMAIL));
|
||||||
|
return new JwtAuthenticationToken(
|
||||||
|
springUser,
|
||||||
|
new ArrayList<>(springUser.getAuthorities()));
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@@ -0,0 +1,27 @@
|
|||||||
|
package com.inteligr8.activiti.auth.service;
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.core.convert.converter.Converter;
|
||||||
|
import org.springframework.security.authentication.AbstractAuthenticationToken;
|
||||||
|
import org.springframework.security.core.userdetails.UserDetailsService;
|
||||||
|
import org.springframework.security.oauth2.jwt.Jwt;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
public class SyncingJwtAuthenticationProvider implements JwtAuthenticationProvider {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private UserDetailsService userDetailsService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private UserSyncService userSyncService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private GroupSyncService groupSyncService;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Converter<Jwt, AbstractAuthenticationToken> create() {
|
||||||
|
return new SyncingJwtAuthenticationConverter(this.userDetailsService, this.userSyncService, this.groupSyncService);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@@ -0,0 +1,76 @@
|
|||||||
|
package com.inteligr8.activiti.auth.service;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.context.annotation.Primary;
|
||||||
|
import org.springframework.security.core.userdetails.UserDetails;
|
||||||
|
import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserRequest;
|
||||||
|
import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserService;
|
||||||
|
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
|
||||||
|
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import com.activiti.security.CustomOAuth2User;
|
||||||
|
import com.activiti.security.UserDetailsService;
|
||||||
|
import com.activiti.security.identity.service.config.IdentityServiceKeycloakProperties;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This class takes precedence over the service provided by APS when the
|
||||||
|
* Activiti Identity Service configuration is enabled. When it isn't
|
||||||
|
* enabled, it will still serve as the default OIDC user service for
|
||||||
|
* Spring Security.
|
||||||
|
*
|
||||||
|
* This is only executed with non-API authentication and authorization use
|
||||||
|
* cases. API authentication/authorization uses the
|
||||||
|
* `SyncingJwtAuthenitcationConverter`.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
@Primary
|
||||||
|
public class SyncingUserService extends OidcUserService {
|
||||||
|
|
||||||
|
private final Logger logger = LoggerFactory.getLogger(this.getClass());
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private UserDetailsService userDetailsService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private UserSyncService userSyncService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private GroupSyncService groupSyncService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private IdentityServiceKeycloakProperties identityServiceKeycloakProperties;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private TokenRecaller tokenRecaller;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public OidcUser loadUser(OidcUserRequest userRequest) throws OAuth2AuthenticationException {
|
||||||
|
this.logger.trace("loadUser({}, {})", userRequest.getIdToken().getEmail(), userRequest.getAccessToken().getScopes());
|
||||||
|
|
||||||
|
OidcUser oidcUser = super.loadUser(userRequest);
|
||||||
|
this.logger.debug("Loaded OIDC user: {}", oidcUser.getEmail());
|
||||||
|
|
||||||
|
if (this.tokenRecaller.recall(userRequest.getAccessToken().getTokenValue())) {
|
||||||
|
this.logger.trace("Skipping sync for '{}' as the same token was already recently sync'd", userRequest.getIdToken().getEmail());
|
||||||
|
} else {
|
||||||
|
this.userSyncService.sync(oidcUser);
|
||||||
|
this.groupSyncService.sync(oidcUser);
|
||||||
|
this.tokenRecaller.add(userRequest.getAccessToken().getTokenValue());
|
||||||
|
}
|
||||||
|
|
||||||
|
UserDetails springUser = this.userDetailsService.loadUserByUsername(oidcUser.getEmail());
|
||||||
|
|
||||||
|
CustomOAuth2User customOAuth2User = new CustomOAuth2User(
|
||||||
|
springUser.getAuthorities(),
|
||||||
|
oidcUser.getIdToken(),
|
||||||
|
oidcUser.getUserInfo(),
|
||||||
|
this.identityServiceKeycloakProperties.getPrincipalAttribute()
|
||||||
|
);
|
||||||
|
customOAuth2User.setUserDetails(springUser);
|
||||||
|
return customOAuth2User;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@@ -12,7 +12,7 @@
|
|||||||
* You should have received a copy of the GNU General Public License along
|
* You should have received a copy of the GNU General Public License along
|
||||||
* with this program. If not, see <https://www.gnu.org/licenses/>.
|
* with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
package com.inteligr8.activiti;
|
package com.inteligr8.activiti.auth.service;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Collection;
|
import java.util.Collection;
|
||||||
@@ -44,7 +44,7 @@ public class TenantFinderService {
|
|||||||
@Autowired(required = false)
|
@Autowired(required = false)
|
||||||
private TenantService tenantService;
|
private TenantService tenantService;
|
||||||
|
|
||||||
@Value("${keycloak-ext.tenant:#{null}}")
|
@Value("${auth-ext.tenant:#{null}}")
|
||||||
private String tenant;
|
private String tenant;
|
||||||
|
|
||||||
public Long findTenantId() {
|
public Long findTenantId() {
|
59
src/main/java/com/inteligr8/activiti/auth/service/TokenRecaller.java
Executable file
59
src/main/java/com/inteligr8/activiti/auth/service/TokenRecaller.java
Executable file
@@ -0,0 +1,59 @@
|
|||||||
|
package com.inteligr8.activiti.auth.service;
|
||||||
|
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
import org.apache.commons.collections4.map.PassiveExpiringMap;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import jakarta.annotation.PostConstruct;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
public class TokenRecaller {
|
||||||
|
|
||||||
|
private final Logger logger = LoggerFactory.getLogger(this.getClass());
|
||||||
|
|
||||||
|
@Value("${auth-ext.sync.resyncInMillis:300000}")
|
||||||
|
private long resyncTimeInMillis;
|
||||||
|
|
||||||
|
private PassiveExpiringMap<String, Long> tokenCache;
|
||||||
|
|
||||||
|
@PostConstruct
|
||||||
|
private void init() {
|
||||||
|
this.tokenCache = new PassiveExpiringMap<>(this.resyncTimeInMillis, TimeUnit.MILLISECONDS);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Scheduled(timeUnit = TimeUnit.MINUTES, initialDelay = 10L, fixedDelay = 5L)
|
||||||
|
private void reap() {
|
||||||
|
int tokens = this.tokenCache.size();
|
||||||
|
this.logger.trace("Reaping token cache of size: {}", tokens);
|
||||||
|
|
||||||
|
synchronized (this.tokenCache) {
|
||||||
|
// clear expired keys
|
||||||
|
this.tokenCache.entrySet();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.debug("Reaped {} expired tokens from cache", this.tokenCache.size() - tokens);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void add(String token) {
|
||||||
|
synchronized (this.tokenCache) {
|
||||||
|
this.tokenCache.put(token, System.currentTimeMillis() + this.resyncTimeInMillis);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean recall(String token) {
|
||||||
|
Long expirationTimeMillis = this.tokenCache.get(token);
|
||||||
|
if (expirationTimeMillis == null) {
|
||||||
|
return false;
|
||||||
|
} else if (expirationTimeMillis < System.currentTimeMillis()) {
|
||||||
|
return false;
|
||||||
|
} else {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@@ -0,0 +1,128 @@
|
|||||||
|
package com.inteligr8.activiti.auth.service;
|
||||||
|
|
||||||
|
import java.util.Date;
|
||||||
|
|
||||||
|
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.userdetails.UserDetails;
|
||||||
|
import org.springframework.security.core.userdetails.UsernameNotFoundException;
|
||||||
|
import org.springframework.security.oauth2.core.ClaimAccessor;
|
||||||
|
import org.springframework.security.oauth2.core.oidc.StandardClaimNames;
|
||||||
|
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
|
||||||
|
import org.springframework.security.oauth2.jwt.Jwt;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import com.activiti.domain.idm.Group;
|
||||||
|
import com.activiti.domain.idm.User;
|
||||||
|
import com.activiti.security.UserDetailsService;
|
||||||
|
import com.activiti.service.api.GroupService;
|
||||||
|
import com.activiti.service.api.UserService;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
@Lazy
|
||||||
|
public class UserSyncService {
|
||||||
|
|
||||||
|
private final Logger logger = LoggerFactory.getLogger(UserSyncService.class);
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private UserDetailsService userDetailsService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private UserService userService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private GroupService groupService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private TenantFinderService tenantFinderService;
|
||||||
|
|
||||||
|
@Value("${auth-ext.externalId:oauth}")
|
||||||
|
protected String externalIdmSource;
|
||||||
|
|
||||||
|
@Value("${auth-ext.sync.user.createMissing:true}")
|
||||||
|
protected boolean createMissingUser;
|
||||||
|
|
||||||
|
@Value("${auth-ext.sync.user.requireOidcGroup:#{null}}")
|
||||||
|
protected String requiredGroup;
|
||||||
|
|
||||||
|
@Value("${auth-ext.sync.user.clearNewUserGroups:true}")
|
||||||
|
protected boolean clearNewUserGroups;
|
||||||
|
|
||||||
|
public void sync(OidcUser oidcUser) {
|
||||||
|
UserDetails springUser = this.loadSpringUser(oidcUser.getEmail(), oidcUser.getGivenName(), oidcUser.getFamilyName(), oidcUser);
|
||||||
|
if (this.logger.isTraceEnabled()) {
|
||||||
|
this.logger.trace("Loaded Spring Security user: {}: {}", springUser.getUsername(), springUser.getAuthorities());
|
||||||
|
} else {
|
||||||
|
this.logger.debug("Loaded Spring Security user: {}", springUser.getUsername());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void sync(Jwt jwt) {
|
||||||
|
String email = jwt.getClaim(StandardClaimNames.EMAIL);
|
||||||
|
if (email == null)
|
||||||
|
throw new IllegalArgumentException("An '" + StandardClaimNames.EMAIL + "' claim is required");
|
||||||
|
|
||||||
|
String givenName = jwt.getClaim(StandardClaimNames.GIVEN_NAME);
|
||||||
|
String familyName = jwt.getClaim(StandardClaimNames.FAMILY_NAME);
|
||||||
|
|
||||||
|
UserDetails springUser = this.loadSpringUser(email, givenName, familyName, jwt);
|
||||||
|
if (this.logger.isTraceEnabled()) {
|
||||||
|
this.logger.trace("Loaded Spring Security user: {}: {}", springUser.getUsername(), springUser.getAuthorities());
|
||||||
|
} else {
|
||||||
|
this.logger.debug("Loaded Spring Security user: {}", springUser.getUsername());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private UserDetails loadSpringUser(String email, String givenName, String familyName, ClaimAccessor claims) throws UsernameNotFoundException {
|
||||||
|
try {
|
||||||
|
UserDetails springUser = this.userDetailsService.loadUserByUsername(email);
|
||||||
|
this.logger.debug("Loaded APS user: {} => {}", email, springUser.getUsername());
|
||||||
|
return springUser;
|
||||||
|
} catch (UsernameNotFoundException unfe) {
|
||||||
|
this.logger.debug("User does not exist: {}", unfe.getMessage());
|
||||||
|
if (!this.createMissingUser)
|
||||||
|
throw unfe;
|
||||||
|
|
||||||
|
if (this.requiredGroup != null && (!claims.hasClaim("groups") || !claims.getClaimAsStringList("groups").contains(this.requiredGroup))) {
|
||||||
|
this.logger.info("User does not exist and does not have the required OIDC group to be created: {} ", email, this.requiredGroup);
|
||||||
|
throw unfe;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.debug("User does not exist; will attempt to create: {}", email);
|
||||||
|
User apsUser = this.createApsUser(email, givenName, familyName);
|
||||||
|
if (this.clearNewUserGroups) {
|
||||||
|
apsUser = this.userService.getUser(apsUser.getId(), true);
|
||||||
|
if (this.logger.isDebugEnabled())
|
||||||
|
this.logger.debug("User is new; clearing default groups: {}: {}", email, apsUser.getGroups().stream().map(group -> group.getName()).toList());
|
||||||
|
this.deleteApsUserGroups(apsUser);
|
||||||
|
}
|
||||||
|
return this.userDetailsService.loadByUserId(apsUser.getId());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private User createApsUser(String email, String givenName, String familyName) {
|
||||||
|
long tenantId = this.tenantFinderService.findTenantId();
|
||||||
|
|
||||||
|
User user = this.userService.createNewUserFromExternalStore(
|
||||||
|
email,
|
||||||
|
givenName,
|
||||||
|
familyName,
|
||||||
|
tenantId,
|
||||||
|
email,
|
||||||
|
this.externalIdmSource,
|
||||||
|
new Date());
|
||||||
|
this.logger.info("Created user: {} => {}", user.getId(), user.getEmail());
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void deleteApsUserGroups(User user) {
|
||||||
|
for (Group group : user.getGroups()) {
|
||||||
|
this.groupService.deleteUserFromGroup(group, user);
|
||||||
|
this.logger.trace("Removed user from group: {} => {}", user.getEmail(), group.getName());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@@ -1,286 +0,0 @@
|
|||||||
/*
|
|
||||||
* This program is free software: you can redistribute it and/or modify it
|
|
||||||
* under the terms of the GNU Lesser General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or (at your
|
|
||||||
* option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful, but WITHOUT
|
|
||||||
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
|
|
||||||
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
|
|
||||||
* more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU General Public License along
|
|
||||||
* with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
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 javax.annotation.OverridingMethodsMustInvokeSuper;
|
|
||||||
|
|
||||||
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
|
|
||||||
@OverridingMethodsMustInvokeSuper
|
|
||||||
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,303 +0,0 @@
|
|||||||
/*
|
|
||||||
* This program is free software: you can redistribute it and/or modify it
|
|
||||||
* under the terms of the GNU Lesser General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or (at your
|
|
||||||
* option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful, but WITHOUT
|
|
||||||
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
|
|
||||||
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
|
|
||||||
* more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU General Public License along
|
|
||||||
* with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
package com.inteligr8.activiti.keycloak;
|
|
||||||
|
|
||||||
import java.util.Date;
|
|
||||||
import java.util.HashSet;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.Set;
|
|
||||||
import java.util.Map.Entry;
|
|
||||||
import java.util.regex.Matcher;
|
|
||||||
import java.util.regex.Pattern;
|
|
||||||
|
|
||||||
import javax.annotation.OverridingMethodsMustInvokeSuper;
|
|
||||||
import javax.persistence.NonUniqueResultException;
|
|
||||||
|
|
||||||
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.User;
|
|
||||||
import com.activiti.service.api.GroupService;
|
|
||||||
import com.activiti.service.api.UserService;
|
|
||||||
import com.inteligr8.activiti.TenantFinderService;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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 implementation is not good for multi-tenancy.
|
|
||||||
*
|
|
||||||
* @author brian.long@yudrio.com
|
|
||||||
*/
|
|
||||||
@Component("keycloak-ext.activiti-app.authenticator")
|
|
||||||
@Lazy
|
|
||||||
public class KeycloakActivitiAppAuthenticator extends AbstractKeycloakActivitiAuthenticator {
|
|
||||||
|
|
||||||
private final Logger logger = LoggerFactory.getLogger(this.getClass());
|
|
||||||
|
|
||||||
private final Pattern emailNamesPattern = Pattern.compile("([A-Za-z]+)[A-Za-z0-9]*\\.([A-Za-z]+)[A-Za-z0-9]*@.*");
|
|
||||||
|
|
||||||
@Autowired
|
|
||||||
private UserService userService;
|
|
||||||
|
|
||||||
@Autowired
|
|
||||||
private GroupService groupService;
|
|
||||||
|
|
||||||
@Autowired
|
|
||||||
private TenantFinderService tenantFinderService;
|
|
||||||
|
|
||||||
@Value("${keycloak-ext.external.id:ais}")
|
|
||||||
protected String externalIdmSource;
|
|
||||||
|
|
||||||
@Value("${keycloak-ext.group.capability.regex.patterns:#{null}}")
|
|
||||||
protected String regexCapIncludes;
|
|
||||||
|
|
||||||
protected final Set<Pattern> capIncludes = new HashSet<>();
|
|
||||||
|
|
||||||
@Override
|
|
||||||
@OverridingMethodsMustInvokeSuper
|
|
||||||
public void afterPropertiesSet() {
|
|
||||||
super.afterPropertiesSet();
|
|
||||||
|
|
||||||
if (this.regexCapIncludes != null) {
|
|
||||||
String[] regexPatternStrs = StringUtils.split(this.regexCapIncludes, ',');
|
|
||||||
for (int i = 0; i < regexPatternStrs.length; i++)
|
|
||||||
this.capIncludes.add(Pattern.compile(regexPatternStrs[i]));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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.tenantFinderService.findTenantId();
|
|
||||||
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.clearNewUserDefaultGroups) {
|
|
||||||
this.logger.debug("Clearing groups: {}", user.getId());
|
|
||||||
// fetch and remove default groups
|
|
||||||
user = this.userService.getUser(user.getId(), true);
|
|
||||||
for (Group group : user.getGroups())
|
|
||||||
this.groupService.deleteUserFromGroup(group, user);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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.tenantFinderService.findTenantId();
|
|
||||||
User user = this.findUser(auth, tenantId);
|
|
||||||
this.logger.debug("Inspecting user: {} => {}", user.getId(), user.getExternalId());
|
|
||||||
|
|
||||||
this.syncUserRoles(user, auth, tenantId);
|
|
||||||
}
|
|
||||||
|
|
||||||
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.getKeycloakAccessToken(auth);
|
|
||||||
if (atoken == null) {
|
|
||||||
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());
|
|
||||||
if (!emailNamesMatcher.matches()) {
|
|
||||||
this.logger.warn("The email address could not be parsed for names: {}", auth.getName());
|
|
||||||
return this.userService.createNewUserFromExternalStore(auth.getName(), "Unknown", "Person", 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 syncUserRoles(User user, Authentication auth, Long tenantId) {
|
|
||||||
Map<String, String> roles = this.getKeycloakRoles(auth);
|
|
||||||
if (roles == null) {
|
|
||||||
this.logger.debug("The user roles could not be determined; skipping sync: {}", user.getEmail());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// check Activiti groups
|
|
||||||
User userWithGroups = this.userService.getUser(user.getId(), true);
|
|
||||||
for (Group group : userWithGroups.getGroups()) {
|
|
||||||
if (group.getExternalId() == null && !this.syncInternalGroups)
|
|
||||||
continue;
|
|
||||||
|
|
||||||
this.logger.trace("Inspecting group: {} => {} ({})", group.getId(), group.getName(), group.getExternalId());
|
|
||||||
|
|
||||||
if (group.getExternalId() != null && this.removeMapEntriesByValue(roles, this.apsGroupExternalIdToKeycloakRole(group.getExternalId()))) {
|
|
||||||
if (group.getTenantId() == null) {
|
|
||||||
// fix stray groups
|
|
||||||
group.setTenantId(tenantId);
|
|
||||||
group.setLastUpdate(new Date());
|
|
||||||
this.groupService.save(group);
|
|
||||||
}
|
|
||||||
// role already existed and the user is already a member
|
|
||||||
} else if (group.getExternalId() == null && roles.remove(this.apsGroupNameToKeycloakRole(group.getName())) != null) {
|
|
||||||
// register the group as external
|
|
||||||
group.setExternalId(this.keycloakRoleToApsGroupExternalId(this.apsGroupNameToKeycloakRole(group.getName())));
|
|
||||||
group.setLastUpdate(new Date());
|
|
||||||
this.groupService.save(group);
|
|
||||||
// internal role already existed and the user is already a member
|
|
||||||
} else {
|
|
||||||
// at this point, we have a group that the user does not have a corresponding role for
|
|
||||||
if (this.syncGroupRemove) {
|
|
||||||
this.logger.trace("Removing user '{}' from group '{}'", user.getExternalId(), group.getName());
|
|
||||||
this.groupService.deleteUserFromGroup(group, userWithGroups);
|
|
||||||
} else {
|
|
||||||
this.logger.debug("User/group membership sync disabled; not removing user from group: {} => {}", user.getExternalId(), group.getName());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// add remaining authorities into Activiti
|
|
||||||
for (Entry<String, String> role : roles.entrySet()) {
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (group == null) {
|
|
||||||
if (this.createMissingGroup) {
|
|
||||||
this.logger.trace("Creating new group for role: {}", role);
|
|
||||||
boolean syncAsOrg = this.isRoleToBeOrganization(role.getKey());
|
|
||||||
this.logger.trace("Creating new group as {}: {}", syncAsOrg ? "organization" : "capability", role);
|
|
||||||
String name = this.keycloakRoleToApsGroupName(role.getValue());
|
|
||||||
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 {
|
|
||||||
this.logger.debug("Group does not exist; group creation is disabled: {}", role);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (group != null && this.syncGroupAdd) {
|
|
||||||
this.logger.trace("Adding user '{}' to group '{}'", user.getExternalId(), group.getName());
|
|
||||||
this.groupService.addUserToGroup(group, userWithGroups);
|
|
||||||
} else {
|
|
||||||
this.logger.debug("User/group membership sync disabled; not adding user to group: {} => {}", user.getExternalId(), group.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;
|
|
||||||
}
|
|
||||||
|
|
||||||
private boolean isRoleToBeOrganization(String role) {
|
|
||||||
if (this.capIncludes.isEmpty())
|
|
||||||
return true;
|
|
||||||
|
|
||||||
for (Pattern regex : this.capIncludes) {
|
|
||||||
Matcher matcher = regex.matcher(role);
|
|
||||||
if (matcher.matches())
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
@@ -1,81 +0,0 @@
|
|||||||
/*
|
|
||||||
* This program is free software: you can redistribute it and/or modify it
|
|
||||||
* under the terms of the GNU Lesser General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or (at your
|
|
||||||
* option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful, but WITHOUT
|
|
||||||
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
|
|
||||||
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
|
|
||||||
* more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU General Public License along
|
|
||||||
* with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
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()));
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
302
src/test/resources/keycloak-import/realm-my-app.json
Normal file
302
src/test/resources/keycloak-import/realm-my-app.json
Normal file
@@ -0,0 +1,302 @@
|
|||||||
|
{
|
||||||
|
"realm": "my-app",
|
||||||
|
"displayName": "My Application",
|
||||||
|
"enabled": true,
|
||||||
|
"sslRequired": "none",
|
||||||
|
"scopeMappings": [
|
||||||
|
{
|
||||||
|
"clientScope": "offline_access",
|
||||||
|
"roles": [
|
||||||
|
"offline_access"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"clientScopeMappings": {
|
||||||
|
"account": [
|
||||||
|
{
|
||||||
|
"client": "account-console",
|
||||||
|
"roles": [
|
||||||
|
"manage-account",
|
||||||
|
"view-groups"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"roles": {
|
||||||
|
"realm": [
|
||||||
|
{
|
||||||
|
"name": "aps-admin",
|
||||||
|
"description": "APS Administrator",
|
||||||
|
"composite": false,
|
||||||
|
"clientRole": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "aps-modeler",
|
||||||
|
"description": "APS Modeler",
|
||||||
|
"composite": false,
|
||||||
|
"clientRole": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "aps-publisher",
|
||||||
|
"description": "APS Publisher",
|
||||||
|
"composite": false,
|
||||||
|
"clientRole": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "biz-employee",
|
||||||
|
"description": "Business Reviewer",
|
||||||
|
"composite": false,
|
||||||
|
"clientRole": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "biz-manager",
|
||||||
|
"description": "Business Reviewer",
|
||||||
|
"composite": false,
|
||||||
|
"clientRole": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"clients": [
|
||||||
|
{
|
||||||
|
"clientId": "aps-app-public",
|
||||||
|
"name": "APS App",
|
||||||
|
"description": "Alfresco Process Services Activiti App",
|
||||||
|
"rootUrl": "http://localhost:8080/activiti-app",
|
||||||
|
"adminUrl": "http://localhost:8080/activiti-app",
|
||||||
|
"baseUrl": "",
|
||||||
|
"surrogateAuthRequired": false,
|
||||||
|
"enabled": true,
|
||||||
|
"alwaysDisplayInConsole": false,
|
||||||
|
"clientAuthenticatorType": "client-secret",
|
||||||
|
"redirectUris": [
|
||||||
|
"*"
|
||||||
|
],
|
||||||
|
"webOrigins": [
|
||||||
|
"*"
|
||||||
|
],
|
||||||
|
"notBefore": 0,
|
||||||
|
"bearerOnly": false,
|
||||||
|
"consentRequired": false,
|
||||||
|
"standardFlowEnabled": true,
|
||||||
|
"implicitFlowEnabled": false,
|
||||||
|
"directAccessGrantsEnabled": true,
|
||||||
|
"serviceAccountsEnabled": false,
|
||||||
|
"publicClient": true,
|
||||||
|
"frontchannelLogout": true,
|
||||||
|
"protocol": "openid-connect",
|
||||||
|
"attributes": {
|
||||||
|
"realm_client": "false",
|
||||||
|
"oidc.ciba.grant.enabled": "false",
|
||||||
|
"backchannel.logout.session.required": "true",
|
||||||
|
"standard.token.exchange.enabled": "false",
|
||||||
|
"oauth2.device.authorization.grant.enabled": "false",
|
||||||
|
"backchannel.logout.revoke.offline.tokens": "false"
|
||||||
|
},
|
||||||
|
"authenticationFlowBindingOverrides": {},
|
||||||
|
"fullScopeAllowed": true,
|
||||||
|
"nodeReRegistrationTimeout": -1,
|
||||||
|
"defaultClientScopes": [
|
||||||
|
"web-origins",
|
||||||
|
"acr",
|
||||||
|
"profile",
|
||||||
|
"roles",
|
||||||
|
"basic",
|
||||||
|
"email",
|
||||||
|
"microprofile-jwt"
|
||||||
|
],
|
||||||
|
"optionalClientScopes": [
|
||||||
|
"address",
|
||||||
|
"phone",
|
||||||
|
"organization",
|
||||||
|
"offline_access"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"clientId": "aps-app-confidential",
|
||||||
|
"name": "APS App",
|
||||||
|
"description": "Alfresco Process Services Activiti App",
|
||||||
|
"rootUrl": "http://localhost:8080/activiti-app",
|
||||||
|
"adminUrl": "http://localhost:8080/activiti-app",
|
||||||
|
"baseUrl": "",
|
||||||
|
"surrogateAuthRequired": false,
|
||||||
|
"enabled": true,
|
||||||
|
"alwaysDisplayInConsole": false,
|
||||||
|
"clientAuthenticatorType": "client-secret",
|
||||||
|
"secret": "a-secret",
|
||||||
|
"redirectUris": [
|
||||||
|
"*"
|
||||||
|
],
|
||||||
|
"webOrigins": [
|
||||||
|
"*"
|
||||||
|
],
|
||||||
|
"notBefore": 0,
|
||||||
|
"bearerOnly": false,
|
||||||
|
"consentRequired": false,
|
||||||
|
"standardFlowEnabled": true,
|
||||||
|
"implicitFlowEnabled": false,
|
||||||
|
"directAccessGrantsEnabled": true,
|
||||||
|
"serviceAccountsEnabled": false,
|
||||||
|
"publicClient": false,
|
||||||
|
"frontchannelLogout": true,
|
||||||
|
"protocol": "openid-connect",
|
||||||
|
"attributes": {
|
||||||
|
"realm_client": "false",
|
||||||
|
"oidc.ciba.grant.enabled": "false",
|
||||||
|
"backchannel.logout.session.required": "true",
|
||||||
|
"standard.token.exchange.enabled": "false",
|
||||||
|
"oauth2.device.authorization.grant.enabled": "false",
|
||||||
|
"backchannel.logout.revoke.offline.tokens": "false"
|
||||||
|
},
|
||||||
|
"authenticationFlowBindingOverrides": {},
|
||||||
|
"fullScopeAllowed": true,
|
||||||
|
"nodeReRegistrationTimeout": -1,
|
||||||
|
"defaultClientScopes": [
|
||||||
|
"web-origins",
|
||||||
|
"acr",
|
||||||
|
"profile",
|
||||||
|
"roles",
|
||||||
|
"basic",
|
||||||
|
"email",
|
||||||
|
"microprofile-jwt"
|
||||||
|
],
|
||||||
|
"optionalClientScopes": [
|
||||||
|
"address",
|
||||||
|
"phone",
|
||||||
|
"organization",
|
||||||
|
"offline_access"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"clientId": "cli",
|
||||||
|
"name": "Command Line Tools",
|
||||||
|
"description": "",
|
||||||
|
"rootUrl": "",
|
||||||
|
"adminUrl": "",
|
||||||
|
"baseUrl": "",
|
||||||
|
"surrogateAuthRequired": false,
|
||||||
|
"enabled": true,
|
||||||
|
"alwaysDisplayInConsole": false,
|
||||||
|
"clientAuthenticatorType": "client-secret",
|
||||||
|
"secret": "eJa5W7bv4ohFbr7QRtaCk0eccRFoYM5x",
|
||||||
|
"redirectUris": [
|
||||||
|
"/*"
|
||||||
|
],
|
||||||
|
"webOrigins": [
|
||||||
|
"/*"
|
||||||
|
],
|
||||||
|
"notBefore": 0,
|
||||||
|
"bearerOnly": false,
|
||||||
|
"consentRequired": false,
|
||||||
|
"standardFlowEnabled": false,
|
||||||
|
"implicitFlowEnabled": false,
|
||||||
|
"directAccessGrantsEnabled": false,
|
||||||
|
"serviceAccountsEnabled": true,
|
||||||
|
"publicClient": false,
|
||||||
|
"frontchannelLogout": true,
|
||||||
|
"protocol": "openid-connect",
|
||||||
|
"attributes": {
|
||||||
|
"realm_client": "false",
|
||||||
|
"oidc.ciba.grant.enabled": "false",
|
||||||
|
"client.secret.creation.time": "1747506410",
|
||||||
|
"backchannel.logout.session.required": "true",
|
||||||
|
"standard.token.exchange.enabled": "true",
|
||||||
|
"oauth2.device.authorization.grant.enabled": "false",
|
||||||
|
"backchannel.logout.revoke.offline.tokens": "false"
|
||||||
|
},
|
||||||
|
"authenticationFlowBindingOverrides": {},
|
||||||
|
"fullScopeAllowed": true,
|
||||||
|
"nodeReRegistrationTimeout": -1,
|
||||||
|
"defaultClientScopes": [
|
||||||
|
"web-origins",
|
||||||
|
"acr",
|
||||||
|
"profile",
|
||||||
|
"roles",
|
||||||
|
"basic",
|
||||||
|
"email",
|
||||||
|
"microprofile-jwt"
|
||||||
|
],
|
||||||
|
"optionalClientScopes": [
|
||||||
|
"address",
|
||||||
|
"phone",
|
||||||
|
"organization",
|
||||||
|
"offline_access"
|
||||||
|
],
|
||||||
|
"access": {
|
||||||
|
"view": true,
|
||||||
|
"configure": true,
|
||||||
|
"manage": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"users": [
|
||||||
|
{
|
||||||
|
"username": "test",
|
||||||
|
"enabled": true,
|
||||||
|
"firstName": "Test",
|
||||||
|
"lastName": "User",
|
||||||
|
"email": "test@tester.com",
|
||||||
|
"credentials": [
|
||||||
|
{
|
||||||
|
"type": "password",
|
||||||
|
"temporary": false,
|
||||||
|
"value": "test"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"username": "test.admin",
|
||||||
|
"enabled": true,
|
||||||
|
"firstName": "Test",
|
||||||
|
"lastName": "Administrator",
|
||||||
|
"email": "test.admin@tester.com",
|
||||||
|
"credentials": [
|
||||||
|
{
|
||||||
|
"type": "password",
|
||||||
|
"temporary": false,
|
||||||
|
"value": "test"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"realmRoles": [
|
||||||
|
"aps-admin"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"username": "test.modeler",
|
||||||
|
"enabled": true,
|
||||||
|
"firstName": "Test",
|
||||||
|
"lastName": "Modeler",
|
||||||
|
"email": "test.modeler@tester.com",
|
||||||
|
"credentials": [
|
||||||
|
{
|
||||||
|
"type": "password",
|
||||||
|
"temporary": false,
|
||||||
|
"value": "test"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"realmRoles": [
|
||||||
|
"aps-modeler",
|
||||||
|
"aps-publisher"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"username": "test.manager",
|
||||||
|
"enabled": true,
|
||||||
|
"firstName": "Test",
|
||||||
|
"lastName": "Manager",
|
||||||
|
"email": "test.manager@tester.com",
|
||||||
|
"credentials": [
|
||||||
|
{
|
||||||
|
"type": "password",
|
||||||
|
"temporary": false,
|
||||||
|
"value": "test"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"realmRoles": [
|
||||||
|
"biz-manager"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"attributes": {
|
||||||
|
"frontendUrl": "http://host.docker.internal:8081"
|
||||||
|
}
|
||||||
|
}
|
21
src/test/resources/log4j2-test.properties
Normal file
21
src/test/resources/log4j2-test.properties
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
rootLogger.level=warn
|
||||||
|
rootLogger.appenderRef.stdout.ref=ConsoleAppender
|
||||||
|
|
||||||
|
###### Console appender definition #######
|
||||||
|
# Direct log messages to stdout
|
||||||
|
appender.console.type=Console
|
||||||
|
appender.console.name=ConsoleAppender
|
||||||
|
appender.console.layout.type=PatternLayout
|
||||||
|
appender.console.layout.pattern=%d{hh:mm:ss,SSS} [%t] %-5p %c %X - %replace{%m}{[\r\n]+}{}%n
|
||||||
|
|
||||||
|
logger.aspose-license.name=com.activiti.conf.TransformationConfiguration
|
||||||
|
logger.aspose-license.level=off
|
||||||
|
|
||||||
|
logger.aps-security.name=com.activiti.security
|
||||||
|
logger.aps-security.level=debug
|
||||||
|
|
||||||
|
logger.spring-security.name=org.springframework.security
|
||||||
|
logger.spring-security.level=debug
|
||||||
|
|
||||||
|
logger.auth-ext.name=com.inteligr8.activiti.auth
|
||||||
|
logger.auth-ext.level=trace
|
8
src/test/resources/realm-master.json
Normal file
8
src/test/resources/realm-master.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"realm": "master",
|
||||||
|
"enabled": true,
|
||||||
|
"sslRequired": "required",
|
||||||
|
"attributes": {
|
||||||
|
"frontendUrl": "http://host.docker.internal:8081"
|
||||||
|
}
|
||||||
|
}
|
35
src/test/vscode/simple.http
Normal file
35
src/test/vscode/simple.http
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
@keycloakRealm = my-app
|
||||||
|
@keycloakBaseUrl = http://localhost:8081
|
||||||
|
@oauthUrl = {{keycloakBaseUrl}}/realms/{{keycloakRealm}}
|
||||||
|
@keycloakTokenUrl = {{oauthUrl}}/protocol/openid-connect/token
|
||||||
|
@oauthClientId = cli
|
||||||
|
@oauthClientSecret = eJa5W7bv4ohFbr7QRtaCk0eccRFoYM5x
|
||||||
|
@apsBaseUrl = http://localhost:8080/activiti-app
|
||||||
|
|
||||||
|
### Token
|
||||||
|
# @name token
|
||||||
|
curl -LX POST {{keycloakTokenUrl}} \
|
||||||
|
-H 'Content-type: application/x-www-form-urlencoded' \
|
||||||
|
-d "grant_type=client_credentials" \
|
||||||
|
-d "client_id={{oauthClientId}}" \
|
||||||
|
-d "client_secret={{oauthClientSecret}}"
|
||||||
|
|
||||||
|
@accessToken = {{token.response.body.access_token}}
|
||||||
|
@auth = Bearer {{accessToken}}
|
||||||
|
|
||||||
|
### APS Version
|
||||||
|
# @name version
|
||||||
|
GET {{apsBaseUrl}}/api/enterprise/app-version
|
||||||
|
Authorization: Bearer {{accessToken}}
|
||||||
|
|
||||||
|
### APS Tenants
|
||||||
|
# @name tenants
|
||||||
|
GET {{apsBaseUrl}}/api/enterprise/admin/tenants
|
||||||
|
Authorization: Bearer {{accessToken}}
|
||||||
|
|
||||||
|
@tenantId = {{tenants.response.body.0.id}}
|
||||||
|
|
||||||
|
### APS Templates
|
||||||
|
# @name templates
|
||||||
|
GET {{apsBaseUrl}}/app/rest/document-templates?tenantId={{tenantId}}&start=0&size=10&sort=sort_by_name_asc
|
||||||
|
Authorization: Bearer {{accessToken}}
|
Reference in New Issue
Block a user