v2.1.x; added APS API support

This commit is contained in:
Brian Long 2025-05-09 19:02:12 -04:00
parent 84d1b4ea2f
commit faba551a2d
8 changed files with 183 additions and 27 deletions

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

@ -1,5 +1,7 @@
package com.inteligr8.activiti.auth.oauth;
import static org.springframework.security.web.util.matcher.AntPathRequestMatcher.antMatcher;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@ -10,11 +12,16 @@ 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.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 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;
/**
@ -33,6 +40,9 @@ public class IdentityServiceConfigurationOverride {
@Autowired
private ApplicationContext appContext;
@Autowired
private JwtAuthenticationProvider jwtAuthenticationProvider;
@Bean("inteligr8.clientRegistrationRepository")
@Primary
public ClientRegistrationRepository clientRegistrationRepository() {
@ -51,7 +61,7 @@ public class IdentityServiceConfigurationOverride {
@Bean(OVERRIDE_CLIENT_REGISTRATION_BEANNAME)
@Primary
public ClientRegistration clientRegistration1() throws ParseException, InterruptedException {
public ClientRegistration clientRegistration() throws ParseException, InterruptedException {
this.logger.trace("clientRegistration()");
ClientRegistration clientRegistration = this.appContext.getBean(OOTB_CLIENT_REGISTRATION_BEANNAME, ClientRegistration.class);
@ -62,4 +72,27 @@ public class IdentityServiceConfigurationOverride {
.build();
}
/**
* Slightly lower priority than the one provided OOTB. This
* allows for the bean injection of the JwtAuthenticationConverter.
*
* A lower priority means it is applied last. This means it replaces the
* JwtAuthenticationConverter provided by Alfresco OOTB.
*
* @see com.activiti.security.identity.service.config.IdentityServiceConfigurationApi#identityServiceApiWebSecurity
*/
@Bean("inteligr8.identityServiceApiWebSecurity")
@Order(-5)
public SecurityFilterChain identityServiceApiWebSecurity(HttpSecurity http) throws Exception {
http
.securityMatcher(antMatcher(ProtectedPaths.API_URL_PATH + "/**"))
.oauth2ResourceServer(oauth2 ->
oauth2.jwt(jwtConfigurer -> {
jwtConfigurer.jwtAuthenticationConverter(this.jwtAuthenticationProvider.create());
})
);
return http.build();
}
}

@ -30,7 +30,10 @@ 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;
@ -129,28 +132,36 @@ public class GroupSyncService {
}
public void sync(OidcUser oidcUser) {
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());
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<>(oidcUser.getClaimAsStringList("groups"));
this.logger.trace("Incoming OIDC groups: {}: {}", oidcUser.getEmail(), oidcGroups);
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: {}: {}", oidcUser.getEmail(), translatedGroups);
this.logger.debug("Filtered/translated OIDC groups: {}: {}", email, translatedGroups);
long tenantId = this.tenantFinderService.findTenantId();
// check Activiti groups
User user = this.userService.findUserByEmailAndTenantId(oidcUser.getEmail(), tenantId);
User user = this.userService.findUserByEmailAndTenantId(email, tenantId);
if (user == null) {
user = this.userService.findUserByEmail(oidcUser.getEmail());
user = this.userService.findUserByEmail(email);
if (user == null)
throw new UsernameNotFoundException("The user could not be found: " + oidcUser.getEmail());
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());

@ -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,50 @@
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()));
}
}

@ -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);
}
}

@ -20,12 +20,16 @@ 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 OIDCUserService extends OidcUserService {
public class SyncingUserService extends OidcUserService {
private final Logger logger = LoggerFactory.getLogger(OIDCUserService.class);
private final Logger logger = LoggerFactory.getLogger(SyncingUserService.class);
@Autowired
private UserDetailsService userDetailsService;

@ -9,7 +9,10 @@ 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;
@ -49,7 +52,7 @@ public class UserSyncService {
protected boolean clearNewUserGroups;
public void sync(OidcUser oidcUser) {
UserDetails springUser = this.loadSpringUser(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 {
@ -57,42 +60,58 @@ public class UserSyncService {
}
}
private UserDetails loadSpringUser(OidcUser oidcUser) throws UsernameNotFoundException {
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(oidcUser.getEmail());
this.logger.debug("Loaded APS user: {} => {}", oidcUser.getEmail(), springUser.getUsername());
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 && (!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);
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: {}", oidcUser.getEmail());
User apsUser = this.createApsUser(oidcUser);
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: {}: {}", oidcUser.getEmail(), apsUser.getGroups().stream().map(group -> group.getName()).toList());
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(OidcUser oidcUser) {
private User createApsUser(String email, String givenName, String familyName) {
long tenantId = this.tenantFinderService.findTenantId();
User user = this.userService.createNewUserFromExternalStore(
oidcUser.getEmail(),
oidcUser.getGivenName(),
oidcUser.getFamilyName(),
email,
givenName,
familyName,
tenantId,
oidcUser.getEmail(),
email,
this.externalIdmSource,
new Date());
this.logger.info("Created user: {} => {}", user.getId(), user.getEmail());