diff --git a/pom.xml b/pom.xml index e151e6c72b..fea021393c 100644 --- a/pom.xml +++ b/pom.xml @@ -2,7 +2,7 @@ 4.0.0 alfresco-repository Alfresco Repository - 6.32-SNAPSHOT + 6.32-TOKEN-AUTH-SNAPSHOT jar @@ -56,7 +56,8 @@ 5.1.40 2.0.1 3.5.2 - + 3.4.3.Final + 3.3.1.Final @@ -693,6 +694,68 @@ + + + org.keycloak + keycloak-core + ${dependency.keycloak.version} + + + * + * + + + + + org.keycloak + keycloak-common + ${dependency.keycloak.version} + + + * + * + + + + + org.keycloak + keycloak-adapter-core + ${dependency.keycloak.version} + + + * + * + + + + + org.keycloak + keycloak-adapter-spi + ${dependency.keycloak.version} + + + * + * + + + + + org.keycloak + keycloak-servlet-adapter-spi + ${dependency.keycloak.version} + + + * + * + + + + + org.jboss.logging + jboss-logging + ${dependency.jboss.logging.version} + + org.mockito diff --git a/src/main/java/org/alfresco/repo/security/authentication/token/AlfrescoBearerTokenRequestAuthenticator.java b/src/main/java/org/alfresco/repo/security/authentication/token/AlfrescoBearerTokenRequestAuthenticator.java new file mode 100644 index 0000000000..63f9749cf8 --- /dev/null +++ b/src/main/java/org/alfresco/repo/security/authentication/token/AlfrescoBearerTokenRequestAuthenticator.java @@ -0,0 +1,61 @@ +/* + * #%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.token; + +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/src/main/java/org/alfresco/repo/security/authentication/token/AlfrescoKeycloakAdapterConfig.java b/src/main/java/org/alfresco/repo/security/authentication/token/AlfrescoKeycloakAdapterConfig.java new file mode 100644 index 0000000000..f365910a6f --- /dev/null +++ b/src/main/java/org/alfresco/repo/security/authentication/token/AlfrescoKeycloakAdapterConfig.java @@ -0,0 +1,81 @@ +/* + * #%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.token; + +import java.util.Map; +import java.util.Properties; +import java.util.TreeMap; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.keycloak.representations.adapters.config.AdapterConfig; +import org.springframework.beans.factory.InitializingBean; + +public class AlfrescoKeycloakAdapterConfig extends AdapterConfig implements InitializingBean +{ + private static Log logger = LogFactory.getLog(AlfrescoKeycloakAdapterConfig.class); + + private static final String CREDENTIALS_SECRET = "keycloak.credentials.secret"; + private static final String CREDENTIALS_PROVIDER = "keycloak.credentials.provider"; + + private Properties globalProperties; + + public void setGlobalProperties(Properties globalProperties) + { + this.globalProperties = globalProperties; + } + + @Override + public void afterPropertiesSet() throws Exception + { + // programatically build the more complex objects i.e. credentials + Map credentials = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); + + String secret = this.globalProperties.getProperty(CREDENTIALS_SECRET); + if (secret != null && !secret.isEmpty()) + { + credentials.put("secret", secret); + } + + String provider = this.globalProperties.getProperty(CREDENTIALS_PROVIDER); + if (provider != null && !provider.isEmpty()) + { + credentials.put("provider", provider); + } + + // TODO: add support for redirect-rewrite-rules and policy-enforcer if and when we need to support it + + if (!credentials.isEmpty()) + { + this.setCredentials(credentials); + + if (logger.isDebugEnabled()) + { + logger.debug("Created credentials map from config: " + credentials); + } + } + } +} diff --git a/src/main/java/org/alfresco/repo/security/authentication/token/AlfrescoKeycloakDeploymentFactoryBean.java b/src/main/java/org/alfresco/repo/security/authentication/token/AlfrescoKeycloakDeploymentFactoryBean.java new file mode 100644 index 0000000000..4414b0aa0e --- /dev/null +++ b/src/main/java/org/alfresco/repo/security/authentication/token/AlfrescoKeycloakDeploymentFactoryBean.java @@ -0,0 +1,77 @@ +/* + * #%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.token; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.keycloak.adapters.KeycloakDeployment; +import org.keycloak.adapters.KeycloakDeploymentBuilder; +import org.keycloak.representations.adapters.config.AdapterConfig; +import org.springframework.beans.factory.FactoryBean; + +/** + * Creates an instance of a KeycloakDeployment object. + * + * @author Gavin Cornwell + */ +public class AlfrescoKeycloakDeploymentFactoryBean implements FactoryBean +{ + private static Log logger = LogFactory.getLog(AlfrescoKeycloakDeploymentFactoryBean.class); + + private AdapterConfig keycloakAdapterConfig; + + public void setAdapterConfig(AdapterConfig adapterConfig) + { + this.keycloakAdapterConfig = adapterConfig; + } + + @Override + public KeycloakDeployment getObject() throws Exception + { + KeycloakDeployment deployment = KeycloakDeploymentBuilder.build(this.keycloakAdapterConfig); + + 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/src/main/java/org/alfresco/repo/security/authentication/token/AlfrescoKeycloakHttpFacade.java b/src/main/java/org/alfresco/repo/security/authentication/token/AlfrescoKeycloakHttpFacade.java new file mode 100644 index 0000000000..ebdb5050ec --- /dev/null +++ b/src/main/java/org/alfresco/repo/security/authentication/token/AlfrescoKeycloakHttpFacade.java @@ -0,0 +1,107 @@ +/* + * #%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.token; + +import java.io.ByteArrayOutputStream; +import java.io.OutputStream; + +import javax.servlet.http.HttpServletRequest; + +import org.keycloak.adapters.servlet.ServletHttpFacade; + +/** + * Keycloak HttpFacade wrapper so we can re-use Keycloak authenticator classes. + * + * @author Gavin Cornwell + */ +public class AlfrescoKeycloakHttpFacade extends ServletHttpFacade +{ + public AlfrescoKeycloakHttpFacade(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/src/main/java/org/alfresco/repo/security/authentication/token/TokenRemoteUserMapper.java b/src/main/java/org/alfresco/repo/security/authentication/token/TokenRemoteUserMapper.java new file mode 100644 index 0000000000..a636b1247d --- /dev/null +++ b/src/main/java/org/alfresco/repo/security/authentication/token/TokenRemoteUserMapper.java @@ -0,0 +1,247 @@ +/* + * #%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.token; + +import javax.servlet.http.HttpServletRequest; + +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.service.cmr.security.PersonService; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.keycloak.adapters.BasicAuthRequestAuthenticator; +import org.keycloak.adapters.KeycloakDeployment; +import org.keycloak.adapters.spi.AuthOutcome; +import org.keycloak.representations.AccessToken; + +/** + * A {@link RemoteUserMapper} implementation that detects and validates JWTs. + * + * @author Gavin Cornwell + */ +public class TokenRemoteUserMapper implements RemoteUserMapper, ActivateableBean +{ + private static Log logger = LogFactory.getLog(TokenRemoteUserMapper.class); + + /** Is the mapper enabled */ + private boolean isEnabled; + + /** Are token validation failures handled silently? */ + private boolean isValidationFailureSilent; + + /** The person service. */ + private PersonService personService; + + /** The Keycloak deployment object */ + private KeycloakDeployment keycloakDeployment; + + /** + * Sets the active flag + * + * @param isEnabled true to enable the subsystem + */ + public void setActive(boolean isEnabled) + { + this.isEnabled = isEnabled; + } + + /** + * Determines whether token validation failures are silent + * + * @param silent true to silently fail, false to throw an exception + */ + public void setValidationFailureSilent(boolean silent) + { + this.isValidationFailureSilent = silent; + } + + /** + * Sets the person service. + * + * @param personService + * the person service + */ + public void setPersonService(PersonService personService) + { + this.personService = personService; + } + + public void setKeycloakDeployment(KeycloakDeployment deployment) + { + this.keycloakDeployment = deployment; + } + + /* + * (non-Javadoc) + * @see org.alfresco.web.app.servlet.RemoteUserMapper#getRemoteUser(javax.servlet.http.HttpServletRequest) + */ + public String getRemoteUser(HttpServletRequest request) + { + if (logger.isDebugEnabled()) + logger.debug("Retrieving username from http request..."); + + if (!this.isEnabled) + { + if (logger.isDebugEnabled()) + logger.debug("TokenRemoteUserMapper 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.isDebugEnabled()) + logger.debug("Returning username: " + normalizedUserId); + + return normalizedUserId; + } + + return null; + } + + /* + * (non-Javadoc) + * @see org.alfresco.repo.management.subsystems.ActivateableBean#isActive() + */ + public boolean isActive() + { + return this.isEnabled; + } + + /** + * Extracts the user name from the JWT in the given request. + * + * @param request The request containing the JWT + * @return The user name or null if it can not be determined + */ + private String extractUserFromHeader(HttpServletRequest request) + { + String userName = null; + + AlfrescoKeycloakHttpFacade facade = new AlfrescoKeycloakHttpFacade(request); + + // try authenticating with bearer token first + if (logger.isDebugEnabled()) + { + logger.debug("Trying bearer token..."); + } + + AlfrescoBearerTokenRequestAuthenticator tokenAuthenticator = + new AlfrescoBearerTokenRequestAuthenticator(this.keycloakDeployment); + AuthOutcome tokenOutcome = tokenAuthenticator.authenticate(facade); + + if (logger.isDebugEnabled()) + { + logger.debug("Bearer token outcome: " + tokenOutcome); + } + + if (tokenOutcome == AuthOutcome.FAILED && !isValidationFailureSilent) + { + throw new AuthenticationException("Token validation failed: " + + tokenAuthenticator.getValidationFailureDescription()); + } + + if (tokenOutcome == AuthOutcome.AUTHENTICATED) + { + userName = extractUserFromToken(tokenAuthenticator.getToken()); + } + else + { + // if bearer token failed, try basic auth, if enabled + if (this.keycloakDeployment.isEnableBasicAuth()) + { + if (logger.isDebugEnabled()) + { + logger.debug("Trying basic auth..."); + } + + BasicAuthRequestAuthenticator basicAuthenticator = + new BasicAuthRequestAuthenticator(this.keycloakDeployment); + AuthOutcome basicOutcome = basicAuthenticator.authenticate(facade); + + if (logger.isDebugEnabled()) + { + logger.debug("Basic auth outcome: " + basicOutcome); + } + + // if auth was successful, extract username and return + if (basicOutcome == AuthOutcome.AUTHENTICATED) + { + userName = extractUserFromToken(basicAuthenticator.getToken()); + } + } + } + + return userName; + } + + private String extractUserFromToken(AccessToken jwt) + { + // retrieve the preferred_username claim + String userName = jwt.getPreferredUsername(); + + if (logger.isDebugEnabled()) + logger.debug("Extracted username: " + userName); + + return userName; + } + + /** + * Normalizes a user id, taking into account existing user accounts and case sensitivity settings. + * + * @param userId + * the user id + * @return the string + */ + private String normalizeUserId(final String userId) + { + if (userId == null) + { + return null; + } + + String normalized = AuthenticationUtil.runAs(new RunAsWork() + { + public String doWork() throws Exception + { + return personService.getUserIdentifier(userId); + } + }, AuthenticationUtil.getSystemUserName()); + + if (logger.isDebugEnabled()) + logger.debug("Normalized user name for '" + userId + "': " + normalized); + + return normalized == null ? userId : normalized; + } +} diff --git a/src/main/resources/alfresco/subsystems/Authentication/token/token-authentication-context.xml b/src/main/resources/alfresco/subsystems/Authentication/token/token-authentication-context.xml new file mode 100644 index 0000000000..afba45e137 --- /dev/null +++ b/src/main/resources/alfresco/subsystems/Authentication/token/token-authentication-context.xml @@ -0,0 +1,222 @@ + + + + + + + + + + + + + + + + + + ${token.authentication.defaultAdministratorUserNames} + + + + + + + + org.alfresco.repo.security.authentication.AuthenticationComponent + + + + + + + + + + + ${server.transaction.mode.default} + + + + + + + + + + + + + + + + + + + + + + false + + + ${authentication.protection.limit} + + + ${authentication.protection.periodSeconds} + + + + + + + + + + + + ${keycloak.realm} + + + ${keycloak.realm-public-key:#{null}} + + + ${keycloak.auth-server-url} + + + ${keycloak.ssl-required:external} + + + ${keycloak.confidential-port:0} + + + ${keycloak.resource} + + + ${keycloak.use-resource-role-mappings:false} + + + ${keycloak.enable-cors:false} + + + ${keycloak.cors-max-age:-1} + + + ${keycloak.cors-allowed-headers:#{null}} + + + ${keycloak.cors-allowed-methods:#{null}} + + + ${keycloak.cors-exposed-headers:#{null}} + + + ${keycloak.expose-token:false} + + + ${keycloak.bearer-only:false} + + + ${keycloak.autodetect-bearer-only:false} + + + ${keycloak.enable-basic-auth:false} + + + ${keycloak.public-client:false} + + + ${keycloak.allow-any-hostname:false} + + + ${keycloak.disable-trust-manager:false} + + + ${keycloak.truststore:#{null}} + + + ${keycloak.truststore-password:#{null}} + + + ${keycloak.client-keystore:#{null}} + + + ${keycloak.client-keystore-password:#{null}} + + + ${keycloak.client-key-password:#{null}} + + + ${keycloak.connection-pool-size:20} + + + ${keycloak.always-refresh-token:false} + + + ${keycloak.register-node-at-startup:false} + + + ${keycloak.register-node-period:-1} + + + ${keycloak.token-store:#{null}} + + + ${keycloak.principal-attribute:#{null}} + + + ${keycloak.turn-off-change-session-id-on-login:false} + + + ${keycloak.token-minimum-time-to-live:0} + + + ${keycloak.min-time-between-jwks-requests:10} + + + ${keycloak.public-key-cache-ttl:86400} + + + ${keycloak.enable-pkce:false} + + + ${keycloak.ignore-oauth-query-parameter:false} + + + + + + + + + + + + + ${token.authentication.enabled} + + + ${token.authentication.validation.failure.silent} + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/main/resources/alfresco/subsystems/Authentication/token/token-authentication.properties b/src/main/resources/alfresco/subsystems/Authentication/token/token-authentication.properties new file mode 100644 index 0000000000..22bd745473 --- /dev/null +++ b/src/main/resources/alfresco/subsystems/Authentication/token/token-authentication.properties @@ -0,0 +1,10 @@ +token.authentication.enabled=true +token.authentication.validation.failure.silent=true +token.authentication.defaultAdministratorUserNames=admin + +# Keycloak configuration +keycloak.auth-server-url=http://localhost:8180/auth +keycloak.realm=springboot +keycloak.ssl-required=none +keycloak.resource=activiti +keycloak.public-client=true \ No newline at end of file diff --git a/src/test/java/org/alfresco/repo/security/SecurityTestSuite.java b/src/test/java/org/alfresco/repo/security/SecurityTestSuite.java index f8d66cc544..de311be4da 100644 --- a/src/test/java/org/alfresco/repo/security/SecurityTestSuite.java +++ b/src/test/java/org/alfresco/repo/security/SecurityTestSuite.java @@ -25,10 +25,6 @@ */ package org.alfresco.repo.security; -import junit.framework.JUnit4TestAdapter; -import junit.framework.Test; -import junit.framework.TestSuite; - import org.alfresco.repo.domain.permissions.FixedAclUpdaterTest; import org.alfresco.repo.ownable.impl.OwnableServiceTest; import org.alfresco.repo.security.authentication.AlfrescoSSLSocketFactoryTest; @@ -42,6 +38,7 @@ import org.alfresco.repo.security.authentication.UpgradePasswordHashTest; import org.alfresco.repo.security.authentication.external.DefaultRemoteUserMapperTest; import org.alfresco.repo.security.authentication.external.LocalAuthenticationServiceTest; import org.alfresco.repo.security.authentication.subsystems.SubsystemChainingFtpAuthenticatorTest; +import org.alfresco.repo.security.authentication.token.TokenRemoteUserMapperTest; import org.alfresco.repo.security.authority.AuthorityBridgeTableAsynchronouslyRefreshedCacheTest; import org.alfresco.repo.security.authority.AuthorityServiceTest; import org.alfresco.repo.security.authority.DuplicateAuthorityTest; @@ -57,6 +54,10 @@ import org.alfresco.repo.security.permissions.impl.model.PermissionModelTest; import org.alfresco.repo.security.person.HomeFolderProviderSynchronizerTest; import org.alfresco.repo.security.person.PersonTest; +import junit.framework.JUnit4TestAdapter; +import junit.framework.Test; +import junit.framework.TestSuite; + /** * @author Andy Hind * @@ -100,6 +101,7 @@ public class SecurityTestSuite extends TestSuite suite.addTestSuite(FixedAclUpdaterTest.class); suite.addTestSuite(DefaultRemoteUserMapperTest.class); + suite.addTestSuite(TokenRemoteUserMapperTest.class); suite.addTestSuite(SubsystemChainingFtpAuthenticatorTest.class); suite.addTest(new JUnit4TestAdapter(LocalAuthenticationServiceTest.class)); diff --git a/src/test/java/org/alfresco/repo/security/authentication/token/TokenRemoteUserMapperTest.java b/src/test/java/org/alfresco/repo/security/authentication/token/TokenRemoteUserMapperTest.java new file mode 100644 index 0000000000..6d6bac6062 --- /dev/null +++ b/src/test/java/org/alfresco/repo/security/authentication/token/TokenRemoteUserMapperTest.java @@ -0,0 +1,512 @@ +/* + * #%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.token; + +import static org.mockito.Matchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.io.ByteArrayInputStream; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.PublicKey; +import java.util.Map; +import java.util.Vector; + +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 org.alfresco.repo.security.authentication.AuthenticationException; +import org.alfresco.repo.security.authentication.external.RemoteUserMapper; +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; + +/** + * Tests the token based authentication subsystem. + * + * @author Gavin Cornwell + */ +public class TokenRemoteUserMapperTest extends AbstractChainedSubsystemTest +{ + private static final String REMOTE_USER_MAPPER_BEAN_NAME = "remoteUserMapper"; + private static final String KEYCLOAK_DEPLOYMENT_BEAN_NAME = "keycloakDeployment"; + + 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 = "token.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 AlfrescoKeycloakAdapterConfig keycloakAdapterConfig; + + /* (non-Javadoc) + * @see junit.framework.TestCase#setUp() + */ + @Override + protected void setUp() throws Exception + { + // switch authentication to use token auth + childApplicationContextManager = (DefaultChildApplicationContextManager) ctx.getBean("Authentication"); + childApplicationContextManager.stop(); + childApplicationContextManager.setProperty("chain", "token1:token"); + childApplicationContextFactory = getChildApplicationContextFactory(childApplicationContextManager, "token1"); + + // generate keys for test + this.keyPair = KeyPairGenerator.getInstance("RSA").generateKeyPair(); + + // hardcode the realm public key in the Keycloak deployment bean to stop it fetching keys + applyHardcodedPublicKey(this.keyPair.getPublic()); + + // extract config + this.keycloakAdapterConfig = (AlfrescoKeycloakAdapterConfig)childApplicationContextFactory. + getApplicationContext().getBean("keycloakAdpapterConfig"); + } + + /* (non-Javadoc) + * @see junit.framework.TestCase#tearDown() + */ + @Override + protected void tearDown() throws Exception + { + childApplicationContextManager.destroy(); + childApplicationContextManager = null; + childApplicationContextFactory = null; + } + + public void testKeycloakConfig() throws Exception + { + // check string overrides + assertEquals("keycloak.auth-server-url", "http://192.168.0.1:8180/auth", + this.keycloakAdapterConfig.getAuthServerUrl()); + + assertEquals("keycloak.realm", "test", + this.keycloakAdapterConfig.getRealm()); + + assertEquals("keycloak.realm-public-key", + "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAvWLQxipXNe6cLnVPGy7l" + + "BgyR51bDiK7Jso8Rmh2TB+bmO4fNaMY1ETsxECSM0f6NTV0QHks9+gBe+pB6JNeM" + + "uPmaE/M/MsE9KUif9L2ChFq3zor6s2foFv2DTiTkij+1aQF9fuIjDNH4FC6L252W" + + "ydZzh+f73Xuy5evdPj+wrPYqWyP7sKd+4Q9EIILWAuTDvKEjwyZmIyfM/nUn6ltD" + + "P6W8xMP0PoEJNAAp79anz2jk2HP2PvC2qdjVsphdTk3JG5qQMB0WJUh4Kjgabd4j" + + "QJ77U8gTRswKgNHRRPWhruiIcmmkP+zI0ozNW6rxH3PF4L7M9rXmfcmUcBcKf+Yx" + + "jwIDAQAB", + this.keycloakAdapterConfig.getRealmKey()); + + assertEquals("keycloak.ssl-required", "external", + this.keycloakAdapterConfig.getSslRequired()); + + assertEquals("keycloak.resource", "test", + this.keycloakAdapterConfig.getResource()); + + assertEquals("keycloak.cors-allowed-headers", "Authorization", + this.keycloakAdapterConfig.getCorsAllowedHeaders()); + + assertEquals("keycloak.cors-allowed-methods", "POST, PUT, DELETE, GET", + this.keycloakAdapterConfig.getCorsAllowedMethods()); + + assertEquals("keycloak.cors-exposed-headers", "WWW-Authenticate, My-custom-exposed-Header", + this.keycloakAdapterConfig.getCorsExposedHeaders()); + + assertEquals("keycloak.truststore", + "classpath:/alfresco/subsystems/tokenAuthentication/keystore.jks", + this.keycloakAdapterConfig.getTruststore()); + + assertEquals("keycloak.truststore-password", "password", + this.keycloakAdapterConfig.getTruststorePassword()); + + assertEquals("keycloak.client-keystore", + "classpath:/alfresco/subsystems/tokenAuthentication/keystore.jks", + this.keycloakAdapterConfig.getClientKeystore()); + + assertEquals("keycloak.client-keystore-password", "password", + this.keycloakAdapterConfig.getClientKeystorePassword()); + + assertEquals("keycloak.client-key-password", "password", + this.keycloakAdapterConfig.getClientKeyPassword()); + + assertEquals("keycloak.token-store", "SESSION", + this.keycloakAdapterConfig.getTokenStore()); + + assertEquals("keycloak.principal-attribute", "preferred_username", + this.keycloakAdapterConfig.getPrincipalAttribute()); + + // check number overrides + assertEquals("keycloak.confidential-port", 100, + this.keycloakAdapterConfig.getConfidentialPort()); + + assertEquals("keycloak.cors-max-age", 1000, + this.keycloakAdapterConfig.getCorsMaxAge()); + + assertEquals("keycloak.connection-pool-size", 5, + this.keycloakAdapterConfig.getConnectionPoolSize()); + + assertEquals("keycloak.register-node-period", 50, + this.keycloakAdapterConfig.getRegisterNodePeriod()); + + assertEquals("keycloak.token-minimum-time-to-live", 10, + this.keycloakAdapterConfig.getTokenMinimumTimeToLive()); + + assertEquals("keycloak.min-time-between-jwks-requests", 60, + this.keycloakAdapterConfig.getMinTimeBetweenJwksRequests()); + + assertEquals("keycloak.public-key-cache-ttl", 3600, + this.keycloakAdapterConfig.getPublicKeyCacheTtl()); + + // check boolean overrides + assertFalse("keycloak.public-client", + this.keycloakAdapterConfig.isPublicClient()); + + assertTrue("keycloak.use-resource-role-mappings", + this.keycloakAdapterConfig.isUseResourceRoleMappings()); + + assertTrue("keycloak.enable-cors", + this.keycloakAdapterConfig.isCors()); + + assertTrue("keycloak.expose-token", + this.keycloakAdapterConfig.isExposeToken()); + + assertTrue("keycloak.bearer-only", + this.keycloakAdapterConfig.isBearerOnly()); + + assertTrue("keycloak.autodetect-bearer-only", + this.keycloakAdapterConfig.isAutodetectBearerOnly()); + + assertTrue("keycloak.enable-basic-auth", + this.keycloakAdapterConfig.isEnableBasicAuth()); + + assertTrue("keycloak.allow-any-hostname", + this.keycloakAdapterConfig.isAllowAnyHostname()); + + assertTrue("keycloak.disable-trust-manager", + this.keycloakAdapterConfig.isDisableTrustManager()); + + assertTrue("keycloak.always-refresh-token", + this.keycloakAdapterConfig.isAlwaysRefreshToken()); + + assertTrue("keycloak.register-node-at-startup", + this.keycloakAdapterConfig.isRegisterNodeAtStartup()); + + assertTrue("keycloak.enable-pkce", + this.keycloakAdapterConfig.isPkce()); + + assertTrue("keycloak.ignore-oauth-query-parameter", + this.keycloakAdapterConfig.isIgnoreOAuthQueryParameter()); + + assertTrue("keycloak.turn-off-change-session-id-on-login", + this.keycloakAdapterConfig.getTurnOffChangeSessionIdOnLogin()); + + // check credentials overrides + Map credentials = this.keycloakAdapterConfig.getCredentials(); + assertNotNull("Expected a credentials map", credentials); + assertFalse("Expected to retrieve a populated credentials map", credentials.isEmpty()); + assertEquals("keycloak.credentials.secret", "11111", credentials.get("secret")); + assertEquals("keycloak.credentials.provider", "secret", credentials.get("provider")); + } + + 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 an exception is thrown with correct description + try + { + ((RemoteUserMapper)childApplicationContextFactory.getApplicationContext().getBean( + REMOTE_USER_MAPPER_BEAN_NAME)).getRemoteUser(mockRequest); + fail("Expected an AuthenticationException to be thrown"); + } + catch (AuthenticationException ae) + { + assertTrue("Exception message contains 'Invalid token signature'", + ae.getMessage().indexOf("Invalid token signature") != -1); + } + } + + 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 + try + { + ((RemoteUserMapper)childApplicationContextFactory.getApplicationContext().getBean( + REMOTE_USER_MAPPER_BEAN_NAME)).getRemoteUser(mockRequest); + fail("Expected an AuthenticationException to be thrown"); + } + catch (AuthenticationException ae) + { + assertTrue("Exception message contains 'Token is not active'", + ae.getMessage().indexOf("Token is not active") != -1); + } + } + + 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)); + } + + public void testBasicAuthFallback() throws Exception + { + // create mock objects + HttpServletRequest mockRequest = createMockBasicRequest(); + HttpClient mockHttpClient = createMockHttpClient(); + + // override the http client on the keycloak deployment + KeycloakDeployment deployment = (KeycloakDeployment)childApplicationContextFactory.getApplicationContext(). + getBean(KEYCLOAK_DEPLOYMENT_BEAN_NAME); + deployment.setClient(mockHttpClient); + + // validate correct user was found + assertEquals(TEST_USER_USERNAME, ((RemoteUserMapper) childApplicationContextFactory.getApplicationContext().getBean( + REMOTE_USER_MAPPER_BEAN_NAME)).getRemoteUser(mockRequest)); + } + + /** + * Utility method for creating a mocked Servlet request with a token. + * + * @param token The token to add to the Authorization header + * @return The mocked request object + */ + private HttpServletRequest createMockTokenRequest(String token) + { + // Mock a request with the token in the Authorization header (if supplied) + HttpServletRequest mockRequest = mock(HttpServletRequest.class); + + Vector authHeaderValues = new Vector<>(1); + if (token != null) + { + 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.keycloakAdapterConfig.getAuthServerUrl() + "/realms/" + this.keycloakAdapterConfig.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()); + + 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(KEYCLOAK_DEPLOYMENT_BEAN_NAME); + HardcodedPublicKeyLocator publicKeyLocator = new HardcodedPublicKeyLocator(publicKey); + deployment.setPublicKeyLocator(publicKeyLocator); + } +} diff --git a/src/test/resources/alfresco-global.properties b/src/test/resources/alfresco-global.properties new file mode 100644 index 0000000000..68878bf0aa --- /dev/null +++ b/src/test/resources/alfresco-global.properties @@ -0,0 +1,45 @@ +# Test token authentication overrides +keycloak.auth-server-url=http://192.168.0.1:8180/auth +keycloak.realm=test +keycloak.realm-public-key=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAvWLQxipXNe6cLnVPGy7l\ +BgyR51bDiK7Jso8Rmh2TB+bmO4fNaMY1ETsxECSM0f6NTV0QHks9+gBe+pB6JNeM\ +uPmaE/M/MsE9KUif9L2ChFq3zor6s2foFv2DTiTkij+1aQF9fuIjDNH4FC6L252W\ +ydZzh+f73Xuy5evdPj+wrPYqWyP7sKd+4Q9EIILWAuTDvKEjwyZmIyfM/nUn6ltD\ +P6W8xMP0PoEJNAAp79anz2jk2HP2PvC2qdjVsphdTk3JG5qQMB0WJUh4Kjgabd4j\ +QJ77U8gTRswKgNHRRPWhruiIcmmkP+zI0ozNW6rxH3PF4L7M9rXmfcmUcBcKf+Yx\ +jwIDAQAB +keycloak.ssl-required=external +keycloak.resource=test +keycloak.public-client=false +keycloak.confidential-port=100 +keycloak.use-resource-role-mappings=true +keycloak.enable-cors=true +keycloak.cors-max-age=1000 +keycloak.cors-allowed-headers=Authorization +keycloak.cors-allowed-methods=POST, PUT, DELETE, GET +keycloak.cors-exposed-headers=WWW-Authenticate, My-custom-exposed-Header +keycloak.expose-token=true +keycloak.bearer-only=true +keycloak.autodetect-bearer-only=true +keycloak.enable-basic-auth=true +keycloak.allow-any-hostname=true +keycloak.disable-trust-manager=true +keycloak.truststore=classpath:/alfresco/subsystems/tokenAuthentication/keystore.jks +keycloak.truststore-password=password +keycloak.client-keystore=classpath:/alfresco/subsystems/tokenAuthentication/keystore.jks +keycloak.client-keystore-password=password +keycloak.client-key-password=password +keycloak.connection-pool-size=5 +keycloak.always-refresh-token=true +keycloak.register-node-at-startup=true +keycloak.register-node-period=50 +keycloak.token-store=SESSION +keycloak.principal-attribute=preferred_username +keycloak.turn-off-change-session-id-on-login=true +keycloak.token-minimum-time-to-live=10 +keycloak.min-time-between-jwks-requests=60 +keycloak.public-key-cache-ttl=3600 +keycloak.enable-pkce=true +keycloak.ignore-oauth-query-parameter=true +keycloak.credentials.secret=11111 +keycloak.credentials.provider=secret \ No newline at end of file diff --git a/src/test/resources/alfresco/subsystems/tokenAuthentication/keystore.jks b/src/test/resources/alfresco/subsystems/tokenAuthentication/keystore.jks new file mode 100644 index 0000000000..63ea4cce60 Binary files /dev/null and b/src/test/resources/alfresco/subsystems/tokenAuthentication/keystore.jks differ diff --git a/src/test/resources/log4j.properties b/src/test/resources/log4j.properties index d710d1ab11..0e6c8e9dbd 100644 --- a/src/test/resources/log4j.properties +++ b/src/test/resources/log4j.properties @@ -254,3 +254,7 @@ log4j.logger.org.alfresco.repo.usage.RepoUsageMonitor=info log4j.logger.org.alfresco.repo.site.SiteServiceImpl=DEBUG log4j.logger.org.alfresco.repo.action.ActionServiceImpl=DEBUG log4j.logger.org.alfresco.repo.security.person.PersonServiceImpl=DEBUG + +# token authentication +log4j.logger.org.alfresco.repo.security.authentication.token=debug +log4j.logger.org.keycloak=debug \ No newline at end of file