diff --git a/share/src/main/java/de/acosix/alfresco/keycloak/share/remote/AccessTokenAwareSlingshotAlfrescoConnector.java b/share/src/main/java/de/acosix/alfresco/keycloak/share/remote/AccessTokenAwareSlingshotAlfrescoConnector.java index e35f482..7a1b753 100644 --- a/share/src/main/java/de/acosix/alfresco/keycloak/share/remote/AccessTokenAwareSlingshotAlfrescoConnector.java +++ b/share/src/main/java/de/acosix/alfresco/keycloak/share/remote/AccessTokenAwareSlingshotAlfrescoConnector.java @@ -31,6 +31,7 @@ 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; +import de.acosix.alfresco.keycloak.share.web.KeycloakAuthenticationFilter; /** * @author Axel Faust @@ -61,17 +62,21 @@ public class AccessTokenAwareSlingshotAlfrescoConnector extends SlingshotAlfresc 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()) + final KeycloakAccount keycloakAccount = (KeycloakAccount) (session != null + ? session.getAttribute(KeycloakAuthenticationFilter.KEYCLOAK_ACCOUNT_SESSION_KEY) : null); final RefreshableAccessTokenHolder accessToken = (RefreshableAccessTokenHolder) (session != null - ? session.getAttribute(AccessTokenAwareSlingshotAlfrescoConnector.class.getName()) + ? session.getAttribute(KeycloakAuthenticationFilter.ACCESS_TOKEN_SESSION_KEY) : null); - if (accessToken != null) + final RefreshableAccessTokenHolder endpointSpecificAccessToken = (RefreshableAccessTokenHolder) (session != null + ? session.getAttribute(KeycloakAuthenticationFilter.BACKEND_ACCESS_TOKEN_SESSION_KEY) + : null); + if (endpointSpecificAccessToken != null) { - if (accessToken.isActive()) + if (endpointSpecificAccessToken.isActive()) { LOGGER.debug("Using access token for backend found in session for request"); - final String tokenString = accessToken.getToken(); + final String tokenString = endpointSpecificAccessToken.getToken(); remoteClient.setRequestProperties(Collections.singletonMap("Authorization", "Bearer " + tokenString)); } else @@ -87,6 +92,13 @@ public class AccessTokenAwareSlingshotAlfrescoConnector extends SlingshotAlfresc final String tokenString = keycloakSecurityContext.getTokenString(); remoteClient.setRequestProperties(Collections.singletonMap("Authorization", "Bearer " + tokenString)); } + else if (accessToken != null) + { + LOGGER.debug( + "Did not find access token for backend in session - using Bearer access token provided in original authentication request for request instead"); + final String tokenString = accessToken.getToken(); + remoteClient.setRequestProperties(Collections.singletonMap("Authorization", "Bearer " + tokenString)); + } else { LOGGER.debug("Did not find Keycloak-related authentication data in session - applying regular request authentication"); diff --git a/share/src/main/java/de/acosix/alfresco/keycloak/share/util/RefreshableAccessTokenHolder.java b/share/src/main/java/de/acosix/alfresco/keycloak/share/util/RefreshableAccessTokenHolder.java index f172038..8876f19 100644 --- a/share/src/main/java/de/acosix/alfresco/keycloak/share/util/RefreshableAccessTokenHolder.java +++ b/share/src/main/java/de/acosix/alfresco/keycloak/share/util/RefreshableAccessTokenHolder.java @@ -26,7 +26,7 @@ import de.acosix.alfresco.keycloak.share.deps.keycloak.representations.AccessTok import de.acosix.alfresco.keycloak.share.deps.keycloak.representations.IDToken; /** - * Instances of this class encapsulate an access token with its associated refresh data. + * Instances of this class encapsulate a potentially refreshable access token. * * @author Axel Faust */ diff --git a/share/src/main/java/de/acosix/alfresco/keycloak/share/web/KeycloakAuthenticationFilter.java b/share/src/main/java/de/acosix/alfresco/keycloak/share/web/KeycloakAuthenticationFilter.java index 6225d83..045bd63 100644 --- a/share/src/main/java/de/acosix/alfresco/keycloak/share/web/KeycloakAuthenticationFilter.java +++ b/share/src/main/java/de/acosix/alfresco/keycloak/share/web/KeycloakAuthenticationFilter.java @@ -85,6 +85,7 @@ 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.BearerTokenRequestAuthenticator; import de.acosix.alfresco.keycloak.share.deps.keycloak.adapters.HttpClientBuilder; import de.acosix.alfresco.keycloak.share.deps.keycloak.adapters.KeycloakDeployment; import de.acosix.alfresco.keycloak.share.deps.keycloak.adapters.KeycloakDeploymentBuilder; @@ -122,9 +123,11 @@ import de.acosix.alfresco.keycloak.share.util.RefreshableAccessTokenHolder; public class KeycloakAuthenticationFilter implements DependencyInjectedFilter, InitializingBean, ApplicationContextAware { - private static final String KEYCLOAK_ACCOUNT_SESSION_KEY = KeycloakAccount.class.getName(); + public static final String KEYCLOAK_ACCOUNT_SESSION_KEY = KeycloakAccount.class.getName(); - private static final String BACKEND_ACCESS_TOKEN_SESSION_KEY = AccessTokenAwareSlingshotAlfrescoConnector.class.getName(); + public static final String ACCESS_TOKEN_SESSION_KEY = AccessToken.class.getName(); + + public static final String BACKEND_ACCESS_TOKEN_SESSION_KEY = AccessTokenAwareSlingshotAlfrescoConnector.class.getName(); private static final String HEADER_AUTHORIZATION = "Authorization"; @@ -543,6 +546,111 @@ public class KeycloakAuthenticationFilter implements DependencyInjectedFilter, I } } + final String authHeader = req.getHeader(HEADER_AUTHORIZATION); + if (authHeader != null && authHeader.toLowerCase(Locale.ENGLISH).startsWith("bearer ")) + { + this.processBearerAuthentication(context, req, res, chain, keycloakAuthConfig.getPerformTokenExchange(), facade); + } + else + { + this.processFilterAuthentication(context, req, res, chain, bodyBufferLimit, sslRedirectPort, facade); + } + } + + /** + * Processes authentication when an explicit "Bearer" authentication header is present in a request. Such authentication is only + * supported when Share is not using OAuth2 token exchange with the Repository backend, and requires a bit of special handling due to + * Keycloak library access restrictions, in order to obtain the access token for validation and passing on to the Repository-tier. + * + * @param context + * the servlet context + * @param req + * the servlet request + * @param res + * the servlet response + * @param chain + * the filter chain + * @param performTokenExchange + * whether Share has been configured to perform OAuth2 token exchange to authenticate against the Repository backend + * @param facade + * the Keycloak HTTP facade + * @throws IOException + * if any error occurs during Keycloak authentication or processing of the filter chain + * @throws ServletException + * if any error occurs during Keycloak authentication or processing of the filter chain + */ + protected void processBearerAuthentication(final ServletContext context, final HttpServletRequest req, final HttpServletResponse res, + final FilterChain chain, final Boolean performTokenExchange, final OIDCServletHttpFacade facade) + throws IOException, ServletException + { + if (Boolean.TRUE.equals(performTokenExchange)) + { + LOGGER.warn( + "Authentication was attempted using Bearer token - this cannot be supported using token exchange for accessing the primary backend endpoint {}", + this.primaryEndpoint); + LOGGER.warn("Continueing with filter chain processing without handling the Bearer token"); + + this.continueFilterChain(context, req, res, chain); + } + else + { + final BearerTokenRequestAuthenticator authenticator = new BearerTokenRequestAuthenticator(this.keycloakDeployment); + final AuthOutcome authOutcome = authenticator.authenticate(facade); + + if (authOutcome == AuthOutcome.AUTHENTICATED) + { + final AccessToken token = authenticator.getToken(); + final RefreshableAccessTokenHolder tokenHolder = new RefreshableAccessTokenHolder(token, token, + authenticator.getTokenString(), null); + this.onKeycloakAuthenticationSuccess(context, req, res, chain, facade, tokenHolder); + } + else if (authOutcome == AuthOutcome.FAILED) + { + LOGGER.warn("Bearer token authentication failed - issueing failure challenge with details"); + // not using regular onKeycloakAuthenticationFailure handling since that only applies to proper OIDC filter authentication, + // with the potential of redirecting to the IdP authentication UI + req.getSession().invalidate(); + + authenticator.getChallenge().challenge(facade); + } + else + { + LOGGER.warn( + "Unexpected authentication outcome {} on Bearer authentication with guaranteed token presence - continueing with filter chain processing", + authOutcome); + + this.continueFilterChain(context, req, res, chain); + } + } + } + + /** + * Processes the regular OIDC filter authentication on a request. + * + * @param context + * the servlet context + * @param req + * the servlet request + * @param res + * the servlet response + * @param chain + * the filter chain + * @param bodyBufferLimit + * the configured size limit to apply to any HTTP POST/PUT body buffering that may need to be applied to process the + * authentication via an intermediary redirect + * @param sslRedirectPort + * the configured port to use for any forced redirection to HTTPS/SSL communication + * @param facade + * the Keycloak HTTP facade + * @throws IOException + * if any error occurs during Keycloak authentication or processing of the filter chain + * @throws ServletException + * if any error occurs during Keycloak authentication or processing of the filter chain + */ + protected void processFilterAuthentication(final ServletContext context, final HttpServletRequest req, final HttpServletResponse res, + final FilterChain chain, final Integer bodyBufferLimit, final Integer sslRedirectPort, final OIDCServletHttpFacade facade) + throws IOException, ServletException + { final OIDCFilterSessionStore tokenStore = new OIDCFilterSessionStore(req, facade, bodyBufferLimit != null ? bodyBufferLimit.intValue() : DEFAULT_BODY_BUFFER_LIMIT, this.keycloakDeployment, this.sessionIdMapper); @@ -689,7 +797,7 @@ public class KeycloakAuthenticationFilter implements DependencyInjectedFilter, I } /** - * Processes a sucessfull authentication via Keycloak. + * Processes a successful authentication via Keycloak. * * @param context * the servlet context @@ -752,6 +860,64 @@ public class KeycloakAuthenticationFilter implements DependencyInjectedFilter, I this.continueFilterChain(context, requestWrapper, res, chain); } + /** + * Processes a successful authentication via Keycloak. + * + * @param context + * the servlet context + * @param req + * the servlet request + * @param res + * the servlet response + * @param chain + * the filter chain + * @param facade + * the Keycloak HTTP facade + * @param tokenHolder + * the holder for access token taken from the successful authentication + * @throws IOException + * if any error occurs during Keycloak authentication or processing of the filter chain + * @throws ServletException + * if any error occurs during Keycloak authentication or processing of the filter chain + */ + protected void onKeycloakAuthenticationSuccess(final ServletContext context, final HttpServletRequest req, + final HttpServletResponse res, final FilterChain chain, final OIDCServletHttpFacade facade, + final RefreshableAccessTokenHolder tokenHolder) throws IOException, ServletException + { + final HttpSession session = req.getSession(); + + session.setAttribute(ACCESS_TOKEN_SESSION_KEY, tokenHolder); + + final String userId = tokenHolder.getAccessToken().getPreferredUsername(); + LOGGER.debug("User {} successfully authenticated via Keycloak", userId); + + session.setAttribute(UserFactory.SESSION_ATTRIBUTE_EXTERNAL_AUTH, Boolean.TRUE); + session.setAttribute(UserFactory.SESSION_ATTRIBUTE_KEY_USER_ID, userId); + + if (facade.isEnded()) + { + LOGGER.debug("Authenticator already handled response"); + return; + } + + final String servletPath = req.getServletPath(); + final String pathInfo = req.getPathInfo(); + final String servletRequestUri = servletPath + (pathInfo != null ? pathInfo : ""); + if (servletRequestUri.matches(KEYCLOAK_ACTION_URL_PATTERN)) + { + LOGGER.debug("Applying Keycloak authenticated actions handler"); + final AuthenticatedActionsHandler actions = new AuthenticatedActionsHandler(this.keycloakDeployment, facade); + if (actions.handledRequest()) + { + LOGGER.debug("Keycloak authenticated actions processed the request - stopping filter chain execution"); + return; + } + } + + LOGGER.debug("Continueing with filter chain processing"); + this.continueFilterChain(context, req, res, chain); + } + /** * Processes a failed authentication via Keycloak. * @@ -858,13 +1024,29 @@ 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(KEYCLOAK_ACCOUNT_SESSION_KEY) != null - && !this.sessionIdMapper.hasSession(currentSession.getId())) + && AuthenticationUtil.isAuthenticated(req)) { - LOGGER.debug("Session {} for Keycloak-authenticated user {} was invalidated by back-channel logout", currentSession.getId(), - AuthenticationUtil.getUserId(req)); - currentSession.invalidate(); - currentSession = req.getSession(false); + if (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(), + AuthenticationUtil.getUserId(req)); + currentSession.invalidate(); + currentSession = req.getSession(false); + } + else if (currentSession.getAttribute(ACCESS_TOKEN_SESSION_KEY) != null) + { + final RefreshableAccessTokenHolder accessToken = (RefreshableAccessTokenHolder) currentSession + .getAttribute(ACCESS_TOKEN_SESSION_KEY); + if (!accessToken.isActive()) + { + LOGGER.debug("Access token in session from previous Bearer authorization for {} has expired - invalidating session", + AuthenticationUtil.getUserId(req)); + + currentSession.invalidate(); + currentSession = req.getSession(false); + } + } } if (!this.externalAuthEnabled || !this.filterEnabled) @@ -888,7 +1070,8 @@ public class KeycloakAuthenticationFilter implements DependencyInjectedFilter, I } else if (authHeader != null && authHeader.toLowerCase(Locale.ENGLISH).startsWith("bearer ")) { - LOGGER.debug("Explicitly not skipping processKeycloakAuthenticationAndActions as Bearer authorization header is present"); + LOGGER.debug( + "Explicitly not skipping processKeycloakAuthenticationAndActions as Bearer authorization header is present and Bearer authentication is not disallowed"); } else if (authHeader != null && authHeader.toLowerCase(Locale.ENGLISH).startsWith("basic ")) { @@ -922,16 +1105,24 @@ public class KeycloakAuthenticationFilter implements DependencyInjectedFilter, I final String noauth = proxyMatcher.group(2); if (noauth != null && !noauth.trim().isEmpty()) { - LOGGER.debug("Skipping processKeycloakAuthenticationAndActions as proxy servlet to noauth endpoint {} is being called"); + LOGGER.debug("Skipping processKeycloakAuthenticationAndActions as proxy servlet to noauth endpoint {} is being called", + endpoint); skip = true; } else if (!endpoint.equals(this.primaryEndpoint) && (this.secondaryEndpoints == null || !this.secondaryEndpoints.contains(endpoint))) { LOGGER.debug( - "Skipping processKeycloakAuthenticationAndActions on proxy servlet call as endpoint {} has not been configured as a primary / secondary endpoint to handle"); + "Skipping processKeycloakAuthenticationAndActions on proxy servlet call as endpoint {} has not been configured as a primary / secondary endpoint to handle", + endpoint); skip = true; } + else + { + LOGGER.debug( + "Explicitely not skipping processKeycloakAuthenticationAndActions on proxy servlet call to endpoint {} which is expected to serve web scripts requiring authentication", + endpoint); + } } else if (PAGE_SERVLET_PATH.equals(servletPath) && (LOGIN_PATH_INFORMATION.equals(pathInfo) || (pathInfo == null && LOGIN_PAGE_TYPE_PARAMETER_VALUE.equals(req.getParameter(PAGE_TYPE_PARAMETER_NAME))))) @@ -1168,7 +1359,8 @@ public class KeycloakAuthenticationFilter implements DependencyInjectedFilter, I // 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.isActive() || (token.canRefresh() && token.shouldRefresh(this.keycloakDeployment.getTokenMinimumTimeToLive()))) + if (token == null || !token.isActive() + || (token.canRefresh() && token.shouldRefresh(this.keycloakDeployment.getTokenMinimumTimeToLive()))) { AccessTokenResponse response; try