35 Commits

Author SHA1 Message Date
9c67dad207 v2.0.2 pom 2025-05-05 18:19:10 -04:00
55619d6b4a Merge branch 'develop' into stable 2025-05-05 18:18:53 -04:00
0b032f1f7f Merge branch 'develop' into stable 2025-05-05 11:18:56 -04:00
3d3a7433c5 Merge branch 'develop' into stable 2025-05-04 20:16:17 -04:00
9c7641b858 v1.4.3 pom 2025-04-19 18:19:07 -04:00
0e34f589c3 Merge branch 'develop' into stable 2025-04-19 18:18:49 -04:00
a73543d2a6 v1.4.1 pom 2025-02-12 14:35:41 -05:00
cd472b9269 Merge branch 'develop' into stable 2025-02-12 14:34:10 -05:00
bf848b009c v1.4.0 pom 2024-10-15 15:56:54 -04:00
52b86c0de4 Merge branch 'develop' into stable 2024-10-15 15:56:38 -04:00
8bc0a7e520 Merge branch 'develop' into stable 2024-06-21 13:17:16 -04:00
0601b2b2b2 Merge branch 'develop' into stable 2024-06-21 13:11:04 -04:00
93af3639cc Merge branch 'develop' into stable 2022-07-28 15:25:32 -04:00
0d402f6014 Merge branch 'develop' into stable 2022-07-01 12:15:48 -04:00
e7b6bd644e Merge branch 'develop' into stable 2022-07-01 12:14:32 -04:00
343e1b65b9 added password resetter 2022-01-24 15:29:17 -05:00
14487b62eb v1.2.1 pom 2021-08-31 19:55:19 -04:00
e87a6b68a7 Merge branch 'develop' into stable 2021-08-31 19:54:53 -04:00
5ecb627dbf Merge branch 'develop' into stable 2021-08-27 00:23:17 -04:00
ea487fee31 v1.1.4 pom 2021-08-25 15:54:37 -04:00
9f9ededab2 Merge branch 'develop' into stable 2021-08-25 15:53:50 -04:00
f76105b979 Merge branch 'develop' into stable 2021-08-24 21:22:46 -04:00
a3cb17e402 v1.1.3 pom 2021-08-24 21:15:19 -04:00
c6d0977b2f Merge branch 'develop' into stable 2021-08-24 21:13:44 -04:00
2405a8a313 v1.1.2 pom 2021-08-24 10:00:03 -04:00
173bfed44f Merge branch 'develop' into stable 2021-08-19 18:54:55 -04:00
dc5a7dad39 Merge branch 'develop' into stable 2021-08-19 17:50:01 -04:00
10ed99b0a2 v1.1.1 pom 2021-08-19 17:38:10 -04:00
4e4a6aca8d Merge branch 'develop' into stable 2021-08-19 17:24:24 -04:00
44d0bf533d Merge branch 'develop' into stable 2021-08-18 23:31:20 -04:00
807294881b v1.0.1 pom 2021-08-11 09:17:20 -04:00
a42c754a09 Merge branch 'develop' into stable 2021-08-11 09:08:26 -04:00
8b05c51ef6 Merge branch 'develop' into stable 2021-07-30 15:42:30 -04:00
8bc03e0ea9 Merge branch 'develop' into stable 2021-07-30 15:40:28 -04:00
d32e3c7051 v1.0.0 pom 2021-07-30 15:38:00 -04:00
13 changed files with 280 additions and 411 deletions

View File

@@ -1,6 +1,6 @@
# Auth Extension for APS (Activiti App)
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.
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.
APS delivers SSO capability and that is about it. It has a few shortcomings:
@@ -9,8 +9,6 @@ APS delivers SSO capability and that is about it. It has a few shortcomings:
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
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:
@@ -37,11 +35,11 @@ This extension requires the [`multiext-activiti-app-ext`](https://git.inteligr8.
## Support Matrix
| Activiti App Extension | Activiti App |
| --------------------------------------- | --------------- |
| `keycloak-activiti-app-ext` v1.0 - v1.2 | v1.11.x |
| `keycloak-activiti-app-ext` v1.3 - v1.4 | v1.11.x - v2.x |
| `auth-activiti-app-ext` v2.0+ | v24.x+ |
| Auth Activiti App Extension | Activiti App |
| --------------------------- | --------------- |
| v1.0 - v1.2 | v1.11.x |
| v1.3 | v1.11.x - v2.x |
| v2.0+ | v24.x+ |
## Configuration
@@ -49,13 +47,6 @@ The library is highly configurable. You configure it with properties specified
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`.
The following properties are used across the functionalities of this extension.
| 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. |
### OAuth Authentication/Authorization
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.
@@ -68,48 +59,48 @@ The following properties were added to increase the configurability of the built
The following properties provide the core functionality of this extension. That is role synchronization.
| Property | Default | Description |
| --------------------------------------------- | ------------ | ----------- |
| `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. |
| 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 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.user.createMissing` | `true` | If the user is authenticated, the user may be created in APS. |
| `auth-ext.sync.user.requireGroup` | | 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.translate.patterns` | | A comma delimited set of regular expression patterns for the translation (reformatting) of authorities. |
| `auth-ext.sync.group.translate.replacements` | | A comma delimited set of regular expression replacement strings for the translation (reformatting) of authorities. |
| `auth-ext.sync.group.include.patterns` | | A comma delimited set of regular expression patterns on what authorities to include. This is processed before `translate` processing. A blank value includes everything. If anything is specified, then only matches could possibly be included; but could still be excluded explicitly. |
| `auth-ext.sync.group.exclude.patterns` | | A comma delimited set of regular expression patterns on what authorities to exclude. This is processed before `translate` processing. A blank value excludes nothing. If anything is specified and `include` is empty, then only matches will be excluded. If both are specified, `exclude` overrules `include` matches. |
| `auth-ext.sync.group.capabilities.patterns` | `Superusers` | A comma delimited set of regular expression patterns on what authorities to associate with APS Capabilities instead of APS Organizations (default). |
### 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` | |
| Property | Default | Description |
| ----------------------------------------- | ------------------------ | ----------- |
| `auth-ext.reset.admin.username` | `admin@app.activiti.com` |
| `auth-ext.reset.admin.password` | | If set, the user's password will be set to this value on startup; otherwise this fixer is skipped. |
#### 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. |
| Property | Default | Description |
| ----------------------------------------- | ------------------------ | ----------- |
| `auth-ext.default.admins.users` | | A comma delimited list of user emails; fixer is skipped if empty. |
| `auth-ext.group.admins.name` | `Superusers` | The APS Group (Capability or Organization) to add the specified users to. |
| `auth-ext.group.admins.externalId` | | If specified, this APS Group will be considered before the specified `name` field. |
#### 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. |
| `auth-ext.group.admins.validate` | `false` | If `true`, the specified APS Group will be granted all capabilities. |
| `auth-ext.group.admins.name` | `Superusers` | The APS Group (Capability or Organization) to add the specified users to. |
| `auth-ext.group.admins.externalId` | | If specified, this APS Group will be considered before the specified `name` field. |

178
dependency-reduced-pom.xml Normal file
View 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 &amp; 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>

View File

@@ -5,7 +5,7 @@
<groupId>com.inteligr8.activiti</groupId>
<artifactId>auth-activiti-app-ext</artifactId>
<version>2.1-SNAPSHOT</version>
<version>2.0.2</version>
<name>Authentication &amp; Authorization for APS</name>
<description>An Alfresco Process Service App extension providing improved authentication and authorization support.</description>
@@ -45,7 +45,6 @@
<!-- for RAD -->
<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 \

View File

@@ -58,7 +58,7 @@ public class ActivitiAppAdministratorMembersFixer implements DataFixer {
@Value("${auth-ext.group.admins.name:Superusers}")
private String adminGroupName;
@Value("${auth-ext.externalId:oauth}")
@Value("${auth-ext.group.admins.externalId:#{null}}")
private String adminGroupExternalId;
@Override

View File

@@ -1,8 +1,5 @@
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;
@@ -13,22 +10,11 @@ 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;
/**
@@ -47,15 +33,6 @@ public class IdentityServiceConfigurationOverride {
@Autowired
private ApplicationContext appContext;
@Autowired
private JwtAuthenticationProvider jwtAuthenticationProvider;
@Autowired
private ActivitiAppRequestHeaderService appRequestHeaderService;
@Autowired
private ActivitiRestAuthorizationService restAuthorizationService;
@Bean("inteligr8.clientRegistrationRepository")
@Primary
public ClientRegistrationRepository clientRegistrationRepository() {
@@ -74,7 +51,7 @@ public class IdentityServiceConfigurationOverride {
@Bean(OVERRIDE_CLIENT_REGISTRATION_BEANNAME)
@Primary
public ClientRegistration clientRegistration() throws ParseException, InterruptedException {
public ClientRegistration clientRegistration1() throws ParseException, InterruptedException {
this.logger.trace("clientRegistration()");
ClientRegistration clientRegistration = this.appContext.getBean(OOTB_CLIENT_REGISTRATION_BEANNAME, ClientRegistration.class);
@@ -85,62 +62,4 @@ public class IdentityServiceConfigurationOverride {
.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();
}
}

View File

@@ -30,10 +30,7 @@ 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;
@@ -132,36 +129,28 @@ public class GroupSyncService {
}
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());
if (!oidcUser.hasClaim("groups")) {
this.logger.warn("There is no 'groups' claim to synchronize: {}", oidcUser.getEmail());
this.logger.debug("The claims available: {}", oidcUser.getClaims().keySet());
return;
}
Set<String> oidcGroups = new HashSet<>(claims.getClaimAsStringList("groups"));
this.logger.trace("Incoming OIDC groups: {}: {}", email, oidcGroups);
Set<String> oidcGroups = new HashSet<>(oidcUser.getClaimAsStringList("groups"));
this.logger.trace("Incoming OIDC groups: {}: {}", oidcUser.getEmail(), oidcGroups);
oidcGroups = this.filterGroups(oidcGroups);
Set<String> translatedGroups = this.translateGroups(oidcGroups);
oidcGroups = this.translateGroups(oidcGroups);
this.logger.debug("Filtered/translated OIDC groups: {}: {}", email, translatedGroups);
this.logger.debug("Filtered/translated OIDC groups: {}: {}", oidcUser.getEmail(), oidcGroups);
long tenantId = this.tenantFinderService.findTenantId();
// check Activiti groups
User user = this.userService.findUserByEmailAndTenantId(email, tenantId);
User user = this.userService.findUserByEmailAndTenantId(oidcUser.getEmail(), tenantId);
if (user == null) {
user = this.userService.findUserByEmail(email);
user = this.userService.findUserByEmail(oidcUser.getEmail());
if (user == null)
throw new UsernameNotFoundException("The user could not be found: " + email);
throw new UsernameNotFoundException("The user could not be found: " + oidcUser.getEmail());
}
User userWithGroups = this.userService.getUser(user.getId(), true);
this.logger.debug("Discovered user belongs to {} APS groups: {}", userWithGroups.getGroups().size(), user.getExternalId());
@@ -177,7 +166,7 @@ public class GroupSyncService {
this.logger.trace("Inspecting APS group: {} => {} ({})", group.getId(), group.getName(), group.getExternalId());
if (group.getExternalId() != null) {
String translatedGroup = this.apsGroupExternalIdToTranslatedOidcGroup(group.getExternalId());
String oidcGroup = this.apsGroupExternalIdToOidcGroup(group.getExternalId());
if (this.retenantUntenantedGroups && group.getTenantId() == null) {
this.logger.warn("Moving tenant-less APS group to tenant: {} => {}", group.getName(), tenantId);
@@ -186,20 +175,20 @@ public class GroupSyncService {
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());
if (oidcGroups.remove(oidcGroup)) {
this.logger.trace("User already belongs to APS group mapped to by OIDC group: {}: {} => {}", user.getExternalId(), oidcGroup, group.getName());
continue;
}
} else {
String translatedGroup = this.apsGroupNameToTranslatedOidcGroup(group.getName());
String oidcGroup = this.apsGroupNameToOidcGroup(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 (oidcGroups.remove(oidcGroup)) {
this.logger.trace("User already belongs to APS group mapped to by OIDC group: {}: {} => {}", user.getExternalId(), oidcGroup, 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.setExternalId(this.oidcGroupToApsGroupExternalId(oidcGroup));
group.setLastUpdate(new Date());
this.groupService.save(group);
// internal role already existed and the user is already a member
@@ -223,21 +212,21 @@ public class GroupSyncService {
}
// the user needs to be added to the remaining authorities
for (String translatedGroup : translatedGroups) {
this.logger.trace("Inspecting unaccounted for (translated) OIDC group: {}", translatedGroup);
for (String oidcGroup : oidcGroups) {
this.logger.trace("Inspecting unaccounted for OIDC group: {}", oidcGroup);
Group group;
try {
group = this.groupService.getGroupByExternalIdAndTenantId(this.translatedOidcGroupToApsGroupExternalId(translatedGroup), tenantId);
group = this.groupService.getGroupByExternalIdAndTenantId(this.oidcGroupToApsGroupExternalId(oidcGroup), 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);
this.logger.warn("There are multiple groups matching the OIDC group for the external system: {} [{}]; skipping consideration of OIDC group", oidcGroup, this.externalIdmSource);
continue;
}
if (group == null && this.syncInternalGroups) {
List<Group> groups = this.groupService.getGroupByNameAndTenantId(this.translatedOidcGroupToApsGroupName(translatedGroup), tenantId);
List<Group> groups = this.groupService.getGroupByNameAndTenantId(this.oidcGroupToApsGroupName(oidcGroup), 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);
this.logger.warn("There are multiple APS groups matching the OIDC group: {} [{}]; skipping consideration of OIDC group", oidcGroup, this.externalIdmSource);
continue;
} else if (groups.size() == 1) {
group = groups.iterator().next();
@@ -245,7 +234,7 @@ public class GroupSyncService {
if (this.externalizeMatchingInternalGroups) {
this.logger.debug("Found an internal APS group; registering as external: {}", group.getName());
group.setExternalId(this.translatedOidcGroupToApsGroupExternalId(translatedGroup));
group.setExternalId(this.oidcGroupToApsGroupExternalId(oidcGroup));
group.setLastSyncTimeStamp(new Date());
group.setLastUpdate(new Date());
this.groupService.save(group);
@@ -255,11 +244,11 @@ public class GroupSyncService {
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);
this.logger.debug("APS Group does not exist for OIDC group; APS group creation is disabled; OIDC group will go unrecognized: {}", oidcGroup);
continue;
}
group = this.createApsGroup(translatedGroup, tenantId);
group = this.createApsGroup(oidcGroup, tenantId);
}
if (this.syncAdditions) {
@@ -272,13 +261,13 @@ public class GroupSyncService {
}
}
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);
protected Group createApsGroup(String oidcGroup, long tenantId) {
this.logger.debug("APS Group does not exist for OIDC group; will attempt to create: {}", oidcGroup);
String name = this.oidcGroupToApsGroupName(oidcGroup);
String externalId = this.oidcGroupToApsGroupExternalId(oidcGroup);
boolean syncAsOrg = this.isTranslatedOidcGroupToBeOrganization(translatedGroup);
this.logger.trace("Creating new APS group as {}: {}", syncAsOrg ? "organization" : "capability", translatedGroup);
boolean syncAsOrg = this.isOidcGroupToBeOrganization(oidcGroup);
this.logger.trace("Creating new APS group as {}: {}", syncAsOrg ? "organization" : "capability", oidcGroup);
int type = syncAsOrg ? Group.TYPE_FUNCTIONAL_GROUP : Group.TYPE_SYSTEM_GROUP;
Group apsGroup = this.groupService.createGroupFromExternalStore(name, tenantId, type, null, externalId, new Date());
@@ -342,29 +331,29 @@ public class GroupSyncService {
return translatedGroups;
}
private String translatedOidcGroupToApsGroupExternalId(String group) {
private String oidcGroupToApsGroupExternalId(String group) {
return this.externalIdmSource + "_" + group;
}
private String apsGroupExternalIdToTranslatedOidcGroup(String externalId) {
private String apsGroupExternalIdToOidcGroup(String externalId) {
int underscorePos = externalId.indexOf('_');
return underscorePos < 0 ? externalId : externalId.substring(underscorePos + 1);
}
private String translatedOidcGroupToApsGroupName(String group) {
private String oidcGroupToApsGroupName(String group) {
return group;
}
private String apsGroupNameToTranslatedOidcGroup(String externalId) {
private String apsGroupNameToOidcGroup(String externalId) {
return externalId;
}
private boolean isTranslatedOidcGroupToBeOrganization(String translatedGroup) {
private boolean isOidcGroupToBeOrganization(String role) {
if (this.capabilities.isEmpty())
return true;
for (Pattern regex : this.capabilities) {
Matcher matcher = regex.matcher(translatedGroup);
Matcher matcher = regex.matcher(role);
if (matcher.matches())
return false;
}

View File

@@ -1,11 +0,0 @@
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();
}

View File

@@ -20,16 +20,12 @@ import com.activiti.security.identity.service.config.IdentityServiceKeycloakProp
* 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 {
public class OIDCUserService extends OidcUserService {
private final Logger logger = LoggerFactory.getLogger(SyncingUserService.class);
private final Logger logger = LoggerFactory.getLogger(OIDCUserService.class);
@Autowired
private UserDetailsService userDetailsService;

View File

@@ -1,50 +0,0 @@
package com.inteligr8.activiti.auth.service;
import java.util.ArrayList;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
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.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;
public SyncingJwtAuthenticationConverter(UserDetailsService userDetailsService, UserSyncService userSyncService, GroupSyncService groupSyncService) {
this.userDetailsService = userDetailsService;
this.userSyncService = userSyncService;
this.groupSyncService = groupSyncService;
}
@Override
public AbstractAuthenticationToken convert(Jwt source) {
this.logger.trace("convert({}, {})", source.getId(), source.getClaim("email"));
try {
this.logger.debug("jwt: {}", new ObjectMapper().registerModule(new JavaTimeModule()).writeValueAsString(source));
} catch (JsonProcessingException jpe) {
this.logger.error("error", jpe);
}
this.userSyncService.sync(source);
this.groupSyncService.sync(source);
UserDetails springUser = this.userDetailsService.loadUserByUsername(source.getClaim("email"));
return new JwtAuthenticationToken(
springUser,
new ArrayList<>(springUser.getAuthorities()));
}
}

View File

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

View File

@@ -9,10 +9,7 @@ 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;
@@ -45,14 +42,14 @@ public class UserSyncService {
@Value("${auth-ext.sync.user.createMissing:true}")
protected boolean createMissingUser;
@Value("${auth-ext.sync.user.requireOidcGroup:#{null}}")
@Value("${auth-ext.sync.user.requireGroup:#{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);
UserDetails springUser = this.loadSpringUser(oidcUser);
if (this.logger.isTraceEnabled()) {
this.logger.trace("Loaded Spring Security user: {}: {}", springUser.getUsername(), springUser.getAuthorities());
} else {
@@ -60,58 +57,42 @@ public class UserSyncService {
}
}
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 {
private UserDetails loadSpringUser(OidcUser oidcUser) throws UsernameNotFoundException {
try {
UserDetails springUser = this.userDetailsService.loadUserByUsername(email);
this.logger.debug("Loaded APS user: {} => {}", email, springUser.getUsername());
UserDetails springUser = this.userDetailsService.loadUserByUsername(oidcUser.getEmail());
this.logger.debug("Loaded APS user: {} => {}", oidcUser.getEmail(), 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);
if (this.requiredGroup != null && (!oidcUser.hasClaim("groups") || !oidcUser.getClaimAsStringList("groups").contains(this.requiredGroup))) {
this.logger.info("User does not exist and does not have the required OIDC group to be created: {} ", oidcUser.getEmail(), this.requiredGroup);
throw unfe;
}
this.logger.debug("User does not exist; will attempt to create: {}", email);
User apsUser = this.createApsUser(email, givenName, familyName);
this.logger.debug("User does not exist; will attempt to create: {}", oidcUser.getEmail());
User apsUser = this.createApsUser(oidcUser);
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.logger.debug("User is new; clearing default groups: {}: {}", oidcUser.getEmail(), 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) {
private User createApsUser(OidcUser oidcUser) {
long tenantId = this.tenantFinderService.findTenantId();
User user = this.userService.createNewUserFromExternalStore(
email,
givenName,
familyName,
oidcUser.getEmail(),
oidcUser.getGivenName(),
oidcUser.getFamilyName(),
tenantId,
email,
oidcUser.getEmail(),
this.externalIdmSource,
new Date());
this.logger.info("Created user: {} => {}", user.getId(), user.getEmail());

View File

@@ -101,14 +101,14 @@
"profile",
"roles",
"basic",
"email",
"microprofile-jwt"
"email"
],
"optionalClientScopes": [
"address",
"phone",
"organization",
"offline_access"
"offline_access",
"microprofile-jwt"
]
},
{
@@ -156,76 +156,15 @@
"profile",
"roles",
"basic",
"email",
"microprofile-jwt"
"email"
],
"optionalClientScopes": [
"address",
"phone",
"organization",
"offline_access"
"offline_access",
"microprofile-jwt"
]
},
{
"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": [

View File

@@ -1,35 +0,0 @@
@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}}