From f0c19c427b035f8132d3ce3cea0f7eb5a285be3c Mon Sep 17 00:00:00 2001 From: Brian Long Date: Tue, 14 Mar 2023 15:14:19 +0100 Subject: [PATCH] added concurrency support for new users --- .../main/config/alfresco-global.properties | 4 + .../keycloak-authentication-context.xml | 10 ++ .../KeycloakAuthenticationComponent.java | 46 +++++++ .../KeycloakAuthenticationFilter.java | 35 ++++- .../KeycloakTokenGroupSyncProcessor.java | 11 +- .../MissingPersonServiceImpl.java | 123 ++++++++++++++++++ 6 files changed, 218 insertions(+), 11 deletions(-) create mode 100644 repository/src/main/java/de/acosix/alfresco/keycloak/repo/authentication/MissingPersonServiceImpl.java diff --git a/repository/src/main/config/alfresco-global.properties b/repository/src/main/config/alfresco-global.properties index ad180f0..213c125 100644 --- a/repository/src/main/config/alfresco-global.properties +++ b/repository/src/main/config/alfresco-global.properties @@ -1,3 +1,7 @@ + +# Missing People are handled by this module in a thread-safe way +create.missing.people=false + ${moduleId}.authorityServiceEnhancement.enabled=true cache.${moduleId}.ssoToSessionCache.maxItems=10000 diff --git a/repository/src/main/globalConfig/subsystems/Authentication/keycloak/keycloak-authentication-context.xml b/repository/src/main/globalConfig/subsystems/Authentication/keycloak/keycloak-authentication-context.xml index 40d3535..027a86d 100644 --- a/repository/src/main/globalConfig/subsystems/Authentication/keycloak/keycloak-authentication-context.xml +++ b/repository/src/main/globalConfig/subsystems/Authentication/keycloak/keycloak-authentication-context.xml @@ -60,6 +60,8 @@ + + @@ -154,6 +156,8 @@ + + @@ -224,6 +228,12 @@ + + + + + + diff --git a/repository/src/main/java/de/acosix/alfresco/keycloak/repo/authentication/KeycloakAuthenticationComponent.java b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/authentication/KeycloakAuthenticationComponent.java index a504808..d02e6ba 100644 --- a/repository/src/main/java/de/acosix/alfresco/keycloak/repo/authentication/KeycloakAuthenticationComponent.java +++ b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/authentication/KeycloakAuthenticationComponent.java @@ -15,13 +15,16 @@ */ package de.acosix.alfresco.keycloak.repo.authentication; +import java.nio.file.AccessDeniedException; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collections; import java.util.List; import org.alfresco.repo.management.subsystems.ActivateableBean; import org.alfresco.repo.security.authentication.AbstractAuthenticationComponent; import org.alfresco.repo.security.authentication.AuthenticationException; +import org.alfresco.repo.transaction.RetryingTransactionHelper; import org.alfresco.util.PropertyCheck; import org.keycloak.adapters.KeycloakDeployment; import org.keycloak.representations.AccessToken; @@ -36,6 +39,7 @@ import de.acosix.alfresco.keycloak.repo.token.AccessTokenClient; import de.acosix.alfresco.keycloak.repo.token.AccessTokenException; import de.acosix.alfresco.keycloak.repo.token.AccessTokenRefreshException; import de.acosix.alfresco.keycloak.repo.util.RefreshableAccessTokenHolder; +import net.sf.acegisecurity.Authentication; /** * This component provides Keycloak-integrated user/password authentication support to an Alfresco instance. @@ -67,6 +71,10 @@ public class KeycloakAuthenticationComponent extends AbstractAuthenticationCompo protected AccessTokenClient accessTokenClient; protected List tokenProcessors; + + private RetryingTransactionHelper rthelper; + + private MissingPersonServiceImpl missingPersonService; /** * @@ -77,12 +85,22 @@ public class KeycloakAuthenticationComponent extends AbstractAuthenticationCompo { PropertyCheck.mandatory(this, "applicationContext", this.applicationContext); PropertyCheck.mandatory(this, "keycloakDeployment", this.deployment); + PropertyCheck.mandatory(this, "missingPersonService", this.missingPersonService); this.accessTokenClient = new AccessTokenClient(this.deployment); this.tokenProcessors = new ArrayList<>(this.applicationContext.getBeansOfType(TokenProcessor.class, false, true).values()); Collections.sort(this.tokenProcessors); this.tokenProcessors = Collections.unmodifiableList(this.tokenProcessors); + + this.rthelper = new RetryingTransactionHelper(); + this.rthelper.setMaxRetries(3); + this.rthelper.setMinRetryWaitMs(2000); + this.rthelper.setTransactionService(this.getTransactionService()); + this.rthelper.setExtraExceptions(Arrays.asList( + AccessDeniedException.class // likely caused by a race condition, where multiple threads are authenticating at the same time + // this is due to modern web apps and threading + )); } /** @@ -159,6 +177,14 @@ public class KeycloakAuthenticationComponent extends AbstractAuthenticationCompo this.deployment = deployment; } + /** + * @param missingPersonService + * the missingPersonService to set + */ + public void setMissingPersonService(MissingPersonServiceImpl missingPersonService) { + this.missingPersonService = missingPersonService; + } + /** * Enables the thread-local storage of the last access token response and verified tokens beyond the internal needs of * {@link #authenticateImpl(String, char[]) authenticateImpl}. @@ -294,4 +320,24 @@ public class KeycloakAuthenticationComponent extends AbstractAuthenticationCompo { return this.allowGuestLogin; } + + public Authentication setCurrentUser(final String realUserName) throws AuthenticationException { + RuntimeException lastre = null; + + for (int l = 0; l < 2; l++) { + LOGGER.trace("Setting current user: {}", realUserName); + + try { + return super.setCurrentUser(realUserName); + } catch (RuntimeException re) { + this.missingPersonService.handle(realUserName, re); + LOGGER.debug("Missing person handled; looping back: {}", realUserName); + lastre = re; + } + } + + if (lastre != null) + throw lastre; + else throw new IllegalStateException("This should never happen"); + } } diff --git a/repository/src/main/java/de/acosix/alfresco/keycloak/repo/authentication/KeycloakAuthenticationFilter.java b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/authentication/KeycloakAuthenticationFilter.java index 03d1a18..9ff5b36 100644 --- a/repository/src/main/java/de/acosix/alfresco/keycloak/repo/authentication/KeycloakAuthenticationFilter.java +++ b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/authentication/KeycloakAuthenticationFilter.java @@ -137,6 +137,8 @@ public class KeycloakAuthenticationFilter extends BaseAuthenticationFilter protected SimpleCache keycloakTicketTokenCache; protected RuntimeContainer publicApiRuntimeContainer; + + private MissingPersonServiceImpl missingPersonService; /** * {@inheritDoc} @@ -159,6 +161,7 @@ public class KeycloakAuthenticationFilter extends BaseAuthenticationFilter PropertyCheck.mandatory(this, "personService", this.personService); PropertyCheck.mandatory(this, "nodeService", this.nodeService); PropertyCheck.mandatory(this, "transactionService", this.transactionService); + PropertyCheck.mandatory(this, "missingPersonService", this.missingPersonService); // basic is handled ourselves this.keycloakDeployment.setEnableBasicAuth(false); @@ -291,6 +294,14 @@ public class KeycloakAuthenticationFilter extends BaseAuthenticationFilter this.publicApiRuntimeContainer = publicApiRuntimeContainer; } + /** + * @param missingPersonService + * the missingPersonService to set + */ + public void setMissingPersonService(MissingPersonServiceImpl missingPersonService) { + this.missingPersonService = missingPersonService; + } + /** * * {@inheritDoc} @@ -636,7 +647,16 @@ public class KeycloakAuthenticationFilter extends BaseAuthenticationFilter @Override protected SessionUser createUserEnvironment(final HttpSession session, final String userName) throws IOException, ServletException { - final SessionUser sessionUser = super.createUserEnvironment(session, userName); + SessionUser sessionUser; + try { + sessionUser = super.createUserEnvironment(session, userName); + } + catch (RuntimeException re) + { + LOGGER.warn("Failed to create user environemnt; trying to resolve: {}", re.getMessage()); + this.missingPersonService.handle(userName, re); + sessionUser = super.createUserEnvironment(session, userName); + } // ensure all common attribute names are mapped // Alfresco is really inconsistent with these attribute names @@ -654,7 +674,16 @@ public class KeycloakAuthenticationFilter extends BaseAuthenticationFilter protected SessionUser createUserEnvironment(final HttpSession session, final String userName, final String ticket, final boolean externalAuth) throws IOException, ServletException { - final SessionUser sessionUser = super.createUserEnvironment(session, userName, ticket, externalAuth); + SessionUser sessionUser; + try { + sessionUser = super.createUserEnvironment(session, userName, ticket, externalAuth); + } + catch (RuntimeException re) + { + LOGGER.warn("Failed to create user environemnt; trying to resolve: {}", re.getMessage()); + this.missingPersonService.handle(userName, re); + sessionUser = super.createUserEnvironment(session, userName, ticket, externalAuth); + } // ensure all common attribute names are mapped // Alfresco is really inconsistent with these attribute names @@ -986,7 +1015,7 @@ public class KeycloakAuthenticationFilter extends BaseAuthenticationFilter if (sessionUser == null) { LOGGER.debug("Propagating through the user identity: {}", AlfrescoCompatibilityUtil.maskUsername(userId)); - this.authenticationComponent.setCurrentUser(userId); + this.keycloakAuthenticationComponent.setCurrentUser(userId); session = httpServletRequest.getSession(); try diff --git a/repository/src/main/java/de/acosix/alfresco/keycloak/repo/authentication/KeycloakTokenGroupSyncProcessor.java b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/authentication/KeycloakTokenGroupSyncProcessor.java index 4a2dcf1..d28eb28 100644 --- a/repository/src/main/java/de/acosix/alfresco/keycloak/repo/authentication/KeycloakTokenGroupSyncProcessor.java +++ b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/authentication/KeycloakTokenGroupSyncProcessor.java @@ -172,13 +172,8 @@ public class KeycloakTokenGroupSyncProcessor implements TokenProcessor, Initiali { AuthenticationUtil.runAsSystem(() -> this.transactionService.getRetryingTransactionHelper().doInTransaction(() -> { boolean changed = this.syncGroupMemberships(groups); - if (changed) { - String ticket = this.authenticationService.getCurrentTicket(); - if (ticket != null) { - LOGGER.debug("Invalidating Alflresco ticket as group membership changed: {}", ticket); - this.authenticationService.invalidateTicket(ticket); - } - } + if (changed) + LOGGER.debug("Group membership changed: {}", accessToken.getSubject()); return null; }, false, requiresNew)); } @@ -202,7 +197,7 @@ public class KeycloakTokenGroupSyncProcessor implements TokenProcessor, Initiali // in case some extractor mapped this pseudo-group groups.remove(PermissionService.ALL_AUTHORITIES); - LOGGER.debug("Mapped user group authorities from access token: {}", groups); + LOGGER.debug("Mapped user group authorities from access token: {} => {}", accessToken.getSubject(), groups); return groups; } diff --git a/repository/src/main/java/de/acosix/alfresco/keycloak/repo/authentication/MissingPersonServiceImpl.java b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/authentication/MissingPersonServiceImpl.java new file mode 100644 index 0000000..5c5ba72 --- /dev/null +++ b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/authentication/MissingPersonServiceImpl.java @@ -0,0 +1,123 @@ +package de.acosix.alfresco.keycloak.repo.authentication; + +import java.io.Serializable; +import java.util.HashMap; +import java.util.Map; + +import org.alfresco.error.AlfrescoRuntimeException; +import org.alfresco.model.ContentModel; +import org.alfresco.repo.lock.JobLockService; +import org.alfresco.repo.security.authentication.AuthenticationException; +import org.alfresco.repo.security.authentication.AuthenticationUtil; +import org.alfresco.repo.security.authentication.AuthenticationUtil.RunAsWork; +import org.alfresco.repo.transaction.RetryingTransactionHelper.RetryingTransactionCallback; +import org.alfresco.service.cmr.repository.NodeRef; +import org.alfresco.service.cmr.security.NoSuchPersonException; +import org.alfresco.service.cmr.security.PersonService; +import org.alfresco.service.namespace.NamespaceService; +import org.alfresco.service.namespace.QName; +import org.alfresco.service.transaction.TransactionService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class MissingPersonServiceImpl { + + private static final Logger LOGGER = LoggerFactory.getLogger(MissingPersonServiceImpl.class); + + private PersonService personService; + + private TransactionService txService; + + private JobLockService jobLockService; + + public void setPersonService(PersonService personService) { + this.personService = personService; + } + + public void setTransactionService(TransactionService txService) { + this.txService = txService; + } + + public void setJobLockService(JobLockService jobLockService) { + this.jobLockService = jobLockService; + } + + public void handleAuthentication(final String username, AuthenticationException ae) throws AuthenticationException { + if (!ae.getMessage().contains("does not exist in Alfresco")) { + LOGGER.debug("Not supported by MissingPersonService: " + ae.getMessage(), ae); + throw ae; + } + + LOGGER.debug("AuthenticationException; user doesn't exist; creating person: {}", username); + this.createPerson(username); + } + + public void handleNoSuchPerson(final String username, NoSuchPersonException nspe) throws NoSuchPersonException { + LOGGER.debug("NoSuchPersonException; Creating person: {}", username); + this.createPerson(username); + } + + public void handle(String username, RuntimeException re) throws RuntimeException { + if (re instanceof AuthenticationException) { + this.handleAuthentication(username, (AuthenticationException) re); + } else if (re instanceof NoSuchPersonException) { + this.handleNoSuchPerson(username, (NoSuchPersonException) re); + } else if (re.getCause() instanceof AuthenticationException) { + this.handleAuthentication(username, (AuthenticationException) re.getCause()); + } else if (re.getCause() instanceof NoSuchPersonException) { + this.handleNoSuchPerson(username, (NoSuchPersonException) re.getCause()); + } else { + LOGGER.debug("Not supported by MissingPersonService: " + re.getMessage() + " => " + re.getCause(), re); + throw re; + } + } + + private void createPerson(final String username) { + final RetryingTransactionCallback rtcallback = new RetryingTransactionCallback() { + @Override + public NodeRef execute() { + if (personService.personExists(username)) { + LOGGER.debug("Person (now) already exists: {}", username); + return null; + } else { + LOGGER.info("Creating person: {}", username); + + Map properties = new HashMap<>(); + properties.put(ContentModel.PROP_USERNAME, username); + properties.put(ContentModel.PROP_FIRSTNAME, "Keycloak"); + properties.put(ContentModel.PROP_LASTNAME, "Unknown"); + return personService.createPerson(properties); + } + } + }; + + try { + AuthenticationUtil.runAsSystem(new RunAsWork() { + @Override + public NodeRef doWork() { + boolean readonly = txService.isReadOnly(); + QName lockQname = QName.createQName(NamespaceService.CONTENT_MODEL_1_0_URI, QName.createValidLocalName(username)); + + LOGGER.trace("Obtaining exclusive lock on creation of person: {}", lockQname); + String lockId = jobLockService.getLock(lockQname, 2000L, 250L, 20); + try { + return txService.getRetryingTransactionHelper().doInTransaction(rtcallback, false, readonly); + } finally { + LOGGER.trace("Releasing exclusive lock: {}", lockQname); + jobLockService.releaseLock(lockId, lockQname); + } + } + }); + } catch (RuntimeException re) { + LOGGER.warn("Failed to create person: {} => {}", username, re.getMessage()); + if (re instanceof AlfrescoRuntimeException) { + if (re.getMessage().contains("already exists")) + LOGGER.info("Person already exists; likely thwarted race condition: {}", username); + return; + } + + throw re; + } + } + +}