Revise Share token exchange handling

- retry if refresh of exchanged token yields invalid token (wrong
  audience - known case of apparently incorrect Keycloak behaviour)
- use custom header instead of redirect patch to have Repository tier not
  redirect to Keycloak login page on unauthenticated access from Share
- activate audience verification which is inactive with Keycloak class
  defaults
This commit is contained in:
AFaust
2020-06-05 14:52:15 +02:00
parent 399419068f
commit 4096f741a5
9 changed files with 110 additions and 37 deletions

View File

@@ -133,6 +133,7 @@
<property name="handlePublicApi" value="${keycloak.authentication.sso.handlePublicApi}" /> <property name="handlePublicApi" value="${keycloak.authentication.sso.handlePublicApi}" />
<property name="loginPageUrl" value="${keycloak.authentication.loginPageUrl}" /> <property name="loginPageUrl" value="${keycloak.authentication.loginPageUrl}" />
<property name="originalRequestUrlHeaderName" value="${keycloak.authentication.sso.originalRequestUrlHeaderName}" /> <property name="originalRequestUrlHeaderName" value="${keycloak.authentication.sso.originalRequestUrlHeaderName}" />
<property name="noKeycloakHandlingHeaderName" value="x-${moduleId}-no-keycloak-handling" />
<property name="bodyBufferLimit" value="${keycloak.authentication.bodyBufferLimit}" /> <property name="bodyBufferLimit" value="${keycloak.authentication.bodyBufferLimit}" />
<property name="sslRedirectPort" value="${keycloak.authentication.sslRedirectPort}" /> <property name="sslRedirectPort" value="${keycloak.authentication.sslRedirectPort}" />
<property name="keycloakDeployment" ref="keycloakDeployment" /> <property name="keycloakDeployment" ref="keycloakDeployment" />

View File

@@ -19,7 +19,7 @@ keycloak.authentication.sslRedirectPort=8443
keycloak.authentication.bodyBufferLimit=10485760 keycloak.authentication.bodyBufferLimit=10485760
# override for a direct route to the auth server host # override for a direct route to the auth server host
# useful primarily for Dockerized deployments where container running Alfresco cannot resolve the auth server via the public DNS name # useful primarily for Docker-ized deployments where container running Alfresco cannot resolve the auth server via the public DNS name
keycloak.authentication.directAuthHost= keycloak.authentication.directAuthHost=
keycloak.adapter.auth-server-url=http://localhost:8180/auth keycloak.adapter.auth-server-url=http://localhost:8180/auth
@@ -29,6 +29,8 @@ keycloak.adapter.ssl-required=none
keycloak.adapter.public-client=false keycloak.adapter.public-client=false
keycloak.adapter.credentials.provider=secret keycloak.adapter.credentials.provider=secret
keycloak.adapter.credentials.secret= keycloak.adapter.credentials.secret=
# for some reason, this is not a sane default in Keycloak Adapter config
keycloak.adapter.verify-token-audience=true
# TODO default settings (identical to AdapterConfig defaults) to better align with default Alfresco subsystem property handling # TODO default settings (identical to AdapterConfig defaults) to better align with default Alfresco subsystem property handling

View File

@@ -116,6 +116,8 @@ public class KeycloakAuthenticationFilter extends BaseAuthenticationFilter
protected String originalRequestUrlHeaderName; protected String originalRequestUrlHeaderName;
protected String noKeycloakHandlingHeaderName;
protected int bodyBufferLimit = DEFAULT_BODY_BUFFER_LIMIT; protected int bodyBufferLimit = DEFAULT_BODY_BUFFER_LIMIT;
// use 8443 as default SSL redirect based on Tomcat default server.xml configuration // use 8443 as default SSL redirect based on Tomcat default server.xml configuration
@@ -146,6 +148,8 @@ public class KeycloakAuthenticationFilter extends BaseAuthenticationFilter
PropertyCheck.mandatory(this, "keycloakTicketTokenCache", this.keycloakTicketTokenCache); PropertyCheck.mandatory(this, "keycloakTicketTokenCache", this.keycloakTicketTokenCache);
PropertyCheck.mandatory(this, "publicApiRuntimeContainer", this.publicApiRuntimeContainer); PropertyCheck.mandatory(this, "publicApiRuntimeContainer", this.publicApiRuntimeContainer);
PropertyCheck.mandatory(this, "noKeycloakHandlingHeaderName", this.noKeycloakHandlingHeaderName);
// parent class does not check, so we do // parent class does not check, so we do
PropertyCheck.mandatory(this, "authenticationService", this.authenticationService); PropertyCheck.mandatory(this, "authenticationService", this.authenticationService);
PropertyCheck.mandatory(this, "authenticationComponent", this.authenticationComponent); PropertyCheck.mandatory(this, "authenticationComponent", this.authenticationComponent);
@@ -222,6 +226,15 @@ public class KeycloakAuthenticationFilter extends BaseAuthenticationFilter
this.originalRequestUrlHeaderName = originalRequestUrlHeaderName; this.originalRequestUrlHeaderName = originalRequestUrlHeaderName;
} }
/**
* @param noKeycloakHandlingHeaderName
* the noKeycloakHandlingHeaderName to set
*/
public void setNoKeycloakHandlingHeaderName(final String noKeycloakHandlingHeaderName)
{
this.noKeycloakHandlingHeaderName = noKeycloakHandlingHeaderName;
}
/** /**
* @param bodyBufferLimit * @param bodyBufferLimit
* the bodyBufferLimit to set * the bodyBufferLimit to set
@@ -710,6 +723,7 @@ public class KeycloakAuthenticationFilter extends BaseAuthenticationFilter
boolean skip = false; boolean skip = false;
final String authHeader = req.getHeader(HEADER_AUTHORIZATION); final String authHeader = req.getHeader(HEADER_AUTHORIZATION);
final String noKeycloakLoginRedirectHeader = req.getHeader(this.noKeycloakHandlingHeaderName);
final String servletPath = req.getServletPath(); final String servletPath = req.getServletPath();
final String pathInfo = req.getPathInfo(); final String pathInfo = req.getPathInfo();
@@ -765,8 +779,7 @@ public class KeycloakAuthenticationFilter extends BaseAuthenticationFilter
// cannot rely on session.isNew() to determine if this is a fresh login // cannot rely on session.isNew() to determine if this is a fresh login
// consider "fresh" login if issued within age limit (implicitly include any token refreshes performed client-side) // consider "fresh" login if issued within age limit (implicitly include any token refreshes performed client-side)
final boolean isFreshLogin = accessToken.getIssuedAt() final boolean isFreshLogin = accessToken.getIat() * 1000l > (System.currentTimeMillis() - FRESH_TOKEN_AGE_LIMIT_MS);
* 1000l > (System.currentTimeMillis() - FRESH_TOKEN_AGE_LIMIT_MS);
this.keycloakAuthenticationComponent.handleUserTokens(accessToken, accessToken, isFreshLogin); this.keycloakAuthenticationComponent.handleUserTokens(accessToken, accessToken, isFreshLogin);
// sessionUser should be guaranteed here, but still check - we need it for the cache key // sessionUser should be guaranteed here, but still check - we need it for the cache key
@@ -857,9 +870,9 @@ public class KeycloakAuthenticationFilter extends BaseAuthenticationFilter
"Skipping processKeycloakAuthenticationAndActions as request is aimed at a Public v1 ReST API which does not require authentication"); "Skipping processKeycloakAuthenticationAndActions as request is aimed at a Public v1 ReST API which does not require authentication");
skip = true; skip = true;
} }
// check no-auth flag (derived e.g. from checking if target web script requires authentication) only after all
// pre-emptive auth // check no-auth flag (derived e.g. from checking if target web script requires authentication) as last resort to see if
// request details have been checked // we need to force authentication after invalidating session
else if (Boolean.TRUE.equals(req.getAttribute(NO_AUTH_REQUIRED))) else if (Boolean.TRUE.equals(req.getAttribute(NO_AUTH_REQUIRED)))
{ {
LOGGER.trace( LOGGER.trace(
@@ -895,6 +908,13 @@ public class KeycloakAuthenticationFilter extends BaseAuthenticationFilter
"Skipping processKeycloakAuthenticationAndActions as filter higher up in chain determined authentication as not required"); "Skipping processKeycloakAuthenticationAndActions as filter higher up in chain determined authentication as not required");
skip = true; skip = true;
} }
else if (Boolean.parseBoolean(noKeycloakLoginRedirectHeader))
{
LOGGER.trace(
"Skipping processKeycloakAuthenticationAndActions as client provided custom 'no Keycloak handling' header {} with value that resolves to 'true'",
this.noKeycloakHandlingHeaderName);
skip = true;
}
// TODO Check for login page URL (rarely configured since Repository by default has no login page since 5.0) // TODO Check for login page URL (rarely configured since Repository by default has no login page since 5.0)
return skip; return skip;

View File

@@ -50,6 +50,8 @@
<credentials> <credentials>
<provider>secret</provider> <provider>secret</provider>
</credentials> </credentials>
<!-- for some reason, this is not a sane default in Keycloak Adapter config -->
<verify-token-audience>true</verify-token-audience>
</keycloak-adapter-config> </keycloak-adapter-config>
</config> </config>

View File

@@ -47,12 +47,17 @@
</property> </property>
</bean> </bean>
<bean id="${moduleId}.maxRemoteClientRedirectPatch" <bean id="${moduleId}.remoteClientNoKeycloakLoginRedirectHeaderPatch"
class="de.acosix.alfresco.utility.common.spring.PropertyAlteringBeanFactoryPostProcessor"> class="de.acosix.alfresco.utility.common.spring.PropertyAlteringBeanFactoryPostProcessor">
<property name="targetBeanName" value="connector.remoteclient.abstract" /> <property name="targetBeanName" value="connector.remoteclient.abstract" />
<property name="propertyName" value="maxRedirects" /> <property name="propertyName" value="requestHeaders" />
<property name="value" value="1" /> <property name="valueMap">
<property name="enabled" value="\${${moduleId}.surf.onlyOneRedirect.enabled}" /> <map>
<entry key="x-${moduleId}-no-keycloak-handling" value="true" />
</map>
</property>
<property name="enabled" value="true" />
<property name="merge" value="true" />
</bean> </bean>
<bean id="${moduleId}.SessionIdMapper" class="${project.artifactId}.web.DefaultSessionIdMapper"> <bean id="${moduleId}.SessionIdMapper" class="${project.artifactId}.web.DefaultSessionIdMapper">

View File

@@ -1 +1 @@
${moduleId}.surf.onlyOneRedirect.enabled=true # no configuration properties necessary - empty file to mark this as a Share module

View File

@@ -71,6 +71,7 @@ public class AccessTokenAwareSlingshotAlfrescoConnector extends SlingshotAlfresc
final RefreshableAccessTokenHolder endpointSpecificAccessToken = (RefreshableAccessTokenHolder) (session != null final RefreshableAccessTokenHolder endpointSpecificAccessToken = (RefreshableAccessTokenHolder) (session != null
? session.getAttribute(KeycloakAuthenticationFilter.BACKEND_ACCESS_TOKEN_SESSION_KEY) ? session.getAttribute(KeycloakAuthenticationFilter.BACKEND_ACCESS_TOKEN_SESSION_KEY)
: null); : null);
if (endpointSpecificAccessToken != null) if (endpointSpecificAccessToken != null)
{ {
if (endpointSpecificAccessToken.isActive()) if (endpointSpecificAccessToken.isActive())

View File

@@ -83,6 +83,7 @@ import de.acosix.alfresco.keycloak.share.config.KeycloakAuthenticationConfigElem
import de.acosix.alfresco.keycloak.share.config.KeycloakConfigConstants; 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.KeycloakSecurityContext;
import de.acosix.alfresco.keycloak.share.deps.keycloak.OAuth2Constants; import de.acosix.alfresco.keycloak.share.deps.keycloak.OAuth2Constants;
import de.acosix.alfresco.keycloak.share.deps.keycloak.TokenVerifier;
import de.acosix.alfresco.keycloak.share.deps.keycloak.adapters.AdapterDeploymentContext; 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.AuthenticatedActionsHandler;
import de.acosix.alfresco.keycloak.share.deps.keycloak.adapters.BearerTokenRequestAuthenticator; import de.acosix.alfresco.keycloak.share.deps.keycloak.adapters.BearerTokenRequestAuthenticator;
@@ -95,6 +96,7 @@ import de.acosix.alfresco.keycloak.share.deps.keycloak.adapters.PreAuthActionsHa
import de.acosix.alfresco.keycloak.share.deps.keycloak.adapters.ServerRequest; 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.authentication.ClientCredentialsProviderUtils;
import de.acosix.alfresco.keycloak.share.deps.keycloak.adapters.rotation.AdapterTokenVerifier; import de.acosix.alfresco.keycloak.share.deps.keycloak.adapters.rotation.AdapterTokenVerifier;
import de.acosix.alfresco.keycloak.share.deps.keycloak.adapters.rotation.AdapterTokenVerifier.VerifiedTokens;
import de.acosix.alfresco.keycloak.share.deps.keycloak.adapters.servlet.FilterRequestAuthenticator; 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.OIDCFilterSessionStore;
import de.acosix.alfresco.keycloak.share.deps.keycloak.adapters.servlet.OIDCServletHttpFacade; import de.acosix.alfresco.keycloak.share.deps.keycloak.adapters.servlet.OIDCServletHttpFacade;
@@ -832,7 +834,7 @@ public class KeycloakAuthenticationFilter implements DependencyInjectedFilter, I
session.setAttribute(UserFactory.SESSION_ATTRIBUTE_EXTERNAL_AUTH, Boolean.TRUE); session.setAttribute(UserFactory.SESSION_ATTRIBUTE_EXTERNAL_AUTH, Boolean.TRUE);
session.setAttribute(UserFactory.SESSION_ATTRIBUTE_KEY_USER_ID, userId); session.setAttribute(UserFactory.SESSION_ATTRIBUTE_KEY_USER_ID, userId);
this.handleAlfrescoResourceAccessToken(session); this.handleAlfrescoResourceAccessToken(session, false);
} }
if (facade.isEnded()) if (facade.isEnded())
@@ -937,13 +939,17 @@ public class KeycloakAuthenticationFilter implements DependencyInjectedFilter, I
protected void onKeycloakAuthenticationFailure(final ServletContext context, final HttpServletRequest req, protected void onKeycloakAuthenticationFailure(final ServletContext context, final HttpServletRequest req,
final HttpServletResponse res, final FilterChain chain) throws IOException, ServletException final HttpServletResponse res, final FilterChain chain) throws IOException, ServletException
{ {
LOGGER.warn("Keycloak authentication failed due to {}", req.getAttribute(AuthenticationError.class.getName())); final Object authError = req.getAttribute(AuthenticationError.class.getName());
LOGGER.warn("Keycloak authentication failed due to {}",
authError != null ? authError : "<missing AuthenticationError details in request context>");
LOGGER.debug("Resetting session and state cookie before continueing with filter chain"); LOGGER.debug("Resetting session and state cookie before continueing with filter chain");
req.getSession().invalidate(); req.getSession().invalidate();
this.resetStateCookies(context, req, res); this.resetStateCookies(context, req, res);
// TODO If error occurred as part of redirect back from Keycloak, strip state / code params from URL query
// TODO If login page may follow, see about providing error message
this.continueFilterChain(context, req, res, chain); this.continueFilterChain(context, req, res, chain);
} }
@@ -1091,9 +1097,11 @@ public class KeycloakAuthenticationFilter implements DependencyInjectedFilter, I
} }
else else
{ {
// TODO Validate via custom /touch to check if session is still valid /*
// custom => handle potential 302 instead of 401 response from Keycloak-enabled backend * Note: We could validate session with a custom call to /touch but we leave that to any remaining SSO filters. We patch
// custom => deal with redirect host being unknown (similar to our auth-server-url vs. directAuthHost case) * remoteClient to submit a custom HTTP header to backend to avoid 302 redirects to Keycloak which other SSO filters cannot
* handle, and this also avoids any issues with (public) Keycloak auth server URL being unknown, e.g. in a Docker scenario
*/
LOGGER.debug( LOGGER.debug(
"Skipping processKeycloakAuthenticationAndActions as non-Keycloak-authenticated session is already established"); "Skipping processKeycloakAuthenticationAndActions as non-Keycloak-authenticated session is already established");
skip = true; skip = true;
@@ -1174,7 +1182,7 @@ public class KeycloakAuthenticationFilter implements DependencyInjectedFilter, I
if (currentSession != null) if (currentSession != null)
{ {
LOGGER.debug("Skipping processKeycloakAuthenticationAndActions as Keycloak-authentication session is still valid"); LOGGER.debug("Skipping processKeycloakAuthenticationAndActions as Keycloak-authentication session is still valid");
this.handleAlfrescoResourceAccessToken(currentSession); this.handleAlfrescoResourceAccessToken(currentSession, false);
skip = true; skip = true;
} }
else else
@@ -1333,8 +1341,11 @@ public class KeycloakAuthenticationFilter implements DependencyInjectedFilter, I
* *
* @param session * @param session
* the active session managing any persistent access token state * the active session managing any persistent access token state
* @param retry
* {@code true} if the invocation of the operation is a retry as the result of a (hopefully temporary) verification failure
* in the current thread
*/ */
protected void handleAlfrescoResourceAccessToken(final HttpSession session) protected void handleAlfrescoResourceAccessToken(final HttpSession session, final boolean retry)
{ {
final KeycloakAuthenticationConfigElement keycloakAuthConfig = (KeycloakAuthenticationConfigElement) this.configService final KeycloakAuthenticationConfigElement keycloakAuthConfig = (KeycloakAuthenticationConfigElement) this.configService
.getConfig(KeycloakConfigConstants.KEYCLOAK_CONFIG_SECTION_NAME).getConfigElement(KeycloakAuthenticationConfigElement.NAME); .getConfig(KeycloakConfigConstants.KEYCLOAK_CONFIG_SECTION_NAME).getConfigElement(KeycloakAuthenticationConfigElement.NAME);
@@ -1390,28 +1401,44 @@ public class KeycloakAuthenticationFilter implements DependencyInjectedFilter, I
} }
final String tokenString = response.getToken(); final String tokenString = response.getToken();
final AdapterTokenVerifier.VerifiedTokens tokens;
try try
{ {
tokens = AdapterTokenVerifier.verifyTokens(tokenString, response.getIdToken(), this.keycloakDeployment); // cannot use simple AdapterTokenVerifier.verifyTokens as it checks for wrong audience
// we also do not care about any IDToken retrieved (implicitly) with token exchange
final TokenVerifier<AccessToken> tokenVerifier = AdapterTokenVerifier.createVerifier(tokenString,
this.keycloakDeployment, true, AccessToken.class);
tokenVerifier.audience(alfrescoResourceName);
tokenVerifier.issuedFor(this.keycloakDeployment.getResourceName());
final AccessToken accessToken = tokenVerifier.verify().getToken();
if ((accessToken.getExp() - 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, new VerifiedTokens(accessToken, null));
session.setAttribute(BACKEND_ACCESS_TOKEN_SESSION_KEY, token);
LOGGER.debug("Successfully retrieved / refresh access token for Alfresco backend");
} }
catch (final VerificationException vex) catch (final VerificationException vex)
{ {
LOGGER.error("Verification of access token for Alfresco backend failed", vex); session.removeAttribute(BACKEND_ACCESS_TOKEN_SESSION_KEY);
throw new AlfrescoRuntimeException("Failed to verify access token for Alfresco backend", vex); if (!retry && token != null && token.canRefresh())
{
LOGGER.warn(
"Verification of refreshed access token for Alfresco backend failed - removed previous token from the session before retrying token exchange from scratch");
this.handleAlfrescoResourceAccessToken(session, true);
}
else
{
LOGGER.error("Verification of access token for Alfresco backend failed in retry", vex);
throw new AlfrescoRuntimeException("Keycloak token exchange for access to backend yielded invalid access token",
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) else if (alfrescoResourceName == null)
@@ -1455,8 +1482,21 @@ public class KeycloakAuthenticationFilter implements DependencyInjectedFilter, I
formParams.add(new BasicNameValuePair(OAuth2Constants.REQUESTED_TOKEN_TYPE, OAuth2Constants.REFRESH_TOKEN_TYPE)); formParams.add(new BasicNameValuePair(OAuth2Constants.REQUESTED_TOKEN_TYPE, OAuth2Constants.REFRESH_TOKEN_TYPE));
final OidcKeycloakAccount keycloakAccount = (OidcKeycloakAccount) session.getAttribute(KEYCLOAK_ACCOUNT_SESSION_KEY); final OidcKeycloakAccount keycloakAccount = (OidcKeycloakAccount) session.getAttribute(KEYCLOAK_ACCOUNT_SESSION_KEY);
final String tokenString = keycloakAccount.getKeycloakSecurityContext().getTokenString(); final RefreshableAccessTokenHolder accessToken = (RefreshableAccessTokenHolder) session.getAttribute(ACCESS_TOKEN_SESSION_KEY);
formParams.add(new BasicNameValuePair(OAuth2Constants.SUBJECT_TOKEN, tokenString)); if (keycloakAccount != null)
{
final String tokenString = keycloakAccount.getKeycloakSecurityContext().getTokenString();
formParams.add(new BasicNameValuePair(OAuth2Constants.SUBJECT_TOKEN, tokenString));
}
else if (accessToken != null && accessToken.isActive())
{
formParams.add(new BasicNameValuePair(OAuth2Constants.SUBJECT_TOKEN, accessToken.getToken()));
}
else
{
throw new IllegalStateException(
"Either an active security context or access token should be present in the session, or previous validations have caught their non-existence and prevented this operation form being called");
}
ClientCredentialsProviderUtils.setClientCredentials(this.keycloakDeployment, post, formParams); ClientCredentialsProviderUtils.setClientCredentials(this.keycloakDeployment, post, formParams);

View File

@@ -245,7 +245,9 @@ public class UserGroupsLoadFilter implements DependencyInjectedFilter, Initializ
} }
else else
{ {
LOGGER.warn("Failed to load user groups for {} with backend call resulting in HTTP {} response and message {}", userId, // TODO Specific handling for expectable error codes (401 / 302)
LOGGER.warn("Failed to load user groups for {} with backend call resulting in HTTP response with status {} {}",
userId,
res.getStatus().getCode(), res.getStatus().getMessage()); res.getStatus().getCode(), res.getStatus().getMessage());
userGroupsCSVList = ""; userGroupsCSVList = "";
} }