From 381b13abab532fe1f8a399a53863aa36e843a8c2 Mon Sep 17 00:00:00 2001 From: pmm Date: Tue, 22 Apr 2025 11:01:58 +0530 Subject: [PATCH] [MNT-24859] Authentication flow for Web script home --- .../RemoteUserAuthenticatorFactory.java | 146 +++++++- .../org/alfresco/calendar/feed.get.desc.xml | 1 + .../extensions/webscripts/index.post.desc.xml | 34 ++ .../webscripts/indexall.get.desc.xml | 18 + .../webscripts/indexfailures.get.desc.xml | 32 ++ .../webscripts/indexfamily.get.desc.xml | 32 ++ .../webscripts/indexlifecycle.get.desc.xml | 58 +++ .../webscripts/indexpackage.get.desc.xml | 32 ++ .../webscripts/indexuri.get.desc.xml | 32 ++ .../webscripts/jsdebugger.get.desc.xml | 32 ++ .../webscripts/jsdebugger.post.desc.xml | 32 ++ .../webscripts/scriptdescription.get.desc.xml | 32 ++ .../webscripts/scriptdump.get.desc.xml | 32 ++ .../web-scripts-application-context.xml | 22 +- .../DefaultWebScriptHomeAuthenticator.java | 55 +++ .../external/WebScriptHomeAuthenticator.java | 57 +++ .../IdentityServiceConfig.java | 13 + ...ntityServiceAdminConsoleAuthenticator.java | 4 - ...tityServiceWebScriptHomeAuthenticator.java | 333 ++++++++++++++++++ ...criptHomeAuthenticationCookiesService.java | 120 +++++++ ...ebScriptHomeHttpServletRequestWrapper.java | 85 +++++ .../authentication-services-context.xml | 16 + .../resources/alfresco/repository.properties | 1 + .../external-authentication-context.xml | 3 + ...dentity-service-authentication-context.xml | 26 ++ ...identity-service-authentication.properties | 1 + ...viceAdminConsoleAuthenticatorUnitTest.java | 50 --- ...iceWebScriptHomeAuthenticatorUnitTest.java | 250 +++++++++++++ 28 files changed, 1491 insertions(+), 58 deletions(-) create mode 100644 remote-api/src/main/resources/alfresco/templates/webscripts/org/springframework/extensions/webscripts/index.post.desc.xml create mode 100644 remote-api/src/main/resources/alfresco/templates/webscripts/org/springframework/extensions/webscripts/indexall.get.desc.xml create mode 100644 remote-api/src/main/resources/alfresco/templates/webscripts/org/springframework/extensions/webscripts/indexfailures.get.desc.xml create mode 100644 remote-api/src/main/resources/alfresco/templates/webscripts/org/springframework/extensions/webscripts/indexfamily.get.desc.xml create mode 100644 remote-api/src/main/resources/alfresco/templates/webscripts/org/springframework/extensions/webscripts/indexlifecycle.get.desc.xml create mode 100644 remote-api/src/main/resources/alfresco/templates/webscripts/org/springframework/extensions/webscripts/indexpackage.get.desc.xml create mode 100644 remote-api/src/main/resources/alfresco/templates/webscripts/org/springframework/extensions/webscripts/indexuri.get.desc.xml create mode 100644 remote-api/src/main/resources/alfresco/templates/webscripts/org/springframework/extensions/webscripts/jsdebugger.get.desc.xml create mode 100644 remote-api/src/main/resources/alfresco/templates/webscripts/org/springframework/extensions/webscripts/jsdebugger.post.desc.xml create mode 100644 remote-api/src/main/resources/alfresco/templates/webscripts/org/springframework/extensions/webscripts/scriptdescription.get.desc.xml create mode 100644 remote-api/src/main/resources/alfresco/templates/webscripts/org/springframework/extensions/webscripts/scriptdump.get.desc.xml create mode 100644 repository/src/main/java/org/alfresco/repo/security/authentication/external/DefaultWebScriptHomeAuthenticator.java create mode 100644 repository/src/main/java/org/alfresco/repo/security/authentication/external/WebScriptHomeAuthenticator.java create mode 100644 repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/webscript/IdentityServiceWebScriptHomeAuthenticator.java create mode 100644 repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/webscript/WebScriptHomeAuthenticationCookiesService.java create mode 100644 repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/webscript/WebScriptHomeHttpServletRequestWrapper.java create mode 100644 repository/src/test/java/org/alfresco/repo/security/authentication/identityservice/webscript/IdentityServiceWebScriptHomeAuthenticatorUnitTest.java 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 8efe42042e..012da25ef9 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 @@ -32,6 +32,7 @@ import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpSession; import net.sf.acegisecurity.DisabledException; +import org.alfresco.repo.security.authentication.external.WebScriptHomeAuthenticator; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; import org.springframework.extensions.webscripts.Authenticator; @@ -72,9 +73,12 @@ public class RemoteUserAuthenticatorFactory extends BasicHttpAuthenticatorFactor protected RemoteUserMapper remoteUserMapper; protected AuthenticationComponent authenticationComponent; protected AdminConsoleAuthenticator adminConsoleAuthenticator; + protected WebScriptHomeAuthenticator webScriptHomeAuthenticator; private boolean alwaysAllowBasicAuthForAdminConsole = true; + private boolean alwaysAllowBasicAuthForWebScriptHome = true; List adminConsoleScriptFamilies; + List webScriptHomeFamilies; long getRemoteUserTimeoutMilliseconds = GET_REMOTE_USER_TIMEOUT_MILLISECONDS_DEFAULT; public void setRemoteUserMapper(RemoteUserMapper remoteUserMapper) @@ -97,6 +101,16 @@ public class RemoteUserAuthenticatorFactory extends BasicHttpAuthenticatorFactor this.alwaysAllowBasicAuthForAdminConsole = alwaysAllowBasicAuthForAdminConsole; } + public boolean isAlwaysAllowBasicAuthForWebScriptHome() + { + return alwaysAllowBasicAuthForWebScriptHome; + } + + public void setAlwaysAllowBasicAuthForWebScriptHome(boolean alwaysAllowBasicAuthForWebScriptHome) + { + this.alwaysAllowBasicAuthForWebScriptHome = alwaysAllowBasicAuthForWebScriptHome; + } + public List getAdminConsoleScriptFamilies() { return adminConsoleScriptFamilies; @@ -107,6 +121,16 @@ public class RemoteUserAuthenticatorFactory extends BasicHttpAuthenticatorFactor this.adminConsoleScriptFamilies = adminConsoleScriptFamilies; } + public List getWebScriptHomeFamilies() + { + return webScriptHomeFamilies; + } + + public void setWebScriptHomeFamilies(List webScriptHomeFamilies) + { + this.webScriptHomeFamilies = webScriptHomeFamilies; + } + public long getGetRemoteUserTimeoutMilliseconds() { return getRemoteUserTimeoutMilliseconds; @@ -123,6 +147,12 @@ public class RemoteUserAuthenticatorFactory extends BasicHttpAuthenticatorFactor this.adminConsoleAuthenticator = adminConsoleAuthenticator; } + public void setWebScriptHomeAuthenticator( + WebScriptHomeAuthenticator webScriptHomeAuthenticator) + { + this.webScriptHomeAuthenticator = webScriptHomeAuthenticator; + } + @Override public Authenticator create(WebScriptServletRequest req, WebScriptServletResponse res) { @@ -160,6 +190,12 @@ public class RemoteUserAuthenticatorFactory extends BasicHttpAuthenticatorFactor { userId = getAdminConsoleUser(); } + else if (servletReq.getServiceMatch() != null && + isWebScriptHome(servletReq.getServiceMatch().getWebScript()) + && isWebScriptAuthenticatorActive()) + { + userId = getWebScriptHomeUser(); + } if (userId == null) { @@ -181,6 +217,25 @@ public class RemoteUserAuthenticatorFactory extends BasicHttpAuthenticatorFactor return false; } } + if (isAlwaysAllowBasicAuthForWebScriptHome()) + { + final boolean useTimeoutForAdminAccessingWebScript = shouldUseTimeoutForAdminAccessingWebScriptHome(required, isGuest); + + if (useTimeoutForAdminAccessingWebScript && isBasicAuthHeaderPresentForAdmin()) + { + return callBasicAuthForWebScriptHomeAccess(required, isGuest); + } + + try + { + userId = getRemoteUserWithTimeout(useTimeoutForAdminAccessingWebScript); + } + catch (AuthenticationTimeoutException e) + { + // return basic auth challenge + return false; + } + } else { // retrieve the remote user if configured and available - authenticate that user directly @@ -252,10 +307,25 @@ public class RemoteUserAuthenticatorFactory extends BasicHttpAuthenticatorFactor authenticated = super.authenticate(required, isGuest); } } - if (!authenticated && servletReq.getServiceMatch() != null && - isAdminConsoleWebScript(servletReq.getServiceMatch().getWebScript()) && isAdminConsoleAuthenticatorActive()) + if (!authenticated && servletReq.getServiceMatch() != null) { - adminConsoleAuthenticator.requestAuthentication(this.servletReq.getHttpServletRequest(), this.servletRes.getHttpServletResponse()); + WebScript webScript = servletReq.getServiceMatch().getWebScript(); + + if (isAdminConsoleWebScript(webScript) && isAdminConsoleAuthenticatorActive()) + { + adminConsoleAuthenticator.requestAuthentication( + this.servletReq.getHttpServletRequest(), + this.servletRes.getHttpServletResponse() + ); + } + else if (isWebScriptHome(webScript) + && isWebScriptAuthenticatorActive()) + { + webScriptHomeAuthenticator.requestAuthentication( + this.servletReq.getHttpServletRequest(), + this.servletRes.getHttpServletResponse() + ); + } } return authenticated; } @@ -274,6 +344,16 @@ public class RemoteUserAuthenticatorFactory extends BasicHttpAuthenticatorFactor return super.authenticate(required, isGuest); } + private boolean callBasicAuthForWebScriptHomeAccess(RequiredAuthentication required, boolean isGuest) + { + // return REST call, after a timeout/basic auth challenge + if (LOGGER.isTraceEnabled()) + { + LOGGER.trace("An Web script request has come in with Basic Auth headers present for an admin user."); + } + return super.authenticate(required, isGuest); + } + private boolean shouldUseTimeoutForAdminAccessingAdminConsole(RequiredAuthentication required, boolean isGuest) { boolean useTimeoutForAdminAccessingAdminConsole = RequiredAuthentication.admin.equals(required) && !isGuest && @@ -286,6 +366,18 @@ public class RemoteUserAuthenticatorFactory extends BasicHttpAuthenticatorFactor return useTimeoutForAdminAccessingAdminConsole; } + private boolean shouldUseTimeoutForAdminAccessingWebScriptHome(RequiredAuthentication required, boolean isGuest) + { + boolean useTimeoutForAdminAccessingAdminConsole = RequiredAuthentication.admin.equals(required) && !isGuest && + servletReq.getServiceMatch() != null && isWebScriptHome(servletReq.getServiceMatch().getWebScript()); + + if (LOGGER.isTraceEnabled()) + { + LOGGER.trace("Should ensure that the admins can login with basic auth: " + useTimeoutForAdminAccessingAdminConsole); + } + return useTimeoutForAdminAccessingAdminConsole; + } + private boolean isRemoteUserMapperActive() { return remoteUserMapper != null && (!(remoteUserMapper instanceof ActivateableBean) || ((ActivateableBean) remoteUserMapper).isActive()); @@ -296,6 +388,11 @@ public class RemoteUserAuthenticatorFactory extends BasicHttpAuthenticatorFactor return adminConsoleAuthenticator != null && (!(adminConsoleAuthenticator instanceof ActivateableBean) || ((ActivateableBean) adminConsoleAuthenticator).isActive()); } + private boolean isWebScriptAuthenticatorActive() + { + return webScriptHomeAuthenticator != null && (!(webScriptHomeAuthenticator instanceof ActivateableBean) || ((ActivateableBean) webScriptHomeAuthenticator).isActive()); + } + protected boolean isAdminConsoleWebScript(WebScript webScript) { if (webScript == null || adminConsoleScriptFamilies == null || webScript.getDescription() == null @@ -322,6 +419,34 @@ public class RemoteUserAuthenticatorFactory extends BasicHttpAuthenticatorFactor return isAdminConsole; } + protected boolean isWebScriptHome(WebScript webScript) + { + if (webScript == null || webScriptHomeFamilies == null || webScript.getDescription() == null + || webScript.getDescription().getFamilys() == null) + { + return false; + } + + if (LOGGER.isTraceEnabled()) + { + LOGGER.trace("WebScript: " + webScript + " has these families: " + webScript.getDescription().getFamilys()); + } + + // intersect the "family" sets defined + Set families = new HashSet<>(webScript.getDescription() + .getFamilys()); + families.retainAll(webScriptHomeFamilies); + final boolean isWebScriptHome = !families.isEmpty(); + + if (LOGGER.isTraceEnabled() && isWebScriptHome) + { + LOGGER.trace("Detected a WebScript Home webscript: " + webScript); + } + + return isWebScriptHome; + } + + protected String getRemoteUserWithTimeout(boolean useTimeout) throws AuthenticationTimeoutException { if (!useTimeout) @@ -425,6 +550,21 @@ public class RemoteUserAuthenticatorFactory extends BasicHttpAuthenticatorFactor return userId; } + protected String getWebScriptHomeUser() + { + String userId = null; + + if (isRemoteUserMapperActive()) + { + userId = webScriptHomeAuthenticator.getWebScriptHomeUser(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/templates/webscripts/org/alfresco/calendar/feed.get.desc.xml b/remote-api/src/main/resources/alfresco/templates/webscripts/org/alfresco/calendar/feed.get.desc.xml index 5fdf6664b1..27cbdb8218 100644 --- a/remote-api/src/main/resources/alfresco/templates/webscripts/org/alfresco/calendar/feed.get.desc.xml +++ b/remote-api/src/main/resources/alfresco/templates/webscripts/org/alfresco/calendar/feed.get.desc.xml @@ -5,4 +5,5 @@ guest required internal + Index \ No newline at end of file diff --git a/remote-api/src/main/resources/alfresco/templates/webscripts/org/springframework/extensions/webscripts/index.post.desc.xml b/remote-api/src/main/resources/alfresco/templates/webscripts/org/springframework/extensions/webscripts/index.post.desc.xml new file mode 100644 index 0000000000..13cbbb85c8 --- /dev/null +++ b/remote-api/src/main/resources/alfresco/templates/webscripts/org/springframework/extensions/webscripts/index.post.desc.xml @@ -0,0 +1,34 @@ + + + + Web Script Maintenance + + Maintain index of Web Scripts + + /index?reset={reset?} + + /?reset={reset?} + + any + + internal + + Index + + admin + + required + + + + true + + false + + true + + + + + + diff --git a/remote-api/src/main/resources/alfresco/templates/webscripts/org/springframework/extensions/webscripts/indexall.get.desc.xml b/remote-api/src/main/resources/alfresco/templates/webscripts/org/springframework/extensions/webscripts/indexall.get.desc.xml new file mode 100644 index 0000000000..91b5e7e797 --- /dev/null +++ b/remote-api/src/main/resources/alfresco/templates/webscripts/org/springframework/extensions/webscripts/indexall.get.desc.xml @@ -0,0 +1,18 @@ + + + All Web Scripts Index + Retrieve an index of all Web Scripts + /index/all?package={package?}&url={url?}&family={family?} + /index/all.mediawiki?package={package?}&url={url?}&family={family?} + any + internal + admin + Index + required + + true + false + true + + + \ No newline at end of file diff --git a/remote-api/src/main/resources/alfresco/templates/webscripts/org/springframework/extensions/webscripts/indexfailures.get.desc.xml b/remote-api/src/main/resources/alfresco/templates/webscripts/org/springframework/extensions/webscripts/indexfailures.get.desc.xml new file mode 100644 index 0000000000..643f7b990e --- /dev/null +++ b/remote-api/src/main/resources/alfresco/templates/webscripts/org/springframework/extensions/webscripts/indexfailures.get.desc.xml @@ -0,0 +1,32 @@ + + + + Failed Web Scripts Index + + Retrieve an index of all failed Web Scripts + + /index/failures + + any + + internal + + Index + + admin + + required + + + + true + + false + + true + + + + + + diff --git a/remote-api/src/main/resources/alfresco/templates/webscripts/org/springframework/extensions/webscripts/indexfamily.get.desc.xml b/remote-api/src/main/resources/alfresco/templates/webscripts/org/springframework/extensions/webscripts/indexfamily.get.desc.xml new file mode 100644 index 0000000000..168930aabd --- /dev/null +++ b/remote-api/src/main/resources/alfresco/templates/webscripts/org/springframework/extensions/webscripts/indexfamily.get.desc.xml @@ -0,0 +1,32 @@ + + + + Web Script Family Index + + Provide an index of Web Scripts for the specified family + + /index/family/{family} + + any + + Index + + internal + + admin + + required + + + + true + + false + + true + + + + + + diff --git a/remote-api/src/main/resources/alfresco/templates/webscripts/org/springframework/extensions/webscripts/indexlifecycle.get.desc.xml b/remote-api/src/main/resources/alfresco/templates/webscripts/org/springframework/extensions/webscripts/indexlifecycle.get.desc.xml new file mode 100644 index 0000000000..fe66354c75 --- /dev/null +++ b/remote-api/src/main/resources/alfresco/templates/webscripts/org/springframework/extensions/webscripts/indexlifecycle.get.desc.xml @@ -0,0 +1,58 @@ + + + + Web Script Lifecycle Index + + + Provide an index of Web Scripts in the various lifecycle states: + +
    + + +
  • none : This web script is not part of a lifecycle
  • + +
  • sample : This web script is a sample and is not intended for production use
  • + +
  • draft : This method may be incomplete, experimental or still subject to change
  • + +
  • public_api : This method is part of the Alfresco public api and should be stable and well tested
  • + +
  • draft_public_api : This method is intended to eventually become part of the public api but is + incomplete or still subject to change
  • + +
  • deprecated : This method should be avoided. It may be removed in future versions of Alfresco
  • + +
  • internal : This script is for Alfresco use only. This script should not be relied upon between + versions. It is likely to change
  • + +
  • limited_support : This web script is no longer being actively developed
  • + +
+ +
+ + /index/lifecycle/{lifecycle} + + any + + internal + + Index + + admin + + required + + + + true + + false + + true + + + + + +
diff --git a/remote-api/src/main/resources/alfresco/templates/webscripts/org/springframework/extensions/webscripts/indexpackage.get.desc.xml b/remote-api/src/main/resources/alfresco/templates/webscripts/org/springframework/extensions/webscripts/indexpackage.get.desc.xml new file mode 100644 index 0000000000..4473249f8b --- /dev/null +++ b/remote-api/src/main/resources/alfresco/templates/webscripts/org/springframework/extensions/webscripts/indexpackage.get.desc.xml @@ -0,0 +1,32 @@ + + + + Web Script Package Index + + Provide an index of Web Scripts for the specified Web Script package + + /index/package/{package} + + any + + internal + + Index + + admin + + required + + + + true + + false + + true + + + + + + diff --git a/remote-api/src/main/resources/alfresco/templates/webscripts/org/springframework/extensions/webscripts/indexuri.get.desc.xml b/remote-api/src/main/resources/alfresco/templates/webscripts/org/springframework/extensions/webscripts/indexuri.get.desc.xml new file mode 100644 index 0000000000..6f0e6ee079 --- /dev/null +++ b/remote-api/src/main/resources/alfresco/templates/webscripts/org/springframework/extensions/webscripts/indexuri.get.desc.xml @@ -0,0 +1,32 @@ + + + + Web Script URI Index + + Provide an index of Web Scripts for the specified Web Script URI + + /index/uri/{uri} + + argument + + internal + + Index + + admin + + required + + + + true + + false + + true + + + + + + diff --git a/remote-api/src/main/resources/alfresco/templates/webscripts/org/springframework/extensions/webscripts/jsdebugger.get.desc.xml b/remote-api/src/main/resources/alfresco/templates/webscripts/org/springframework/extensions/webscripts/jsdebugger.get.desc.xml new file mode 100644 index 0000000000..645c5aa979 --- /dev/null +++ b/remote-api/src/main/resources/alfresco/templates/webscripts/org/springframework/extensions/webscripts/jsdebugger.get.desc.xml @@ -0,0 +1,32 @@ + + + + Javascript Debugger + + Javascript Debugger + + /api/javascript/debugger + + any + + internal + + admin + + required + + Index + + + + true + + false + + true + + + + + + diff --git a/remote-api/src/main/resources/alfresco/templates/webscripts/org/springframework/extensions/webscripts/jsdebugger.post.desc.xml b/remote-api/src/main/resources/alfresco/templates/webscripts/org/springframework/extensions/webscripts/jsdebugger.post.desc.xml new file mode 100644 index 0000000000..26a3d78940 --- /dev/null +++ b/remote-api/src/main/resources/alfresco/templates/webscripts/org/springframework/extensions/webscripts/jsdebugger.post.desc.xml @@ -0,0 +1,32 @@ + + + + Javascript Debugger Maintenance + + Javascript Debugger Maintenance + + /api/javascript/debugger?active={active?} + + any + + internal + + Index + + admin + + required + + + + true + + false + + true + + + + + + diff --git a/remote-api/src/main/resources/alfresco/templates/webscripts/org/springframework/extensions/webscripts/scriptdescription.get.desc.xml b/remote-api/src/main/resources/alfresco/templates/webscripts/org/springframework/extensions/webscripts/scriptdescription.get.desc.xml new file mode 100644 index 0000000000..14c9147b96 --- /dev/null +++ b/remote-api/src/main/resources/alfresco/templates/webscripts/org/springframework/extensions/webscripts/scriptdescription.get.desc.xml @@ -0,0 +1,32 @@ + + + + Web Script Description + + Retrieve description document for identified Web Script + + /description/{serviceId} + + argument + + internal + + Admin + + admin + + required + + + + true + + false + + true + + + + + + diff --git a/remote-api/src/main/resources/alfresco/templates/webscripts/org/springframework/extensions/webscripts/scriptdump.get.desc.xml b/remote-api/src/main/resources/alfresco/templates/webscripts/org/springframework/extensions/webscripts/scriptdump.get.desc.xml new file mode 100644 index 0000000000..3e37441dd1 --- /dev/null +++ b/remote-api/src/main/resources/alfresco/templates/webscripts/org/springframework/extensions/webscripts/scriptdump.get.desc.xml @@ -0,0 +1,32 @@ + + + + Display Web Script + + Full inspection of Web Script implementation - useful for diagnostics and download/upload + + /script/{serviceId} + + argument + + internal + + admin + + required + + Admin + + + + true + + false + + true + + + + + + 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 d96034b090..ffd346ff7d 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 @@ -214,9 +214,13 @@ + ${authentication.alwaysAllowBasicAuthForAdminConsole.enabled} + + ${authentication.alwaysAllowBasicAuthForWebScriptHome.enabled} + ${authentication.getRemoteUserTimeoutMilliseconds} @@ -224,9 +228,25 @@ AdminConsole AdminConsoleHelper - Index + + + Index + Admin + Content Applications + Audit + Authentication + Bulk Filesystem Import + IMAP + MultiTenantAdmin + QuADDS + Remote Share + SOLR + Tagging + googledocs + + diff --git a/repository/src/main/java/org/alfresco/repo/security/authentication/external/DefaultWebScriptHomeAuthenticator.java b/repository/src/main/java/org/alfresco/repo/security/authentication/external/DefaultWebScriptHomeAuthenticator.java new file mode 100644 index 0000000000..20cda2eddd --- /dev/null +++ b/repository/src/main/java/org/alfresco/repo/security/authentication/external/DefaultWebScriptHomeAuthenticator.java @@ -0,0 +1,55 @@ +/* + * #%L + * Alfresco Repository + * %% + * Copyright (C) 2005 - 2025 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 WebScriptHomeAuthenticator} implementation. Returns null to request a basic auth challenge. + */ +public class DefaultWebScriptHomeAuthenticator implements WebScriptHomeAuthenticator, ActivateableBean +{ + @Override + public String getWebScriptHomeUser(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/external/WebScriptHomeAuthenticator.java b/repository/src/main/java/org/alfresco/repo/security/authentication/external/WebScriptHomeAuthenticator.java new file mode 100644 index 0000000000..d098bfebe7 --- /dev/null +++ b/repository/src/main/java/org/alfresco/repo/security/authentication/external/WebScriptHomeAuthenticator.java @@ -0,0 +1,57 @@ +/* + * #%L + * Alfresco Repository + * %% + * Copyright (C) 2005 - 2025 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 WebScripts Home request. + */ +public interface WebScriptHomeAuthenticator +{ + /** + * Gets an externally authenticated user ID from the HTTP WebScripts Home request. + * + * @param request + * the request + * @param response + * the response + * @return the user ID or null if the user is unauthenticated + */ + String getWebScriptHomeUser(HttpServletRequest request, HttpServletResponse response); + + /** + * Requests an authentication for accessing WebScripts Home. + * + * @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/identityservice/IdentityServiceConfig.java b/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/IdentityServiceConfig.java index f5e31bbb87..15e7857d69 100644 --- a/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/IdentityServiceConfig.java +++ b/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/IdentityServiceConfig.java @@ -77,6 +77,7 @@ public class IdentityServiceConfig private String emailAttribute; private long jwtClockSkewMs; private String webScriptHomeRedirectPath; + private String webScriptHomeScopes; public String getWebScriptHomeRedirectPath() { @@ -370,6 +371,18 @@ public class IdentityServiceConfig this.adminConsoleScopes = adminConsoleScopes; } + public Set getWebScriptHomeScopes() + { + return Stream.of(webScriptHomeScopes.split(",")) + .map(String::trim) + .collect(Collectors.toUnmodifiableSet()); + } + + public void setWebScriptHomeScopes(String webScriptHomeScopes) + { + this.webScriptHomeScopes = webScriptHomeScopes; + } + public Set getPasswordGrantScopes() { return Stream.of(passwordGrantScopes.split(",")) 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 index 2545128ecf..bc5981bc48 100644 --- 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 @@ -240,10 +240,6 @@ public class IdentityServiceAdminConsoleAuthenticator implements AdminConsoleAut { URI originalUri = new URI(requestURL); String redirectPath = identityServiceConfig.getAdminConsoleRedirectPath(); - if (originalUri.getPath().equals(identityServiceConfig.getWebScriptHomeRedirectPath())) - { - redirectPath = originalUri.getPath(); - } URI redirectUri = new URI(originalUri.getScheme(), originalUri.getAuthority(), redirectPath, originalUri.getQuery(), originalUri.getFragment()); return redirectUri.toASCIIString(); } diff --git a/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/webscript/IdentityServiceWebScriptHomeAuthenticator.java b/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/webscript/IdentityServiceWebScriptHomeAuthenticator.java new file mode 100644 index 0000000000..3e586cbd46 --- /dev/null +++ b/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/webscript/IdentityServiceWebScriptHomeAuthenticator.java @@ -0,0 +1,333 @@ +/* + * #%L + * Alfresco Repository + * %% + * Copyright (C) 2005 - 2025 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.webscript; + +import static org.alfresco.repo.security.authentication.identityservice.IdentityServiceFacade.AuthorizationGrant.authorizationCode; +import static org.alfresco.repo.security.authentication.identityservice.IdentityServiceMetadataKey.SCOPES_SUPPORTED; + +import java.io.IOException; +import java.net.URI; +import java.net.URISyntaxException; +import java.time.Instant; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import com.nimbusds.oauth2.sdk.Scope; +import com.nimbusds.oauth2.sdk.id.Identifier; +import com.nimbusds.oauth2.sdk.id.State; + +import org.alfresco.repo.security.authentication.external.WebScriptHomeAuthenticator; +import org.apache.commons.lang.StringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.client.registration.ClientRegistration.ProviderDetails; +import org.springframework.web.util.UriComponentsBuilder; + +import org.alfresco.repo.management.subsystems.ActivateableBean; +import org.alfresco.repo.security.authentication.AuthenticationException; +import org.alfresco.repo.security.authentication.external.RemoteUserMapper; +import org.alfresco.repo.security.authentication.identityservice.IdentityServiceConfig; +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; + + +/** + * A {@link WebScriptHomeAuthenticator} implementation to extract an externally authenticated user ID or to initiate the OIDC authorization code flow. + */ +public class IdentityServiceWebScriptHomeAuthenticator implements WebScriptHomeAuthenticator, ActivateableBean +{ + private static final Logger LOGGER = LoggerFactory.getLogger(IdentityServiceWebScriptHomeAuthenticator.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 IdentityServiceConfig identityServiceConfig; + private IdentityServiceFacade identityServiceFacade; + private WebScriptHomeAuthenticationCookiesService cookiesService; + private RemoteUserMapper remoteUserMapper; + private boolean isEnabled; + + @Override + public String getWebScriptHomeUser(HttpServletRequest request, HttpServletResponse response) + { + 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); + } + + + public 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("WebScript Home Auth challenge failed: {}", 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) + { + LOGGER.debug("WebScript token refresh failed: {}", 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) + { + ClientRegistration clientRegistration = identityServiceFacade.getClientRegistration(); + State state = new State(); + + UriComponentsBuilder builder = UriComponentsBuilder.fromUriString(clientRegistration.getProviderDetails().getAuthorizationUri()) + .queryParam("client_id", clientRegistration.getClientId()) + .queryParam("redirect_uri", getRedirectUri(request.getRequestURL().toString())) + .queryParam("response_type", "code") + .queryParam("scope", String.join("+", getScopes(clientRegistration))) + .queryParam("state", state.toString()); + + if (StringUtils.isNotBlank(identityServiceConfig.getAudience())) + { + builder.queryParam("audience", identityServiceConfig.getAudience()); + } + + return builder.build().toUriString(); + } + + private Set getScopes(ClientRegistration clientRegistration) + { + return Optional.ofNullable(clientRegistration.getProviderDetails()) + .map(ProviderDetails::getConfigurationMetadata) + .map(metadata -> metadata.get(SCOPES_SUPPORTED.getValue())) + .filter(Scope.class::isInstance) + .map(Scope.class::cast) + .map(this::getSupportedScopes) + .orElse(clientRegistration.getScopes()); + } + + private Set getSupportedScopes(Scope scopes) + { + return scopes.stream() + .filter(this::hasWebScriptHomeScope) + .map(Identifier::getValue) + .collect(Collectors.toSet()); + } + + private boolean hasWebScriptHomeScope(Scope.Value scope) + { + return identityServiceConfig.getWebScriptHomeScopes().contains(scope.getValue()); + } + + private String getRedirectUri(String requestURL) + { + try + { + URI originalUri = new URI(requestURL); + + // Keep full original path so we return to the correct page after login + String fullOriginalPath = originalUri.getPath(); + String query = originalUri.getQuery(); + String fragment = originalUri.getFragment(); + + URI redirectUri = new URI( + originalUri.getScheme(), + originalUri.getAuthority(), + fullOriginalPath, // preserves /alfresco/s/index/** whatever it is + query, + fragment + ); + + return redirectUri.toASCIIString(); + } + catch (URISyntaxException e) + { + LOGGER.error("WebScript redirect URI construction failed: {}", e.getMessage(), e); + throw new AuthenticationException(e.getMessage(), e); + } + } + + + 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("WebScript refresh token response is invalid."); + } + 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 headers = new HashMap<>(); + headers.put("Authorization", "Bearer " + authToken); + return new WebScriptHomeHttpServletRequestWrapper(headers, servletRequest); + } + + public void setIdentityServiceFacade(IdentityServiceFacade identityServiceFacade) + { + this.identityServiceFacade = identityServiceFacade; + } + + public void setRemoteUserMapper(RemoteUserMapper remoteUserMapper) + { + this.remoteUserMapper = remoteUserMapper; + } + + public void setCookiesService(WebScriptHomeAuthenticationCookiesService cookiesService) + { + this.cookiesService = cookiesService; + } + + public void setIdentityServiceConfig(IdentityServiceConfig identityServiceConfig) + { + this.identityServiceConfig = identityServiceConfig; + } + + @Override + public boolean isActive() + { + return this.isEnabled; + } + + public void setActive(boolean isEnabled) + { + this.isEnabled = isEnabled; + } +} diff --git a/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/webscript/WebScriptHomeAuthenticationCookiesService.java b/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/webscript/WebScriptHomeAuthenticationCookiesService.java new file mode 100644 index 0000000000..4983c37074 --- /dev/null +++ b/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/webscript/WebScriptHomeAuthenticationCookiesService.java @@ -0,0 +1,120 @@ +/* + * #%L + * Alfresco Repository + * %% + * Copyright (C) 2005 - 2025 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.webscript; + +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import org.alfresco.repo.admin.SysAdminParams; + +/** + * Service to handle Web Script authentication-related cookies. + */ +public class WebScriptHomeAuthenticationCookiesService +{ + private final SysAdminParams sysAdminParams; + private final int cookieLifetime; + + public WebScriptHomeAuthenticationCookiesService(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 value, 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("/"); // Set the cookie's valid path + authCookie.setMaxAge(maxAge); // Set expiration time (in seconds) + + // Ensure the cookie is only transmitted over secure connections (HTTPS) + authCookie.setSecure(sysAdminParams.getAlfrescoProtocol().equalsIgnoreCase("https")); + + // Prevent JavaScript access to this cookie for security reasons (XSS protection) + authCookie.setHttpOnly(true); + + // Add the cookie to the response + servletResponse.addCookie(authCookie); + } +} diff --git a/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/webscript/WebScriptHomeHttpServletRequestWrapper.java b/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/webscript/WebScriptHomeHttpServletRequestWrapper.java new file mode 100644 index 0000000000..6ffe0660e3 --- /dev/null +++ b/repository/src/main/java/org/alfresco/repo/security/authentication/identityservice/webscript/WebScriptHomeHttpServletRequestWrapper.java @@ -0,0 +1,85 @@ +/* + * #%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.webscript; + +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 WebScriptHomeHttpServletRequestWrapper extends HttpServletRequestWrapper +{ + private final Map additionalHeaders; + private final HttpServletRequest wrappedRequest; + + public WebScriptHomeHttpServletRequestWrapper(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/resources/alfresco/authentication-services-context.xml b/repository/src/main/resources/alfresco/authentication-services-context.xml index 7d4f6d9666..c52eb5ff22 100644 --- a/repository/src/main/resources/alfresco/authentication-services-context.xml +++ b/repository/src/main/resources/alfresco/authentication-services-context.xml @@ -144,6 +144,22 @@ + + + + + + + org.alfresco.repo.security.authentication.external.WebScriptHomeAuthenticator + org.alfresco.repo.management.subsystems.ActivateableBean + + + + webScriptHomeAuthenticator + + + diff --git a/repository/src/main/resources/alfresco/repository.properties b/repository/src/main/resources/alfresco/repository.properties index fe6889a2ee..85420fc4e8 100644 --- a/repository/src/main/resources/alfresco/repository.properties +++ b/repository/src/main/resources/alfresco/repository.properties @@ -563,6 +563,7 @@ authentication.ticket.validDuration=PT1H authentication.ticket.useSingleTicketPerUser=true authentication.alwaysAllowBasicAuthForAdminConsole.enabled=true +authentication.alwaysAllowBasicAuthForWebScriptHome.enabled=true authentication.getRemoteUserTimeoutMilliseconds=10000 # FTP access 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 e25132c527..e0366a4167 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 @@ -104,4 +104,7 @@ + + + \ No newline at end of file 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 6c90e0680a..1edc6df889 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 @@ -170,6 +170,9 @@ ${identity-service.admin-console.scopes:openid,profile,email,offline_access} + + ${identity-service.webscript-home.scopes:openid,profile,email,offline_access} + ${identity-service.password-grant.scopes:openid,profile,email} @@ -205,6 +208,11 @@ + + + + + ${identity-service.authentication.enabled} @@ -223,6 +231,24 @@ + + + ${identity-service.authentication.enabled} + + + + + + + + + + + + + + + diff --git a/repository/src/main/resources/alfresco/subsystems/Authentication/identity-service/identity-service-authentication.properties b/repository/src/main/resources/alfresco/subsystems/Authentication/identity-service/identity-service-authentication.properties index 777251e597..d185ebc9d7 100644 --- a/repository/src/main/resources/alfresco/subsystems/Authentication/identity-service/identity-service-authentication.properties +++ b/repository/src/main/resources/alfresco/subsystems/Authentication/identity-service/identity-service-authentication.properties @@ -18,6 +18,7 @@ identity-service.first-name-attribute=given_name identity-service.last-name-attribute=family_name identity-service.email-attribute=email identity-service.admin-console.scopes=openid,profile,email,offline_access +identity-service.webscript-home.scopes=openid,profile,email,offline_access identity-service.password-grant.scopes=openid,profile,email identity-service.issuer-attribute=issuer identity-service.jwt-clock-skew-ms=0 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 index d9aab51016..d94dc6aac5 100644 --- 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 @@ -90,8 +90,6 @@ public class IdentityServiceAdminConsoleAuthenticatorUnitTest StringBuffer adminConsoleURL = new StringBuffer("http://localhost:8080/admin-console"); - StringBuffer webScriptHomeURL = new StringBuffer("http://localhost:8080/alfresco/s/index"); - @Before public void setup() { @@ -176,29 +174,6 @@ public class IdentityServiceAdminConsoleAuthenticatorUnitTest assertTrue(authenticationRequest.getValue().contains("state")); } - @Test - public void shouldCallAuthChallengeWebScriptHome() throws IOException - { - - String redirectPath = "/alfresco/s/index"; - when(request.getRequestURL()).thenReturn(webScriptHomeURL); - when(identityServiceConfig.getAdminConsoleScopes()).thenReturn(Set.of("openid", "email", "profile", "offline_access")); - when(identityServiceConfig.getWebScriptHomeRedirectPath()).thenReturn(redirectPath); - ArgumentCaptor authenticationRequest = ArgumentCaptor.forClass(String.class); - String expectedUri = "http://localhost:8999/auth?client_id=alfresco&redirect_uri=%s%s&response_type=code&scope=" - .formatted("http://localhost:8080", redirectPath); - - authenticator.requestAuthentication(request, response); - - verify(response).sendRedirect(authenticationRequest.capture()); - assertTrue(authenticationRequest.getValue().contains(expectedUri)); - assertTrue(authenticationRequest.getValue().contains("openid")); - assertTrue(authenticationRequest.getValue().contains("profile")); - assertTrue(authenticationRequest.getValue().contains("email")); - assertTrue(authenticationRequest.getValue().contains("offline_access")); - assertTrue(authenticationRequest.getValue().contains("state")); - } - @Test public void shouldCallAuthChallengeWithAudience() throws IOException { @@ -224,31 +199,6 @@ public class IdentityServiceAdminConsoleAuthenticatorUnitTest assertTrue(authenticationRequest.getValue().contains("state")); } - @Test - public void shouldCallAuthChallengeWebScriptHomeWithAudience() throws IOException - { - String audience = "http://localhost:8082"; - String redirectPath = "/alfresco/s/index"; - when(request.getRequestURL()).thenReturn(webScriptHomeURL); - when(identityServiceConfig.getAudience()).thenReturn(audience); - when(identityServiceConfig.getWebScriptHomeRedirectPath()).thenReturn(redirectPath); - when(identityServiceConfig.getAdminConsoleScopes()).thenReturn(Set.of("openid", "email", "profile", "offline_access")); - ArgumentCaptor authenticationRequest = ArgumentCaptor.forClass(String.class); - String expectedUri = "http://localhost:8999/auth?client_id=alfresco&redirect_uri=%s%s&response_type=code&scope=" - .formatted("http://localhost:8080", redirectPath); - - authenticator.requestAuthentication(request, response); - - verify(response).sendRedirect(authenticationRequest.capture()); - assertTrue(authenticationRequest.getValue().contains(expectedUri)); - assertTrue(authenticationRequest.getValue().contains("openid")); - assertTrue(authenticationRequest.getValue().contains("profile")); - assertTrue(authenticationRequest.getValue().contains("email")); - assertTrue(authenticationRequest.getValue().contains("offline_access")); - assertTrue(authenticationRequest.getValue().contains("audience=%s".formatted(audience))); - assertTrue(authenticationRequest.getValue().contains("state")); - } - @Test public void shouldResetCookiesAndCallAuthChallenge() throws IOException { diff --git a/repository/src/test/java/org/alfresco/repo/security/authentication/identityservice/webscript/IdentityServiceWebScriptHomeAuthenticatorUnitTest.java b/repository/src/test/java/org/alfresco/repo/security/authentication/identityservice/webscript/IdentityServiceWebScriptHomeAuthenticatorUnitTest.java new file mode 100644 index 0000000000..69d6f8e665 --- /dev/null +++ b/repository/src/test/java/org/alfresco/repo/security/authentication/identityservice/webscript/IdentityServiceWebScriptHomeAuthenticatorUnitTest.java @@ -0,0 +1,250 @@ +/* + * #%L + * Alfresco Repository + * %% + * Copyright (C) 2005 - 2025 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.webscript; + +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 java.util.Arrays; +import java.util.Map; +import java.util.Set; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import com.nimbusds.oauth2.sdk.Scope; +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; + +import org.alfresco.repo.security.authentication.external.RemoteUserMapper; +import org.alfresco.repo.security.authentication.identityservice.IdentityServiceConfig; +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; + +@SuppressWarnings("PMD.AvoidStringBufferField") +public class IdentityServiceWebScriptHomeAuthenticatorUnitTest +{ + + 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 + IdentityServiceConfig identityServiceConfig; + @Mock + WebScriptHomeAuthenticationCookiesService cookiesService; + @Mock + RemoteUserMapper remoteUserMapper; + @Mock + AccessTokenAuthorization accessTokenAuthorization; + @Mock + AccessToken accessToken; + @Captor + ArgumentCaptor requestCaptor; + + IdentityServiceWebScriptHomeAuthenticator authenticator; + + StringBuffer webScriptHomeURL = new StringBuffer("http://localhost:8080/alfresco/s/index"); + + @Before + public void setup() + { + initMocks(this); + ClientRegistration clientRegistration = mock(ClientRegistration.class); + ProviderDetails providerDetails = mock(ProviderDetails.class); + Scope scope = Scope.parse(Arrays.asList("openid", "profile", "email", "offline_access")); + + when(clientRegistration.getProviderDetails()).thenReturn(providerDetails); + when(clientRegistration.getClientId()).thenReturn("alfresco"); + when(providerDetails.getAuthorizationUri()).thenReturn("http://localhost:8999/auth"); + when(providerDetails.getConfigurationMetadata()).thenReturn(Map.of("scopes_supported", scope)); + when(identityServiceFacade.getClientRegistration()).thenReturn(clientRegistration); + when(request.getRequestURL()).thenReturn(webScriptHomeURL); + when(remoteUserMapper.getRemoteUser(request)).thenReturn(null); + + authenticator = new IdentityServiceWebScriptHomeAuthenticator(); + authenticator.setActive(true); + authenticator.setIdentityServiceFacade(identityServiceFacade); + authenticator.setCookiesService(cookiesService); + authenticator.setRemoteUserMapper(remoteUserMapper); + authenticator.setIdentityServiceConfig(identityServiceConfig); + } + + @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.getWebScriptHomeUser(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.getWebScriptHomeUser(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 shouldCallAuthChallengeWebScriptHome() throws IOException + { + + String redirectPath = "/alfresco/s/index"; + when(request.getRequestURL()).thenReturn(webScriptHomeURL); + when(identityServiceConfig.getWebScriptHomeScopes()).thenReturn(Set.of("openid", "email", "profile", "offline_access")); + when(identityServiceConfig.getWebScriptHomeRedirectPath()).thenReturn(redirectPath); + ArgumentCaptor authenticationRequest = ArgumentCaptor.forClass(String.class); + String expectedUri = "http://localhost:8999/auth?client_id=alfresco&redirect_uri=%s%s&response_type=code&scope=" + .formatted("http://localhost:8080", redirectPath); + + authenticator.requestAuthentication(request, response); + + verify(response).sendRedirect(authenticationRequest.capture()); + assertTrue(authenticationRequest.getValue().contains(expectedUri)); + assertTrue(authenticationRequest.getValue().contains("openid")); + assertTrue(authenticationRequest.getValue().contains("profile")); + assertTrue(authenticationRequest.getValue().contains("email")); + assertTrue(authenticationRequest.getValue().contains("offline_access")); + assertTrue(authenticationRequest.getValue().contains("state")); + } + + @Test + public void shouldCallAuthChallengeWebScriptHomeWithAudience() throws IOException + { + String audience = "http://localhost:8082"; + String redirectPath = "/alfresco/s/index"; + when(request.getRequestURL()).thenReturn(webScriptHomeURL); + when(identityServiceConfig.getAudience()).thenReturn(audience); + when(identityServiceConfig.getWebScriptHomeRedirectPath()).thenReturn(redirectPath); + when(identityServiceConfig.getWebScriptHomeScopes()).thenReturn(Set.of("openid", "email", "profile", "offline_access")); + ArgumentCaptor authenticationRequest = ArgumentCaptor.forClass(String.class); + String expectedUri = "http://localhost:8999/auth?client_id=alfresco&redirect_uri=%s%s&response_type=code&scope=" + .formatted("http://localhost:8080", redirectPath); + + authenticator.requestAuthentication(request, response); + + verify(response).sendRedirect(authenticationRequest.capture()); + assertTrue(authenticationRequest.getValue().contains(expectedUri)); + assertTrue(authenticationRequest.getValue().contains("openid")); + assertTrue(authenticationRequest.getValue().contains("profile")); + assertTrue(authenticationRequest.getValue().contains("email")); + assertTrue(authenticationRequest.getValue().contains("offline_access")); + assertTrue(authenticationRequest.getValue().contains("audience=%s".formatted(audience))); + assertTrue(authenticationRequest.getValue().contains("state")); + } + + @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.getWebScriptHomeUser(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", webScriptHomeURL.toString()))) + .thenReturn(accessTokenAuthorization); + when(remoteUserMapper.getRemoteUser(requestCaptor.capture())).thenReturn("admin"); + + String username = authenticator.getWebScriptHomeUser(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.getWebScriptHomeUser(request, response); + + assertEquals("admin", username); + } +}