13 Commits

Author SHA1 Message Date
0cd6083acb v2.2.2 pom 2026-04-09 09:42:43 -04:00
16bf35c676 Merge branch 'develop' into stable 2026-04-09 09:42:22 -04:00
89d85bf2b9 fix injected class 2026-04-09 09:42:04 -04:00
802736e532 v2.2.1 pom 2026-04-08 17:33:42 -04:00
60d844b136 Merge branch 'develop' into stable 2026-04-08 17:32:46 -04:00
95aaaa5fcb componentize SyncingJwtAuthenticationConverter
remove SyncingJwtAuthenticationProvider
2026-04-08 17:31:54 -04:00
efcf80dfc1 Merge branch 'develop' into stable 2026-03-24 11:23:35 -04:00
458650db94 ossrh-release to central-publish 2026-03-24 11:23:17 -04:00
dafbe33f39 Merge branch 'develop' into stable 2026-03-24 11:18:58 -04:00
3983fcabff v2.2.x; APS v26 support due to spring v7 2026-03-24 11:16:59 -04:00
514ba6bae1 v2.1.2 pom 2025-05-21 15:35:25 -04:00
6c93637dbb Merge branch 'develop' into stable 2025-05-21 15:35:03 -04:00
65d80dc82d throttle sync 2025-05-21 15:34:22 -04:00
8 changed files with 129 additions and 85 deletions

View File

@@ -41,7 +41,8 @@ This extension requires the [`multiext-activiti-app-ext`](https://git.inteligr8.
| --------------------------------------- | --------------- |
| `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-ext` v2.0 | v24.x - v25.x |
| `auth-activiti-app-ext` v2.1+ | v26.x+ |
## Configuration
@@ -55,6 +56,7 @@ The following properties are used across the functionalities of this extension.
| --------------------------------- | --------- | ----------- |
| `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. |
### OAuth Authentication/Authorization

27
pom.xml
View File

@@ -5,7 +5,7 @@
<groupId>com.inteligr8.activiti</groupId>
<artifactId>auth-activiti-app-ext</artifactId>
<version>2.1.1</version>
<version>2.2.2</version>
<name>Authentication &amp; Authorization for APS</name>
<description>An Alfresco Process Service App extension providing improved authentication and authorization support.</description>
@@ -41,10 +41,10 @@
<maven.compiler.target>17</maven.compiler.target>
<maven.compiler.release>17</maven.compiler.release>
<aps.version>25.1.1</aps.version>
<aps.version>26.1.0</aps.version>
<!-- for RAD -->
<tomcat-rad.version>10-2.2</tomcat-rad.version>
<tomcat-rad.version>2.3-tomcat-11.0.20</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 \
@@ -105,7 +105,7 @@
<plugin>
<groupId>io.repaint.maven</groupId>
<artifactId>tiles-maven-plugin</artifactId>
<version>2.40</version>
<version>2.43</version>
<extensions>true</extensions>
<configuration>
<tiles>
@@ -230,7 +230,7 @@
</build>
</profile>
<profile>
<id>ossrh-release</id>
<id>central-publish</id>
<properties>
<maven.deploy.skip>true</maven.deploy.skip>
</properties>
@@ -270,19 +270,20 @@
</executions>
</plugin>
<plugin>
<groupId>org.sonatype.plugins</groupId>
<artifactId>nexus-staging-maven-plugin</artifactId>
<version>1.7.0</version>
<groupId>org.sonatype.central</groupId>
<artifactId>central-publishing-maven-plugin</artifactId>
<version>0.8.0</version>
<extensions>true</extensions>
<configuration>
<serverId>ossrh</serverId>
<nexusUrl>https://s01.oss.sonatype.org/</nexusUrl>
<autoReleaseAfterClose>true</autoReleaseAfterClose>
<publishingServerId>central</publishingServerId>
<autoPublish>true</autoPublish>
</configuration>
<!-- for some reason this is required... -->
<executions>
<execution>
<id>ossrh-deploy</id>
<id>deploy</id>
<phase>deploy</phase>
<goals><goal>deploy</goal></goals>
<goals><goal>publish</goal></goals>
</execution>
</executions>
</plugin>

View File

@@ -1,7 +1,6 @@
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;
@@ -20,6 +19,7 @@ import org.springframework.security.oauth2.client.registration.ClientRegistratio
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.servlet.util.matcher.PathPatternRequestMatcher;
import org.springframework.security.web.util.matcher.AndRequestMatcher;
import org.springframework.security.web.util.matcher.RequestHeaderRequestMatcher;
@@ -28,7 +28,7 @@ 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.inteligr8.activiti.auth.service.SyncingJwtAuthenticationConverter;
import com.nimbusds.oauth2.sdk.ParseException;
/**
@@ -48,7 +48,7 @@ public class IdentityServiceConfigurationOverride {
private ApplicationContext appContext;
@Autowired
private JwtAuthenticationProvider jwtAuthenticationProvider;
private SyncingJwtAuthenticationConverter jwtAuthenticationConverter;
@Autowired
private ActivitiAppRequestHeaderService appRequestHeaderService;
@@ -103,11 +103,11 @@ public class IdentityServiceConfigurationOverride {
.securityMatchers(matchers -> {
matchers.requestMatchers(
// same as OOTB
antMatcher(ProtectedPaths.API_URL_PATH + "/**"),
PathPatternRequestMatcher.pathPattern(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/**"))
new AndRequestMatcher(new RequestHeaderRequestMatcher("Authorization"), PathPatternRequestMatcher.pathPattern(ProtectedPaths.APP_URL_PATH + "/rest/**"))
);
})
.csrf(csrf -> {
@@ -118,24 +118,24 @@ public class IdentityServiceConfigurationOverride {
.oauth2ResourceServer(oauth2 ->
oauth2.jwt(jwtConfigurer -> {
// here is where we are injecting a Spring extendible `JwtAuthenticationConverter`.
jwtConfigurer.jwtAuthenticationConverter(this.jwtAuthenticationProvider.create());
jwtConfigurer.jwtAuthenticationConverter(this.jwtAuthenticationConverter);
})
)
.authorizeHttpRequests(request ->
request
// same as OOTB
.requestMatchers(antMatcher(ProtectedPaths.API_URL_PATH + "/enterprise/**"))
.requestMatchers(PathPatternRequestMatcher.pathPattern(ProtectedPaths.API_URL_PATH + "/enterprise/**"))
.access(this.appRequestHeaderService)
.requestMatchers(antMatcher(ProtectedPaths.API_URL_PATH + "/**"))
.requestMatchers(PathPatternRequestMatcher.pathPattern(ProtectedPaths.API_URL_PATH + "/**"))
.access(this.restAuthorizationService)
// borrowed from OOTB /app/rest security
.requestMatchers(antMatcher(ProtectedPaths.APP_URL_PATH + "/rest/reporting/**"))
.requestMatchers(PathPatternRequestMatcher.pathPattern(ProtectedPaths.APP_URL_PATH + "/rest/reporting/**"))
.hasAuthority(Capabilities.ACCESS_REPORTS)
.requestMatchers(
antMatcher(ProtectedPaths.API_URL_PATH + "/**"),
antMatcher(ProtectedPaths.APP_URL_PATH + "/rest/**")
PathPatternRequestMatcher.pathPattern(ProtectedPaths.API_URL_PATH + "/**"),
PathPatternRequestMatcher.pathPattern(ProtectedPaths.APP_URL_PATH + "/rest/**")
)
.authenticated()
);

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

@@ -4,44 +4,57 @@ 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 org.springframework.stereotype.Component;
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;
@Component
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;
}
@Autowired
private UserDetailsService userDetailsService;
@Autowired
private UserSyncService userSyncService;
@Autowired
private GroupSyncService groupSyncService;
@Autowired
private TokenRecaller tokenRecaller;
@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);
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);
}
}
this.userSyncService.sync(source);
this.groupSyncService.sync(source);
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.getClaim("email"));
UserDetails springUser = this.userDetailsService.loadUserByUsername(source.getClaimAsString(StandardClaimNames.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

@@ -29,7 +29,7 @@ import com.activiti.security.identity.service.config.IdentityServiceKeycloakProp
@Primary
public class SyncingUserService extends OidcUserService {
private final Logger logger = LoggerFactory.getLogger(SyncingUserService.class);
private final Logger logger = LoggerFactory.getLogger(this.getClass());
@Autowired
private UserDetailsService userDetailsService;
@@ -43,17 +43,24 @@ public class SyncingUserService extends OidcUserService {
@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());
this.userSyncService.sync(oidcUser);
this.groupSyncService.sync(oidcUser);
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());
}
// reload for sync'd group changes
UserDetails springUser = this.userDetailsService.loadUserByUsername(oidcUser.getEmail());
CustomOAuth2User customOAuth2User = new CustomOAuth2User(

View 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;
}
}
}