Add token exchange support for Share/Repo integration

This commit is contained in:
AFaust 2020-02-17 02:03:57 +01:00
parent 32c4fabff0
commit 9d9f665f29
26 changed files with 931 additions and 165 deletions

15
pom.xml
View File

@ -219,6 +219,21 @@
<scope>test</scope>
</dependency>
<dependency>
<groupId>de.acosix.alfresco.utility</groupId>
<artifactId>de.acosix.alfresco.utility.share</artifactId>
<version>${acosix.utility.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>de.acosix.alfresco.utility</groupId>
<artifactId>de.acosix.alfresco.utility.share</artifactId>
<version>${acosix.utility.version}</version>
<classifier>installable</classifier>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.orderofthebee.support-tools</groupId>
<artifactId>support-tools-repo</artifactId>

View File

@ -121,7 +121,7 @@
<!-- no change to Search image -->
</image>
<image>
<name>jboss/keycloak</name>
<name>jboss/keycloak:${keycloak.version}</name>
<alias>keycloak</alias>
<run>
<hostname>keycloak</hostname>

View File

@ -285,12 +285,12 @@ public class KeycloakAuthenticationComponent extends AbstractAuthenticationCompo
throw new AuthenticationException("Failed to refresh Keycloak authentication", ioex);
}
}
else if (this.failExpiredTicketTokens && ticketToken.isExpired())
else if (this.failExpiredTicketTokens && !ticketToken.isActive())
{
throw new AuthenticationException("Keycloak access token has expired - authentication ticket is no longer valid");
}
if (result != null || !ticketToken.isExpired())
if (result != null || ticketToken.isActive())
{
this.handleUserTokens(result != null ? result.getAccessToken() : ticketToken.getAccessToken(),
result != null ? result.getIdToken() : ticketToken.getIdToken(), false);

View File

@ -67,6 +67,7 @@ import de.acosix.alfresco.keycloak.repo.deps.keycloak.adapters.spi.Authenticatio
import de.acosix.alfresco.keycloak.repo.deps.keycloak.adapters.spi.KeycloakAccount;
import de.acosix.alfresco.keycloak.repo.deps.keycloak.adapters.spi.SessionIdMapper;
import de.acosix.alfresco.keycloak.repo.deps.keycloak.adapters.spi.UserSessionManagement;
import de.acosix.alfresco.keycloak.repo.deps.keycloak.common.util.KeycloakUriBuilder;
import de.acosix.alfresco.keycloak.repo.deps.keycloak.representations.AccessToken;
import de.acosix.alfresco.keycloak.repo.util.AlfrescoCompatibilityUtil;
import de.acosix.alfresco.keycloak.repo.util.RefreshableAccessTokenHolder;
@ -248,7 +249,15 @@ public class KeycloakAuthenticationFilter extends BaseAuthenticationFilter
final HttpServletRequest req = (HttpServletRequest) request;
final HttpServletResponse res = (HttpServletResponse) response;
final boolean skip = this.checkForSkipCondition(context, req, res);
final KeycloakUriBuilder authUrl = this.keycloakDeployment.getAuthUrl();
final boolean keycloakDeploymentReady = authUrl != null;
if (!keycloakDeploymentReady)
{
LOGGER.warn("Cannot process Keycloak-specifics as Keycloak library was unable to resolve relative URLs from {}",
this.keycloakDeployment.getAuthServerBaseUrl());
}
final boolean skip = !keycloakDeploymentReady || this.checkForSkipCondition(context, req, res);
if (skip)
{
@ -635,53 +644,96 @@ public class KeycloakAuthenticationFilter extends BaseAuthenticationFilter
if (!this.active)
{
LOGGER.trace("Skipping doFilter as filter is not active");
LOGGER.trace("Skipping processKeycloakAuthenticationAndActions as filter is not active");
skip = true;
}
else if (servletRequestUri.matches(KEYCLOAK_ACTION_URL_PATTERN))
{
LOGGER.trace("Explicitly not skipping doFilter as Keycloak action URL is being called");
LOGGER.trace("Explicitly not skipping processKeycloakAuthenticationAndActions as Keycloak action URL is being called");
}
else if (req.getParameter("state") != null && req.getParameter("code") != null && this.hasStateCookie(req))
{
LOGGER.trace(
"Explicitly not skipping doFilter as state and code query parameters of OAuth2 redirect as well as state cookie are present");
"Explicitly not skipping processKeycloakAuthenticationAndActions as state and code query parameters of OAuth2 redirect as well as state cookie are present");
}
else if (authHeader != null && authHeader.toLowerCase(Locale.ENGLISH).startsWith("bearer "))
{
LOGGER.trace("Explicitly not skipping doFilter as Bearer authorization header is present");
final AccessToken accessToken = (AccessToken) session.getAttribute(KeycloakRemoteUserMapper.class.getName());
if (accessToken != null)
{
if (accessToken.isActive())
{
LOGGER.trace(
"Skipping processKeycloakAuthenticationAndActions as Bearer authorization header for {} has already been processed by remote user mapper",
AlfrescoCompatibilityUtil.maskUsername(accessToken.getPreferredUsername()));
this.keycloakAuthenticationComponent.handleUserTokens(accessToken, accessToken, session.isNew());
skip = true;
}
else
{
LOGGER.trace(
"Explicitly not skipping processKeycloakAuthenticationAndActions as processed Bearer authorization token for {} has expired",
AlfrescoCompatibilityUtil.maskUsername(accessToken.getPreferredUsername()));
}
}
else
{
LOGGER.trace(
"Explicitly not skipping processKeycloakAuthenticationAndActions as unprocessed Bearer authorization header is present");
}
}
else if (authHeader != null && authHeader.toLowerCase(Locale.ENGLISH).startsWith("basic "))
{
LOGGER.trace("Explicitly not skipping doFilter as Basic authorization header is present");
LOGGER.trace("Explicitly not skipping processKeycloakAuthenticationAndActions as Basic authorization header is present");
}
else if (authHeader != null)
{
LOGGER.trace("Skipping doFilter as non-OIDC / non-Basic authorization header is present");
LOGGER.trace("Skipping processKeycloakAuthenticationAndActions as non-OIDC / non-Basic authorization header is present");
skip = true;
}
else if (this.allowTicketLogon && this.checkForTicketParameter(context, req, res))
{
LOGGER.trace("Skipping doFilter as user was authenticated by ticket URL parameter");
LOGGER.trace("Skipping processKeycloakAuthenticationAndActions as user was authenticated by ticket URL parameter");
skip = true;
}
// check no-auth flag (derived e.g. from checking if target web script requires authentication) only after all pre-emptive auth
// request details have been checked
else if (Boolean.TRUE.equals(req.getAttribute(NO_AUTH_REQUIRED)))
{
LOGGER.trace("Skipping doFilter as filter higher up in chain determined authentication as not required");
LOGGER.trace(
"Skipping processKeycloakAuthenticationAndActions as filter higher up in chain determined authentication as not required");
skip = true;
}
else if (sessionUser != null)
{
final KeycloakAccount keycloakAccount = (KeycloakAccount) session.getAttribute(KeycloakAccount.class.getName());
final AccessToken accessToken = (AccessToken) session.getAttribute(KeycloakRemoteUserMapper.class.getName());
if (keycloakAccount != null)
{
skip = this.validateAndRefreshKeycloakAuthentication(req, res, sessionUser.getUserName());
}
else if (accessToken != null)
{
if (accessToken.isActive())
{
LOGGER.trace(
"Skipping processKeycloakAuthenticationAndActions as access token in session from previous Bearer authorization for {} is still valid",
AlfrescoCompatibilityUtil.maskUsername(sessionUser.getUserName()));
this.keycloakAuthenticationComponent.handleUserTokens(accessToken, accessToken, false);
skip = true;
}
else
{
LOGGER.trace(
"Explicitly not skipping processKeycloakAuthenticationAndActions as access token in session from previous Bearer authorization for {} has expired",
AlfrescoCompatibilityUtil.maskUsername(sessionUser.getUserName()));
this.invalidateSession(req);
}
}
else
{
LOGGER.trace("Skipping doFilter as non-Keycloak-authenticated session is already established");
LOGGER.trace(
"Skipping processKeycloakAuthenticationAndActions as non-Keycloak-authenticated session is already established");
skip = true;
}
}

View File

@ -134,7 +134,7 @@ public class KeycloakAuthenticationServiceImpl extends AuthenticationServiceImpl
this.keycloakTicketTokenCache.put(ticket, refreshedToken);
}
// apparently expiration is allowed - remove from cache to avoid unnecessary checks in the future
else if (refreshableAccessToken.isExpired())
else if (!refreshableAccessToken.isActive())
{
LOGGER.warn(
"The Keycloak access token associated with ticket {} for user {} has expired - Keycloak roles / claims are no longer available for the corresponding user",

View File

@ -18,6 +18,7 @@ package de.acosix.alfresco.keycloak.repo.authentication;
import java.util.List;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;
import org.alfresco.repo.management.subsystems.ActivateableBean;
import org.alfresco.repo.security.authentication.AuthenticationException;
@ -32,6 +33,7 @@ import org.springframework.beans.factory.InitializingBean;
import de.acosix.alfresco.keycloak.repo.deps.keycloak.adapters.BearerTokenRequestAuthenticator;
import de.acosix.alfresco.keycloak.repo.deps.keycloak.adapters.KeycloakDeployment;
import de.acosix.alfresco.keycloak.repo.deps.keycloak.adapters.spi.AuthOutcome;
import de.acosix.alfresco.keycloak.repo.deps.keycloak.representations.AccessToken;
/**
* @author Axel Faust
@ -120,11 +122,22 @@ public class KeycloakRemoteUserMapper implements RemoteUserMapper, ActivateableB
final BearerTokenRequestAuthenticator authenticator = new BearerTokenRequestAuthenticator(this.keycloakDeployment);
final AuthOutcome authOutcome = authenticator.authenticate(httpFacade);
// TODO Check on how to enable / add client/audience validation
// currently, Share token seems to be valid here, which it shouldn't be
// also, Share token may not contain Alfresco client roles (e.g. admin)
if (authOutcome == AuthOutcome.AUTHENTICATED)
{
final String preferredUsername = authenticator.getToken().getPreferredUsername();
final String normalisedUserName = AuthenticationUtil
.runAsSystem(() -> this.personService.getUserIdentifier(preferredUsername));
final AccessToken token = authenticator.getToken();
final String preferredUsername = token.getPreferredUsername();
// need to store token for later validation
final HttpSession session = request.getSession(true);
session.setAttribute(KeycloakRemoteUserMapper.class.getName(), token);
// need case distinction to avoid user name being nulled when user does not exist yet
final String normalisedUserName = AuthenticationUtil.runAsSystem(
() -> this.personService.personExists(preferredUsername) ? this.personService.getUserIdentifier(preferredUsername)
: preferredUsername);
LOGGER.debug("Authenticated user {} via bearer token, normalised as {}", preferredUsername, normalisedUserName);

View File

@ -236,6 +236,10 @@ public class KeycloakAdapterConfigBeanFactory implements FactoryBean<AdapterConf
}
});
PropertyCheck.mandatory(adapterConfig, "auth-server-url", adapterConfig.getAuthServerUrl());
PropertyCheck.mandatory(adapterConfig, "realm", adapterConfig.getRealm());
PropertyCheck.mandatory(adapterConfig, "resource", adapterConfig.getResource());
return adapterConfig;
}

View File

@ -96,6 +96,17 @@ public class RefreshableAccessTokenHolder implements Serializable
this.refreshExpiration = Time.currentTime() - (accessToken.getExpiration() - Time.currentTime()) / 100;
}
/**
* Checks whether the encapsulated access token is active.
*
* @return {@code true} if the access token is active, {@code false} otherwise
*/
public boolean isActive()
{
final boolean isActive = this.accessToken.isActive();
return isActive;
}
/**
* Checks whether the encapsulated access token has expired.
*
@ -103,7 +114,7 @@ public class RefreshableAccessTokenHolder implements Serializable
*/
public boolean isExpired()
{
final boolean isExpired = this.accessToken.getExpiration() < Time.currentTime();
final boolean isExpired = this.accessToken.isExpired();
return isExpired;
}

View File

@ -25,7 +25,7 @@ keycloak.adapter.credentials.provider=secret
keycloak.adapter.credentials.secret=6f70a28f-98cd-41ca-8f2f-368a8797d708
# localhost in auth-server-url won't work for direct access in a Docker deployment
keycloak.authentication.directAuthHost=http://host.docker.internal:8380
keycloak.authentication.directAuthHost=http://keycloak:8080
keycloak.synchronization.userFilter.containedInGroup.property.groupPaths=/Test A
keycloak.synchronization.groupFilter.containedInGroup.property.groupPaths=/Test A

View File

@ -97,24 +97,12 @@
<dependency>
<groupId>de.acosix.alfresco.utility</groupId>
<artifactId>de.acosix.alfresco.utility.core.share</artifactId>
<exclusions>
<exclusion>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-log4j12</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>de.acosix.alfresco.utility</groupId>
<artifactId>de.acosix.alfresco.utility.core.share</artifactId>
<classifier>installable</classifier>
<exclusions>
<exclusion>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-log4j12</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
@ -199,7 +187,7 @@
<!-- no change to Search image -->
</image>
<image>
<name>jboss/keycloak</name>
<name>jboss/keycloak:${keycloak.version}</name>
<alias>keycloak</alias>
<run>
<hostname>keycloak</hostname>
@ -219,6 +207,7 @@
</network>
<volumes>
<bind>
<volume>${project.build.directory}/docker/keycloakProfile.properties:/opt/jboss/keycloak/standalone/configuration/profile.properties</volume>
<volume>${project.build.directory}/docker/test-realm.json:/tmp/test-realm.json</volume>
</bind>
</volumes>

View File

@ -35,8 +35,12 @@
<ssl-redirect-port>8443</ssl-redirect-port>
<body-buffer-limit>10485760</body-buffer-limit>
<session-mapper-limit>1000</session-mapper-limit>
<ignore-default-filter>true</ignore-default-filter>
<perform-token-exchange>false</perform-token-exchange>
<alfresco-resource-name>alfresco</alfresco-resource-name>
</keycloak-auth-config>
<keycloak-adapter-config>
<!-- by default use the same client as alfresco (not really "clean") -->
<auth-server-url>http://localhost:8180/auth</auth-server-url>
<realm>alfresco</realm>
<resource>alfresco</resource>

View File

@ -46,7 +46,15 @@
</list>
</property>
</bean>
<bean id="${moduleId}.maxRemoteClientRedirectPatch"
class="de.acosix.alfresco.utility.common.spring.PropertyAlteringBeanFactoryPostProcessor">
<property name="targetBeanName" value="connector.remoteclient.abstract" />
<property name="propertyName" value="maxRedirects" />
<property name="value" value="1" />
<property name="enabled" value="\${${moduleId}.surf.onlyOneRedirect.enabled}" />
</bean>
<bean id="${moduleId}.SessionIdMapper" class="${project.artifactId}.web.DefaultSessionIdMapper">
<property name="configService" ref="web.config" />
</bean>
@ -69,7 +77,7 @@
<property name="configService" ref="web.config" />
<property name="connectorService" ref="connector.service" />
</bean>
<bean class="${project.artifactId}.spring.KeycloakAuthenticationFilterActivation">
<property name="moduleId" value="${moduleId}" />
</bean>

View File

@ -0,0 +1 @@
${moduleId}.surf.onlyOneRedirect.enabled=true

View File

@ -31,6 +31,7 @@ import java.util.Set;
import org.alfresco.error.AlfrescoRuntimeException;
import org.alfresco.util.EqualsHelper;
import org.alfresco.util.ParameterCheck;
import org.alfresco.util.PropertyCheck;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.extensions.config.ConfigElement;
@ -318,7 +319,7 @@ public class KeycloakAdapterConfigElement extends BaseCustomConfigElement
*/
public AdapterConfig buildAdapterConfiguration()
{
final AdapterConfig config = new AdapterConfig();
final AdapterConfig adapterConfig = new AdapterConfig();
try
{
@ -328,7 +329,7 @@ public class KeycloakAdapterConfigElement extends BaseCustomConfigElement
if (value != null)
{
final Method setter = SETTER_BY_CONFIG_NAME.get(configName);
setter.invoke(config, value);
setter.invoke(adapterConfig, value);
}
}
}
@ -337,7 +338,11 @@ public class KeycloakAdapterConfigElement extends BaseCustomConfigElement
throw new AlfrescoRuntimeException("Error building adapter configuration", ex);
}
return config;
PropertyCheck.mandatory(adapterConfig, "auth-server-url", adapterConfig.getAuthServerUrl());
PropertyCheck.mandatory(adapterConfig, "realm", adapterConfig.getRealm());
PropertyCheck.mandatory(adapterConfig, "resource", adapterConfig.getResource());
return adapterConfig;
}
/**

View File

@ -43,6 +43,12 @@ public class KeycloakAuthenticationConfigElement extends BaseCustomConfigElement
protected final ConfigValueHolder<Integer> sessionMapperLimit = new ConfigValueHolder<>();
protected final ConfigValueHolder<Boolean> ignoreDefaultFilter = new ConfigValueHolder<>();
protected final ConfigValueHolder<Boolean> performTokenExchange = new ConfigValueHolder<>();
protected final ConfigValueHolder<String> alfrescoResourceName = new ConfigValueHolder<>();
/**
* Creates a new instance of this class.
*/
@ -153,6 +159,57 @@ public class KeycloakAuthenticationConfigElement extends BaseCustomConfigElement
return this.sessionMapperLimit.getValue();
}
/**
* @param ignoreDefaultFilter
* the ignoreDefaultFilter to set
*/
public void setIgnoreDefaultFilter(final Boolean ignoreDefaultFilter)
{
this.ignoreDefaultFilter.setValue(ignoreDefaultFilter);
}
/**
* @return the ignoreDefaultFilter
*/
public Boolean getIgnoreDefaultFilter()
{
return this.ignoreDefaultFilter.getValue();
}
/**
* @param performTokenExchange
* the performTokenExchange to set
*/
public void setPerformTokenExchange(final Boolean performTokenExchange)
{
this.performTokenExchange.setValue(performTokenExchange);
}
/**
* @return the performTokenExchange
*/
public Boolean getPerformTokenExchange()
{
return this.performTokenExchange.getValue();
}
/**
* @param alfrescoResourceName
* the alfrescoResourceName to set
*/
public void setAlfrescoResourceName(final String alfrescoResourceName)
{
this.alfrescoResourceName.setValue(alfrescoResourceName);
}
/**
* @return the alfrescoResourceName
*/
public String getAlfrescoResourceName()
{
return this.alfrescoResourceName.getValue();
}
/**
*
* {@inheritDoc}
@ -228,6 +285,39 @@ public class KeycloakAuthenticationConfigElement extends BaseCustomConfigElement
: this.getSessionMapperLimit());
}
if (otherConfigElement.ignoreDefaultFilter.isUnset())
{
combined.ignoreDefaultFilter.unset();
}
else
{
combined.setIgnoreDefaultFilter(
otherConfigElement.getIgnoreDefaultFilter() != null ? otherConfigElement.getIgnoreDefaultFilter()
: this.getIgnoreDefaultFilter());
}
if (otherConfigElement.performTokenExchange.isUnset())
{
combined.performTokenExchange.unset();
}
else
{
combined.setPerformTokenExchange(
otherConfigElement.getPerformTokenExchange() != null ? otherConfigElement.getPerformTokenExchange()
: this.getPerformTokenExchange());
}
if (otherConfigElement.alfrescoResourceName.isUnset())
{
combined.alfrescoResourceName.unset();
}
else
{
combined.setAlfrescoResourceName(
otherConfigElement.getAlfrescoResourceName() != null ? otherConfigElement.getAlfrescoResourceName()
: this.getAlfrescoResourceName());
}
return combined;
}
@ -256,6 +346,15 @@ public class KeycloakAuthenticationConfigElement extends BaseCustomConfigElement
builder.append(", ");
builder.append("sessionMapperLimit=");
builder.append(this.sessionMapperLimit);
builder.append(", ");
builder.append("ignoreDefaultFilter=");
builder.append(this.ignoreDefaultFilter);
builder.append(", ");
builder.append("performTokenExchange=");
builder.append(this.performTokenExchange);
builder.append(", ");
builder.append("alfrescoResourceName=");
builder.append(this.alfrescoResourceName);
builder.append("]");
return builder.toString();
}

View File

@ -79,6 +79,27 @@ public class KeycloakAuthenticationConfigElementReader implements ConfigElementR
configElement.setSessionMapperLimit(value.isEmpty() ? null : Integer.valueOf(value));
}
final Element ignoreDefaultFilter = element.element("ignore-default-filter");
if (ignoreDefaultFilter != null)
{
final String value = ignoreDefaultFilter.getTextTrim();
configElement.setIgnoreDefaultFilter(value.isEmpty() ? null : Boolean.valueOf(value));
}
final Element performTokenExchange = element.element("perform-token-exchange");
if (performTokenExchange != null)
{
final String value = performTokenExchange.getTextTrim();
configElement.setPerformTokenExchange(value.isEmpty() ? null : Boolean.valueOf(value));
}
final Element alfrescoResourceName = element.element("alfresco-resource-name");
if (alfrescoResourceName != null)
{
final String value = alfrescoResourceName.getTextTrim();
configElement.setAlfrescoResourceName(value.isEmpty() ? null : value);
}
LOGGER.debug("Read configuration element {} from XML section", configElement);
return configElement;

View File

@ -0,0 +1,96 @@
/*
* Copyright 2019 - 2020 Acosix GmbH
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package de.acosix.alfresco.keycloak.share.remote;
import java.util.Collections;
import javax.servlet.http.HttpSession;
import org.alfresco.web.site.servlet.SlingshotAlfrescoConnector;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.extensions.config.RemoteConfigElement.ConnectorDescriptor;
import org.springframework.extensions.surf.ServletUtil;
import org.springframework.extensions.webscripts.connector.ConnectorContext;
import org.springframework.extensions.webscripts.connector.RemoteClient;
import de.acosix.alfresco.keycloak.share.deps.keycloak.KeycloakSecurityContext;
import de.acosix.alfresco.keycloak.share.deps.keycloak.adapters.OidcKeycloakAccount;
import de.acosix.alfresco.keycloak.share.deps.keycloak.adapters.spi.KeycloakAccount;
import de.acosix.alfresco.keycloak.share.util.RefreshableAccessTokenHolder;
/**
* @author Axel Faust
*/
public class AccessTokenAwareSlingshotAlfrescoConnector extends SlingshotAlfrescoConnector
{
private static final Logger LOGGER = LoggerFactory.getLogger(AccessTokenAwareSlingshotAlfrescoConnector.class);
/**
* Constructs a new instance of this class.
*
* @param descriptor
* the descriptor / configuration of this connector
* @param endpoint
* the endpoint with which this connector instance should connect
*/
public AccessTokenAwareSlingshotAlfrescoConnector(final ConnectorDescriptor descriptor, final String endpoint)
{
super(descriptor, endpoint);
}
/**
*
* {@inheritDoc}
*/
@Override
protected void applyRequestAuthentication(final RemoteClient remoteClient, final ConnectorContext context)
{
final HttpSession session = ServletUtil.getSession();
final KeycloakAccount keycloakAccount = (KeycloakAccount) (session != null ? session.getAttribute(KeycloakAccount.class.getName())
: null);
final RefreshableAccessTokenHolder accessToken = (RefreshableAccessTokenHolder) (session != null
? session.getAttribute(AccessTokenAwareSlingshotAlfrescoConnector.class.getName())
: null);
if (accessToken != null)
{
if (accessToken.isActive())
{
LOGGER.debug("Using access token for backend found in session for request");
final String tokenString = accessToken.getToken();
remoteClient.setRequestProperties(Collections.singletonMap("Authorization", "Bearer " + tokenString));
}
else
{
LOGGER.warn("Acesss token for backend stored in session has expired");
}
}
else if (keycloakAccount instanceof OidcKeycloakAccount)
{
LOGGER.debug(
"Did not find access token for backend in session - using regularly authenticated Keycloak account access token for request instead");
final KeycloakSecurityContext keycloakSecurityContext = ((OidcKeycloakAccount) keycloakAccount).getKeycloakSecurityContext();
final String tokenString = keycloakSecurityContext.getTokenString();
remoteClient.setRequestProperties(Collections.singletonMap("Authorization", "Bearer " + tokenString));
}
else
{
LOGGER.debug("Did not find Keycloak-related authentication data in session - applying regular request authentication");
super.applyRequestAuthentication(remoteClient, context);
}
}
}

View File

@ -1,67 +0,0 @@
/*
* Copyright 2019 - 2020 Acosix GmbH
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package de.acosix.alfresco.keycloak.share.remote;
import java.util.Collections;
import org.alfresco.web.site.servlet.SlingshotAlfrescoConnector;
import org.springframework.extensions.config.RemoteConfigElement.ConnectorDescriptor;
import org.springframework.extensions.webscripts.connector.ConnectorContext;
import org.springframework.extensions.webscripts.connector.ConnectorSession;
import org.springframework.extensions.webscripts.connector.RemoteClient;
/**
* @author Axel Faust
*/
public class BearerTokenAwareSlingshotAlfrescoConnector extends SlingshotAlfrescoConnector
{
public static final String CS_PARAM_BEARER_TOKEN = "bearerToken";
/**
* Constructs a new instance of this class.
*
* @param descriptor
* the descriptor / configuration of this connector
* @param endpoint
* the endpoint with which this connector instance should connect
*/
public BearerTokenAwareSlingshotAlfrescoConnector(final ConnectorDescriptor descriptor, final String endpoint)
{
super(descriptor, endpoint);
}
/**
*
* {@inheritDoc}
*/
@Override
protected void applyRequestHeaders(final RemoteClient remoteClient, final ConnectorContext context)
{
// apply default mapping of headers
super.applyRequestHeaders(remoteClient, context);
final ConnectorSession connectorSession = this.getConnectorSession();
if (connectorSession != null)
{
final String bearerToken = connectorSession.getParameter(CS_PARAM_BEARER_TOKEN);
if (bearerToken != null && !bearerToken.trim().isEmpty())
{
remoteClient.setRequestProperties(Collections.singletonMap("Authorization", "Bearer " + bearerToken));
}
}
}
}

View File

@ -0,0 +1,178 @@
/*
* Copyright 2019 - 2020 Acosix GmbH
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package de.acosix.alfresco.keycloak.share.util;
import java.io.Serializable;
import org.alfresco.util.ParameterCheck;
import de.acosix.alfresco.keycloak.share.deps.keycloak.adapters.rotation.AdapterTokenVerifier.VerifiedTokens;
import de.acosix.alfresco.keycloak.share.deps.keycloak.common.util.Time;
import de.acosix.alfresco.keycloak.share.deps.keycloak.representations.AccessToken;
import de.acosix.alfresco.keycloak.share.deps.keycloak.representations.AccessTokenResponse;
import de.acosix.alfresco.keycloak.share.deps.keycloak.representations.IDToken;
/**
* Instances of this class encapsulate an access token with its associated refresh data.
*
* @author Axel Faust
*/
public class RefreshableAccessTokenHolder implements Serializable
{
private static final long serialVersionUID = -3230026569734591820L;
protected final AccessToken accessToken;
protected final IDToken idToken;
protected final String token;
protected final String refreshToken;
protected final int refreshExpiration;
/**
* Constructs a new instance of this class from an access token response, typically from an initial authentication or token refresh
*
* @param tokenResponse
* the response to a request for an access token
* @param verifiedTokens
* the token wrapper from the response verification step any client should do before constructing a new instance of this
* class
*/
public RefreshableAccessTokenHolder(final AccessTokenResponse tokenResponse, final VerifiedTokens verifiedTokens)
{
ParameterCheck.mandatory("tokenResponse", tokenResponse);
ParameterCheck.mandatory("verifiedTokens", verifiedTokens);
this.accessToken = verifiedTokens.getAccessToken();
this.idToken = verifiedTokens.getIdToken();
this.token = tokenResponse.getToken();
this.refreshToken = tokenResponse.getRefreshToken();
this.refreshExpiration = Time.currentTime() + (int) tokenResponse.getRefreshExpiresIn();
}
/**
* Constructs a new instance of this class from details exposed by Keycloak servlet adapter APIs. Since these APIs do not provide some
* access to token response details, this constructor assumes that the refresh token is valid for at least 1/100th the duration of the
* overall access token.
*
* @param accessToken
* the access token
* @param idToken
* the ID token
* @param token
* the textual representation of the access token
* @param refreshToken
* the textual representation of the refresh token
*/
public RefreshableAccessTokenHolder(final AccessToken accessToken, final IDToken idToken, final String token, final String refreshToken)
{
ParameterCheck.mandatory("accessToken", accessToken);
ParameterCheck.mandatory("idToken", idToken);
ParameterCheck.mandatoryString("token", token);
this.accessToken = accessToken;
this.idToken = idToken;
this.token = token;
this.refreshToken = refreshToken;
// no explicit refresh expiration, so assume validity period is 1/100th
this.refreshExpiration = Time.currentTime() - (accessToken.getExpiration() - Time.currentTime()) / 100;
}
/**
* Checks whether the encapsulated access token is active.
*
* @return {@code true} if the access token is active, {@code false} otherwise
*/
public boolean isActive()
{
final boolean isActive = this.accessToken.isActive();
return isActive;
}
/**
* Checks whether the encapsulated access token has expired.
*
* @return {@code true} if the access token as expired, {@code false} otherwise
*/
public boolean isExpired()
{
final boolean isExpired = this.accessToken.isExpired();
return isExpired;
}
/**
* Checks whether the encapsulated access token can be refreshed.
*
* @return {@code true} if the token can be refreshed, {@code false} otherwise
*/
public boolean canRefresh()
{
final boolean canRefresh = this.refreshToken != null && this.refreshExpiration > Time.currentTime();
return canRefresh;
}
/**
* Checks whether the encapsulated access token should be refreshed.
*
* @param minTokenTTL
* the minimum time-to-live remaining before a token needs to be refreshed
*
* @return {@code true} if the token should be refreshed, {@code false} otherwise
*/
public boolean shouldRefresh(final int minTokenTTL)
{
final boolean shouldRefresh = this.refreshToken != null && this.accessToken.getExpiration() - minTokenTTL < Time.currentTime();
return shouldRefresh;
}
/**
* @return the token
*/
public String getToken()
{
return this.token;
}
/**
* @return the refreshToken
*/
public String getRefreshToken()
{
return this.refreshToken;
}
/**
* @return the access token
*/
public AccessToken getAccessToken()
{
return this.accessToken;
}
/**
* @return the idToken
*/
public IDToken getIdToken()
{
return this.idToken;
}
}

View File

@ -16,6 +16,7 @@
package de.acosix.alfresco.keycloak.share.web;
import java.io.IOException;
import java.io.InputStream;
import java.net.InetAddress;
import java.util.ArrayList;
import java.util.Arrays;
@ -37,14 +38,23 @@ import javax.servlet.http.HttpServletRequestWrapper;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import org.alfresco.error.AlfrescoRuntimeException;
import org.alfresco.util.EqualsHelper;
import org.alfresco.util.PropertyCheck;
import org.alfresco.web.site.servlet.SSOAuthenticationFilter;
import org.apache.http.HttpEntity;
import org.apache.http.HttpHost;
import org.apache.http.HttpResponse;
import org.apache.http.NameValuePair;
import org.apache.http.client.HttpClient;
import org.apache.http.client.entity.UrlEncodedFormEntity;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.conn.params.ConnRoutePNames;
import org.apache.http.conn.params.ConnRouteParams;
import org.apache.http.conn.routing.HttpRoute;
import org.apache.http.message.BasicNameValuePair;
import org.apache.http.params.HttpParams;
import org.apache.http.util.EntityUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.InitializingBean;
@ -57,14 +67,12 @@ import org.springframework.extensions.surf.RequestContext;
import org.springframework.extensions.surf.RequestContextUtil;
import org.springframework.extensions.surf.ServletUtil;
import org.springframework.extensions.surf.UserFactory;
import org.springframework.extensions.surf.exception.ConnectorServiceException;
import org.springframework.extensions.surf.mvc.PageViewResolver;
import org.springframework.extensions.surf.site.AuthenticationUtil;
import org.springframework.extensions.surf.types.Page;
import org.springframework.extensions.surf.types.PageType;
import org.springframework.extensions.webscripts.Description.RequiredAuthentication;
import org.springframework.extensions.webscripts.Status;
import org.springframework.extensions.webscripts.connector.Connector;
import org.springframework.extensions.webscripts.connector.ConnectorService;
import org.springframework.extensions.webscripts.servlet.DependencyInjectedFilter;
@ -72,6 +80,7 @@ import de.acosix.alfresco.keycloak.share.config.KeycloakAdapterConfigElement;
import de.acosix.alfresco.keycloak.share.config.KeycloakAuthenticationConfigElement;
import de.acosix.alfresco.keycloak.share.config.KeycloakConfigConstants;
import de.acosix.alfresco.keycloak.share.deps.keycloak.KeycloakSecurityContext;
import de.acosix.alfresco.keycloak.share.deps.keycloak.OAuth2Constants;
import de.acosix.alfresco.keycloak.share.deps.keycloak.adapters.AdapterDeploymentContext;
import de.acosix.alfresco.keycloak.share.deps.keycloak.adapters.AuthenticatedActionsHandler;
import de.acosix.alfresco.keycloak.share.deps.keycloak.adapters.HttpClientBuilder;
@ -80,6 +89,9 @@ import de.acosix.alfresco.keycloak.share.deps.keycloak.adapters.KeycloakDeployme
import de.acosix.alfresco.keycloak.share.deps.keycloak.adapters.OAuthRequestAuthenticator;
import de.acosix.alfresco.keycloak.share.deps.keycloak.adapters.OidcKeycloakAccount;
import de.acosix.alfresco.keycloak.share.deps.keycloak.adapters.PreAuthActionsHandler;
import de.acosix.alfresco.keycloak.share.deps.keycloak.adapters.ServerRequest;
import de.acosix.alfresco.keycloak.share.deps.keycloak.adapters.authentication.ClientCredentialsProviderUtils;
import de.acosix.alfresco.keycloak.share.deps.keycloak.adapters.rotation.AdapterTokenVerifier;
import de.acosix.alfresco.keycloak.share.deps.keycloak.adapters.servlet.FilterRequestAuthenticator;
import de.acosix.alfresco.keycloak.share.deps.keycloak.adapters.servlet.OIDCFilterSessionStore;
import de.acosix.alfresco.keycloak.share.deps.keycloak.adapters.servlet.OIDCServletHttpFacade;
@ -88,9 +100,16 @@ import de.acosix.alfresco.keycloak.share.deps.keycloak.adapters.spi.Authenticati
import de.acosix.alfresco.keycloak.share.deps.keycloak.adapters.spi.KeycloakAccount;
import de.acosix.alfresco.keycloak.share.deps.keycloak.adapters.spi.SessionIdMapper;
import de.acosix.alfresco.keycloak.share.deps.keycloak.adapters.spi.UserSessionManagement;
import de.acosix.alfresco.keycloak.share.deps.keycloak.common.VerificationException;
import de.acosix.alfresco.keycloak.share.deps.keycloak.common.util.KeycloakUriBuilder;
import de.acosix.alfresco.keycloak.share.deps.keycloak.common.util.Time;
import de.acosix.alfresco.keycloak.share.deps.keycloak.constants.ServiceUrlConstants;
import de.acosix.alfresco.keycloak.share.deps.keycloak.representations.AccessToken;
import de.acosix.alfresco.keycloak.share.deps.keycloak.representations.AccessTokenResponse;
import de.acosix.alfresco.keycloak.share.deps.keycloak.representations.adapters.config.AdapterConfig;
import de.acosix.alfresco.keycloak.share.remote.BearerTokenAwareSlingshotAlfrescoConnector;
import de.acosix.alfresco.keycloak.share.deps.keycloak.util.JsonSerialization;
import de.acosix.alfresco.keycloak.share.remote.AccessTokenAwareSlingshotAlfrescoConnector;
import de.acosix.alfresco.keycloak.share.util.RefreshableAccessTokenHolder;
/**
* Keycloak-based authentication filter class which can act as a standalone filter or a facade to the default {@link SSOAuthenticationFilter
@ -101,6 +120,10 @@ import de.acosix.alfresco.keycloak.share.remote.BearerTokenAwareSlingshotAlfresc
public class KeycloakAuthenticationFilter implements DependencyInjectedFilter, InitializingBean, ApplicationContextAware
{
private static final String KEYCLOAK_ACCOUNT_SESSION_KEY = KeycloakAccount.class.getName();
private static final String BACKEND_ACCESS_TOKEN_SESSION_KEY = AccessTokenAwareSlingshotAlfrescoConnector.class.getName();
private static final String HEADER_AUTHORIZATION = "Authorization";
private static final Logger LOGGER = LoggerFactory.getLogger(KeycloakAuthenticationFilter.class);
@ -149,6 +172,8 @@ public class KeycloakAuthenticationFilter implements DependencyInjectedFilter, I
protected boolean forceSso = false;
protected boolean ignoreDefaultFilter = false;
protected KeycloakDeployment keycloakDeployment;
protected AdapterDeploymentContext deploymentContext;
@ -178,7 +203,7 @@ public class KeycloakAuthenticationFilter implements DependencyInjectedFilter, I
{
final HttpSession currentSession = req.getSession(false);
authenticatedByKeycloak = currentSession != null && AuthenticationUtil.isAuthenticated(req)
&& currentSession.getAttribute(KeycloakAccount.class.getName()) != null;
&& currentSession.getAttribute(KEYCLOAK_ACCOUNT_SESSION_KEY) != null;
}
return authenticatedByKeycloak;
}
@ -286,6 +311,7 @@ public class KeycloakAuthenticationFilter implements DependencyInjectedFilter, I
this.filterEnabled = Boolean.TRUE.equals(keycloakAuthConfig.getEnableSsoFilter());
this.loginFormEnhancementEnabled = Boolean.TRUE.equals(keycloakAuthConfig.getEnhanceLoginForm());
this.forceSso = Boolean.TRUE.equals(keycloakAuthConfig.getForceKeycloakSso());
this.ignoreDefaultFilter = Boolean.TRUE.equals(keycloakAuthConfig.getIgnoreDefaultFilter());
}
else
{
@ -375,17 +401,26 @@ public class KeycloakAuthenticationFilter implements DependencyInjectedFilter, I
final HttpServletResponse res = (HttpServletResponse) response;
LOGGER.debug("Entered doFilter for {}", req);
if (this.isLogoutRequest(req))
final KeycloakUriBuilder authUrl = this.keycloakDeployment.getAuthUrl();
final boolean keycloakDeploymentReady = authUrl != null;
if (!keycloakDeploymentReady)
{
LOGGER.warn("Cannot process Keycloak-specifics as Keycloak library was unable to resolve relative URLs from {}",
this.keycloakDeployment.getAuthServerBaseUrl());
}
if (keycloakDeploymentReady && this.isLogoutRequest(req))
{
this.processLogout(context, req, res, chain);
}
else
{
final boolean skip = this.checkForSkipCondition(req, res);
final boolean skip = !keycloakDeploymentReady || this.checkForSkipCondition(req, res);
if (skip)
{
if (!AuthenticationUtil.isAuthenticated(req) && this.loginFormEnhancementEnabled && this.isLoginPage(req))
if (keycloakDeploymentReady && !AuthenticationUtil.isAuthenticated(req) && this.loginFormEnhancementEnabled
&& this.isLoginPage(req))
{
this.prepareLoginFormEnhancement(context, req, res);
}
@ -414,7 +449,7 @@ public class KeycloakAuthenticationFilter implements DependencyInjectedFilter, I
final HttpSession currentSession = req.getSession(false);
if (currentSession != null && AuthenticationUtil.isAuthenticated(req)
&& currentSession.getAttribute(KeycloakAccount.class.getName()) != null
&& currentSession.getAttribute(KEYCLOAK_ACCOUNT_SESSION_KEY) != null
&& this.sessionIdMapper.hasSession(currentSession.getId()))
{
LOGGER.debug("Processing logout for Keycloak-authenticated user {} in session {}", AuthenticationUtil.getUserId(req),
@ -676,7 +711,7 @@ public class KeycloakAuthenticationFilter implements DependencyInjectedFilter, I
final OIDCFilterSessionStore tokenStore) throws IOException, ServletException
{
final HttpSession session = req.getSession();
final Object keycloakAccount = session != null ? session.getAttribute(KeycloakAccount.class.getName()) : null;
final Object keycloakAccount = session != null ? session.getAttribute(KEYCLOAK_ACCOUNT_SESSION_KEY) : null;
if (keycloakAccount instanceof OidcKeycloakAccount)
{
final KeycloakSecurityContext keycloakSecurityContext = ((OidcKeycloakAccount) keycloakAccount).getKeycloakSecurityContext();
@ -684,17 +719,10 @@ public class KeycloakAuthenticationFilter implements DependencyInjectedFilter, I
final String userId = accessToken.getPreferredUsername();
LOGGER.debug("User {} successfully authenticated via Keycloak", userId);
final String accessTokenString = keycloakSecurityContext.getTokenString();
this.updateEndpointConnectorBearerToken(this.primaryEndpoint, userId, session, accessTokenString);
if (this.secondaryEndpoints != null)
{
this.secondaryEndpoints.forEach(endpoint -> {
this.updateEndpointConnectorBearerToken(endpoint, userId, session, accessTokenString);
});
}
session.setAttribute(UserFactory.SESSION_ATTRIBUTE_EXTERNAL_AUTH, Boolean.TRUE);
session.setAttribute(UserFactory.SESSION_ATTRIBUTE_KEY_USER_ID, userId);
this.handleAlfrescoResourceAccessToken(session);
}
if (facade.isEnded())
@ -771,10 +799,10 @@ public class KeycloakAuthenticationFilter implements DependencyInjectedFilter, I
final FilterChain chain) throws IOException, ServletException
{
final HttpSession session = ((HttpServletRequest) request).getSession(false);
final Object keycloakAccount = session != null ? session.getAttribute(KeycloakAccount.class.getName()) : null;
final Object keycloakAccount = session != null ? session.getAttribute(KEYCLOAK_ACCOUNT_SESSION_KEY) : null;
// no point in forwarding to default SSO filter if already authenticated
if (this.defaultSsoFilter != null && keycloakAccount == null)
if (this.defaultSsoFilter != null && keycloakAccount == null && !this.ignoreDefaultFilter)
{
this.defaultSsoFilter.doFilter(context, request, response, chain);
}
@ -812,7 +840,7 @@ public class KeycloakAuthenticationFilter implements DependencyInjectedFilter, I
// check for back-channel logout (sessionIdMapper should now of all authenticated sessions)
if (this.externalAuthEnabled && this.filterEnabled && this.keycloakDeployment != null && currentSession != null
&& AuthenticationUtil.isAuthenticated(req) && currentSession.getAttribute(KeycloakAccount.class.getName()) != null
&& AuthenticationUtil.isAuthenticated(req) && currentSession.getAttribute(KEYCLOAK_ACCOUNT_SESSION_KEY) != null
&& !this.sessionIdMapper.hasSession(currentSession.getId()))
{
LOGGER.debug("Session {} for Keycloak-authenticated user {} was invalidated by back-channel logout", currentSession.getId(),
@ -855,7 +883,7 @@ public class KeycloakAuthenticationFilter implements DependencyInjectedFilter, I
}
else if (currentSession != null && AuthenticationUtil.isAuthenticated(req))
{
final KeycloakAccount keycloakAccount = (KeycloakAccount) currentSession.getAttribute(KeycloakAccount.class.getName());
final KeycloakAccount keycloakAccount = (KeycloakAccount) currentSession.getAttribute(KEYCLOAK_ACCOUNT_SESSION_KEY);
if (keycloakAccount != null)
{
skip = this.validateAndRefreshKeycloakAuthentication(req, res, AuthenticationUtil.getUserId(req), keycloakAccount);
@ -937,24 +965,8 @@ public class KeycloakAuthenticationFilter implements DependencyInjectedFilter, I
if (currentSession != null)
{
LOGGER.debug("Skipping processKeycloakAuthenticationAndActions as Keycloak-authentication session is still valid");
this.handleAlfrescoResourceAccessToken(currentSession);
skip = true;
if (keycloakAccount instanceof OidcKeycloakAccount)
{
final KeycloakSecurityContext keycloakSecurityContext = ((OidcKeycloakAccount) keycloakAccount)
.getKeycloakSecurityContext();
final String accessTokenString = keycloakSecurityContext.getTokenString();
final HttpSession effectiveSession = currentSession;
this.updateEndpointConnectorBearerToken(this.primaryEndpoint, userId, effectiveSession, accessTokenString);
if (this.secondaryEndpoints != null)
{
this.secondaryEndpoints.forEach(endpoint -> {
this.updateEndpointConnectorBearerToken(endpoint, userId, effectiveSession, accessTokenString);
});
}
}
}
else
{
@ -1089,20 +1101,6 @@ public class KeycloakAuthenticationFilter implements DependencyInjectedFilter, I
return isLogoutRequest;
}
protected void updateEndpointConnectorBearerToken(final String endpoint, final String userId, final HttpSession session,
final String tokenString)
{
try
{
final Connector conn = this.connectorService.getConnector(endpoint, userId, session);
conn.getConnectorSession().setParameter(BearerTokenAwareSlingshotAlfrescoConnector.CS_PARAM_BEARER_TOKEN, tokenString);
}
catch (final ConnectorServiceException e)
{
LOGGER.warn("Endpoint {} has not been defined", endpoint);
}
}
/**
* Checks if the HTTP request has set the Keycloak state cookie.
*
@ -1120,6 +1118,176 @@ public class KeycloakAuthenticationFilter implements DependencyInjectedFilter, I
return hasStateCookie;
}
/**
* Checks, initialises and/or refreshes the access token for accessing the Alfresco backend based on configuration and current session
* state / validity of any existing token.
*
* @param session
* the active session managing any persistent access token state
*/
protected void handleAlfrescoResourceAccessToken(final HttpSession session)
{
final KeycloakAuthenticationConfigElement keycloakAuthConfig = (KeycloakAuthenticationConfigElement) this.configService
.getConfig(KeycloakConfigConstants.KEYCLOAK_CONFIG_SECTION_NAME).getConfigElement(KeycloakAuthenticationConfigElement.NAME);
if (keycloakAuthConfig != null && Boolean.TRUE.equals(keycloakAuthConfig.getPerformTokenExchange()))
{
final String alfrescoResourceName = keycloakAuthConfig.getAlfrescoResourceName();
if (!EqualsHelper.nullSafeEquals(alfrescoResourceName, this.keycloakDeployment.getResourceName())
&& alfrescoResourceName != null)
{
final Object backendAccessTokenCandidate = session.getAttribute(BACKEND_ACCESS_TOKEN_SESSION_KEY);
RefreshableAccessTokenHolder token;
if (!(backendAccessTokenCandidate instanceof RefreshableAccessTokenHolder))
{
LOGGER.debug("Session does not yet contain an access token for the Alfresco backend resource {}", alfrescoResourceName);
token = null;
}
else
{
token = (RefreshableAccessTokenHolder) backendAccessTokenCandidate;
}
// not really feasible to synchronise / lock concurrent refresh on token
// not a big problem - apart from wasted CPU cycles / latency - since each concurrently refreshed token is valid
// independently
if (token == null || (token.canRefresh() && token.shouldRefresh(this.keycloakDeployment.getTokenMinimumTimeToLive())))
{
AccessTokenResponse response;
try
{
if (token != null)
{
LOGGER.debug("Refreshing access token for Alfresco backend resource {}", alfrescoResourceName);
response = ServerRequest.invokeRefresh(this.keycloakDeployment, token.getRefreshToken());
}
else
{
LOGGER.debug("Retrieving initial access token for Alfresco backend resource {}", alfrescoResourceName);
response = this.getAccessToken(alfrescoResourceName, session);
}
}
catch (final IOException ioex)
{
LOGGER.error("Error retrieving / refreshing access token for Alfresco backend", ioex);
throw new AlfrescoRuntimeException("Error retrieving / refreshing access token for Alfresco backend", ioex);
}
catch (final ServerRequest.HttpFailure httpFailure)
{
LOGGER.error("Refreshing access token for Alfresco backend failed: {} {}", httpFailure.getStatus(),
httpFailure.getError());
throw new AlfrescoRuntimeException("Failed to refresh access token for Alfresco backend: " + httpFailure.getStatus()
+ " " + httpFailure.getError());
}
final String tokenString = response.getToken();
final AdapterTokenVerifier.VerifiedTokens tokens;
try
{
tokens = AdapterTokenVerifier.verifyTokens(tokenString, response.getIdToken(), this.keycloakDeployment);
}
catch (final VerificationException vex)
{
LOGGER.error("Verification of access token for Alfresco backend failed", vex);
throw new AlfrescoRuntimeException("Failed to verify access token for Alfresco backend", vex);
}
final AccessToken accessToken = tokens.getAccessToken();
if ((accessToken.getExpiration() - this.keycloakDeployment.getTokenMinimumTimeToLive()) <= Time.currentTime())
{
throw new AlfrescoRuntimeException(
"Failed to retrieve / refresh the access token for the Alfresco backend with a longer time-to-live than the minimum");
}
token = new RefreshableAccessTokenHolder(response, tokens);
session.setAttribute(BACKEND_ACCESS_TOKEN_SESSION_KEY, token);
LOGGER.debug("Successfully retrieved / refresh access token for Alfresco backend");
}
}
else if (alfrescoResourceName == null)
{
LOGGER.warn(
"Encountered configuration error: alfresco-resource-name has not been set, which is required for performing token exchange");
}
else
{
LOGGER.warn(
"Encountered configuration error: alfresco-resource-name is set to the same value as Share's adapter resource, which is unsuitable for performing token exchange");
}
}
else
{
LOGGER.debug("Use of token exchange has not been enabled - calls to Alfresco backend will reuse the global access token");
}
}
/**
* Obtains an access token for the Alfresco backend by exchanging the current user access token in the session for an access token to
* that backend resource.
*
* @param alfrescoResourceName
* the name of the Alfresco backend resource within the Keycloak realm
* @param session
* the active session managing any persistent access token state
* @return the response to obtaining the access token for the Alfresco backend
*/
protected AccessTokenResponse getAccessToken(final String alfrescoResourceName, final HttpSession session) throws IOException
{
AccessTokenResponse tokenResponse = null;
final HttpClient client = this.keycloakDeployment.getClient();
final HttpPost post = new HttpPost(KeycloakUriBuilder.fromUri(this.keycloakDeployment.getAuthServerBaseUrl())
.path(ServiceUrlConstants.TOKEN_PATH).build(this.keycloakDeployment.getRealm()));
final List<NameValuePair> formParams = new ArrayList<>();
formParams.add(new BasicNameValuePair(OAuth2Constants.GRANT_TYPE, OAuth2Constants.TOKEN_EXCHANGE_GRANT_TYPE));
formParams.add(new BasicNameValuePair(OAuth2Constants.AUDIENCE, alfrescoResourceName));
formParams.add(new BasicNameValuePair(OAuth2Constants.REQUESTED_TOKEN_TYPE, OAuth2Constants.REFRESH_TOKEN_TYPE));
final OidcKeycloakAccount keycloakAccount = (OidcKeycloakAccount) session.getAttribute(KEYCLOAK_ACCOUNT_SESSION_KEY);
final String tokenString = keycloakAccount.getKeycloakSecurityContext().getTokenString();
formParams.add(new BasicNameValuePair(OAuth2Constants.SUBJECT_TOKEN, tokenString));
ClientCredentialsProviderUtils.setClientCredentials(this.keycloakDeployment, post, formParams);
final UrlEncodedFormEntity form = new UrlEncodedFormEntity(formParams, "UTF-8");
post.setEntity(form);
final HttpResponse response = client.execute(post);
final int status = response.getStatusLine().getStatusCode();
final HttpEntity entity = response.getEntity();
if (status != 200)
{
final String statusReason = response.getStatusLine().getReasonPhrase();
LOGGER.debug("Failed to retrieve access token due to HTTP {}: {}", status, statusReason);
EntityUtils.consumeQuietly(entity);
throw new AlfrescoRuntimeException("Failed to retrieve access token due to HTTP error " + status + ": " + statusReason);
}
if (entity == null)
{
throw new AlfrescoRuntimeException("Response to access token request did not contain a response body");
}
final InputStream is = entity.getContent();
try
{
tokenResponse = JsonSerialization.readValue(is, AccessTokenResponse.class);
}
finally
{
try
{
is.close();
}
catch (final IOException e)
{
LOGGER.trace("Error closing entity stream", e);
}
}
return tokenResponse;
}
/**
* Resets any Keycloak-related state cookies present in the current request.
*
@ -1147,6 +1315,16 @@ public class KeycloakAuthenticationFilter implements DependencyInjectedFilter, I
}
}
/**
* Sets up a forced route for the Keycloak-library backing HTTP client if configured. This may be necessary to deal with situations
* where Share cannot use the public address of the authentication server (used in authentication redirects) to talk with the server
* directly, due to network isolation / addressing restrictions (e.g. in Docker-ized deployments).
*
* @param configElement
* the adapter configuration
* @param client
* the client to configure
*/
@SuppressWarnings("deprecation")
protected void configureForcedRouteIfNecessary(final KeycloakAdapterConfigElement configElement, final HttpClient client)
{

View File

@ -25,7 +25,7 @@ keycloak.adapter.credentials.provider=secret
keycloak.adapter.credentials.secret=6f70a28f-98cd-41ca-8f2f-368a8797d708
# localhost in auth-server-url won't work for direct access in a Docker deployment
keycloak.authentication.directAuthHost=http://host.docker.internal:8380
keycloak.authentication.directAuthHost=http://keycloak:8080
keycloak.synchronization.userFilter.containedInGroup.property.groupPaths=/Test A
keycloak.synchronization.groupFilter.containedInGroup.property.groupPaths=/Test A

View File

@ -21,8 +21,8 @@
<connector>
<id>alfrescoCookie</id>
<name>Alfresco Connector</name>
<description>Connects to an Alfresco instance using cookie-based authentication and awareness of OIDC bearer tokens</description>
<class>de.acosix.alfresco.keycloak.share.remote.BearerTokenAwareSlingshotAlfrescoConnector</class>
<description>Connects to an Alfresco instance using cookie-based authentication and awareness of Keycloak access tokens</description>
<class>de.acosix.alfresco.keycloak.share.remote.AccessTokenAwareSlingshotAlfrescoConnector</class>
</connector>
<endpoint>
@ -64,9 +64,10 @@
<enhance-login-form>true</enhance-login-form>
<enable-sso-filter>true</enable-sso-filter>
<force-keycloak-sso>false</force-keycloak-sso>
<perform-token-exchange>true</perform-token-exchange>
</keycloak-auth-config>
<keycloak-adapter-config>
<directAuthHost>http://host.docker.internal:8380</directAuthHost>
<directAuthHost>http://keycloak:8080</directAuthHost>
<auth-server-url>http://${docker.tests.host.name}:${docker.tests.keycloakPort}/auth</auth-server-url>
<realm>test</realm>
<resource>alfresco-share</resource>

View File

@ -0,0 +1 @@
feature.token_exchange=enabled

View File

@ -14,8 +14,7 @@
See the License for the specific language governing permissions and
limitations under the License.
-->
<assembly
xmlns="http://maven.apache.org/plugins/maven-assembly-plugin/assembly/1.1.3"
<assembly xmlns="http://maven.apache.org/plugins/maven-assembly-plugin/assembly/1.1.3"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/plugins/maven-assembly-plugin/assembly/1.1.3 http://maven.apache.org/xsd/assembly-1.1.3.xsd">
<id>share-it-docker</id>
@ -74,6 +73,7 @@
<dependencySet>
<outputDirectory>WEB-INF/lib</outputDirectory>
<includes>
<include>org.slf4j:slf4j-log4j12:*</include>
<include>org.orderofthebee.support-tools:support-tools-share:*</include>
<include>de.acosix.alfresco.utility:de.acosix.alfresco.utility.common:*</include>
<include>de.acosix.alfresco.utility:de.acosix.alfresco.utility.core.share:jar:installable:*</include>

View File

@ -30,7 +30,8 @@
"serviceAccountsEnabled": true,
"publicClient": false,
"protocol": "openid-connect"
}, {
},
{
"id": "alfresco-share",
"clientId": "alfresco-share",
"name": "Alfresco Share",
@ -47,6 +48,158 @@
"secret": "a5b3e8bc-39cc-4ddd-8c8f-1c34e7a35975",
"publicClient": false,
"protocol": "openid-connect"
},
{
"clientId": "realm-management",
"name": "Realm Management",
"enabled": true,
"clientAuthenticatorType": "client-secret",
"bearerOnly": true,
"authorizationServicesEnabled": true,
"protocol": "openid-connect",
"authorizationSettings": {
"allowRemoteResourceManagement": false,
"policyEnforcementMode": "ENFORCING",
"resources": [
{
"name": "client.resource.alfresco",
"type": "Client",
"ownerManagedAccess": false,
"attributes": {
},
"uris": [],
"scopes": [
{
"name": "view"
},
{
"name": "map-roles-client-scope"
},
{
"name": "configure"
},
{
"name": "map-roles"
},
{
"name": "manage"
},
{
"name": "token-exchange"
},
{
"name": "map-roles-composite"
}
]
}
],
"policies": [
{
"name": "alfresco-token-exchange",
"type": "client",
"logic": "POSITIVE",
"decisionStrategy": "UNANIMOUS",
"config": {
"clients": "[\"alfresco-share\"]"
}
},
{
"name": "token-exchange.permission.client.alfresco",
"type": "scope",
"logic": "POSITIVE",
"decisionStrategy": "UNANIMOUS",
"config": {
"resources": "[\"client.resource.alfresco\"]",
"scopes": "[\"token-exchange\"]",
"applyPolicies": "[\"alfresco-token-exchange\"]"
}
},
{
"name": "map-roles-composite.permission.client.alfresco",
"type": "scope",
"logic": "POSITIVE",
"decisionStrategy": "UNANIMOUS",
"config": {
"resources": "[\"client.resource.alfresco\"]",
"scopes": "[\"map-roles-composite\"]"
}
},
{
"name": "map-roles-client-scope.permission.client.alfresco",
"type": "scope",
"logic": "POSITIVE",
"decisionStrategy": "UNANIMOUS",
"config": {
"resources": "[\"client.resource.alfresco\"]",
"scopes": "[\"map-roles-client-scope\"]"
}
},
{
"name": "map-roles.permission.client.alfresco",
"type": "scope",
"logic": "POSITIVE",
"decisionStrategy": "UNANIMOUS",
"config": {
"resources": "[\"client.resource.alfresco\"]",
"scopes": "[\"map-roles\"]"
}
},
{
"name": "view.permission.client.alfresco",
"type": "scope",
"logic": "POSITIVE",
"decisionStrategy": "UNANIMOUS",
"config": {
"resources": "[\"client.resource.alfresco\"]",
"scopes": "[\"view\"]"
}
},
{
"name": "configure.permission.client.alfresco",
"type": "scope",
"logic": "POSITIVE",
"decisionStrategy": "UNANIMOUS",
"config": {
"resources": "[\"client.resource.alfresco\"]",
"scopes": "[\"configure\"]"
}
},
{
"name": "manage.permission.client.alfresco",
"type": "scope",
"logic": "POSITIVE",
"decisionStrategy": "UNANIMOUS",
"config": {
"resources": "[\"client.resource.alfresco\"]",
"scopes": "[\"manage\"]"
}
}
],
"scopes": [
{
"name": "token-exchange"
},
{
"name": "configure"
},
{
"name": "map-roles-composite"
},
{
"name": "map-roles-client-scope"
},
{
"name": "map-roles"
},
{
"name": "view"
},
{
"name": "manage"
}
]
}
}
],
"roles": {

View File

@ -25,7 +25,7 @@
</element-readers>
</plug-ins>
<!-- sensible default configuration (similar to Repository identity-service-authentication.properties -->
<!-- sensible default configuration -->
<config evaluator="string-compare" condition="Keycloak">
<keycloak-auth-config>
<enhance-login-form>true</enhance-login-form>
@ -35,8 +35,12 @@
<ssl-redirect-port>8443</ssl-redirect-port>
<body-buffer-limit>10485760</body-buffer-limit>
<session-mapper-limit>1000</session-mapper-limit>
<ignore-default-filter>true</ignore-default-filter>
<perform-token-exchange>false</perform-token-exchange>
<alfresco-resource-name>alfresco</alfresco-resource-name>
</keycloak-auth-config>
<keycloak-adapter-config>
<!-- by default use the same client as alfresco (not really "clean") -->
<auth-server-url>http://localhost:8180/auth</auth-server-url>
<realm>alfresco</realm>
<resource>alfresco</resource>