diff --git a/remote-api/src/main/java/org/alfresco/repo/web/scripts/servlet/RemoteUserAuthenticatorFactory.java b/remote-api/src/main/java/org/alfresco/repo/web/scripts/servlet/RemoteUserAuthenticatorFactory.java index 5ccd2d6d16..46b8f65d3f 100644 --- a/remote-api/src/main/java/org/alfresco/repo/web/scripts/servlet/RemoteUserAuthenticatorFactory.java +++ b/remote-api/src/main/java/org/alfresco/repo/web/scripts/servlet/RemoteUserAuthenticatorFactory.java @@ -34,6 +34,7 @@ import org.alfresco.repo.management.subsystems.ActivateableBean; import org.alfresco.repo.security.authentication.AuthenticationComponent; import org.alfresco.repo.security.authentication.AuthenticationException; import org.alfresco.repo.security.authentication.AuthenticationUtil; +import org.alfresco.repo.security.authentication.external.AdminConsoleAuthenticator; import org.alfresco.repo.security.authentication.external.RemoteUserMapper; import org.alfresco.repo.web.auth.AuthenticationListener; import org.alfresco.repo.web.auth.TicketCredentials; @@ -67,16 +68,18 @@ import java.util.Set; */ public class RemoteUserAuthenticatorFactory extends BasicHttpAuthenticatorFactory { - private static Log logger = LogFactory.getLog(RemoteUserAuthenticatorFactory.class); + private static final Log LOGGER = LogFactory.getLog(RemoteUserAuthenticatorFactory.class); public static final long GET_REMOTE_USER_TIMEOUT_MILLISECONDS_DEFAULT = 10000L; // 10 sec protected RemoteUserMapper remoteUserMapper; protected AuthenticationComponent authenticationComponent; + protected AdminConsoleAuthenticator adminConsoleAuthenticator; private boolean alwaysAllowBasicAuthForAdminConsole = true; List adminConsoleScriptFamilies; long getRemoteUserTimeoutMilliseconds = GET_REMOTE_USER_TIMEOUT_MILLISECONDS_DEFAULT; + public void setRemoteUserMapper(RemoteUserMapper remoteUserMapper) { this.remoteUserMapper = remoteUserMapper; @@ -117,6 +120,12 @@ public class RemoteUserAuthenticatorFactory extends BasicHttpAuthenticatorFactor this.getRemoteUserTimeoutMilliseconds = getRemoteUserTimeoutMilliseconds; } + public void setAdminConsoleAuthenticator( + AdminConsoleAuthenticator adminConsoleAuthenticator) + { + this.adminConsoleAuthenticator = adminConsoleAuthenticator; + } + @Override public Authenticator create(WebScriptServletRequest req, WebScriptServletResponse res) { @@ -140,36 +149,46 @@ public class RemoteUserAuthenticatorFactory extends BasicHttpAuthenticatorFactor { boolean authenticated = false; - if (logger.isTraceEnabled()) + if (LOGGER.isTraceEnabled()) { - logger.trace("Authenticate level required: " + required + " is guest: " + isGuest); + LOGGER.trace("Authenticate level required: " + required + " is guest: " + isGuest); } String userId = null; if (isRemoteUserMapperActive()) { - if (isAlwaysAllowBasicAuthForAdminConsole()) - { - final boolean useTimeoutForAdminAccessingAdminConsole = shouldUseTimeoutForAdminAccessingAdminConsole(required, isGuest); - if (useTimeoutForAdminAccessingAdminConsole && isBasicAuthHeaderPresentForAdmin()) - { - return callBasicAuthForAdminConsoleAccess(required, isGuest); - } - try - { - userId = getRemoteUserWithTimeout(useTimeoutForAdminAccessingAdminConsole); - } - catch (AuthenticationTimeoutException e) - { - //return basic auth challenge - return false; - } - } - else + if (servletReq.getServiceMatch() != null && + isAdminConsoleWebScript(servletReq.getServiceMatch().getWebScript()) && isAdminConsoleAuthenticatorActive()) { - // retrieve the remote user if configured and available - authenticate that user directly - userId = getRemoteUser(); + userId = getAdminConsoleUser(); + } + + if (userId == null) + { + if (isAlwaysAllowBasicAuthForAdminConsole()) + { + final boolean useTimeoutForAdminAccessingAdminConsole = shouldUseTimeoutForAdminAccessingAdminConsole(required, isGuest); + + if (useTimeoutForAdminAccessingAdminConsole && isBasicAuthHeaderPresentForAdmin()) + { + return callBasicAuthForAdminConsoleAccess(required, isGuest); + } + try + { + userId = getRemoteUserWithTimeout(useTimeoutForAdminAccessingAdminConsole); + } + catch (AuthenticationTimeoutException e) + { + //return basic auth challenge + return false; + } + } + else + { + // retrieve the remote user if configured and available - authenticate that user directly + userId = getRemoteUser(); + } } } @@ -208,9 +227,9 @@ public class RemoteUserAuthenticatorFactory extends BasicHttpAuthenticatorFactor { // Validate the ticket for the current SessionUser authenticationService.validate(user.getTicket()); - if (logger.isDebugEnabled()) + if (LOGGER.isDebugEnabled()) { - logger.debug("Ticket is valid. Retaining cached user in session."); + LOGGER.debug("Ticket is valid. Retaining cached user in session."); } listener.userAuthenticated(new TicketCredentials(user.getTicket())); authenticated = true; @@ -222,9 +241,9 @@ public class RemoteUserAuthenticatorFactory extends BasicHttpAuthenticatorFactor } catch (AuthenticationException authErr) { - if (logger.isDebugEnabled()) + if (LOGGER.isDebugEnabled()) { - logger.debug("An Authentication error occur. Removing User session.", authErr); + LOGGER.debug("An Authentication error occur. Removing User session.", authErr); } session.removeAttribute(AuthenticationDriver.AUTHENTICATION_USER); session.invalidate(); @@ -236,15 +255,20 @@ public class RemoteUserAuthenticatorFactory extends BasicHttpAuthenticatorFactor authenticated = super.authenticate(required, isGuest); } } + if(!authenticated && servletReq.getServiceMatch() != null && + isAdminConsoleWebScript(servletReq.getServiceMatch().getWebScript()) && isAdminConsoleAuthenticatorActive()) + { + adminConsoleAuthenticator.requestAuthentication(this.servletReq.getHttpServletRequest(), this.servletRes.getHttpServletResponse()); + } return authenticated; } private boolean callBasicAuthForAdminConsoleAccess(RequiredAuthentication required, boolean isGuest) { // return REST call, after a timeout/basic auth challenge - if (logger.isTraceEnabled()) + if (LOGGER.isTraceEnabled()) { - logger.trace("An Admin Console request has come in with Basic Auth headers present for an admin user."); + LOGGER.trace("An Admin Console request has come in with Basic Auth headers present for an admin user."); } // In order to prompt for another password, in case it was not entered correctly, // the output of this method should be returned by the calling "authenticate" method; @@ -258,9 +282,9 @@ public class RemoteUserAuthenticatorFactory extends BasicHttpAuthenticatorFactor boolean useTimeoutForAdminAccessingAdminConsole = RequiredAuthentication.admin.equals(required) && !isGuest && servletReq.getServiceMatch() != null && isAdminConsoleWebScript(servletReq.getServiceMatch().getWebScript()); - if (logger.isTraceEnabled()) + if (LOGGER.isTraceEnabled()) { - logger.trace("Should ensure that the admins can login with basic auth: " + useTimeoutForAdminAccessingAdminConsole); + LOGGER.trace("Should ensure that the admins can login with basic auth: " + useTimeoutForAdminAccessingAdminConsole); } return useTimeoutForAdminAccessingAdminConsole; } @@ -270,6 +294,11 @@ public class RemoteUserAuthenticatorFactory extends BasicHttpAuthenticatorFactor return remoteUserMapper != null && (!(remoteUserMapper instanceof ActivateableBean) || ((ActivateableBean) remoteUserMapper).isActive()); } + private boolean isAdminConsoleAuthenticatorActive() + { + return adminConsoleAuthenticator != null && (!(adminConsoleAuthenticator instanceof ActivateableBean) || ((ActivateableBean) adminConsoleAuthenticator).isActive()); + } + protected boolean isAdminConsoleWebScript(WebScript webScript) { if (webScript == null || adminConsoleScriptFamilies == null || webScript.getDescription() == null @@ -278,9 +307,9 @@ public class RemoteUserAuthenticatorFactory extends BasicHttpAuthenticatorFactor return false; } - if (logger.isTraceEnabled()) + if (LOGGER.isTraceEnabled()) { - logger.trace("WebScript: " + webScript + " has these families: " + webScript.getDescription().getFamilys()); + LOGGER.trace("WebScript: " + webScript + " has these families: " + webScript.getDescription().getFamilys()); } // intersect the "family" sets defined @@ -288,9 +317,9 @@ public class RemoteUserAuthenticatorFactory extends BasicHttpAuthenticatorFactor families.retainAll(adminConsoleScriptFamilies); final boolean isAdminConsole = !families.isEmpty(); - if (logger.isTraceEnabled() && isAdminConsole) + if (LOGGER.isTraceEnabled() && isAdminConsole) { - logger.trace("Detected an Admin Console webscript: " + webScript ); + LOGGER.trace("Detected an Admin Console webscript: " + webScript); } return isAdminConsole; @@ -316,7 +345,7 @@ public class RemoteUserAuthenticatorFactory extends BasicHttpAuthenticatorFactor } catch (Exception e) { - logger.warn("Exception trying to get the remote user: " + e.getMessage(), e); + LOGGER.warn("Exception trying to get the remote user: " + e.getMessage(), e); } returnedRemoteUser = getRemoteUserRunnable.getReturnedRemoteUser(); @@ -330,9 +359,9 @@ public class RemoteUserAuthenticatorFactory extends BasicHttpAuthenticatorFactor final String message = "Could not get the remote user in a reasonable time: " + getRemoteUserTimeoutMilliseconds + " milliseconds. " + "Adjust the timeout property 'authentication.getRemoteUserTimeoutMilliseconds' if required."; - if (logger.isWarnEnabled()) + if (LOGGER.isWarnEnabled()) { - logger.warn("Returning basic auth challenge for Admin Console. Cause: " + message); + LOGGER.warn("Returning basic auth challenge for Admin Console. Cause: " + message); } HttpServletResponse res = servletRes.getHttpServletResponse(); res.setStatus(401); @@ -379,15 +408,29 @@ public class RemoteUserAuthenticatorFactory extends BasicHttpAuthenticatorFactor private void logRemoteUserID(String userId) { - if (logger.isDebugEnabled()) + if (LOGGER.isDebugEnabled()) { String message = (userId == null) ? "No external user ID in request." : "Extracted external user ID from request: " + AuthenticationUtil.maskUsername(userId); - logger.debug(message); + LOGGER.debug(message); } } + protected String getAdminConsoleUser() + { + String userId = null; + + if (isRemoteUserMapperActive()) + { + userId = adminConsoleAuthenticator.getAdminConsoleUser(this.servletReq.getHttpServletRequest(), this.servletRes.getHttpServletResponse()); + } + + logRemoteUserID(userId); + + return userId; + } + class GetRemoteUserRunnable implements Runnable { private volatile String returnedRemoteUser; diff --git a/remote-api/src/main/resources/alfresco/web-scripts-application-context.xml b/remote-api/src/main/resources/alfresco/web-scripts-application-context.xml index 4da5da51b0..f255f96162 100644 --- a/remote-api/src/main/resources/alfresco/web-scripts-application-context.xml +++ b/remote-api/src/main/resources/alfresco/web-scripts-application-context.xml @@ -213,6 +213,7 @@ + ${authentication.alwaysAllowBasicAuthForAdminConsole.enabled} diff --git a/repository/src/main/java/org/alfresco/repo/security/authentication/external/AdminConsoleAuthenticator.java b/repository/src/main/java/org/alfresco/repo/security/authentication/external/AdminConsoleAuthenticator.java new file mode 100644 index 0000000000..9ff97debbb --- /dev/null +++ b/repository/src/main/java/org/alfresco/repo/security/authentication/external/AdminConsoleAuthenticator.java @@ -0,0 +1,56 @@ +/* + * #%L + * Alfresco Repository + * %% + * Copyright (C) 2005 - 2023 Alfresco Software Limited + * %% + * This file is part of the Alfresco software. + * If the software was purchased under a paid Alfresco license, the terms of + * the paid license agreement will prevail. Otherwise, the software is + * provided under the following open source license terms: + * + * Alfresco is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Alfresco is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Alfresco. If not, see . + * #L% + */ +package org.alfresco.repo.security.authentication.external; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +/** + * An interface for objects capable of extracting an externally authenticated user ID from the HTTP Admin Console webscript request. + */ +public interface AdminConsoleAuthenticator +{ + /** + * Gets an externally authenticated user ID from the HTTP Admin Console webscript request. + * + * @param request + * the request + * @param response + * the response + * @return the user ID or null if the user is unauthenticated + */ + String getAdminConsoleUser(HttpServletRequest request, HttpServletResponse response); + + /** + * Requests an authentication. + * + * @param request + * the request + * @param response + * the response + */ + void requestAuthentication(HttpServletRequest request, HttpServletResponse response); +} diff --git a/repository/src/main/java/org/alfresco/repo/security/authentication/external/DefaultAdminConsoleAuthenticator.java b/repository/src/main/java/org/alfresco/repo/security/authentication/external/DefaultAdminConsoleAuthenticator.java new file mode 100644 index 0000000000..d57f3f2012 --- /dev/null +++ b/repository/src/main/java/org/alfresco/repo/security/authentication/external/DefaultAdminConsoleAuthenticator.java @@ -0,0 +1,54 @@ +/* + * #%L + * Alfresco Repository + * %% + * Copyright (C) 2005 - 2023 Alfresco Software Limited + * %% + * This file is part of the Alfresco software. + * If the software was purchased under a paid Alfresco license, the terms of + * the paid license agreement will prevail. Otherwise, the software is + * provided under the following open source license terms: + * + * Alfresco is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Alfresco is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Alfresco. If not, see . + * #L% + */ +package org.alfresco.repo.security.authentication.external; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.alfresco.repo.management.subsystems.ActivateableBean; + +/** + * A default {@link AdminConsoleAuthenticator} implementation. Returns null to request a basic auth challenge. + */ +public class DefaultAdminConsoleAuthenticator implements AdminConsoleAuthenticator, ActivateableBean +{ + @Override + public String getAdminConsoleUser(HttpServletRequest request, HttpServletResponse response) + { + return null; + } + + @Override + public void requestAuthentication(HttpServletRequest request, HttpServletResponse response) + { + // No implementation + } + + @Override + public boolean isActive() + { + return false; + } +} diff --git a/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/IdentityServiceFacade.java b/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/IdentityServiceFacade.java index 8569fea5c7..946503bfde 100644 --- a/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/IdentityServiceFacade.java +++ b/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/IdentityServiceFacade.java @@ -32,6 +32,9 @@ import java.time.Instant; import java.util.Objects; import java.util.Optional; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.client.registration.ClientRegistration.ProviderDetails; + /** * Allows to interact with the Identity Service */ @@ -61,6 +64,11 @@ public interface IdentityServiceFacade */ Optional getUserInfo(String token); + /** + * Gets a client registration + */ + ClientRegistration getClientRegistration(); + class IdentityServiceFacadeException extends RuntimeException { public IdentityServiceFacadeException(String message) @@ -216,8 +224,14 @@ public interface IdentityServiceFacade @Override public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; + if (this == o) + { + return true; + } + if (o == null || getClass() != o.getClass()) + { + return false; + } AuthorizationGrant that = (AuthorizationGrant) o; return Objects.equals(username, that.username) && Objects.equals(password, that.password) && diff --git a/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/IdentityServiceFacadeFactoryBean.java b/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/IdentityServiceFacadeFactoryBean.java index 44ac5f6298..bdfe2910ea 100644 --- a/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/IdentityServiceFacadeFactoryBean.java +++ b/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/IdentityServiceFacadeFactoryBean.java @@ -194,6 +194,12 @@ public class IdentityServiceFacadeFactoryBean implements FactoryBean new OIDCUserInfo(userInfo.getPreferredUsername(), userInfo.getGivenName(), userInfo.getFamilyName(), userInfo.getEmailAddress())); } + @Override + public ClientRegistration getClientRegistration() + { + return clientRegistration; + } + @Override public DecodedAccessToken decodeToken(String token) { diff --git a/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/admin/AdminConsoleAuthenticationCookiesService.java b/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/admin/AdminConsoleAuthenticationCookiesService.java new file mode 100644 index 0000000000..b01dbd4297 --- /dev/null +++ b/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/admin/AdminConsoleAuthenticationCookiesService.java @@ -0,0 +1,107 @@ +/* + * #%L + * Alfresco Repository + * %% + * Copyright (C) 2005 - 2023 Alfresco Software Limited + * %% + * This file is part of the Alfresco software. + * If the software was purchased under a paid Alfresco license, the terms of + * the paid license agreement will prevail. Otherwise, the software is + * provided under the following open source license terms: + * + * Alfresco is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Alfresco is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Alfresco. If not, see . + * #L% + */ +package org.alfresco.repo.security.authentication.identityservice.admin; + +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.alfresco.repo.admin.SysAdminParams; + +/** + * Service to handle Admin Console authentication-related cookies. + */ +public class AdminConsoleAuthenticationCookiesService +{ + private final SysAdminParams sysAdminParams; + private final int cookieLifetime; + + public AdminConsoleAuthenticationCookiesService(SysAdminParams sysAdminParams, int cookieLifetime) + { + this.sysAdminParams = sysAdminParams; + this.cookieLifetime = cookieLifetime; + } + + /** + * Get the cookie with the given name. + * + * @param name the name of the cookie + * @param request the request that might contain the cookie + * @return the cookie, or null if the cookie cannot be found + */ + public String getCookie(String name, HttpServletRequest request) + { + String result = null; + Cookie[] cookies = request.getCookies(); + + if (cookies != null) + { + for (Cookie cookie : cookies) + { + if (cookie.getName().equals(name)) + { + result = cookie.getValue(); + break; + } + } + } + + return result; + } + + /** + * Add a cookie to the response. + * + * @param name the name of the cookie + * @param value the value of the cookie + * @param servletResponse the response to add the cookie to + */ + public void addCookie(String name, String value, HttpServletResponse servletResponse) + { + internalAddCookie(name, value, cookieLifetime, servletResponse); + } + + /** + * Issue a cookie reset within the given response. + * + * @param name the cookie to reset + * @param servletResponse the response to issue the cookie reset + */ + public void resetCookie(String name, HttpServletResponse servletResponse) + { + internalAddCookie(name, "", 0, servletResponse); + } + + private void internalAddCookie(String name, String value, int maxAge, HttpServletResponse servletResponse) + { + Cookie authCookie = new Cookie(name, value); + authCookie.setPath("/"); + authCookie.setMaxAge(maxAge); + authCookie.setSecure(sysAdminParams.getAlfrescoProtocol().equalsIgnoreCase("https")); + authCookie.setHttpOnly(true); + servletResponse.addCookie(authCookie); + } + +} diff --git a/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/admin/AdminConsoleHttpServletRequestWrapper.java b/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/admin/AdminConsoleHttpServletRequestWrapper.java new file mode 100644 index 0000000000..d80eca391a --- /dev/null +++ b/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/admin/AdminConsoleHttpServletRequestWrapper.java @@ -0,0 +1,91 @@ +/* + * #%L + * Alfresco Repository + * %% + * Copyright (C) 2005 - 2023 Alfresco Software Limited + * %% + * This file is part of the Alfresco software. + * If the software was purchased under a paid Alfresco license, the terms of + * the paid license agreement will prevail. Otherwise, the software is + * provided under the following open source license terms: + * + * Alfresco is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Alfresco is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Alfresco. If not, see . + * #L% + */ +package org.alfresco.repo.security.authentication.identityservice.admin; + +import static java.util.Arrays.asList; +import static java.util.Collections.enumeration; + +import java.util.ArrayList; +import java.util.Enumeration; +import java.util.List; +import java.util.Map; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletRequestWrapper; +import org.alfresco.util.PropertyCheck; + +public class AdminConsoleHttpServletRequestWrapper extends HttpServletRequestWrapper +{ + private final Map additionalHeaders; + private final HttpServletRequest wrappedRequest; + + /** + * Constructs a request object wrapping the given request. + * + * @param request the request to wrap + * @throws IllegalArgumentException if the request is null + */ + public AdminConsoleHttpServletRequestWrapper(Map additionalHeaders, HttpServletRequest request) + { + super(request); + PropertyCheck.mandatory(this, "additionalHeaders", additionalHeaders); + this.additionalHeaders = additionalHeaders; + this.wrappedRequest = request; + } + + @Override + public Enumeration getHeaderNames() + { + List result = new ArrayList<>(); + Enumeration originalHeaders = wrappedRequest.getHeaderNames(); + if (originalHeaders != null) + { + while (originalHeaders.hasMoreElements()) + { + String header = originalHeaders.nextElement(); + if (!additionalHeaders.containsKey(header)) + { + result.add(header); + } + } + } + + result.addAll(additionalHeaders.keySet()); + return enumeration(result); + } + + @Override + public String getHeader(String name) + { + return additionalHeaders.getOrDefault(name, super.getHeader(name)); + } + + @Override + public Enumeration getHeaders(String name) + { + return enumeration(asList(additionalHeaders.getOrDefault(name, super.getHeader(name)))); + } +} diff --git a/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/admin/IdentityServiceAdminConsoleAuthenticator.java b/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/admin/IdentityServiceAdminConsoleAuthenticator.java new file mode 100644 index 0000000000..b95687e264 --- /dev/null +++ b/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/admin/IdentityServiceAdminConsoleAuthenticator.java @@ -0,0 +1,253 @@ +/* + * #%L + * Alfresco Repository + * %% + * Copyright (C) 2005 - 2023 Alfresco Software Limited + * %% + * This file is part of the Alfresco software. + * If the software was purchased under a paid Alfresco license, the terms of + * the paid license agreement will prevail. Otherwise, the software is + * provided under the following open source license terms: + * + * Alfresco is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Alfresco is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Alfresco. If not, see . + * #L% + */ +package org.alfresco.repo.security.authentication.identityservice.admin; + +import static org.alfresco.repo.security.authentication.identityservice.IdentityServiceFacade.AuthorizationGrant.authorizationCode; + +import java.io.IOException; +import java.time.Instant; +import java.util.HashMap; +import java.util.Map; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.alfresco.repo.management.subsystems.ActivateableBean; +import org.alfresco.repo.security.authentication.AuthenticationException; +import org.alfresco.repo.security.authentication.external.AdminConsoleAuthenticator; +import org.alfresco.repo.security.authentication.external.RemoteUserMapper; +import org.alfresco.repo.security.authentication.identityservice.IdentityServiceFacade; +import org.alfresco.repo.security.authentication.identityservice.IdentityServiceFacade.AccessTokenAuthorization; +import org.alfresco.repo.security.authentication.identityservice.IdentityServiceFacade.AuthorizationException; +import org.alfresco.repo.security.authentication.identityservice.IdentityServiceFacade.AuthorizationGrant; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * An {@link AdminConsoleAuthenticator} implementation to extract an externally authenticated user ID + * or to initiate the OIDC authorization code flow. + */ +public class IdentityServiceAdminConsoleAuthenticator implements AdminConsoleAuthenticator, ActivateableBean +{ + private static final Logger LOGGER = LoggerFactory.getLogger(IdentityServiceAdminConsoleAuthenticator.class); + + private static final String ALFRESCO_ACCESS_TOKEN = "ALFRESCO_ACCESS_TOKEN"; + private static final String ALFRESCO_REFRESH_TOKEN = "ALFRESCO_REFRESH_TOKEN"; + private static final String ALFRESCO_TOKEN_EXPIRATION = "ALFRESCO_TOKEN_EXPIRATION"; + + private IdentityServiceFacade identityServiceFacade; + private AdminConsoleAuthenticationCookiesService cookiesService; + private RemoteUserMapper remoteUserMapper; + private boolean isEnabled; + + @Override + public String getAdminConsoleUser(HttpServletRequest request, HttpServletResponse response) + { + // Try to extract username from the authorization header + String username = remoteUserMapper.getRemoteUser(request); + if (username != null) + { + return username; + } + + String bearerToken = cookiesService.getCookie(ALFRESCO_ACCESS_TOKEN, request); + + if (bearerToken != null) + { + bearerToken = refreshTokenIfNeeded(request, response, bearerToken); + } + else + { + String code = request.getParameter("code"); + if (code != null) + { + bearerToken = retrieveTokenUsingAuthCode(request, response, code); + } + } + + if (bearerToken == null) + { + return null; + } + + return remoteUserMapper.getRemoteUser(decorateBearerHeader(bearerToken, request)); + } + + @Override + public void requestAuthentication(HttpServletRequest request, HttpServletResponse response) + { + respondWithAuthChallenge(request, response); + } + + private void respondWithAuthChallenge(HttpServletRequest request, HttpServletResponse response) + { + try + { + if (LOGGER.isDebugEnabled()) + { + LOGGER.debug("Responding with the authentication challenge"); + } + response.sendRedirect(getAuthenticationRequest(request)); + } + catch (IOException e) + { + LOGGER.error("Error while trying to respond with the authentication challenge: {}", e.getMessage(), e); + throw new AuthenticationException(e.getMessage(), e); + } + } + + private String retrieveTokenUsingAuthCode(HttpServletRequest request, HttpServletResponse response, String code) + { + String bearerToken = null; + if (LOGGER.isDebugEnabled()) + { + LOGGER.debug("Retrieving a response using the Authorization Code at the Token Endpoint"); + } + try + { + AccessTokenAuthorization accessTokenAuthorization = identityServiceFacade.authorize( + authorizationCode(code, request.getRequestURL().toString())); + addCookies(response, accessTokenAuthorization); + bearerToken = accessTokenAuthorization.getAccessToken().getTokenValue(); + } + catch (AuthorizationException exception) + { + if (LOGGER.isWarnEnabled()) + { + LOGGER.warn( + "Error while trying to retrieve a response using the Authorization Code at the Token Endpoint: {}", + exception.getMessage()); + } + } + return bearerToken; + } + + private String refreshTokenIfNeeded(HttpServletRequest request, HttpServletResponse response, String bearerToken) + { + String refreshToken = cookiesService.getCookie(ALFRESCO_REFRESH_TOKEN, request); + String authTokenExpiration = cookiesService.getCookie(ALFRESCO_TOKEN_EXPIRATION, request); + try + { + if (isAuthTokenExpired(authTokenExpiration)) + { + bearerToken = refreshAuthToken(refreshToken, response); + } + } + catch (Exception e) + { + if (LOGGER.isDebugEnabled()) + { + LOGGER.debug("Error while trying to refresh Auth Token: {}", e.getMessage()); + } + bearerToken = null; + resetCookies(response); + } + return bearerToken; + } + + private void addCookies(HttpServletResponse response, AccessTokenAuthorization accessTokenAuthorization) + { + cookiesService.addCookie(ALFRESCO_ACCESS_TOKEN, accessTokenAuthorization.getAccessToken().getTokenValue(), response); + cookiesService.addCookie(ALFRESCO_TOKEN_EXPIRATION, String.valueOf( + accessTokenAuthorization.getAccessToken().getExpiresAt().toEpochMilli()), response); + cookiesService.addCookie(ALFRESCO_REFRESH_TOKEN, accessTokenAuthorization.getRefreshTokenValue(), response); + } + + private String getAuthenticationRequest(HttpServletRequest request) + { + return identityServiceFacade.getClientRegistration().getProviderDetails().getAuthorizationUri() + + "?client_id=" + + identityServiceFacade.getClientRegistration().getClientId() + + "&redirect_uri=" + + request.getRequestURL() + + "&response_type=code" + + "&scope=openid"; + } + + private void resetCookies(HttpServletResponse response) + { + cookiesService.resetCookie(ALFRESCO_TOKEN_EXPIRATION, response); + cookiesService.resetCookie(ALFRESCO_ACCESS_TOKEN, response); + cookiesService.resetCookie(ALFRESCO_REFRESH_TOKEN, response); + } + + private String refreshAuthToken(String refreshToken, HttpServletResponse response) + { + AccessTokenAuthorization accessTokenAuthorization = doRefreshAuthToken(refreshToken); + addCookies(response, accessTokenAuthorization); + return accessTokenAuthorization.getAccessToken().getTokenValue(); + } + + private AccessTokenAuthorization doRefreshAuthToken(String refreshToken) + { + AccessTokenAuthorization accessTokenAuthorization = identityServiceFacade.authorize( + AuthorizationGrant.refreshToken(refreshToken)); + if (accessTokenAuthorization == null || accessTokenAuthorization.getAccessToken() == null) + { + throw new AuthenticationException("AccessTokenResponse is null or empty"); + } + return accessTokenAuthorization; + } + + private static boolean isAuthTokenExpired(String authTokenExpiration) + { + return Instant.now().compareTo(Instant.ofEpochMilli(Long.parseLong(authTokenExpiration))) >= 0; + } + + private HttpServletRequest decorateBearerHeader(String authToken, HttpServletRequest servletRequest) + { + Map additionalHeaders = new HashMap<>(); + additionalHeaders.put("Authorization", "Bearer " + authToken); + return new AdminConsoleHttpServletRequestWrapper(additionalHeaders, servletRequest); + } + + public void setIdentityServiceFacade( + IdentityServiceFacade identityServiceFacade) + { + this.identityServiceFacade = identityServiceFacade; + } + + public void setRemoteUserMapper(RemoteUserMapper remoteUserMapper) + { + this.remoteUserMapper = remoteUserMapper; + } + + public void setCookiesService( + AdminConsoleAuthenticationCookiesService cookiesService) + { + this.cookiesService = cookiesService; + } + + @Override + public boolean isActive() + { + return this.isEnabled; + } + + public void setActive(boolean isEnabled) + { + this.isEnabled = isEnabled; + } +} diff --git a/repository/src/main/resources/alfresco/authentication-services-context.xml b/repository/src/main/resources/alfresco/authentication-services-context.xml index 36c9a22bd4..1925c02d35 100644 --- a/repository/src/main/resources/alfresco/authentication-services-context.xml +++ b/repository/src/main/resources/alfresco/authentication-services-context.xml @@ -128,6 +128,22 @@ + + + + + + + org.alfresco.repo.security.authentication.external.AdminConsoleAuthenticator + org.alfresco.repo.management.subsystems.ActivateableBean + + + + adminConsoleAuthenticator + + + diff --git a/repository/src/main/resources/alfresco/subsystems/Authentication/external/external-authentication-context.xml b/repository/src/main/resources/alfresco/subsystems/Authentication/external/external-authentication-context.xml index 6845680ea3..e25132c527 100644 --- a/repository/src/main/resources/alfresco/subsystems/Authentication/external/external-authentication-context.xml +++ b/repository/src/main/resources/alfresco/subsystems/Authentication/external/external-authentication-context.xml @@ -89,6 +89,8 @@ + + diff --git a/repository/src/main/resources/alfresco/subsystems/Authentication/identity-service/identity-service-authentication-context.xml b/repository/src/main/resources/alfresco/subsystems/Authentication/identity-service/identity-service-authentication-context.xml index 483bdd5b04..7bf04b29dd 100644 --- a/repository/src/main/resources/alfresco/subsystems/Authentication/identity-service/identity-service-authentication-context.xml +++ b/repository/src/main/resources/alfresco/subsystems/Authentication/identity-service/identity-service-authentication-context.xml @@ -141,7 +141,7 @@ ${identity-service.public-client:false} - + @@ -158,6 +158,26 @@ + + + + + + + + ${identity-service.authentication.enabled} + + + + + + + + + + + + diff --git a/repository/src/test/java/org/alfresco/AllUnitTestsSuite.java b/repository/src/test/java/org/alfresco/AllUnitTestsSuite.java index 0977a7d3b0..8b8787db3f 100644 --- a/repository/src/test/java/org/alfresco/AllUnitTestsSuite.java +++ b/repository/src/test/java/org/alfresco/AllUnitTestsSuite.java @@ -29,6 +29,9 @@ import org.alfresco.repo.security.authentication.identityservice.IdentityService import org.alfresco.repo.security.authentication.identityservice.IdentityServiceJITProvisioningHandlerUnitTest; import org.alfresco.repo.security.authentication.identityservice.LazyInstantiatingIdentityServiceFacadeUnitTest; import org.alfresco.repo.security.authentication.identityservice.SpringBasedIdentityServiceFacadeUnitTest; +import org.alfresco.repo.security.authentication.identityservice.admin.AdminConsoleAuthenticationCookiesServiceUnitTest; +import org.alfresco.repo.security.authentication.identityservice.admin.AdminConsoleHttpServletRequestWrapperUnitTest; +import org.alfresco.repo.security.authentication.identityservice.admin.IdentityServiceAdminConsoleAuthenticatorUnitTest; import org.alfresco.util.testing.category.DBTests; import org.alfresco.util.testing.category.NonBuildTests; import org.junit.experimental.categories.Categories; @@ -145,6 +148,9 @@ import org.junit.runners.Suite; LazyInstantiatingIdentityServiceFacadeUnitTest.class, SpringBasedIdentityServiceFacadeUnitTest.class, IdentityServiceJITProvisioningHandlerUnitTest.class, + AdminConsoleAuthenticationCookiesServiceUnitTest.class, + AdminConsoleHttpServletRequestWrapperUnitTest.class, + IdentityServiceAdminConsoleAuthenticatorUnitTest.class, org.alfresco.repo.security.authentication.CompositePasswordEncoderTest.class, org.alfresco.repo.security.authentication.PasswordHashingTest.class, org.alfresco.repo.security.authority.script.ScriptAuthorityService_RegExTest.class, diff --git a/repository/src/test/java/org/alfresco/repo/security/authentication/identityservice/admin/AdminConsoleAuthenticationCookiesServiceUnitTest.java b/repository/src/test/java/org/alfresco/repo/security/authentication/identityservice/admin/AdminConsoleAuthenticationCookiesServiceUnitTest.java new file mode 100644 index 0000000000..e5c0b8cd06 --- /dev/null +++ b/repository/src/test/java/org/alfresco/repo/security/authentication/identityservice/admin/AdminConsoleAuthenticationCookiesServiceUnitTest.java @@ -0,0 +1,185 @@ +/* + * #%L + * Alfresco Repository + * %% + * Copyright (C) 2005 - 2023 Alfresco Software Limited + * %% + * This file is part of the Alfresco software. + * If the software was purchased under a paid Alfresco license, the terms of + * the paid license agreement will prevail. Otherwise, the software is + * provided under the following open source license terms: + * + * Alfresco is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Alfresco is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Alfresco. If not, see . + * #L% + */ +package org.alfresco.repo.security.authentication.identityservice.admin; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.mockito.MockitoAnnotations.initMocks; + +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.alfresco.repo.admin.SysAdminParams; +import org.junit.Before; +import org.junit.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; + +public class AdminConsoleAuthenticationCookiesServiceUnitTest +{ + private static final int DEFAULT_COOKIE_LIFETIME = 86400; + private static final String COOKIE_NAME = "cookie"; + private static final String COOKIE_VALUE = "value"; + @Mock + private HttpServletRequest request; + @Mock + private HttpServletResponse response; + @Mock + private SysAdminParams sysAdminParams; + @Captor + private ArgumentCaptor cookieCaptor; + private AdminConsoleAuthenticationCookiesService cookiesService; + + @Before + public void setUp() + { + initMocks(this); + cookiesService = new AdminConsoleAuthenticationCookiesService(sysAdminParams, DEFAULT_COOKIE_LIFETIME); + } + + @Test + public void cookieShouldBeFoundInRequestThatContainsIt() + { + when(request.getCookies()).thenReturn(new Cookie[] { new Cookie(COOKIE_NAME, COOKIE_VALUE) }); + + String cookie = cookiesService.getCookie(COOKIE_NAME, request); + + assertNotNull("The cookie should not be null", cookie); + assertEquals("The cookie's value should match", COOKIE_VALUE, cookie); + verify(request).getCookies(); + } + + @Test + public void cookieShouldNotBeFoundInRequestThatDoesNotContainIt() + { + when(request.getCookies()).thenReturn(new Cookie[] { new Cookie(COOKIE_NAME, COOKIE_VALUE) }); + + assertNull("The cookie should be null", cookiesService.getCookie("non-contained-cookie", request)); + + verify(request).getCookies(); + } + + @Test + public void cookieShouldNotBeFoundInRequestWithoutCookies() + { + when(request.getCookies()).thenReturn(null); + + assertNull("The cookie should be null", cookiesService.getCookie(COOKIE_NAME, request)); + + verify(request).getCookies(); + } + + @Test + public void cookieShouldBeAddedToTheResponseWithDefaultParams() + { + when(sysAdminParams.getAlfrescoProtocol()).thenReturn("http"); + + cookiesService.addCookie(COOKIE_NAME, COOKIE_VALUE, response); + + verify(sysAdminParams).getAlfrescoProtocol(); + verify(response).addCookie(cookieCaptor.capture()); + + Cookie cookie = cookieCaptor.getValue(); + assertNotNull("The cookie should not be null", cookie); + assertEquals("Cookie's name should match", COOKIE_NAME, cookie.getName()); + assertEquals("Cookie's value should match", COOKIE_VALUE, cookie.getValue()); + assertEquals("Cookie's path should be the root", "/", cookie.getPath()); + assertEquals("Cookie's maxAge should match the default lifetime", DEFAULT_COOKIE_LIFETIME, cookie.getMaxAge()); + assertFalse("Cookie's secure flag should be false", cookie.getSecure()); + } + + @Test + public void secureCookieShouldBeAddedToTheResponseWhenAlfrescoProtocolIsHttps() + { + when(sysAdminParams.getAlfrescoProtocol()).thenReturn("https"); + + cookiesService.addCookie(COOKIE_NAME, COOKIE_VALUE, response); + + verify(sysAdminParams).getAlfrescoProtocol(); + verify(response).addCookie(cookieCaptor.capture()); + + Cookie cookie = cookieCaptor.getValue(); + assertNotNull("The cookie should not be null", cookie); + assertTrue("Cookie's secure flag should be true", cookie.getSecure()); + } + + @Test + public void cookieWithCustomMaxAgeShouldBeAddedToTheResponse() + { + int customMaxAge = 60; + cookiesService = new AdminConsoleAuthenticationCookiesService(sysAdminParams, customMaxAge); + when(sysAdminParams.getAlfrescoProtocol()).thenReturn("https"); + + cookiesService.addCookie(COOKIE_NAME, COOKIE_VALUE, response); + + verify(sysAdminParams).getAlfrescoProtocol(); + verify(response).addCookie(cookieCaptor.capture()); + + Cookie cookie = cookieCaptor.getValue(); + assertNotNull("The cookie should not be null", cookie); + assertEquals("Cookie's maxAge should match the custom lifetime", customMaxAge, cookie.getMaxAge()); + } + + @Test + public void cookieShouldBeReset() + { + when(sysAdminParams.getAlfrescoProtocol()).thenReturn("http"); + + cookiesService.resetCookie(COOKIE_NAME, response); + + verify(sysAdminParams).getAlfrescoProtocol(); + verify(response).addCookie(cookieCaptor.capture()); + + Cookie cookie = cookieCaptor.getValue(); + assertNotNull("The cookie should not be null", cookie); + assertEquals("Cookie's name should match", COOKIE_NAME, cookie.getName()); + assertEquals("Cookie's value should be reset", "", cookie.getValue()); + assertEquals("Cookie's path should be the root", "/", cookie.getPath()); + assertEquals("Cookie's maxAge should be 0", 0, cookie.getMaxAge()); + assertFalse("Cookie's secure flag should be false", cookie.getSecure()); + } + + @Test + public void secureCookieShouldBeReset() + { + when(sysAdminParams.getAlfrescoProtocol()).thenReturn("https"); + + cookiesService.resetCookie(COOKIE_NAME, response); + + verify(sysAdminParams).getAlfrescoProtocol(); + verify(response).addCookie(cookieCaptor.capture()); + + Cookie cookie = cookieCaptor.getValue(); + assertNotNull("The cookie should not be null", cookie); + assertTrue("Cookie's secure flag should be true", cookie.getSecure()); + } +} diff --git a/repository/src/test/java/org/alfresco/repo/security/authentication/identityservice/admin/AdminConsoleHttpServletRequestWrapperUnitTest.java b/repository/src/test/java/org/alfresco/repo/security/authentication/identityservice/admin/AdminConsoleHttpServletRequestWrapperUnitTest.java new file mode 100644 index 0000000000..9b470d50f5 --- /dev/null +++ b/repository/src/test/java/org/alfresco/repo/security/authentication/identityservice/admin/AdminConsoleHttpServletRequestWrapperUnitTest.java @@ -0,0 +1,213 @@ +/* + * #%L + * Alfresco Repository + * %% + * Copyright (C) 2005 - 2023 Alfresco Software Limited + * %% + * This file is part of the Alfresco software. + * If the software was purchased under a paid Alfresco license, the terms of + * the paid license agreement will prevail. Otherwise, the software is + * provided under the following open source license terms: + * + * Alfresco is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Alfresco is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Alfresco. If not, see . + * #L% + */ +package org.alfresco.repo.security.authentication.identityservice.admin; + +import static java.util.Collections.enumeration; +import static java.util.Collections.list; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.mockito.MockitoAnnotations.initMocks; + +import java.util.Enumeration; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import jakarta.servlet.http.HttpServletRequest; +import org.alfresco.error.AlfrescoRuntimeException; +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mock; + +@SuppressWarnings("PMD.UseDiamondOperator") +public class AdminConsoleHttpServletRequestWrapperUnitTest +{ + + private static final String DEFAULT_HEADER = "default_header"; + private static final String DEFAULT_HEADER_VALUE = "default_value"; + private static final String ADDITIONAL_HEADER = "additional_header"; + private static final String ADDITIONAL_HEADER_VALUE = "additional_value"; + private static final Map DEFAULT_HEADERS = new HashMap() + {{ + put(DEFAULT_HEADER, DEFAULT_HEADER_VALUE); + }}; + private static final Map ADDITIONAL_HEADERS = new HashMap() + {{ + put(ADDITIONAL_HEADER, ADDITIONAL_HEADER_VALUE); + }}; + + @Mock + private HttpServletRequest request; + private AdminConsoleHttpServletRequestWrapper requestWrapper; + + @Before + public void setUp() + { + initMocks(this); + requestWrapper = new AdminConsoleHttpServletRequestWrapper(ADDITIONAL_HEADERS, request); + } + + @Test(expected = AlfrescoRuntimeException.class) + public void wrapperShouldNotBeInstancedWithoutAdditionalHeaders() + { + new AdminConsoleHttpServletRequestWrapper(null, request); + } + + @Test(expected = IllegalArgumentException.class) + public void wrapperShouldNotBeInstancedWithoutRequestsToWrap() + { + new AdminConsoleHttpServletRequestWrapper(new HashMap<>(), null); + } + + @Test + public void wrapperShouldReturnAdditionalHeaderNamesOnTopOfDefaultOnes() + { + when(request.getHeaderNames()).thenReturn(enumeration(DEFAULT_HEADERS.keySet())); + + Enumeration headerNames = requestWrapper.getHeaderNames(); + assertNotNull("headerNames should not be null", headerNames); + assertTrue("headerNames should not be empty", headerNames.hasMoreElements()); + + List headers = list(headerNames); + assertEquals("There should be 2 headers", 2, headers.size()); + assertTrue("The default header should be included", headers.contains(DEFAULT_HEADER)); + assertTrue("The additional header should be included", headers.contains(ADDITIONAL_HEADER)); + + verify(request).getHeaderNames(); + } + + @Test + public void wrapperShouldReturnDefaultHeaderNamesIfNoAdditionalHeaders() + { + when(request.getHeaderNames()).thenReturn(enumeration(DEFAULT_HEADERS.keySet())); + + requestWrapper = new AdminConsoleHttpServletRequestWrapper(new HashMap<>(), request); + Enumeration headerNames = requestWrapper.getHeaderNames(); + assertNotNull("headerNames should not be null", headerNames); + assertTrue("headerNames should not be empty", headerNames.hasMoreElements()); + assertEquals("The returned header should be the default header", DEFAULT_HEADER, headerNames.nextElement()); + assertFalse("There should be no additional headers", headerNames.hasMoreElements()); + + verify(request).getHeaderNames(); + } + + @Test + public void wrapperShouldReturnAdditionalHeaderNamesIfNoDefaultHeaders() + { + when(request.getHeaderNames()).thenReturn(null); + + Enumeration headerNames = requestWrapper.getHeaderNames(); + assertNotNull("headerNames should not be null", headerNames); + assertTrue("headerNames should not be empty", headerNames.hasMoreElements()); + assertEquals("The returned header should be the additional header", ADDITIONAL_HEADER, + headerNames.nextElement()); + assertFalse("There should be no more headers", headerNames.hasMoreElements()); + + verify(request).getHeaderNames(); + } + + @Test + public void wrapperShouldReturnDefaultHeaderValues() + { + when(request.getHeader(DEFAULT_HEADER)).thenReturn(DEFAULT_HEADER_VALUE); + + String header = requestWrapper.getHeader(DEFAULT_HEADER); + assertEquals("The header should be the default one", DEFAULT_HEADER_VALUE, header); + + verify(request).getHeader(DEFAULT_HEADER); + } + + @Test + public void wrapperShouldReturnAdditionalHeaderValues() + { + String header = requestWrapper.getHeader(ADDITIONAL_HEADER); + assertEquals("The header should be the additional one", ADDITIONAL_HEADER_VALUE, header); + } + + @Test + public void wrapperShouldPreferAdditionalHeaderValuesToDefaultOnes() + { + when(request.getHeader(DEFAULT_HEADER)).thenReturn(DEFAULT_HEADER_VALUE); + + String overrideHeaderValue = "override"; + Map overrideHeaders = new HashMap<>(); + overrideHeaders.put(DEFAULT_HEADER, overrideHeaderValue); + + requestWrapper = new AdminConsoleHttpServletRequestWrapper(overrideHeaders, request); + String header = requestWrapper.getHeader(DEFAULT_HEADER); + assertEquals("The header should have the overridden value", overrideHeaderValue, header); + + verify(request).getHeader(DEFAULT_HEADER); + } + + @Test + public void wrapperShouldReturnDefaultHeaderEnumeration() + { + when(request.getHeader(DEFAULT_HEADER)).thenReturn(DEFAULT_HEADER_VALUE); + + Enumeration headers = requestWrapper.getHeaders(DEFAULT_HEADER); + assertNotNull("The headers enumeration should not be null", headers); + assertTrue("The headers enumeration should not be empty", headers.hasMoreElements()); + assertEquals("The header should be the default one", DEFAULT_HEADER_VALUE, headers.nextElement()); + assertFalse("There should be no more headers", headers.hasMoreElements()); + + verify(request).getHeader(DEFAULT_HEADER); + } + + @Test + public void wrapperShouldReturnAdditionalHeaderEnumeration() + { + Enumeration headers = requestWrapper.getHeaders(ADDITIONAL_HEADER); + assertNotNull("The headers enumeration should not be null", headers); + assertTrue("The headers enumeration should not be empty", headers.hasMoreElements()); + assertEquals("The header should be the additional one", ADDITIONAL_HEADER_VALUE, headers.nextElement()); + assertFalse("There should be no more headers", headers.hasMoreElements()); + } + + @Test + public void wrapperShouldPreferAdditionalHeaderEnumerationValuesToDefaultOnes() + { + when(request.getHeader(DEFAULT_HEADER)).thenReturn(DEFAULT_HEADER_VALUE); + + String overrideHeaderValue = "override"; + Map overrideHeaders = new HashMap<>(); + overrideHeaders.put(DEFAULT_HEADER, overrideHeaderValue); + + requestWrapper = new AdminConsoleHttpServletRequestWrapper(overrideHeaders, request); + Enumeration headers = requestWrapper.getHeaders(DEFAULT_HEADER); + assertNotNull("The headers enumeration should not be null", headers); + assertTrue("The headers enumeration should not be empty", headers.hasMoreElements()); + assertEquals("The header should be the overridden one", overrideHeaderValue, headers.nextElement()); + assertFalse("There should be no more headers", headers.hasMoreElements()); + + verify(request).getHeader(DEFAULT_HEADER); + } +} diff --git a/repository/src/test/java/org/alfresco/repo/security/authentication/identityservice/admin/IdentityServiceAdminConsoleAuthenticatorUnitTest.java b/repository/src/test/java/org/alfresco/repo/security/authentication/identityservice/admin/IdentityServiceAdminConsoleAuthenticatorUnitTest.java new file mode 100644 index 0000000000..155b6be779 --- /dev/null +++ b/repository/src/test/java/org/alfresco/repo/security/authentication/identityservice/admin/IdentityServiceAdminConsoleAuthenticatorUnitTest.java @@ -0,0 +1,200 @@ +/* + * #%L + * Alfresco Repository + * %% + * Copyright (C) 2005 - 2023 Alfresco Software Limited + * %% + * This file is part of the Alfresco software. + * If the software was purchased under a paid Alfresco license, the terms of + * the paid license agreement will prevail. Otherwise, the software is + * provided under the following open source license terms: + * + * Alfresco is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Alfresco is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Alfresco. If not, see . + * #L% + */ +package org.alfresco.repo.security.authentication.identityservice.admin; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.mockito.MockitoAnnotations.initMocks; + +import java.io.IOException; +import java.time.Instant; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.alfresco.repo.security.authentication.external.RemoteUserMapper; +import org.alfresco.repo.security.authentication.identityservice.IdentityServiceFacade; +import org.alfresco.repo.security.authentication.identityservice.IdentityServiceFacade.AccessToken; +import org.alfresco.repo.security.authentication.identityservice.IdentityServiceFacade.AccessTokenAuthorization; +import org.alfresco.repo.security.authentication.identityservice.IdentityServiceFacade.AuthorizationException; +import org.alfresco.repo.security.authentication.identityservice.IdentityServiceFacade.AuthorizationGrant; +import org.junit.Before; +import org.junit.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.client.registration.ClientRegistration.ProviderDetails; + +@SuppressWarnings("PMD.AvoidStringBufferField") +public class IdentityServiceAdminConsoleAuthenticatorUnitTest +{ + + private static final String ALFRESCO_ACCESS_TOKEN = "ALFRESCO_ACCESS_TOKEN"; + private static final String ALFRESCO_REFRESH_TOKEN = "ALFRESCO_REFRESH_TOKEN"; + private static final String ALFRESCO_TOKEN_EXPIRATION = "ALFRESCO_TOKEN_EXPIRATION"; + + @Mock + HttpServletRequest request; + @Mock + HttpServletResponse response; + @Mock + IdentityServiceFacade identityServiceFacade; + @Mock + AdminConsoleAuthenticationCookiesService cookiesService; + @Mock + RemoteUserMapper remoteUserMapper; + @Mock + AccessTokenAuthorization accessTokenAuthorization; + @Mock + AccessToken accessToken; + @Captor + ArgumentCaptor requestCaptor; + + IdentityServiceAdminConsoleAuthenticator authenticator; + + StringBuffer adminConsoleURL = new StringBuffer("http://localhost:8080/admin-console"); + + @Before + public void setup() + { + initMocks(this); + ClientRegistration clientRegistration = mock(ClientRegistration.class); + ProviderDetails providerDetails = mock(ProviderDetails.class); + when(clientRegistration.getProviderDetails()).thenReturn(providerDetails); + when(clientRegistration.getClientId()).thenReturn("alfresco"); + when(providerDetails.getAuthorizationUri()).thenReturn("http://localhost:8999/auth"); + when(identityServiceFacade.getClientRegistration()).thenReturn(clientRegistration); + when(request.getRequestURL()).thenReturn(adminConsoleURL); + when(remoteUserMapper.getRemoteUser(request)).thenReturn(null); + + authenticator = new IdentityServiceAdminConsoleAuthenticator(); + authenticator.setActive(true); + authenticator.setIdentityServiceFacade(identityServiceFacade); + authenticator.setCookiesService(cookiesService); + authenticator.setRemoteUserMapper(remoteUserMapper); + } + + @Test + public void shouldCallRemoteMapperIfTokenIsInCookies() + { + when(cookiesService.getCookie(ALFRESCO_ACCESS_TOKEN, request)).thenReturn("JWT_TOKEN"); + when(cookiesService.getCookie(ALFRESCO_TOKEN_EXPIRATION, request)).thenReturn( + String.valueOf(Instant.now().plusSeconds(60).toEpochMilli())); + when(remoteUserMapper.getRemoteUser(requestCaptor.capture())).thenReturn("admin"); + + String username = authenticator.getAdminConsoleUser(request, response); + + assertEquals("Bearer JWT_TOKEN", requestCaptor.getValue().getHeader("Authorization")); + assertEquals("admin", username); + assertTrue(authenticator.isActive()); + } + + @Test + public void shouldRefreshExpiredTokenAndCallRemoteMapper() + { + when(cookiesService.getCookie(ALFRESCO_ACCESS_TOKEN, request)).thenReturn("EXPIRED_JWT_TOKEN"); + when(cookiesService.getCookie(ALFRESCO_REFRESH_TOKEN, request)).thenReturn("REFRESH_TOKEN"); + when(cookiesService.getCookie(ALFRESCO_TOKEN_EXPIRATION, request)).thenReturn( + String.valueOf(Instant.now().minusSeconds(60).toEpochMilli())); + when(accessToken.getTokenValue()).thenReturn("REFRESHED_JWT_TOKEN"); + when(accessToken.getExpiresAt()).thenReturn(Instant.now().plusSeconds(60)); + when(accessTokenAuthorization.getAccessToken()).thenReturn(accessToken); + when(accessTokenAuthorization.getRefreshTokenValue()).thenReturn("REFRESH_TOKEN"); + when(identityServiceFacade.authorize(any(AuthorizationGrant.class))).thenReturn(accessTokenAuthorization); + when(remoteUserMapper.getRemoteUser(requestCaptor.capture())).thenReturn("admin"); + + String username = authenticator.getAdminConsoleUser(request, response); + + verify(cookiesService).addCookie(ALFRESCO_ACCESS_TOKEN, "REFRESHED_JWT_TOKEN", response); + verify(cookiesService).addCookie(ALFRESCO_REFRESH_TOKEN, "REFRESH_TOKEN", response); + assertEquals("Bearer REFRESHED_JWT_TOKEN", requestCaptor.getValue().getHeader("Authorization")); + assertEquals("admin", username); + } + + @Test + public void shouldCallAuthChallenge() throws IOException + { + String authenticationRequest = "http://localhost:8999/auth?client_id=alfresco&redirect_uri=" + adminConsoleURL + + "&response_type=code&scope=openid"; + authenticator.requestAuthentication(request, response); + + verify(response).sendRedirect(authenticationRequest); + } + + @Test + public void shouldResetCookiesAndCallAuthChallenge() throws IOException + { + when(cookiesService.getCookie(ALFRESCO_ACCESS_TOKEN, request)).thenReturn("EXPIRED_JWT_TOKEN"); + when(cookiesService.getCookie(ALFRESCO_REFRESH_TOKEN, request)).thenReturn("REFRESH_TOKEN"); + when(cookiesService.getCookie(ALFRESCO_TOKEN_EXPIRATION, request)).thenReturn( + String.valueOf(Instant.now().minusSeconds(60).toEpochMilli())); + + when(identityServiceFacade.authorize(any(AuthorizationGrant.class))).thenThrow(AuthorizationException.class); + + String username = authenticator.getAdminConsoleUser(request, response); + + verify(cookiesService).resetCookie(ALFRESCO_ACCESS_TOKEN, response); + verify(cookiesService).resetCookie(ALFRESCO_REFRESH_TOKEN, response); + verify(cookiesService).resetCookie(ALFRESCO_TOKEN_EXPIRATION, response); + assertNull(username); + } + + @Test + public void shouldAuthorizeCodeAndSetCookies() + { + when(request.getParameter("code")).thenReturn("auth_code"); + when(accessToken.getTokenValue()).thenReturn("JWT_TOKEN"); + when(accessToken.getExpiresAt()).thenReturn(Instant.now().plusSeconds(60)); + when(accessTokenAuthorization.getAccessToken()).thenReturn(accessToken); + when(accessTokenAuthorization.getRefreshTokenValue()).thenReturn("REFRESH_TOKEN"); + when(identityServiceFacade.authorize( + AuthorizationGrant.authorizationCode("auth_code", adminConsoleURL.toString()))) + .thenReturn(accessTokenAuthorization); + when(remoteUserMapper.getRemoteUser(requestCaptor.capture())).thenReturn("admin"); + + String username = authenticator.getAdminConsoleUser(request, response); + + verify(cookiesService).addCookie(ALFRESCO_ACCESS_TOKEN, "JWT_TOKEN", response); + verify(cookiesService).addCookie(ALFRESCO_REFRESH_TOKEN, "REFRESH_TOKEN", response); + assertEquals("Bearer JWT_TOKEN", requestCaptor.getValue().getHeader("Authorization")); + assertEquals("admin", username); + } + + @Test + public void shouldExtractUsernameFromAuthorizationHeader() + { + when(remoteUserMapper.getRemoteUser(request)).thenReturn("admin"); + + String username = authenticator.getAdminConsoleUser(request, response); + + assertEquals("admin", username); + } +}