From a1ccc14a9306c57b494f0e5001d8769defd70f9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20=C5=BBurek?= Date: Wed, 22 Mar 2023 12:58:13 +0100 Subject: [PATCH] ACS- 4752 keycloak migration - resource server role (#1818) --- .../rest/api/tests/AuthenticationsTest.java | 2 +- repository/pom.xml | 8 + ...frescoBearerTokenRequestAuthenticator.java | 61 --- ...dentityServiceAuthenticationComponent.java | 55 +- .../IdentityServiceDeploymentFactoryBean.java | 105 ---- .../IdentityServiceFacade.java | 90 ++++ .../IdentityServiceFacadeFactoryBean.java | 388 ++++++++++++++ .../IdentityServiceHttpFacade.java | 107 ---- .../IdentityServiceRemoteUserMapper.java | 138 ++--- .../OAuth2ClientFactoryBean.java | 270 ---------- ...dentity-service-authentication-context.xml | 19 +- .../java/org/alfresco/AllUnitTestsSuite.java | 5 +- ...ityServiceAuthenticationComponentTest.java | 21 +- .../IdentityServiceRemoteUserMapperTest.java | 485 ++---------------- ...ntiatingIdentityServiceFacadeUnitTest.java | 101 ++++ ...ingBasedIdentityServiceFacadeUnitTest.java | 73 +++ .../SpringOAuth2ClientUnitTest.java | 124 ----- 17 files changed, 808 insertions(+), 1244 deletions(-) delete mode 100644 repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/AlfrescoBearerTokenRequestAuthenticator.java delete mode 100644 repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/IdentityServiceDeploymentFactoryBean.java create mode 100644 repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/IdentityServiceFacade.java create mode 100644 repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/IdentityServiceFacadeFactoryBean.java delete mode 100644 repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/IdentityServiceHttpFacade.java delete mode 100644 repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/OAuth2ClientFactoryBean.java create mode 100644 repository/src/test/java/org/alfresco/repo/security/authentication/identityservice/LazyInstantiatingIdentityServiceFacadeUnitTest.java create mode 100644 repository/src/test/java/org/alfresco/repo/security/authentication/identityservice/SpringBasedIdentityServiceFacadeUnitTest.java delete mode 100644 repository/src/test/java/org/alfresco/repo/security/authentication/identityservice/SpringOAuth2ClientUnitTest.java diff --git a/remote-api/src/test/java/org/alfresco/rest/api/tests/AuthenticationsTest.java b/remote-api/src/test/java/org/alfresco/rest/api/tests/AuthenticationsTest.java index bee6884e4a..80d17a7471 100644 --- a/remote-api/src/test/java/org/alfresco/rest/api/tests/AuthenticationsTest.java +++ b/remote-api/src/test/java/org/alfresco/rest/api/tests/AuthenticationsTest.java @@ -529,7 +529,7 @@ public class AuthenticationsTest extends AbstractSingleNetworkSiteTest InterceptingIdentityRemoteUserMapper interceptingRemoteUserMapper = new InterceptingIdentityRemoteUserMapper(); interceptingRemoteUserMapper.setActive(true); interceptingRemoteUserMapper.setPersonService(personServiceLocal); - interceptingRemoteUserMapper.setIdentityServiceDeployment(null); + interceptingRemoteUserMapper.setIdentityServiceFacade(null); interceptingRemoteUserMapper.setUserIdToReturn(user2); remoteUserMapper = interceptingRemoteUserMapper; } diff --git a/repository/pom.xml b/repository/pom.xml index 8c1b59c988..463d4f67a8 100644 --- a/repository/pom.xml +++ b/repository/pom.xml @@ -397,6 +397,14 @@ org.springframework.security spring-security-oauth2-client + + org.springframework.security + spring-security-oauth2-jose + + + org.springframework.security + spring-security-oauth2-resource-server + org.quartz-scheduler quartz diff --git a/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/AlfrescoBearerTokenRequestAuthenticator.java b/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/AlfrescoBearerTokenRequestAuthenticator.java deleted file mode 100644 index 6c050bc9c4..0000000000 --- a/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/AlfrescoBearerTokenRequestAuthenticator.java +++ /dev/null @@ -1,61 +0,0 @@ -/* - * #%L - * Alfresco Repository - * %% - * Copyright (C) 2005 - 2016 Alfresco Software Limited - * %% - * This file is part of the Alfresco software. - * If the software was purchased under a paid Alfresco license, the terms of - * the paid license agreement will prevail. Otherwise, the software is - * provided under the following open source license terms: - * - * Alfresco is free software: you can redistribute it and/or modify - * it under the terms of the GNU Lesser General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * Alfresco is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public License - * along with Alfresco. If not, see . - * #L% - */ -package org.alfresco.repo.security.authentication.identityservice; - -import org.keycloak.adapters.BearerTokenRequestAuthenticator; -import org.keycloak.adapters.KeycloakDeployment; -import org.keycloak.adapters.OIDCAuthenticationError.Reason; -import org.keycloak.adapters.spi.AuthChallenge; -import org.keycloak.adapters.spi.HttpFacade; - -/** - * Extends the Keycloak BearerTokenRequestAuthenticator class to capture the error description - * when token valiation fails. - * - * @author Gavin Cornwell - */ -public class AlfrescoBearerTokenRequestAuthenticator extends BearerTokenRequestAuthenticator -{ - private String validationFailureDescription; - - public AlfrescoBearerTokenRequestAuthenticator(KeycloakDeployment deployment) - { - super(deployment); - } - - public String getValidationFailureDescription() - { - return this.validationFailureDescription; - } - - @Override - protected AuthChallenge challengeResponse(HttpFacade facade, Reason reason, String error, String description) - { - this.validationFailureDescription = description; - - return super.challengeResponse(facade, reason, error, description); - } -} diff --git a/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/IdentityServiceAuthenticationComponent.java b/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/IdentityServiceAuthenticationComponent.java index c63b2ff1c2..a8da8a2d28 100644 --- a/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/IdentityServiceAuthenticationComponent.java +++ b/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/IdentityServiceAuthenticationComponent.java @@ -28,32 +28,32 @@ package org.alfresco.repo.security.authentication.identityservice; 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.security.authentication.identityservice.IdentityServiceAuthenticationComponent.OAuth2Client.CredentialsVerificationException; +import org.alfresco.repo.security.authentication.identityservice.IdentityServiceFacade.CredentialsVerificationException; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; /** * - * Authenticates a user against Identity Service (Keycloak). - * {@link OAuth2Client} is used to verify provided user credentials. User is set as the current user if the user - * credentials are valid. + * Authenticates a user against Identity Service (Keycloak/Authorization Server). + * {@link IdentityServiceFacade} is used to verify provided user credentials. User is set as the current user if the + * user credentials are valid. *
- * The {@link IdentityServiceAuthenticationComponent#oAuth2Client} can be null in which case this authenticator will - * just fall through to the next one in the chain. + * The {@link IdentityServiceAuthenticationComponent#identityServiceFacade} can be null in which case this authenticator + * will just fall through to the next one in the chain. * */ public class IdentityServiceAuthenticationComponent extends AbstractAuthenticationComponent implements ActivateableBean { private final Log LOGGER = LogFactory.getLog(IdentityServiceAuthenticationComponent.class); /** client used to authenticate user credentials against Authorization Server **/ - private OAuth2Client oAuth2Client; + private IdentityServiceFacade identityServiceFacade; /** enabled flag for the identity service subsystem**/ private boolean active; private boolean allowGuestLogin; - public void setOAuth2Client(OAuth2Client oAuth2Client) + public void setIdentityServiceFacade(IdentityServiceFacade identityServiceFacade) { - this.oAuth2Client = oAuth2Client; + this.identityServiceFacade = identityServiceFacade; } public void setAllowGuestLogin(boolean allowGuestLogin) @@ -63,21 +63,20 @@ public class IdentityServiceAuthenticationComponent extends AbstractAuthenticati public void authenticateImpl(String userName, char[] password) throws AuthenticationException { - - if (oAuth2Client == null) + if (identityServiceFacade == null) { if (LOGGER.isDebugEnabled()) { - LOGGER.debug("OAuth2Client was not set, possibly due to the 'identity-service.authentication.enable-username-password-authentication=false' property."); + LOGGER.debug("IdentityServiceFacade was not set, possibly due to the 'identity-service.authentication.enable-username-password-authentication=false' property."); } - throw new AuthenticationException("User not authenticated because OAuth2Client was not set."); + throw new AuthenticationException("User not authenticated because IdentityServiceFacade was not set."); } try { // Attempt to verify user credentials - oAuth2Client.verifyCredentials(userName, new String(password)); + identityServiceFacade.verifyCredentials(userName, new String(password)); // Verification was successful so treat as authenticated user setCurrentUser(userName); @@ -108,32 +107,4 @@ public class IdentityServiceAuthenticationComponent extends AbstractAuthenticati { return allowGuestLogin; } - - /** - * An abstraction for acting as an OAuth2 Client - */ - interface OAuth2Client - { - /** - * The OAuth2's Client role is only used to verify the user credentials (Resource Owner Password - * Credentials Flow) this is why there is an explicit method for verifying these. - * @param userName user's name - * @param password user's password - * @throws CredentialsVerificationException when the verification failed or couldn't be performed - */ - void verifyCredentials(String userName, String password); - - class CredentialsVerificationException extends RuntimeException - { - CredentialsVerificationException(String message) - { - super(message); - } - - CredentialsVerificationException(String message, Throwable cause) - { - super(message, cause); - } - } - } } diff --git a/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/IdentityServiceDeploymentFactoryBean.java b/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/IdentityServiceDeploymentFactoryBean.java deleted file mode 100644 index fc26f6f7bc..0000000000 --- a/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/IdentityServiceDeploymentFactoryBean.java +++ /dev/null @@ -1,105 +0,0 @@ -/* - * #%L - * Alfresco Repository - * %% - * Copyright (C) 2005 - 2016 Alfresco Software Limited - * %% - * This file is part of the Alfresco software. - * If the software was purchased under a paid Alfresco license, the terms of - * the paid license agreement will prevail. Otherwise, the software is - * provided under the following open source license terms: - * - * Alfresco is free software: you can redistribute it and/or modify - * it under the terms of the GNU Lesser General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * Alfresco is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public License - * along with Alfresco. If not, see . - * #L% - */ -package org.alfresco.repo.security.authentication.identityservice; - -import org.apache.commons.logging.Log; -import org.apache.commons.logging.LogFactory; -import org.apache.http.client.HttpClient; -import org.keycloak.adapters.HttpClientBuilder; -import org.keycloak.adapters.KeycloakDeployment; -import org.keycloak.adapters.KeycloakDeploymentBuilder; -import org.springframework.beans.factory.FactoryBean; - -import java.util.concurrent.TimeUnit; - -/** - * Creates an instance of a KeycloakDeployment object for communicating with the Identity Service. - * - * @author Gavin Cornwell - */ -public class IdentityServiceDeploymentFactoryBean implements FactoryBean -{ - private static Log logger = LogFactory.getLog(IdentityServiceDeploymentFactoryBean.class); - - private IdentityServiceConfig identityServiceConfig; - - public void setIdentityServiceConfig(IdentityServiceConfig config) - { - this.identityServiceConfig = config; - } - - @Override - public KeycloakDeployment getObject() throws Exception - { - KeycloakDeployment deployment = KeycloakDeploymentBuilder.build(this.identityServiceConfig); - - // Set client with custom timeout values if client was created by the KeycloakDeploymentBuilder. - // This can be removed if the future versions of Keycloak accept timeout values through the config. - if (deployment.getClient() != null) - { - int connectionTimeout = identityServiceConfig.getClientConnectionTimeout(); - int socketTimeout = identityServiceConfig.getClientSocketTimeout(); - HttpClient client = new HttpClientBuilder() - .establishConnectionTimeout(connectionTimeout, TimeUnit.MILLISECONDS) - .socketTimeout(socketTimeout, TimeUnit.MILLISECONDS) - .build(this.identityServiceConfig); - deployment.setClient(client); - - if (logger.isDebugEnabled()) - { - logger.debug("Created HttpClient for Keycloak deployment with connection timeout: "+ connectionTimeout + " ms, socket timeout: "+ socketTimeout+" ms."); - } - } - else - { - if (logger.isDebugEnabled()) - { - logger.debug("HttpClient for Keycloak deployment was not set."); - } - } - - if (logger.isInfoEnabled()) - { - logger.info("Keycloak JWKS URL: " + deployment.getJwksUrl()); - logger.info("Keycloak Realm: " + deployment.getRealm()); - logger.info("Keycloak Client ID: " + deployment.getResourceName()); - } - - return deployment; - } - - @Override - public Class getObjectType() - { - return KeycloakDeployment.class; - } - - @Override - public boolean isSingleton() - { - return true; - } -} diff --git a/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/IdentityServiceFacade.java b/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/IdentityServiceFacade.java new file mode 100644 index 0000000000..d669d6053c --- /dev/null +++ b/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/IdentityServiceFacade.java @@ -0,0 +1,90 @@ +/* + * #%L + * Alfresco Repository + * %% + * Copyright (C) 2005 - 2023 Alfresco Software Limited + * %% + * This file is part of the Alfresco software. + * If the software was purchased under a paid Alfresco license, the terms of + * the paid license agreement will prevail. Otherwise, the software is + * provided under the following open source license terms: + * + * Alfresco is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Alfresco is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Alfresco. If not, see . + * #L% + */ +package org.alfresco.repo.security.authentication.identityservice; + +import java.util.Optional; + +/** + * Allows to interact with the Identity Service + */ +interface IdentityServiceFacade +{ + /** + * Verifies provided user credentials. The OAuth2's Client role is only used to verify the user credentials (Resource Owner Password + * Credentials Flow) this is why there is an explicit method for verifying these. + * + * @param username user's name + * @param password user's password + * @throws CredentialsVerificationException when the verification failed or couldn't be performed + */ + void verifyCredentials(String username, String password); + + /** + * Extracts username from provided token + * + * @param token token representation + * @return possible username + */ + Optional extractUsernameFromToken(String token); + + class IdentityServiceFacadeException extends RuntimeException + { + IdentityServiceFacadeException(String message) + { + super(message); + } + + IdentityServiceFacadeException(String message, Throwable cause) + { + super(message, cause); + } + } + class CredentialsVerificationException extends IdentityServiceFacadeException + { + CredentialsVerificationException(String message) + { + super(message); + } + + CredentialsVerificationException(String message, Throwable cause) + { + super(message, cause); + } + } + + class TokenException extends IdentityServiceFacadeException + { + TokenException(String message) + { + super(message); + } + + TokenException(String message, Throwable cause) + { + super(message, cause); + } + } +} diff --git a/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/IdentityServiceFacadeFactoryBean.java b/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/IdentityServiceFacadeFactoryBean.java new file mode 100644 index 0000000000..cb5887778d --- /dev/null +++ b/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/IdentityServiceFacadeFactoryBean.java @@ -0,0 +1,388 @@ +/* + * #%L + * Alfresco Repository + * %% + * Copyright (C) 2005 - 2023 Alfresco Software Limited + * %% + * This file is part of the Alfresco software. + * If the software was purchased under a paid Alfresco license, the terms of + * the paid license agreement will prevail. Otherwise, the software is + * provided under the following open source license terms: + * + * Alfresco is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Alfresco is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Alfresco. If not, see . + * #L% + */ +package org.alfresco.repo.security.authentication.identityservice; + +import static java.util.Objects.requireNonNull; +import static java.util.Optional.ofNullable; + +import java.time.Duration; +import java.time.temporal.ChronoUnit; +import java.util.Arrays; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; +import java.util.function.Supplier; + +import org.alfresco.repo.security.authentication.identityservice.IdentityServiceFacade.IdentityServiceFacadeException; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.springframework.beans.factory.FactoryBean; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.http.converter.FormHttpMessageConverter; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.client.AuthorizedClientServiceOAuth2AuthorizedClientManager; +import org.springframework.security.oauth2.client.OAuth2AuthorizationContext; +import org.springframework.security.oauth2.client.OAuth2AuthorizeRequest; +import org.springframework.security.oauth2.client.OAuth2AuthorizedClient; +import org.springframework.security.oauth2.client.OAuth2AuthorizedClientManager; +import org.springframework.security.oauth2.client.OAuth2AuthorizedClientProviderBuilder; +import org.springframework.security.oauth2.client.OAuth2AuthorizedClientProviderBuilder.PasswordGrantBuilder; +import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService; +import org.springframework.security.oauth2.client.endpoint.DefaultPasswordTokenResponseClient; +import org.springframework.security.oauth2.client.http.OAuth2ErrorResponseErrorHandler; +import org.springframework.security.oauth2.client.oidc.authentication.OidcIdTokenDecoderFactory; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; +import org.springframework.security.oauth2.client.registration.ClientRegistrations; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.ClientAuthenticationMethod; +import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2AuthorizationException; +import org.springframework.security.oauth2.core.http.converter.OAuth2AccessTokenResponseHttpMessageConverter; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtClaimNames; +import org.springframework.security.oauth2.jwt.JwtClaimValidator; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.jwt.JwtIssuerValidator; +import org.springframework.security.oauth2.jwt.JwtTimestampValidator; +import org.springframework.web.client.RestTemplate; + +/** + * + * Creates an instance of {@link IdentityServiceFacade}.
+ * This factory can return a null if it is disabled. + * + */ +public class IdentityServiceFacadeFactoryBean implements FactoryBean +{ + private static final Log LOGGER = LogFactory.getLog(IdentityServiceFacadeFactoryBean.class); + private boolean enabled; + private SpringBasedIdentityServiceFacadeFactory factory; + + public void setEnabled(boolean enabled) + { + this.enabled = enabled; + } + + public void setIdentityServiceConfig(IdentityServiceConfig identityServiceConfig) + { + factory = new SpringBasedIdentityServiceFacadeFactory(identityServiceConfig); + } + + @Override + public IdentityServiceFacade getObject() throws Exception + { + // The creation of the client can be disabled for testing or when the username/password authentication is not required, + // for instance when Keycloak is configured for 'bearer only' authentication or Direct Access Grants are disabled. + if (!enabled) + { + return null; + } + + return new LazyInstantiatingIdentityServiceFacade(factory::createIdentityServiceFacade); + } + + @Override + public Class getObjectType() + { + return IdentityServiceFacade.class; + } + + @Override + public boolean isSingleton() + { + return true; + } + + private static IdentityServiceFacadeException authorizationServerCantBeUsedException(RuntimeException cause) + { + return new IdentityServiceFacadeException("Unable to use the Authorization Server.", cause); + } + + // The target facade is created lazily to improve resiliency on Identity Service + // (Keycloak/Authorization Server) failures when Spring Context is starting up. + static class LazyInstantiatingIdentityServiceFacade implements IdentityServiceFacade + { + private final AtomicReference targetFacade = new AtomicReference<>(); + private final Supplier targetFacadeCreator; + + LazyInstantiatingIdentityServiceFacade(Supplier targetFacadeCreator) + { + this.targetFacadeCreator = requireNonNull(targetFacadeCreator); + } + + @Override + public void verifyCredentials(String username, String password) + { + getTargetFacade().verifyCredentials(username, password); + } + + @Override + public Optional extractUsernameFromToken(String token) + { + return getTargetFacade().extractUsernameFromToken(token); + } + + private IdentityServiceFacade getTargetFacade() + { + return ofNullable(targetFacade.get()) + .orElseGet(() -> targetFacade.updateAndGet(prev -> + ofNullable(prev).orElseGet(this::createTargetFacade))); + } + + private IdentityServiceFacade createTargetFacade() + { + try + { + return targetFacadeCreator.get(); + } + catch (IdentityServiceFacadeException e) + { + throw e; + } + catch (RuntimeException e) + { + LOGGER.warn("Failed to instantiate IdentityServiceFacade.", e); + throw authorizationServerCantBeUsedException(e); + } + } + } + + private static class SpringBasedIdentityServiceFacadeFactory + { + private static final long CLOCK_SKEW_MS = 0; + private final IdentityServiceConfig config; + + SpringBasedIdentityServiceFacadeFactory(IdentityServiceConfig config) + { + this.config = Objects.requireNonNull(config); + } + + private IdentityServiceFacade createIdentityServiceFacade() + { + //Here we preserve the behaviour of previously used Keycloak Adapter + // * Client is authenticating itself using basic auth + // * Resource Owner Password Credentials Flow is used to authenticate Resource Owner + // * There is no caching of authenticated clients (NoStoredAuthorizedClient) + // * There is only one Authorization Server/Client pair (SingleClientRegistration) + + final RestTemplate restTemplate = createRestTemplate(); + final ClientRegistration clientRegistration = createClientRegistration(restTemplate); + final OAuth2AuthorizedClientManager clientManager = createAuthorizedClientManager(restTemplate, clientRegistration); + final JwtDecoder jwtDecoder = createJwtDecoder(clientRegistration); + + return new SpringBasedIdentityServiceFacade(clientManager, jwtDecoder); + } + + private RestTemplate createRestTemplate() + { + final SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory(); + requestFactory.setConnectTimeout(config.getClientConnectionTimeout()); + requestFactory.setReadTimeout(config.getClientSocketTimeout()); + + final RestTemplate restTemplate = new RestTemplate(Arrays.asList(new FormHttpMessageConverter(), new OAuth2AccessTokenResponseHttpMessageConverter())); + restTemplate.setRequestFactory(requestFactory); + restTemplate.setErrorHandler(new OAuth2ErrorResponseErrorHandler()); + + return restTemplate; + } + + private ClientRegistration createClientRegistration(RestTemplate restTemplate) + { + try + { + return ClientRegistrations + .fromIssuerLocation(config.getIssuerUrl()) + .clientId(config.getResource()) + .clientSecret(config.getClientSecret()) + .authorizationGrantType(AuthorizationGrantType.PASSWORD) + .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC) + .registrationId(SpringBasedIdentityServiceFacade.CLIENT_REGISTRATION_ID) + .build(); + } + catch (RuntimeException e) + { + LOGGER.warn("Failed to create ClientRegistration.", e); + throw authorizationServerCantBeUsedException(e); + } + } + + private OAuth2AuthorizedClientManager createAuthorizedClientManager(RestTemplate restTemplate, ClientRegistration clientRegistration) + { + final AuthorizedClientServiceOAuth2AuthorizedClientManager manager = + new AuthorizedClientServiceOAuth2AuthorizedClientManager( + new SingleClientRegistration(clientRegistration), + new NoStoredAuthorizedClient()); + + final Consumer passwordGrantConfigurer = b -> { + final DefaultPasswordTokenResponseClient client = new DefaultPasswordTokenResponseClient(); + client.setRestOperations(restTemplate); + b.accessTokenResponseClient(client); + + b.clockSkew(Duration.of(CLOCK_SKEW_MS, ChronoUnit.MILLIS)); + }; + manager.setAuthorizedClientProvider(OAuth2AuthorizedClientProviderBuilder.builder() + .password(passwordGrantConfigurer) + .build()); + manager.setContextAttributesMapper(OAuth2AuthorizeRequest::getAttributes); + + return manager; + } + + private JwtDecoder createJwtDecoder(ClientRegistration clientRegistration) + { + final OidcIdTokenDecoderFactory decoderFactory = new OidcIdTokenDecoderFactory(); + decoderFactory.setJwtValidatorFactory(c -> new DelegatingOAuth2TokenValidator<>( + new JwtTimestampValidator(Duration.of(CLOCK_SKEW_MS, ChronoUnit.MILLIS)), + new JwtIssuerValidator(c.getProviderDetails().getIssuerUri()), + new JwtClaimValidator("typ", "Bearer"::equals), + new JwtClaimValidator(JwtClaimNames.SUB, Objects::nonNull) + + )); + try + { + return decoderFactory.createDecoder(clientRegistration); + } + catch (RuntimeException e) + { + LOGGER.warn("Failed to create JwtDecoder.", e); + throw authorizationServerCantBeUsedException(e); + } + } + + private static class NoStoredAuthorizedClient implements OAuth2AuthorizedClientService + { + + @Override + public T loadAuthorizedClient(String clientRegistrationId, String principalName) + { + return null; + } + + @Override + public void saveAuthorizedClient(OAuth2AuthorizedClient authorizedClient, Authentication principal) + { + //do nothing + } + + @Override + public void removeAuthorizedClient(String clientRegistrationId, String principalName) + { + //do nothing + } + } + + private static class SingleClientRegistration implements ClientRegistrationRepository + { + private final ClientRegistration clientRegistration; + + private SingleClientRegistration(ClientRegistration clientRegistration) + { + this.clientRegistration = requireNonNull(clientRegistration); + } + + @Override + public ClientRegistration findByRegistrationId(String registrationId) + { + return Objects.equals(registrationId, clientRegistration.getRegistrationId()) ? clientRegistration : null; + } + } + } + + static class SpringBasedIdentityServiceFacade implements IdentityServiceFacade + { + static final String CLIENT_REGISTRATION_ID = "ids"; + private final OAuth2AuthorizedClientManager oAuth2AuthorizedClientManager; + private JwtDecoder jwtDecoder; + + SpringBasedIdentityServiceFacade(OAuth2AuthorizedClientManager oAuth2AuthorizedClientManager, JwtDecoder jwtDecoder) + { + this.oAuth2AuthorizedClientManager = requireNonNull(oAuth2AuthorizedClientManager); + this.jwtDecoder = requireNonNull(jwtDecoder); + } + + @Override + public void verifyCredentials(String username, String password) + { + final OAuth2AuthorizedClient authorizedClient; + try + { + final OAuth2AuthorizeRequest authRequest = createPasswordCredentialsRequest(username, password); + authorizedClient = oAuth2AuthorizedClientManager.authorize(authRequest); + } + catch (OAuth2AuthorizationException e) + { + LOGGER.debug("Failed to authorize against Authorization Server. Reason: " + e.getError() + "."); + throw new CredentialsVerificationException("Authorization against the Authorization Server failed with " + e.getError() + ".", e); + } + catch (RuntimeException e) + { + LOGGER.warn("Failed to authorize against Authorization Server. Reason: " + e.getMessage()); + throw new CredentialsVerificationException("Failed to authorize against Authorization Server.", e); + } + + if (authorizedClient == null || authorizedClient.getAccessToken() == null) + { + throw new CredentialsVerificationException("Resource Owner Password Credentials is not supported by the Authorization Server."); + } + } + + @Override + public Optional extractUsernameFromToken(String token) + { + final Jwt validToken; + try + { + validToken = jwtDecoder.decode(requireNonNull(token)); + } + catch (RuntimeException e) + { + throw new TokenException("Failed to decode token. " + e.getMessage(), e); + } + if (LOGGER.isDebugEnabled()) + { + LOGGER.debug("Bearer token outcome: " + validToken); + } + return Optional.ofNullable(validToken) + .map(Jwt::getClaims) + .map(c -> c.get("preferred_username")) + .filter(String.class::isInstance) + .map(String.class::cast); + } + + private OAuth2AuthorizeRequest createPasswordCredentialsRequest(String userName, String password) + { + return OAuth2AuthorizeRequest + .withClientRegistrationId(CLIENT_REGISTRATION_ID) + .principal(userName) + .attribute(OAuth2AuthorizationContext.USERNAME_ATTRIBUTE_NAME, userName) + .attribute(OAuth2AuthorizationContext.PASSWORD_ATTRIBUTE_NAME, password) + .build(); + } + } +} diff --git a/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/IdentityServiceHttpFacade.java b/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/IdentityServiceHttpFacade.java deleted file mode 100644 index aa0e477a2a..0000000000 --- a/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/IdentityServiceHttpFacade.java +++ /dev/null @@ -1,107 +0,0 @@ -/* - * #%L - * Alfresco Repository - * %% - * Copyright (C) 2005 - 2016 Alfresco Software Limited - * %% - * This file is part of the Alfresco software. - * If the software was purchased under a paid Alfresco license, the terms of - * the paid license agreement will prevail. Otherwise, the software is - * provided under the following open source license terms: - * - * Alfresco is free software: you can redistribute it and/or modify - * it under the terms of the GNU Lesser General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * Alfresco is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public License - * along with Alfresco. If not, see . - * #L% - */ -package org.alfresco.repo.security.authentication.identityservice; - -import java.io.ByteArrayOutputStream; -import java.io.OutputStream; - -import javax.servlet.http.HttpServletRequest; - -import org.keycloak.adapters.servlet.ServletHttpFacade; - -/** - * HttpFacade wrapper so we can re-use Keycloak authenticator classes. - * - * @author Gavin Cornwell - */ -public class IdentityServiceHttpFacade extends ServletHttpFacade -{ - public IdentityServiceHttpFacade(HttpServletRequest request) - { - super(request, null); - } - - @Override - public Response getResponse() - { - // return our dummy NoOp implementation so we don't effect the ACS response - return new NoOpResponseFacade(); - } - - /** - * NoOp implementation of Keycloak Response interface. - */ - private class NoOpResponseFacade implements Response - { - - @Override - public void setStatus(int status) - { - } - - @Override - public void addHeader(String name, String value) - { - } - - @Override - public void setHeader(String name, String value) - { - } - - @Override - public void resetCookie(String name, String path) - { - } - - @Override - public void setCookie(String name, String value, String path, String domain, int maxAge, - boolean secure, boolean httpOnly) - { - } - - @Override - public OutputStream getOutputStream() - { - return new ByteArrayOutputStream(); - } - - @Override - public void sendError(int code) - { - } - - @Override - public void sendError(int code, String message) - { - } - - @Override - public void end() - { - } - } -} diff --git a/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/IdentityServiceRemoteUserMapper.java b/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/IdentityServiceRemoteUserMapper.java index ef0c1751bf..ab8028fb1f 100644 --- a/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/IdentityServiceRemoteUserMapper.java +++ b/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/IdentityServiceRemoteUserMapper.java @@ -2,7 +2,7 @@ * #%L * Alfresco Repository * %% - * Copyright (C) 2005 - 2016 Alfresco Software Limited + * Copyright (C) 2005 - 2023 Alfresco Software Limited * %% * This file is part of the Alfresco software. * If the software was purchased under a paid Alfresco license, the terms of @@ -27,17 +27,19 @@ package org.alfresco.repo.security.authentication.identityservice; import javax.servlet.http.HttpServletRequest; +import java.util.Optional; + import org.alfresco.repo.management.subsystems.ActivateableBean; 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.security.authentication.external.RemoteUserMapper; +import org.alfresco.repo.security.authentication.identityservice.IdentityServiceFacade.TokenException; import org.alfresco.service.cmr.security.PersonService; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; -import org.keycloak.adapters.KeycloakDeployment; -import org.keycloak.adapters.spi.AuthOutcome; -import org.keycloak.representations.AccessToken; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.server.resource.web.BearerTokenResolver; /** * A {@link RemoteUserMapper} implementation that detects and validates JWTs @@ -47,7 +49,7 @@ import org.keycloak.representations.AccessToken; */ public class IdentityServiceRemoteUserMapper implements RemoteUserMapper, ActivateableBean { - private static Log logger = LogFactory.getLog(IdentityServiceRemoteUserMapper.class); + private static final Log LOGGER = LogFactory.getLog(IdentityServiceRemoteUserMapper.class); /** Is the mapper enabled */ private boolean isEnabled; @@ -57,9 +59,9 @@ public class IdentityServiceRemoteUserMapper implements RemoteUserMapper, Activa /** The person service. */ private PersonService personService; - - /** The Keycloak deployment object */ - private KeycloakDeployment keycloakDeployment; + + private BearerTokenResolver bearerTokenResolver; + private IdentityServiceFacade identityServiceFacade; /** * Sets the active flag @@ -91,58 +93,57 @@ public class IdentityServiceRemoteUserMapper implements RemoteUserMapper, Activa { this.personService = personService; } - - public void setIdentityServiceDeployment(KeycloakDeployment deployment) + + public void setBearerTokenResolver(BearerTokenResolver bearerTokenResolver) { - this.keycloakDeployment = deployment; + this.bearerTokenResolver = bearerTokenResolver; + } + + public void setIdentityServiceFacade(IdentityServiceFacade identityServiceFacade) + { + this.identityServiceFacade = identityServiceFacade; } /* * (non-Javadoc) * @see org.alfresco.web.app.servlet.RemoteUserMapper#getRemoteUser(javax.servlet.http.HttpServletRequest) */ + @Override public String getRemoteUser(HttpServletRequest request) { + LOGGER.trace("Retrieving username from http request..."); + + if (!this.isEnabled) + { + LOGGER.debug("IdentityServiceRemoteUserMapper is disabled, returning null."); + return null; + } try { - if (logger.isTraceEnabled()) - { - logger.trace("Retrieving username from http request..."); - } - - if (!this.isEnabled) - { - if (logger.isDebugEnabled()) - { - logger.debug("IdentityServiceRemoteUserMapper is disabled, returning null."); - } - - return null; - } - String headerUserId = extractUserFromHeader(request); if (headerUserId != null) { // Normalize the user ID taking into account case sensitivity settings String normalizedUserId = normalizeUserId(headerUserId); - - if (logger.isTraceEnabled()) - { - logger.trace("Returning userId: " + AuthenticationUtil.maskUsername(normalizedUserId)); - } + LOGGER.trace("Returning userId: " + AuthenticationUtil.maskUsername(normalizedUserId)); return normalizedUserId; } } - catch (Exception e) + catch (TokenException e) { - logger.error("Failed to authenticate user using IdentityServiceRemoteUserMapper: " + e.getMessage(), e); + if (!isValidationFailureSilent) + { + throw new AuthenticationException("Failed to extract username from token: " + e.getMessage(), e); + } + LOGGER.error("Failed to authenticate user using IdentityServiceRemoteUserMapper: " + e.getMessage(), e); } - if (logger.isTraceEnabled()) + catch (RuntimeException e) { - logger.trace("Could not identify a userId. Returning null."); + LOGGER.error("Failed to authenticate user using IdentityServiceRemoteUserMapper: " + e.getMessage(), e); } + LOGGER.trace("Could not identify a userId. Returning null."); return null; } @@ -163,57 +164,32 @@ public class IdentityServiceRemoteUserMapper implements RemoteUserMapper, Activa */ private String extractUserFromHeader(HttpServletRequest request) { - String userName = null; - - IdentityServiceHttpFacade facade = new IdentityServiceHttpFacade(request); - // try authenticating with bearer token first - if (logger.isDebugEnabled()) + LOGGER.debug("Trying bearer token..."); + + final String bearerToken; + try { - logger.debug("Trying bearer token..."); + bearerToken = bearerTokenResolver.resolve(request); } - - AlfrescoBearerTokenRequestAuthenticator tokenAuthenticator = - new AlfrescoBearerTokenRequestAuthenticator(this.keycloakDeployment); - AuthOutcome tokenOutcome = tokenAuthenticator.authenticate(facade); - - if (logger.isDebugEnabled()) + catch (OAuth2AuthenticationException e) { - logger.debug("Bearer token outcome: " + tokenOutcome); + LOGGER.debug("Failed to resolve Bearer token.", e); + return null; } - - if (tokenOutcome == AuthOutcome.FAILED && !isValidationFailureSilent) + + final Optional possibleUsername = Optional.ofNullable(bearerToken) + .flatMap(identityServiceFacade::extractUsernameFromToken); + if (possibleUsername.isEmpty()) { - throw new AuthenticationException("Token validation failed: " + - tokenAuthenticator.getValidationFailureDescription()); + LOGGER.debug("User could not be authenticated by IdentityServiceRemoteUserMapper."); + return null; } - - if (tokenOutcome == AuthOutcome.AUTHENTICATED) - { - userName = extractUserFromToken(tokenAuthenticator.getToken()); - } - else - { - if (logger.isDebugEnabled()) - { - logger.debug("User could not be authenticated by IdentityServiceRemoteUserMapper."); - } - } - - return userName; - } - - private String extractUserFromToken(AccessToken jwt) - { - // retrieve the preferred_username claim - String userName = jwt.getPreferredUsername(); - - if (logger.isTraceEnabled()) - { - logger.trace("Extracted username: " + AuthenticationUtil.maskUsername(userName)); - } - - return userName; + + String username = possibleUsername.get(); + LOGGER.trace("Extracted username: " + AuthenticationUtil.maskUsername(username)); + + return username; } /** @@ -238,9 +214,9 @@ public class IdentityServiceRemoteUserMapper implements RemoteUserMapper, Activa } }, AuthenticationUtil.getSystemUserName()); - if (logger.isDebugEnabled()) + if (LOGGER.isDebugEnabled()) { - logger.debug("Normalized user name for '" + AuthenticationUtil.maskUsername(userId) + "': " + AuthenticationUtil.maskUsername(normalized)); + LOGGER.debug("Normalized user name for '" + AuthenticationUtil.maskUsername(userId) + "': " + AuthenticationUtil.maskUsername(normalized)); } return normalized == null ? userId : normalized; diff --git a/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/OAuth2ClientFactoryBean.java b/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/OAuth2ClientFactoryBean.java deleted file mode 100644 index bcf01ebaf2..0000000000 --- a/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/OAuth2ClientFactoryBean.java +++ /dev/null @@ -1,270 +0,0 @@ -/* - * #%L - * Alfresco Repository - * %% - * Copyright (C) 2005 - 2023 Alfresco Software Limited - * %% - * This file is part of the Alfresco software. - * If the software was purchased under a paid Alfresco license, the terms of - * the paid license agreement will prevail. Otherwise, the software is - * provided under the following open source license terms: - * - * Alfresco is free software: you can redistribute it and/or modify - * it under the terms of the GNU Lesser General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * Alfresco is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public License - * along with Alfresco. If not, see . - * #L% - */ -package org.alfresco.repo.security.authentication.identityservice; - -import java.util.Arrays; -import java.util.Objects; -import java.util.concurrent.atomic.AtomicReference; -import java.util.function.Supplier; - -import org.alfresco.repo.security.authentication.identityservice.IdentityServiceAuthenticationComponent.OAuth2Client; -import org.apache.commons.logging.Log; -import org.apache.commons.logging.LogFactory; -import org.springframework.beans.factory.FactoryBean; -import org.springframework.http.client.SimpleClientHttpRequestFactory; -import org.springframework.http.converter.FormHttpMessageConverter; -import org.springframework.security.core.Authentication; -import org.springframework.security.oauth2.client.AuthorizedClientServiceOAuth2AuthorizedClientManager; -import org.springframework.security.oauth2.client.OAuth2AuthorizationContext; -import org.springframework.security.oauth2.client.OAuth2AuthorizeRequest; -import org.springframework.security.oauth2.client.OAuth2AuthorizedClient; -import org.springframework.security.oauth2.client.OAuth2AuthorizedClientManager; -import org.springframework.security.oauth2.client.OAuth2AuthorizedClientProviderBuilder; -import org.springframework.security.oauth2.client.OAuth2AuthorizedClientProviderBuilder.PasswordGrantBuilder; -import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService; -import org.springframework.security.oauth2.client.endpoint.DefaultPasswordTokenResponseClient; -import org.springframework.security.oauth2.client.http.OAuth2ErrorResponseErrorHandler; -import org.springframework.security.oauth2.client.registration.ClientRegistration; -import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; -import org.springframework.security.oauth2.client.registration.ClientRegistrations; -import org.springframework.security.oauth2.core.AuthorizationGrantType; -import org.springframework.security.oauth2.core.ClientAuthenticationMethod; -import org.springframework.security.oauth2.core.OAuth2AuthorizationException; -import org.springframework.security.oauth2.core.http.converter.OAuth2AccessTokenResponseHttpMessageConverter; -import org.springframework.web.client.RestTemplate; - -/** - * - * Creates an instance of {@link OAuth2Client}.
- * The creation of {@link OAuth2Client} requires connection to the Identity Service (Keycloak), disable this factory if - * the server cannot be reached.
- * This factory can return a null if it is disabled. - * - */ -public class OAuth2ClientFactoryBean implements FactoryBean -{ - - private static final Log LOGGER = LogFactory.getLog(OAuth2ClientFactoryBean.class); - private IdentityServiceConfig identityServiceConfig; - private boolean enabled; - - public void setEnabled(boolean enabled) - { - this.enabled = enabled; - } - - public void setIdentityServiceConfig(IdentityServiceConfig identityServiceConfig) - { - this.identityServiceConfig = identityServiceConfig; - } - - @Override - public OAuth2Client getObject() throws Exception - { - // The creation of the client can be disabled for testing or when the username/password authentication is not required, - // for instance when Keycloak is configured for 'bearer only' authentication or Direct Access Grants are disabled. - if (!enabled) - { - return null; - } - - // The OAuth2AuthorizedClientManager isn't created upfront to make the code resilient to Identity Service being down. - // If it's down the Application Context will start and when it's back online it can be used. - return new SpringOAuth2Client(this::createOAuth2AuthorizedClientManager); - } - - private OAuth2AuthorizedClientManager createOAuth2AuthorizedClientManager() - { - //Here we preserve the behaviour of previously used Keycloak Adapter - // * Client is authenticating itself using basic auth - // * Resource Owner Password Credentials Flow is used to authenticate Resource Owner - // * There is no caching of authenticated clients (NoStoredAuthorizedClient) - // * There is only one Authorization Server/Client pair (SingleClientRegistration) - - final ClientRegistration clientRegistration = ClientRegistrations - .fromIssuerLocation(identityServiceConfig.getIssuerUrl()) - .clientId(identityServiceConfig.getResource()) - .clientSecret(identityServiceConfig.getClientSecret()) - .authorizationGrantType(AuthorizationGrantType.PASSWORD) - .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC) - .registrationId(SpringOAuth2Client.CLIENT_REGISTRATION_ID) - .build(); - - final AuthorizedClientServiceOAuth2AuthorizedClientManager oauth2 = - new AuthorizedClientServiceOAuth2AuthorizedClientManager( - new SingleClientRegistration(clientRegistration), - new NoStoredAuthorizedClient()); - oauth2.setContextAttributesMapper(OAuth2AuthorizeRequest::getAttributes); - oauth2.setAuthorizedClientProvider(OAuth2AuthorizedClientProviderBuilder.builder() - .password(this::configureTimeouts) - .build()); - - if (LOGGER.isDebugEnabled()) - { - LOGGER.debug(" Created OAuth2 Client"); - LOGGER.debug(" OAuth2 Issuer URL: " + clientRegistration.getProviderDetails().getIssuerUri()); - LOGGER.debug(" OAuth2 ClientId: " + clientRegistration.getClientId()); - } - - return oauth2; - } - - private void configureTimeouts(PasswordGrantBuilder builder) - { - final SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory(); - requestFactory.setConnectTimeout(identityServiceConfig.getClientConnectionTimeout()); - requestFactory.setReadTimeout(identityServiceConfig.getClientSocketTimeout()); - - final RestTemplate restTemplate = new RestTemplate(Arrays.asList(new FormHttpMessageConverter(), new OAuth2AccessTokenResponseHttpMessageConverter())); - restTemplate.setRequestFactory(requestFactory); - restTemplate.setErrorHandler(new OAuth2ErrorResponseErrorHandler()); - - final DefaultPasswordTokenResponseClient client = new DefaultPasswordTokenResponseClient(); - client.setRestOperations(restTemplate); - - builder.accessTokenResponseClient(client); - } - - @Override - public Class getObjectType() - { - return OAuth2Client.class; - } - - @Override - public boolean isSingleton() - { - return true; - } - - static class SpringOAuth2Client implements OAuth2Client - { - private static final String CLIENT_REGISTRATION_ID = "ids"; - private final Supplier authorizedClientManagerSupplier; - private final AtomicReference authorizedClientManager = new AtomicReference<>(); - - public SpringOAuth2Client(Supplier authorizedClientManagerSupplier) - { - this.authorizedClientManagerSupplier = Objects.requireNonNull(authorizedClientManagerSupplier); - } - - @Override - public void verifyCredentials(String userName, String password) - { - final OAuth2AuthorizedClientManager clientManager; - try - { - clientManager = getAuthorizedClientManager(); - } - catch (RuntimeException e) - { - LOGGER.warn("Failed to instantiate OAuth2AuthorizedClientManager.", e); - throw new CredentialsVerificationException("Unable to use the Authorization Server.", e); - } - - final OAuth2AuthorizedClient authorizedClient; - try - { - final OAuth2AuthorizeRequest authRequest = createPasswordCredentialsRequest(userName, password); - authorizedClient = clientManager.authorize(authRequest); - } - catch (OAuth2AuthorizationException e) - { - LOGGER.debug("Failed to authorize against Authorization Server. Reason: " + e.getError() + "."); - throw new CredentialsVerificationException("Authorization against the Authorization Server failed with " + e.getError() + ".", e); - } - catch (RuntimeException e) - { - LOGGER.warn("Failed to authorize against Authorization Server. Reason: " + e.getMessage()); - throw new CredentialsVerificationException("Failed to authorize against Authorization Server.", e); - } - - if (authorizedClient == null || authorizedClient.getAccessToken() == null) - { - throw new CredentialsVerificationException("Resource Owner Password Credentials is not supported by the Authorization Server."); - } - } - - private OAuth2AuthorizedClientManager getAuthorizedClientManager() - { - final OAuth2AuthorizedClientManager current = authorizedClientManager.get(); - if (current != null) - { - return current; - } - return authorizedClientManager - .updateAndGet(prev -> prev != null ? prev : authorizedClientManagerSupplier.get()); - } - - private OAuth2AuthorizeRequest createPasswordCredentialsRequest(String userName, String password) - { - return OAuth2AuthorizeRequest - .withClientRegistrationId(CLIENT_REGISTRATION_ID) - .principal(userName) - .attribute(OAuth2AuthorizationContext.USERNAME_ATTRIBUTE_NAME, userName) - .attribute(OAuth2AuthorizationContext.PASSWORD_ATTRIBUTE_NAME, password) - .build(); - } - } - - private static class NoStoredAuthorizedClient implements OAuth2AuthorizedClientService - { - - @Override - public T loadAuthorizedClient(String clientRegistrationId, String principalName) - { - return null; - } - - @Override - public void saveAuthorizedClient(OAuth2AuthorizedClient authorizedClient, Authentication principal) - { - //do nothing - } - - @Override - public void removeAuthorizedClient(String clientRegistrationId, String principalName) - { - //do nothing - } - } - - private static class SingleClientRegistration implements ClientRegistrationRepository - { - private final ClientRegistration clientRegistration; - - private SingleClientRegistration(ClientRegistration clientRegistration) - { - this.clientRegistration = Objects.requireNonNull(clientRegistration); - } - - @Override - public ClientRegistration findByRegistrationId(String registrationId) - { - return Objects.equals(registrationId, clientRegistration.getRegistrationId()) ? clientRegistration : null; - } - } -} diff --git a/repository/src/main/resources/alfresco/subsystems/Authentication/identity-service/identity-service-authentication-context.xml b/repository/src/main/resources/alfresco/subsystems/Authentication/identity-service/identity-service-authentication-context.xml index e3b5d3e0fc..a467b26824 100644 --- a/repository/src/main/resources/alfresco/subsystems/Authentication/identity-service/identity-service-authentication-context.xml +++ b/repository/src/main/resources/alfresco/subsystems/Authentication/identity-service/identity-service-authentication-context.xml @@ -21,12 +21,12 @@ ${identity-service.authentication.allowGuestLogin} - - + + - + @@ -204,12 +204,6 @@ ${identity-service.client-socket-timeout:2000} - - - - - - @@ -222,8 +216,11 @@ - - + + + + + diff --git a/repository/src/test/java/org/alfresco/AllUnitTestsSuite.java b/repository/src/test/java/org/alfresco/AllUnitTestsSuite.java index 969f70b60e..3b2441f36e 100644 --- a/repository/src/test/java/org/alfresco/AllUnitTestsSuite.java +++ b/repository/src/test/java/org/alfresco/AllUnitTestsSuite.java @@ -25,6 +25,8 @@ */ package org.alfresco; +import org.alfresco.repo.security.authentication.identityservice.LazyInstantiatingIdentityServiceFacadeUnitTest; +import org.alfresco.repo.security.authentication.identityservice.SpringBasedIdentityServiceFacadeUnitTest; import org.alfresco.util.testing.category.DBTests; import org.alfresco.util.testing.category.NonBuildTests; import org.junit.experimental.categories.Categories; @@ -136,7 +138,8 @@ import org.junit.runners.Suite; org.alfresco.repo.search.impl.solr.facet.FacetQNameUtilsTest.class, org.alfresco.util.BeanExtenderUnitTest.class, org.alfresco.repo.solr.SOLRTrackingComponentUnitTest.class, - org.alfresco.repo.security.authentication.identityservice.SpringOAuth2ClientUnitTest.class, + LazyInstantiatingIdentityServiceFacadeUnitTest.class, + SpringBasedIdentityServiceFacadeUnitTest.class, org.alfresco.repo.security.authentication.CompositePasswordEncoderTest.class, org.alfresco.repo.security.authentication.PasswordHashingTest.class, org.alfresco.repo.security.authority.script.ScriptAuthorityService_RegExTest.class, diff --git a/repository/src/test/java/org/alfresco/repo/security/authentication/identityservice/IdentityServiceAuthenticationComponentTest.java b/repository/src/test/java/org/alfresco/repo/security/authentication/identityservice/IdentityServiceAuthenticationComponentTest.java index 009e6ec7df..ad79b82ef8 100644 --- a/repository/src/test/java/org/alfresco/repo/security/authentication/identityservice/IdentityServiceAuthenticationComponentTest.java +++ b/repository/src/test/java/org/alfresco/repo/security/authentication/identityservice/IdentityServiceAuthenticationComponentTest.java @@ -34,8 +34,7 @@ import java.net.ConnectException; import org.alfresco.error.ExceptionStackUtil; import org.alfresco.repo.security.authentication.AuthenticationContext; import org.alfresco.repo.security.authentication.AuthenticationException; -import org.alfresco.repo.security.authentication.identityservice.IdentityServiceAuthenticationComponent.OAuth2Client; -import org.alfresco.repo.security.authentication.identityservice.IdentityServiceAuthenticationComponent.OAuth2Client.CredentialsVerificationException; +import org.alfresco.repo.security.authentication.identityservice.IdentityServiceFacade.CredentialsVerificationException; import org.alfresco.repo.security.sync.UserRegistrySynchronizer; import org.alfresco.service.cmr.repository.NodeService; import org.alfresco.service.cmr.security.PersonService; @@ -65,7 +64,7 @@ public class IdentityServiceAuthenticationComponentTest extends BaseSpringTest @Autowired private PersonService personService; - private OAuth2Client mockOAuth2Client; + private IdentityServiceFacade mockIdentityServiceFacade; @Before public void setUp() @@ -76,8 +75,8 @@ public class IdentityServiceAuthenticationComponentTest extends BaseSpringTest authComponent.setNodeService(nodeService); authComponent.setPersonService(personService); - mockOAuth2Client = mock(OAuth2Client.class); - authComponent.setOAuth2Client(mockOAuth2Client); + mockIdentityServiceFacade = mock(IdentityServiceFacade.class); + authComponent.setIdentityServiceFacade(mockIdentityServiceFacade); } @After @@ -90,7 +89,7 @@ public class IdentityServiceAuthenticationComponentTest extends BaseSpringTest public void testAuthenticationFail() { doThrow(new CredentialsVerificationException("Failed")) - .when(mockOAuth2Client) + .when(mockIdentityServiceFacade) .verifyCredentials("username", "password"); authComponent.authenticateImpl("username", "password".toCharArray()); @@ -100,7 +99,7 @@ public class IdentityServiceAuthenticationComponentTest extends BaseSpringTest public void testAuthenticationFail_connectionException() { doThrow(new CredentialsVerificationException("Couldn't connect to server", new ConnectException("ConnectionRefused"))) - .when(mockOAuth2Client) + .when(mockIdentityServiceFacade) .verifyCredentials("username", "password"); try @@ -119,7 +118,7 @@ public class IdentityServiceAuthenticationComponentTest extends BaseSpringTest public void testAuthenticationFail_otherException() { doThrow(new RuntimeException("Some other errors!")) - .when(mockOAuth2Client) + .when(mockIdentityServiceFacade) .verifyCredentials("username", "password"); authComponent.authenticateImpl("username", "password".toCharArray()); @@ -128,7 +127,7 @@ public class IdentityServiceAuthenticationComponentTest extends BaseSpringTest @Test public void testAuthenticationPass() { - doNothing().when(mockOAuth2Client).verifyCredentials("username", "password"); + doNothing().when(mockIdentityServiceFacade).verifyCredentials("username", "password"); authComponent.authenticateImpl("username", "password".toCharArray()); @@ -137,9 +136,9 @@ public class IdentityServiceAuthenticationComponentTest extends BaseSpringTest } @Test (expected= AuthenticationException.class) - public void testFallthroughWhenOAuth2ClientIsNull() + public void testFallthroughWhenIdentityServiceFacadeIsNull() { - authComponent.setOAuth2Client(null); + authComponent.setIdentityServiceFacade(null); authComponent.authenticateImpl("username", "password".toCharArray()); } diff --git a/repository/src/test/java/org/alfresco/repo/security/authentication/identityservice/IdentityServiceRemoteUserMapperTest.java b/repository/src/test/java/org/alfresco/repo/security/authentication/identityservice/IdentityServiceRemoteUserMapperTest.java index d669812d0b..eff83bf452 100644 --- a/repository/src/test/java/org/alfresco/repo/security/authentication/identityservice/IdentityServiceRemoteUserMapperTest.java +++ b/repository/src/test/java/org/alfresco/repo/security/authentication/identityservice/IdentityServiceRemoteUserMapperTest.java @@ -2,7 +2,7 @@ * #%L * Alfresco Repository * %% - * Copyright (C) 2005 - 2016 Alfresco Software Limited + * Copyright (C) 2005 - 2023 Alfresco Software Limited * %% * This file is part of the Alfresco software. * If the software was purchased under a paid Alfresco license, the terms of @@ -25,377 +25,89 @@ */ package org.alfresco.repo.security.authentication.identityservice; -import static org.mockito.ArgumentMatchers.any; +import static java.util.Optional.ofNullable; + +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; -import java.io.ByteArrayInputStream; -import java.net.InetAddress; -import java.net.NetworkInterface; -import java.net.SocketException; -import java.security.KeyPair; -import java.security.KeyPairGenerator; -import java.security.PublicKey; -import java.util.Enumeration; import java.util.Map; import java.util.Vector; -import java.util.regex.Pattern; +import java.util.function.Supplier; import javax.servlet.http.HttpServletRequest; -import org.alfresco.repo.management.subsystems.AbstractChainedSubsystemTest; -import org.alfresco.repo.management.subsystems.ChildApplicationContextFactory; -import org.alfresco.repo.management.subsystems.DefaultChildApplicationContextManager; +import junit.framework.TestCase; import org.alfresco.repo.security.authentication.AuthenticationException; -import org.alfresco.repo.security.authentication.external.RemoteUserMapper; -import org.alfresco.repo.security.authentication.identityservice.IdentityServiceConfig; -import org.alfresco.util.ApplicationContextHelper; -import org.apache.http.HttpEntity; -import org.apache.http.HttpResponse; -import org.apache.http.StatusLine; -import org.apache.http.client.HttpClient; -import org.keycloak.adapters.KeycloakDeployment; -import org.keycloak.adapters.rotation.HardcodedPublicKeyLocator; -import org.keycloak.common.util.Base64; -import org.keycloak.common.util.Time; -import org.keycloak.jose.jws.JWSBuilder; -import org.keycloak.representations.AccessToken; -import org.springframework.context.ApplicationContext; +import org.alfresco.repo.security.authentication.identityservice.IdentityServiceFacade.TokenException; +import org.alfresco.service.cmr.security.PersonService; +import org.springframework.security.oauth2.server.resource.web.DefaultBearerTokenResolver; /** * Tests the Identity Service based authentication subsystem. * * @author Gavin Cornwell */ -public class IdentityServiceRemoteUserMapperTest extends AbstractChainedSubsystemTest +public class IdentityServiceRemoteUserMapperTest extends TestCase { - private static final String REMOTE_USER_MAPPER_BEAN_NAME = "remoteUserMapper"; - private static final String DEPLOYMENT_BEAN_NAME = "identityServiceDeployment"; - private static final String CONFIG_BEAN_NAME = "identityServiceConfig"; - - private static final String TEST_USER_USERNAME = "testuser"; - private static final String TEST_USER_EMAIL = "testuser@mail.com"; - private static final String AUTHORIZATION_HEADER = "Authorization"; private static final String BEARER_PREFIX = "Bearer "; - private static final String BASIC_PREFIX = "Basic "; - - private static final String CONFIG_SILENT_ERRORS = "identity-service.authentication.validation.failure.silent"; - - private static final String PASSWORD_GRANT_RESPONSE = "{" + - "\"access_token\": \"%s\"," + - "\"expires_in\": 300," + - "\"refresh_expires_in\": 1800," + - "\"refresh_token\": \"%s\"," + - "\"token_type\": \"bearer\"," + - "\"not-before-policy\": 0," + - "\"session_state\": \"71c2c5ba-9c98-49fc-882f-dedcf80ee1b5\"}"; - - ApplicationContext ctx = ApplicationContextHelper.getApplicationContext(); - DefaultChildApplicationContextManager childApplicationContextManager; - ChildApplicationContextFactory childApplicationContextFactory; - - private KeyPair keyPair; - private IdentityServiceConfig identityServiceConfig; - @Override - protected void setUp() throws Exception + public void testValidToken() { - // switch authentication to use token auth - childApplicationContextManager = (DefaultChildApplicationContextManager) ctx.getBean("Authentication"); - childApplicationContextManager.stop(); - childApplicationContextManager.setProperty("chain", "identity-service1:identity-service"); - childApplicationContextFactory = getChildApplicationContextFactory(childApplicationContextManager, "identity-service1"); - - // generate keys for test - this.keyPair = KeyPairGenerator.getInstance("RSA").generateKeyPair(); - - // hardcode the realm public key in the deployment bean to stop it fetching keys - applyHardcodedPublicKey(this.keyPair.getPublic()); - - // extract config - this.identityServiceConfig = (IdentityServiceConfig)childApplicationContextFactory. - getApplicationContext().getBean(CONFIG_BEAN_NAME); + final IdentityServiceRemoteUserMapper mapper = givenMapper(Map.of("VaLiD-ToKeN", () -> "johny")); + + HttpServletRequest mockRequest = createMockTokenRequest("VaLiD-ToKeN"); + + final String user = mapper.getRemoteUser(mockRequest); + assertEquals("johny", user); } - @Override - protected void tearDown() throws Exception + public void testWrongTokenWithSilentValidation() { - childApplicationContextManager.destroy(); - childApplicationContextManager = null; - childApplicationContextFactory = null; + final IdentityServiceRemoteUserMapper mapper = givenMapper(Map.of("WrOnG-ToKeN", () -> {throw new TokenException("Expected ");})); + mapper.setValidationFailureSilent(true); + + HttpServletRequest mockRequest = createMockTokenRequest("WrOnG-ToKeN"); + + final String user = mapper.getRemoteUser(mockRequest); + assertNull(user); } - public void testKeycloakConfig() throws Exception + public void testWrongTokenWithoutSilentValidation() { - //Get the host of the IDS test server - String ip = "localhost"; - try { - Enumeration interfaces = NetworkInterface.getNetworkInterfaces(); - while (interfaces.hasMoreElements()) { - NetworkInterface iface = interfaces.nextElement(); - // filters out 127.0.0.1 and inactive interfaces - if (iface.isLoopback() || !iface.isUp()) - continue; + final IdentityServiceRemoteUserMapper mapper = givenMapper(Map.of("WrOnG-ToKeN", () -> {throw new TokenException("Expected");})); + mapper.setValidationFailureSilent(false); - Enumeration addresses = iface.getInetAddresses(); - while(addresses.hasMoreElements()) { - InetAddress addr = addresses.nextElement(); - if(Pattern.matches("([0-9]{1,3}\\.){3}[0-9]{1,3}", addr.getHostAddress())){ - ip = addr.getHostAddress(); - break; - } - } - } - } catch (SocketException e) { - throw new RuntimeException(e); - } + HttpServletRequest mockRequest = createMockTokenRequest("WrOnG-ToKeN"); - // check string overrides - assertEquals("identity-service.auth-server-url", "http://"+ip+":8999/auth", - this.identityServiceConfig.getAuthServerUrl()); - - assertEquals("identity-service.realm", "alfresco", - this.identityServiceConfig.getRealm()); + assertThatExceptionOfType(AuthenticationException.class) + .isThrownBy(() -> mapper.getRemoteUser(mockRequest)) + .havingCause().withNoCause().withMessage("Expected"); + } - assertEquals("identity-service.realm-public-key", - "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAvWLQxipXNe6cLnVPGy7l" + - "BgyR51bDiK7Jso8Rmh2TB+bmO4fNaMY1ETsxECSM0f6NTV0QHks9+gBe+pB6JNeM" + - "uPmaE/M/MsE9KUif9L2ChFq3zor6s2foFv2DTiTkij+1aQF9fuIjDNH4FC6L252W" + - "ydZzh+f73Xuy5evdPj+wrPYqWyP7sKd+4Q9EIILWAuTDvKEjwyZmIyfM/nUn6ltD" + - "P6W8xMP0PoEJNAAp79anz2jk2HP2PvC2qdjVsphdTk3JG5qQMB0WJUh4Kjgabd4j" + - "QJ77U8gTRswKgNHRRPWhruiIcmmkP+zI0ozNW6rxH3PF4L7M9rXmfcmUcBcKf+Yx" + - "jwIDAQAB", - this.identityServiceConfig.getRealmKey()); - - assertEquals("identity-service.ssl-required", "external", - this.identityServiceConfig.getSslRequired()); - - assertEquals("identity-service.resource", "test", - this.identityServiceConfig.getResource()); - - assertEquals("identity-service.cors-allowed-headers", "Authorization", - this.identityServiceConfig.getCorsAllowedHeaders()); - - assertEquals("identity-service.cors-allowed-methods", "POST, PUT, DELETE, GET", - this.identityServiceConfig.getCorsAllowedMethods()); - - assertEquals("identity-service.cors-exposed-headers", "WWW-Authenticate, My-custom-exposed-Header", - this.identityServiceConfig.getCorsExposedHeaders()); - - assertEquals("identity-service.truststore", - "classpath:/alfresco/subsystems/identityServiceAuthentication/keystore.jks", - this.identityServiceConfig.getTruststore()); - - assertEquals("identity-service.truststore-password", "password", - this.identityServiceConfig.getTruststorePassword()); - - assertEquals("identity-service.client-keystore", - "classpath:/alfresco/subsystems/identityServiceAuthentication/keystore.jks", - this.identityServiceConfig.getClientKeystore()); - - assertEquals("identity-service.client-keystore-password", "password", - this.identityServiceConfig.getClientKeystorePassword()); - - assertEquals("identity-service.client-key-password", "password", - this.identityServiceConfig.getClientKeyPassword()); - - assertEquals("identity-service.token-store", "SESSION", - this.identityServiceConfig.getTokenStore()); - - assertEquals("identity-service.principal-attribute", "preferred_username", - this.identityServiceConfig.getPrincipalAttribute()); - - // check number overrides - assertEquals("identity-service.confidential-port", 100, - this.identityServiceConfig.getConfidentialPort()); - - assertEquals("identity-service.cors-max-age", 1000, - this.identityServiceConfig.getCorsMaxAge()); - - assertEquals("identity-service.connection-pool-size", 5, - this.identityServiceConfig.getConnectionPoolSize()); - - assertEquals("identity-service.register-node-period", 50, - this.identityServiceConfig.getRegisterNodePeriod()); - - assertEquals("identity-service.token-minimum-time-to-live", 10, - this.identityServiceConfig.getTokenMinimumTimeToLive()); - - assertEquals("identity-service.min-time-between-jwks-requests", 60, - this.identityServiceConfig.getMinTimeBetweenJwksRequests()); - - assertEquals("identity-service.public-key-cache-ttl", 3600, - this.identityServiceConfig.getPublicKeyCacheTtl()); + private IdentityServiceRemoteUserMapper givenMapper(Map> tokenToUser) + { + final IdentityServiceFacade facade = mock(IdentityServiceFacade.class); + when(facade.extractUsernameFromToken(anyString())) + .thenAnswer(i -> + ofNullable(tokenToUser.get(i.getArgument(0, String.class))) + .map(Supplier::get)); - assertEquals("identity-service.client-connection-timeout", 3000, - this.identityServiceConfig.getClientConnectionTimeout()); + final PersonService personService = mock(PersonService.class); + when(personService.getUserIdentifier(anyString())).thenAnswer(i -> i.getArgument(0, String.class)); - assertEquals("identity-service.client-socket-timeout", 1000, - this.identityServiceConfig.getClientSocketTimeout()); + final IdentityServiceRemoteUserMapper mapper = new IdentityServiceRemoteUserMapper(); + mapper.setIdentityServiceFacade(facade); + mapper.setPersonService(personService); + mapper.setActive(true); + mapper.setBearerTokenResolver(new DefaultBearerTokenResolver()); - // check boolean overrides - assertFalse("identity-service.public-client", - this.identityServiceConfig.isPublicClient()); - - assertTrue("identity-service.use-resource-role-mappings", - this.identityServiceConfig.isUseResourceRoleMappings()); - - assertTrue("identity-service.enable-cors", - this.identityServiceConfig.isCors()); - - assertTrue("identity-service.expose-token", - this.identityServiceConfig.isExposeToken()); - - assertTrue("identity-service.bearer-only", - this.identityServiceConfig.isBearerOnly()); - - assertTrue("identity-service.autodetect-bearer-only", - this.identityServiceConfig.isAutodetectBearerOnly()); - - assertTrue("identity-service.enable-basic-auth", - this.identityServiceConfig.isEnableBasicAuth()); - - assertTrue("identity-service.allow-any-hostname", - this.identityServiceConfig.isAllowAnyHostname()); - - assertTrue("identity-service.disable-trust-manager", - this.identityServiceConfig.isDisableTrustManager()); - - assertTrue("identity-service.always-refresh-token", - this.identityServiceConfig.isAlwaysRefreshToken()); - - assertTrue("identity-service.register-node-at-startup", - this.identityServiceConfig.isRegisterNodeAtStartup()); - - assertTrue("identity-service.enable-pkce", - this.identityServiceConfig.isPkce()); - - assertTrue("identity-service.ignore-oauth-query-parameter", - this.identityServiceConfig.isIgnoreOAuthQueryParameter()); - - assertTrue("identity-service.turn-off-change-session-id-on-login", - this.identityServiceConfig.getTurnOffChangeSessionIdOnLogin()); - // check credentials overrides - Map credentials = this.identityServiceConfig.getCredentials(); - assertNotNull("Expected a credentials map", credentials); - assertFalse("Expected to retrieve a populated credentials map", credentials.isEmpty()); - assertEquals("identity-service.credentials.secret", "11111", credentials.get("secret")); - assertEquals("identity-service.credentials.provider", "secret", credentials.get("provider")); + return mapper; } - - public void testValidToken() throws Exception - { - // create token - String jwt = generateToken(false); - - // create mock request object - HttpServletRequest mockRequest = createMockTokenRequest(jwt); - - // validate correct user was found - assertEquals(TEST_USER_USERNAME, ((RemoteUserMapper) childApplicationContextFactory.getApplicationContext().getBean( - REMOTE_USER_MAPPER_BEAN_NAME)).getRemoteUser(mockRequest)); - } - - public void testWrongPublicKey() throws Exception - { - // generate and apply an incorrect public key - childApplicationContextFactory.stop(); - applyHardcodedPublicKey(KeyPairGenerator.getInstance("RSA").generateKeyPair().getPublic()); - - // create token - String jwt = generateToken(false); - - // create mock request object - HttpServletRequest mockRequest = createMockTokenRequest(jwt); - - // ensure null is returned if the public key is wrong - assertNull(((RemoteUserMapper) childApplicationContextFactory.getApplicationContext().getBean( - REMOTE_USER_MAPPER_BEAN_NAME)).getRemoteUser(mockRequest)); - } - - public void testWrongPublicKeyWithError() throws Exception - { - // generate and apply an incorrect public key - childApplicationContextFactory.stop(); - childApplicationContextFactory.setProperty(CONFIG_SILENT_ERRORS, "false"); - applyHardcodedPublicKey(KeyPairGenerator.getInstance("RSA").generateKeyPair().getPublic()); - - // create token - String jwt = generateToken(false); - - // create mock request object - HttpServletRequest mockRequest = createMockTokenRequest(jwt); - - // ensure user mapper falls through instead of throwing an exception - String user = ((RemoteUserMapper)childApplicationContextFactory.getApplicationContext().getBean( - REMOTE_USER_MAPPER_BEAN_NAME)).getRemoteUser(mockRequest); - assertEquals("Returned user should be null when wrong public key is used.", null, user); - } - - public void testInvalidJwt() throws Exception - { - // create mock request object - HttpServletRequest mockRequest = createMockTokenRequest("thisisnotaJWT"); - - // ensure null is returned if the JWT is invalid - assertNull(((RemoteUserMapper) childApplicationContextFactory.getApplicationContext().getBean( - REMOTE_USER_MAPPER_BEAN_NAME)).getRemoteUser(mockRequest)); - } - - public void testMissingToken() throws Exception - { - // create mock request object - HttpServletRequest mockRequest = createMockTokenRequest(""); - - // ensure null is returned if the token is missing - assertNull(((RemoteUserMapper) childApplicationContextFactory.getApplicationContext().getBean( - REMOTE_USER_MAPPER_BEAN_NAME)).getRemoteUser(mockRequest)); - } - - public void testExpiredToken() throws Exception - { - // create token - String jwt = generateToken(true); - - // create mock request object - HttpServletRequest mockRequest = createMockTokenRequest(jwt); - - // ensure null is returned if the token has expired - assertNull(((RemoteUserMapper) childApplicationContextFactory.getApplicationContext().getBean( - REMOTE_USER_MAPPER_BEAN_NAME)).getRemoteUser(mockRequest)); - } - - public void testExpiredTokenWithError() throws Exception - { - // turn on validation failure reporting - childApplicationContextFactory.stop(); - childApplicationContextFactory.setProperty(CONFIG_SILENT_ERRORS, "false"); - applyHardcodedPublicKey(this.keyPair.getPublic()); - - // create token - String jwt = generateToken(true); - - // create mock request object - HttpServletRequest mockRequest = createMockTokenRequest(jwt); - - // ensure an exception is thrown with correct description - String user = ((RemoteUserMapper)childApplicationContextFactory.getApplicationContext().getBean( - REMOTE_USER_MAPPER_BEAN_NAME)).getRemoteUser(mockRequest); - assertEquals("Returned user should be null when the token is expired.", null, user); - } - - public void testMissingHeader() throws Exception - { - // create mock request object with no Authorization header - HttpServletRequest mockRequest = createMockTokenRequest(null); - - // ensure null is returned if the header was missing - assertNull(((RemoteUserMapper) childApplicationContextFactory.getApplicationContext().getBean( - REMOTE_USER_MAPPER_BEAN_NAME)).getRemoteUser(mockRequest)); - } - + /** * Utility method for creating a mocked Servlet request with a token. * @@ -412,99 +124,12 @@ public class IdentityServiceRemoteUserMapperTest extends AbstractChainedSubsyste { authHeaderValues.add(BEARER_PREFIX + token); } - - when(mockRequest.getHeaders(AUTHORIZATION_HEADER)).thenReturn(authHeaderValues.elements()); - - return mockRequest; - } - - /** - * Utility method for creating a mocked Servlet request with basic auth. - * - * @return The mocked request object - */ - @SuppressWarnings("unchecked") - private HttpServletRequest createMockBasicRequest() - { - // Mock a request with the token in the Authorization header (if supplied) - HttpServletRequest mockRequest = mock(HttpServletRequest.class); - - Vector authHeaderValues = new Vector<>(1); - String userPwd = TEST_USER_USERNAME + ":" + TEST_USER_USERNAME; - authHeaderValues.add(BASIC_PREFIX + Base64.encodeBytes(userPwd.getBytes())); - - // NOTE: as getHeaders gets called twice provide two separate Enumeration objects so that - // an empty result is not returned for the second invocation. - when(mockRequest.getHeaders(AUTHORIZATION_HEADER)).thenReturn(authHeaderValues.elements(), - authHeaderValues.elements()); - - return mockRequest; - } - - private HttpClient createMockHttpClient() throws Exception - { - // mock HttpClient object and set on keycloak deployment to avoid basic auth - // attempting to get a token using HTTP POST - HttpClient mockHttpClient = mock(HttpClient.class); - HttpResponse mockHttpResponse = mock(HttpResponse.class); - StatusLine mockStatusLine = mock(StatusLine.class); - HttpEntity mockHttpEntity = mock(HttpEntity.class); - - // for the purpose of this test use the same token for access and refresh - String token = generateToken(false); - String jsonResponse = String.format(PASSWORD_GRANT_RESPONSE, token, token); - ByteArrayInputStream jsonResponseStream = new ByteArrayInputStream(jsonResponse.getBytes()); - - when(mockHttpClient.execute(any())).thenReturn(mockHttpResponse); - when(mockHttpResponse.getStatusLine()).thenReturn(mockStatusLine); - when(mockHttpResponse.getEntity()).thenReturn(mockHttpEntity); - when(mockStatusLine.getStatusCode()).thenReturn(200); - when(mockHttpEntity.getContent()).thenReturn(jsonResponseStream); - - return mockHttpClient; - } - - /** - * Utility method to create tokens for testing. - * - * @param expired Determines whether to create an expired JWT - * @return The string representation of the JWT - */ - private String generateToken(boolean expired) throws Exception - { - String issuerUrl = this.identityServiceConfig.getAuthServerUrl() + "/realms/" + this.identityServiceConfig.getRealm(); - - AccessToken token = new AccessToken(); - token.type("Bearer"); - token.id("1234"); - token.subject("abc123"); - token.issuer(issuerUrl); - token.setPreferredUsername(TEST_USER_USERNAME); - token.setEmail(TEST_USER_EMAIL); - token.setGivenName("Joe"); - token.setFamilyName("Bloggs"); - - if (expired) - { - token.expiration(Time.currentTime() - 60); - } - String jwt = new JWSBuilder() - .jsonContent(token) - .rsa256(keyPair.getPrivate()); + when(mockRequest.getHeaders(AUTHORIZATION_HEADER)) + .thenReturn(authHeaderValues.elements()); + when(mockRequest.getHeader(AUTHORIZATION_HEADER)) + .thenReturn(authHeaderValues.isEmpty() ? null : authHeaderValues.get(0)); - return jwt; - } - - /** - * Finds the keycloak deployment bean and applies a hardcoded public key locator using the - * provided public key. - */ - private void applyHardcodedPublicKey(PublicKey publicKey) - { - KeycloakDeployment deployment = (KeycloakDeployment)childApplicationContextFactory.getApplicationContext(). - getBean(DEPLOYMENT_BEAN_NAME); - HardcodedPublicKeyLocator publicKeyLocator = new HardcodedPublicKeyLocator(publicKey); - deployment.setPublicKeyLocator(publicKeyLocator); + return mockRequest; } } diff --git a/repository/src/test/java/org/alfresco/repo/security/authentication/identityservice/LazyInstantiatingIdentityServiceFacadeUnitTest.java b/repository/src/test/java/org/alfresco/repo/security/authentication/identityservice/LazyInstantiatingIdentityServiceFacadeUnitTest.java new file mode 100644 index 0000000000..47eb82563e --- /dev/null +++ b/repository/src/test/java/org/alfresco/repo/security/authentication/identityservice/LazyInstantiatingIdentityServiceFacadeUnitTest.java @@ -0,0 +1,101 @@ +/* + * #%L + * Alfresco Repository + * %% + * Copyright (C) 2005 - 2023 Alfresco Software Limited + * %% + * This file is part of the Alfresco software. + * If the software was purchased under a paid Alfresco license, the terms of + * the paid license agreement will prevail. Otherwise, the software is + * provided under the following open source license terms: + * + * Alfresco is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Alfresco is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Alfresco. If not, see . + * #L% + */ +package org.alfresco.repo.security.authentication.identityservice; + +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import java.util.function.Supplier; + +import org.alfresco.repo.security.authentication.identityservice.IdentityServiceFacade.IdentityServiceFacadeException; +import org.alfresco.repo.security.authentication.identityservice.IdentityServiceFacadeFactoryBean.LazyInstantiatingIdentityServiceFacade; +import org.junit.Test; + +public class LazyInstantiatingIdentityServiceFacadeUnitTest +{ + private static final String USER_NAME = "marlon"; + private static final String PASSWORD = "brando"; + private static final String TOKEN = "token"; + @Test + public void shouldRecoverFromInitialAuthorizationServerUnavailability() + { + final IdentityServiceFacade targetFacade = mock(IdentityServiceFacade.class); + final LazyInstantiatingIdentityServiceFacade facade = new LazyInstantiatingIdentityServiceFacade(faultySupplier(3, targetFacade)); + + assertThatExceptionOfType(IdentityServiceFacadeException.class) + .isThrownBy(() -> facade.extractUsernameFromToken(TOKEN)) + .havingCause().withNoCause().withMessage("Expected failure #1"); + verifyNoInteractions(targetFacade); + + assertThatExceptionOfType(IdentityServiceFacadeException.class) + .isThrownBy(() -> facade.verifyCredentials(USER_NAME, PASSWORD)) + .havingCause().withNoCause().withMessage("Expected failure #2"); + verifyNoInteractions(targetFacade); + + assertThatExceptionOfType(IdentityServiceFacadeException.class) + .isThrownBy(() -> facade.extractUsernameFromToken(TOKEN)) + .havingCause().withNoCause().withMessage("Expected failure #3"); + verifyNoInteractions(targetFacade); + + facade.verifyCredentials(USER_NAME, PASSWORD); + verify(targetFacade).verifyCredentials(USER_NAME, PASSWORD); + } + + @Test + public void shouldAvoidCreatingMultipleInstanceOfOAuth2AuthorizedClientManager() + { + final IdentityServiceFacade targetFacade = mock(IdentityServiceFacade.class); + final Supplier supplier = mock(Supplier.class); + when(supplier.get()).thenReturn(targetFacade); + + final LazyInstantiatingIdentityServiceFacade facade = new LazyInstantiatingIdentityServiceFacade(supplier); + + facade.verifyCredentials(USER_NAME, PASSWORD); + facade.extractUsernameFromToken(TOKEN); + facade.verifyCredentials(USER_NAME, PASSWORD); + facade.extractUsernameFromToken(TOKEN); + facade.verifyCredentials(USER_NAME, PASSWORD); + verify(supplier, times(1)).get(); + verify(targetFacade, times(3)).verifyCredentials(USER_NAME, PASSWORD); + verify(targetFacade, times(2)).extractUsernameFromToken(TOKEN); + } + + private Supplier faultySupplier(int numberOfInitialFailures, IdentityServiceFacade facade) + { + final int[] counter = new int[]{0}; + return () -> { + if (counter[0]++ < numberOfInitialFailures) + { + throw new RuntimeException("Expected failure #" + counter[0]); + } + return facade; + }; + } +} diff --git a/repository/src/test/java/org/alfresco/repo/security/authentication/identityservice/SpringBasedIdentityServiceFacadeUnitTest.java b/repository/src/test/java/org/alfresco/repo/security/authentication/identityservice/SpringBasedIdentityServiceFacadeUnitTest.java new file mode 100644 index 0000000000..5b3f13531f --- /dev/null +++ b/repository/src/test/java/org/alfresco/repo/security/authentication/identityservice/SpringBasedIdentityServiceFacadeUnitTest.java @@ -0,0 +1,73 @@ +/* + * #%L + * Alfresco Repository + * %% + * Copyright (C) 2005 - 2023 Alfresco Software Limited + * %% + * This file is part of the Alfresco software. + * If the software was purchased under a paid Alfresco license, the terms of + * the paid license agreement will prevail. Otherwise, the software is + * provided under the following open source license terms: + * + * Alfresco is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Alfresco is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Alfresco. If not, see . + * #L% + */ +package org.alfresco.repo.security.authentication.identityservice; + +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import org.alfresco.repo.security.authentication.identityservice.IdentityServiceFacade.CredentialsVerificationException; +import org.alfresco.repo.security.authentication.identityservice.IdentityServiceFacade.TokenException; +import org.alfresco.repo.security.authentication.identityservice.IdentityServiceFacadeFactoryBean.SpringBasedIdentityServiceFacade; +import org.junit.Test; +import org.springframework.security.oauth2.client.OAuth2AuthorizedClientManager; +import org.springframework.security.oauth2.jwt.JwtDecoder; + +public class SpringBasedIdentityServiceFacadeUnitTest +{ + private static final String USER_NAME = "user"; + private static final String PASSWORD = "password"; + private static final String TOKEN = "tEsT-tOkEn"; + + @Test + public void shouldThrowVerificationExceptionOnFailure() + { + final OAuth2AuthorizedClientManager authClientManager = mock(OAuth2AuthorizedClientManager.class); + final JwtDecoder jwtDecoder = mock(JwtDecoder.class); + when(authClientManager.authorize(any())).thenThrow(new RuntimeException("Expected")); + + final SpringBasedIdentityServiceFacade facade = new SpringBasedIdentityServiceFacade(authClientManager, jwtDecoder); + + assertThatExceptionOfType(CredentialsVerificationException.class) + .isThrownBy(() -> facade.verifyCredentials(USER_NAME, PASSWORD)) + .havingCause().withNoCause().withMessage("Expected"); + } + + @Test + public void shouldThrowTokenExceptionOnFailure() + { + final OAuth2AuthorizedClientManager authClientManager = mock(OAuth2AuthorizedClientManager.class); + final JwtDecoder jwtDecoder = mock(JwtDecoder.class); + when(jwtDecoder.decode(TOKEN)).thenThrow(new RuntimeException("Expected")); + + final SpringBasedIdentityServiceFacade facade = new SpringBasedIdentityServiceFacade(authClientManager, jwtDecoder); + + assertThatExceptionOfType(TokenException.class) + .isThrownBy(() -> facade.extractUsernameFromToken(TOKEN)) + .havingCause().withNoCause().withMessage("Expected"); + } +} \ No newline at end of file diff --git a/repository/src/test/java/org/alfresco/repo/security/authentication/identityservice/SpringOAuth2ClientUnitTest.java b/repository/src/test/java/org/alfresco/repo/security/authentication/identityservice/SpringOAuth2ClientUnitTest.java deleted file mode 100644 index 551232d2d3..0000000000 --- a/repository/src/test/java/org/alfresco/repo/security/authentication/identityservice/SpringOAuth2ClientUnitTest.java +++ /dev/null @@ -1,124 +0,0 @@ -/* - * #%L - * Alfresco Repository - * %% - * Copyright (C) 2005 - 2023 Alfresco Software Limited - * %% - * This file is part of the Alfresco software. - * If the software was purchased under a paid Alfresco license, the terms of - * the paid license agreement will prevail. Otherwise, the software is - * provided under the following open source license terms: - * - * Alfresco is free software: you can redistribute it and/or modify - * it under the terms of the GNU Lesser General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * Alfresco is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public License - * along with Alfresco. If not, see . - * #L% - */ -package org.alfresco.repo.security.authentication.identityservice; - -import static org.assertj.core.api.Assertions.assertThatExceptionOfType; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.argThat; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.verifyNoInteractions; -import static org.mockito.Mockito.when; - -import java.util.function.Supplier; - -import org.alfresco.repo.security.authentication.identityservice.IdentityServiceAuthenticationComponent.OAuth2Client.CredentialsVerificationException; -import org.alfresco.repo.security.authentication.identityservice.OAuth2ClientFactoryBean.SpringOAuth2Client; -import org.junit.Test; -import org.springframework.security.oauth2.client.OAuth2AuthorizedClient; -import org.springframework.security.oauth2.client.OAuth2AuthorizedClientManager; -import org.springframework.security.oauth2.core.OAuth2AccessToken; - -public class SpringOAuth2ClientUnitTest -{ - private static final String USER_NAME = "user"; - private static final String PASSWORD = "password"; - - @Test - public void shouldRecoverFromInitialAuthorizationServerUnavailability() - { - final OAuth2AuthorizedClient authorizedClient = mock(OAuth2AuthorizedClient.class); - when(authorizedClient.getAccessToken()).thenReturn(mock(OAuth2AccessToken.class)); - final OAuth2AuthorizedClientManager authClientManager = mock(OAuth2AuthorizedClientManager.class); - when(authClientManager.authorize(any())).thenReturn(authorizedClient); - - final SpringOAuth2Client client = new SpringOAuth2Client(faultySupplier(3, authClientManager)); - - assertThatExceptionOfType(CredentialsVerificationException.class) - .isThrownBy(() -> client.verifyCredentials(USER_NAME, PASSWORD)) - .havingCause().withNoCause().withMessage("Expected failure #1"); - verifyNoInteractions(authClientManager); - - assertThatExceptionOfType(CredentialsVerificationException.class) - .isThrownBy(() -> client.verifyCredentials(USER_NAME, PASSWORD)) - .havingCause().withNoCause().withMessage("Expected failure #2"); - verifyNoInteractions(authClientManager); - - assertThatExceptionOfType(CredentialsVerificationException.class) - .isThrownBy(() -> client.verifyCredentials(USER_NAME, PASSWORD)) - .havingCause().withNoCause().withMessage("Expected failure #3"); - verifyNoInteractions(authClientManager); - - client.verifyCredentials(USER_NAME, PASSWORD); - verify(authClientManager).authorize(argThat(r -> r.getPrincipal() != null && USER_NAME.equals(r.getPrincipal().getPrincipal()))); - } - - @Test - public void shouldThrowVerificationExceptionOnFailure() - { - final OAuth2AuthorizedClientManager authClientManager = mock(OAuth2AuthorizedClientManager.class); - when(authClientManager.authorize(any())).thenThrow(new RuntimeException("Expected")); - - final SpringOAuth2Client client = new SpringOAuth2Client(() -> authClientManager); - - assertThatExceptionOfType(CredentialsVerificationException.class) - .isThrownBy(() -> client.verifyCredentials(USER_NAME, PASSWORD)) - .havingCause().withNoCause().withMessage("Expected"); - } - - @Test - public void shouldAvoidCreatingMultipleInstanceOfOAuth2AuthorizedClientManager() - { - final OAuth2AuthorizedClient authorizedClient = mock(OAuth2AuthorizedClient.class); - when(authorizedClient.getAccessToken()).thenReturn(mock(OAuth2AccessToken.class)); - final OAuth2AuthorizedClientManager authClientManager = mock(OAuth2AuthorizedClientManager.class); - when(authClientManager.authorize(any())).thenReturn(authorizedClient); - final Supplier supplier = mock(Supplier.class); - when(supplier.get()).thenReturn(authClientManager); - - final SpringOAuth2Client client = new SpringOAuth2Client(supplier); - - client.verifyCredentials(USER_NAME, PASSWORD); - client.verifyCredentials(USER_NAME, PASSWORD); - client.verifyCredentials(USER_NAME, PASSWORD); - verify(supplier, times(1)).get(); - verify(authClientManager, times(3)).authorize(any()); - } - - private Supplier faultySupplier(int numberOfInitialFailures, OAuth2AuthorizedClientManager authClientManager) - { - final int[] counter = new int[]{0}; - return () -> { - if (counter[0]++ < numberOfInitialFailures) - { - throw new RuntimeException("Expected failure #" + counter[0]); - } - return authClientManager; - }; - } - -} \ No newline at end of file