diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1f1a13607..d28b78d44 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -35,6 +35,7 @@ jobs: src: - 'src/**' - 'pom.xml' + - '.mvn/**' - 'docker-compose.yml' - 'docker/**' - '.env' @@ -75,6 +76,8 @@ jobs: cache: maven - name: Build Maven with Docker Profile run: mvn clean install -Pdocker -DskipTests -B + - name: Check PGP verification rejects tampered and wrongly-signed OpenSAML + run: bash src/test/scripts/pgp-verification-test.sh - name: Docker Compose Build run: docker compose build - name: Check Tomcat config placeholders were substituted diff --git a/.mvn/maven.config b/.mvn/maven.config new file mode 100644 index 000000000..eb0f85399 --- /dev/null +++ b/.mvn/maven.config @@ -0,0 +1,2 @@ +-Daether.remoteRepositoryFilter.groupId=true +-Daether.remoteRepositoryFilter.groupId.basedir=${maven.multiModuleProjectDirectory}/.mvn/rrf diff --git a/.mvn/pgp-keys.map b/.mvn/pgp-keys.map new file mode 100644 index 000000000..ef7086dec --- /dev/null +++ b/.mvn/pgp-keys.map @@ -0,0 +1,74 @@ +# PGP keys map for pgpverify-maven-plugin (see pom.xml). Every dependency must match a rule here. +# +# OpenSAML / Shibboleth come from Shibboleth's own repository (not Maven Central), so they are +# pinned to the Shibboleth release key: Philip David Smart (Jisc), fingerprint +# B5B5 DD33 2142 AD65 7E8D 87AC 7D27 E610 B8A3 DC52, as published at +# https://shibboleth.net/downloads/PGP_KEYS. Never add a wildcard that could also match these +# groups: any matching rule is enough for an artifact to pass, which would undo the pin. +org.opensaml = 0xB5B5DD332142AD657E8D87AC7D27E610B8A3DC52 +net.shibboleth = 0xB5B5DD332142AD657E8D87AC7D27E610B8A3DC52 + +# Everything else comes from Maven Central only (.mvn/rrf), and must carry a valid signature by +# any key. A new dependency from an unlisted groupId fails the build ("Not allowed artifact"): +# review it, then add its groupId here. +com.fasterxml.jackson.core = any +com.fasterxml.jackson.datatype = any +com.github.ben-manes.caffeine = any +com.github.waffle = any +com.google.code.findbugs = any +com.google.errorprone = any +com.google.guava = any +com.google.j2objc = any +com.google.protobuf = any +com.googlecode.json-simple = any +com.mysql = any +com.zaxxer = any +commons-codec = any +commons-io = any +commons-logging = any +de.mkammerer = any +io.dropwizard.metrics = any +io.github.cdimascio = any +io.micrometer = any +jakarta.activation = any +jakarta.el = any +jakarta.mail = any +jakarta.servlet.jsp.jstl = any +junit = any +net.bytebuddy = any +net.java.dev.jna = any +net.sf.jtidy = any +org.apache.commons = any +org.apache.httpcomponents.client5 = any +org.apache.httpcomponents.core5 = any +org.apache.logging.log4j = any +org.apache.santuario = any +org.apache.velocity = any +org.apiguardian = any +org.bouncycastle = any +org.checkerframework = any +org.cryptacular = any +org.eclipse.angus = any +org.glassfish = any +org.glassfish.web = any +org.hamcrest = any +org.jetbrains = any +org.jetbrains.kotlin = any +org.json = any +org.jsoup = any +org.jspecify = any +org.junit.jupiter = any +org.junit.platform = any +org.mariadb.jdbc = any +org.mockito = any +org.mongodb = any +org.objenesis = any +org.opentest4j = any +org.owasp.encoder = any +org.pac4j = any +org.slf4j = any +org.springframework = any + +# Unsigned legacy artifact pulled in via json-simple -> junit 4.10 (to be removed, see #944). +# Exact version only. +org.hamcrest:hamcrest-core:1.1 = noSig diff --git a/.mvn/rrf/groupId-shibboleth-releases.txt b/.mvn/rrf/groupId-shibboleth-releases.txt new file mode 100644 index 000000000..5f1727b3a --- /dev/null +++ b/.mvn/rrf/groupId-shibboleth-releases.txt @@ -0,0 +1,2 @@ +net.shibboleth +org.opensaml diff --git a/pom.xml b/pom.xml index 27221b43b..256c46c8a 100644 --- a/pom.xml +++ b/pom.xml @@ -13,15 +13,66 @@ false ${skipTests} ${skipTests} - + 7.0.10 + 6.6.0 + + ${project.basedir}/.mvn/pgp-keys.map + + + + shibboleth-releases + Shibboleth Releases Repository + https://build.shibboleth.net/maven/releases/ + + true + fail + + + false + + + + + + + + + org.springframework + spring-framework-bom + ${spring.version} + pom + import + + + + - + + + org.pac4j + pac4j-saml + ${pac4j.version} + + + org.pac4j + pac4j-jakartaee + ${pac4j.version} + + + + org.apache.logging.log4j + log4j-slf4j2-impl + 2.25.4 + de.mkammerer argon2-jvm @@ -48,7 +99,7 @@ commons-io commons-io - 2.18.0 + 2.22.0 @@ -155,13 +206,6 @@ test - - - org.springframework - spring-core - ${spring.version} - test - + + org.apache.maven.plugins + maven-enforcer-plugin + 3.6.3 + + + require-maven-for-repository-filter + + enforce + + + + + + [3.9.2,) + Maven 3.9.2 or newer is required: older versions ignore the Shibboleth repository restriction in .mvn/maven.config. + + + + + + + + org.simplify4u.plugins + pgpverify-maven-plugin + 1.19.1 + + + + check + + + + + ${pgp.keysMap} + true + + true + hkps://keys.openpgp.org,hkps://keyserver.ubuntu.com + + org.codehaus.mojo properties-maven-plugin diff --git a/src/main/java/servlets/ACS.java b/src/main/java/servlets/ACS.java index 2ec6f4a5a..9a8d7237a 100644 --- a/src/main/java/servlets/ACS.java +++ b/src/main/java/servlets/ACS.java @@ -1,12 +1,25 @@ package servlets; +import dbProcs.Getter; import jakarta.servlet.ServletException; +import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServlet; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import jakarta.servlet.http.HttpSession; import java.io.IOException; +import java.io.InputStream; +import java.util.Optional; +import java.util.Properties; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import org.pac4j.saml.profile.SAML2Profile; +import utils.Hash; +import utils.LoginMethod; +import utils.SamlSso; +import utils.ShepherdLogManager; +import utils.SsoUserMapper; +import utils.UserKicker; /** * Control class for the authentication procedure.
@@ -31,25 +44,142 @@ public class ACS extends HttpServlet { private static final long serialVersionUID = 1L; private static final Logger log = LogManager.getLogger(ACS.class); - /** - * SAML SSO is unavailable at this step of the Jakarta migration (#861): java-saml has no Jakarta - * release, and the pac4j-based replacement follows in the next pull request of the stack. - */ - private void unavailable(HttpServletResponse response) throws IOException { - log.error("SAML SSO is unavailable during the Jakarta migration (#861)"); - response.sendError( - HttpServletResponse.SC_SERVICE_UNAVAILABLE, "SAML SSO is unavailable during migration"); + public void doPost(HttpServletRequest request, HttpServletResponse response) + throws ServletException, IOException { + if (!LoginMethod.isSaml()) { + // SSO is off (sso.enabled in sso.properties): this endpoint must not authenticate anyone, + // even if a SAML configuration is present + response.sendError(HttpServletResponse.SC_NOT_FOUND); + return; + } + + ShepherdLogManager.setRequestIp(request.getRemoteAddr(), request.getHeader("X-Forwarded-For")); + log.debug("**** servlets.ACS ***"); + response.setCharacterEncoding("UTF-8"); + request.setCharacterEncoding("UTF-8"); + HttpSession ses = request.getSession(true); + + Object language = ses.getAttribute("lang"); + + response.setContentType("text/plain"); + + log.debug("Getting ApplicationRoot"); + String ApplicationRoot = getServletContext().getRealPath(""); + log.debug("Servlet root = " + ApplicationRoot); + + log.debug("Processing SAML data..."); + Optional profile = SamlSso.authenticate(request, response); + log.debug("SAML data processed"); + + if (!profile.isPresent()) { + log.debug("User not authenticated"); + ses.setAttribute("loginFailed", "Not authenticated"); + response.sendRedirect("../login.jsp"); + log.debug("**** End servlets.ACS ***"); + return; + } + + log.debug("User authenticated, unpacking SAML attributes..."); + SsoUserMapper.Result ssoUser = + SsoUserMapper.map(profile.get().getAttributes(), loadSsoProperties()); + String errorMessage = ""; + boolean loggedIn = false; + + if (ssoUser.isValid()) { + log.debug("ssoName = " + ssoUser.ssoName); + log.debug("userName = " + ssoUser.userName); + log.debug("userRole = " + ssoUser.userRole); + log.debug("Saml userdata loaded, calling authUserSSO"); + + String user[] = + Getter.authUserSSO( + ApplicationRoot, null, ssoUser.userName, ssoUser.ssoName, ssoUser.userRole); + + if (user != null && !user[0].isEmpty()) { + + // Kill Session and Create a new one with user logged in + log.debug("Creating new session for " + user[2] + " " + user[1]); + ses.invalidate(); + ses = request.getSession(true); + ses.setAttribute("userStamp", user[0]); + ses.setAttribute("userName", user[1]); + ses.setAttribute("userRole", user[2]); + ses.setAttribute("lang", language); + // Kept for single logout, which needs the NameID and session index + ses.setAttribute(SamlSso.PROFILE_SESSION_ATTRIBUTE, profile.get()); + // Lets an IdP-initiated logout end this session + SamlSso.registerLoginSession(ses, profile.get()); + log.debug("userClassId = " + user[4]); + + ses.setAttribute("userClass", user[4]); + + if (user[5].equalsIgnoreCase("true")) { + log.debug("Temporary Username Detected, user will be prompted to change"); + ses.setAttribute("ChangeUsername", "true"); + } + + log.debug("Setting CSRF cookie"); + Cookie token = new Cookie("token", Hash.randomString()); + if (request.getRequestURL().toString().startsWith("https")) // If Requested over HTTPs + { + token.setSecure(true); + } + + // We must set the path because the ACS servlet is in a subdir... + token.setPath("/"); + response.addCookie(token); + + // Removing user from kick list. If they were on it before, their suspension + // must have ended if their authentication Succeeded + UserKicker.removeFromKicklist(user[1]); + loggedIn = true; + } + } else { + log.debug("SSO attributes rejected: " + ssoUser.error); + errorMessage = ssoUser.error; + } + + if (loggedIn) { + response.sendRedirect("../index.jsp"); + } else { + log.debug("Could not authenticate"); + ses.setAttribute("loginFailed", errorMessage + "SSO login failed."); + // Lagging Response + try { + Thread.sleep(2000); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + } + response.sendRedirect("../login.jsp"); + } + + log.debug("**** End servlets.ACS ***"); } - @Override + /** Redirects user to index.jsp */ public void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { - unavailable(response); + response.sendRedirect("../index.jsp"); } - @Override - public void doPost(HttpServletRequest request, HttpServletResponse response) - throws ServletException, IOException { - unavailable(response); + private Properties loadSsoProperties() { + String unpackFileName = "sso.properties"; + log.debug("Loading saml unpack properties file"); + try (InputStream inputStream = + getClass().getClassLoader().getResourceAsStream(unpackFileName)) { + if (inputStream == null) { + String errorMsg = + "SAML unpack properties file '" + unpackFileName + "' not found in the classpath"; + log.error(errorMsg); + throw new RuntimeException(errorMsg); + } + Properties prop = new Properties(); + prop.load(inputStream); + return prop; + } catch (IOException e) { + String errorMsg = "SAML unpack properties file '" + unpackFileName + "' cannot be loaded"; + log.error(errorMsg); + throw new RuntimeException(errorMsg); + } } } diff --git a/src/main/java/servlets/Logout.java b/src/main/java/servlets/Logout.java index 772816450..7604a62c9 100644 --- a/src/main/java/servlets/Logout.java +++ b/src/main/java/servlets/Logout.java @@ -9,6 +9,9 @@ import java.io.IOException; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import org.pac4j.saml.profile.SAML2Profile; +import utils.LoginMethod; +import utils.SamlSso; import utils.ShepherdLogManager; import utils.Validate; @@ -58,6 +61,10 @@ public void doGet(HttpServletRequest request, HttpServletResponse response) Object tokenParmeter = request.getParameter("csrfToken"); if (Validate.validateTokens(tokenCookie, tokenParmeter)) { + // Read before the session is invalidated: single logout needs the NameID and + // session index captured at login + Object samlProfile = ses.getAttribute(SamlSso.PROFILE_SESSION_ATTRIBUTE); + // Remove Everything ses.removeAttribute("userStamp"); ses.removeAttribute("userName"); @@ -73,8 +80,14 @@ public void doGet(HttpServletRequest request, HttpServletResponse response) response.addCookie(emptyCookie); log.debug("User Logged Out"); - // SAML single logout is unavailable during the Jakarta migration (#861) - response.sendRedirect("login.jsp"); + if (LoginMethod.isSaml() && samlProfile instanceof SAML2Profile) { + if (!SamlSso.logout(request, response, (SAML2Profile) samlProfile)) { + log.debug("No IdP single logout URL configured, ending local session only"); + response.sendRedirect("login.jsp"); + } + } else { + response.sendRedirect("login.jsp"); + } } else { log.error("CSRF Attack Detected"); diff --git a/src/main/java/servlets/SLS.java b/src/main/java/servlets/SLS.java index 9d6088671..601396eae 100644 --- a/src/main/java/servlets/SLS.java +++ b/src/main/java/servlets/SLS.java @@ -4,9 +4,13 @@ import jakarta.servlet.http.HttpServlet; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import jakarta.servlet.http.HttpSession; import java.io.IOException; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import utils.LoginMethod; +import utils.SamlSso; +import utils.ShepherdLogManager; /** * Control class for the SSO logout operation
@@ -31,25 +35,61 @@ public class SLS extends HttpServlet { private static final long serialVersionUID = -5824919455464886874L; private static final Logger log = LogManager.getLogger(SLS.class); - /** - * SAML SSO is unavailable at this step of the Jakarta migration (#861): java-saml has no Jakarta - * release, and the pac4j-based replacement follows in the next pull request of the stack. - */ - private void unavailable(HttpServletResponse response) throws IOException { - log.error("SAML SSO is unavailable during the Jakarta migration (#861)"); - response.sendError( - HttpServletResponse.SC_SERVICE_UNAVAILABLE, "SAML SSO is unavailable during migration"); - } - + /** IdP logout messages arrive here over the HTTP-Redirect binding */ @Override public void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { - unavailable(response); + handleRequest(request, response); } + /** IdP logout messages arrive here over the HTTP-POST binding */ @Override public void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { - unavailable(response); + handleRequest(request, response); + } + + /** + * Single logout service. Handles the IdP's LogoutResponse to an SP-initiated logout (see Logout), + * and IdP-initiated LogoutRequests, answering them with a LogoutResponse. The SAML message is + * validated (including its IdP signature when want_messages_signed is set), so no CSRF token is + * needed: the request originates from the IdP, not from a Security Shepherd page. + * + *

Sessions are ended by SamlSso for the login an IdP LogoutRequest names, not by whichever + * session the delivering browser happens to have. A LogoutResponse ends nothing: Logout already + * ended the local session before sending the user to the IdP. An invalid message gets a 400, + * never a redirect to login.jsp (which would end the browser's session). + */ + public void handleRequest(HttpServletRequest request, HttpServletResponse response) + throws ServletException, IOException { + if (!LoginMethod.isSaml()) { + // SSO is off (sso.enabled in sso.properties): this endpoint must not authenticate anyone, + // even if a SAML configuration is present + response.sendError(HttpServletResponse.SC_NOT_FOUND); + return; + } + // Setting IpAddress To Log and taking header for original IP if forwarded from + // proxy + ShepherdLogManager.setRequestIp(request.getRemoteAddr(), request.getHeader("X-Forwarded-For")); + log.debug("**** servlets.SLS ***"); + response.setCharacterEncoding("UTF-8"); + request.setCharacterEncoding("UTF-8"); + + SamlSso.LogoutMessage message = SamlSso.processLogoutMessage(request, response); + log.debug("SAML logout message: " + message); + + if (message == SamlSso.LogoutMessage.INVALID) { + // No redirect: login.jsp invalidates the current session, so redirecting there would let + // any page log a user out by sending their browser here with a bogus message + response.sendError(HttpServletResponse.SC_BAD_REQUEST); + } else if (message == SamlSso.LogoutMessage.LOGOUT_RESPONSE) { + // Logout already ended this browser's session; if it still has one (e.g. the response was + // delivered to a different browser), don't end it via login.jsp + HttpSession ses = request.getSession(false); + boolean loggedIn = ses != null && ses.getAttribute("userRole") != null; + response.sendRedirect(loggedIn ? "../index.jsp" : "../login.jsp"); + } + // For an IdP LogoutRequest the LogoutResponse back to the IdP has already been written + log.debug("*** END SLS ***"); } } diff --git a/src/main/java/servlets/SSOLogin.java b/src/main/java/servlets/SSOLogin.java index b9ab8dc54..232658650 100644 --- a/src/main/java/servlets/SSOLogin.java +++ b/src/main/java/servlets/SSOLogin.java @@ -7,6 +7,9 @@ import java.io.IOException; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import utils.LoginMethod; +import utils.SamlSso; +import utils.ShepherdLogManager; /** * Does SSO login.
@@ -31,25 +34,31 @@ public class SSOLogin extends HttpServlet { private static final long serialVersionUID = 1488140446224058032L; private static final Logger log = LogManager.getLogger(SSOLogin.class); - /** - * SAML SSO is unavailable at this step of the Jakarta migration (#861): java-saml has no Jakarta - * release, and the pac4j-based replacement follows in the next pull request of the stack. - */ - private void unavailable(HttpServletResponse response) throws IOException { - log.error("SAML SSO is unavailable during the Jakarta migration (#861)"); - response.sendError( - HttpServletResponse.SC_SERVICE_UNAVAILABLE, "SAML SSO is unavailable during migration"); + /** Redirects user to index.jsp */ + public void doPost(HttpServletRequest request, HttpServletResponse response) + throws ServletException, IOException { + + response.sendRedirect("index.jsp"); } - @Override public void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { - unavailable(response); - } + if (!LoginMethod.isSaml()) { + // SSO is off (sso.enabled in sso.properties): this endpoint must not authenticate anyone, + // even if a SAML configuration is present + response.sendError(HttpServletResponse.SC_NOT_FOUND); + return; + } + ShepherdLogManager.setRequestIp(request.getRemoteAddr(), request.getHeader("X-Forwarded-For")); + log.debug("**** servlets.SSOLogin ***"); + request.setCharacterEncoding("UTF-8"); + try { + SamlSso.login(request, response); + } catch (RuntimeException e) { + log.error("Caught exception when initializing SSO: " + e.toString()); + throw e; + } - @Override - public void doPost(HttpServletRequest request, HttpServletResponse response) - throws ServletException, IOException { - unavailable(response); + log.debug("**** End servlets.SSOLogin ***"); } } diff --git a/src/main/java/servlets/SSOMetadata.java b/src/main/java/servlets/SSOMetadata.java index 2faaf569c..4db1a786f 100644 --- a/src/main/java/servlets/SSOMetadata.java +++ b/src/main/java/servlets/SSOMetadata.java @@ -7,6 +7,9 @@ import java.io.IOException; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import utils.LoginMethod; +import utils.SamlSso; +import utils.ShepherdLogManager; /** * Does SSO login.
@@ -31,25 +34,30 @@ public class SSOMetadata extends HttpServlet { private static final long serialVersionUID = 3679066677850981985L; private static final Logger log = LogManager.getLogger(SSOMetadata.class); - /** - * SAML SSO is unavailable at this step of the Jakarta migration (#861): java-saml has no Jakarta - * release, and the pac4j-based replacement follows in the next pull request of the stack. - */ - private void unavailable(HttpServletResponse response) throws IOException { - log.error("SAML SSO is unavailable during the Jakarta migration (#861)"); - response.sendError( - HttpServletResponse.SC_SERVICE_UNAVAILABLE, "SAML SSO is unavailable during migration"); + public void doPost(HttpServletRequest request, HttpServletResponse response) + throws ServletException, IOException { + + /** Redirects user to index.jsp */ + response.sendRedirect("index.jsp"); } - @Override public void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { - unavailable(response); - } + if (!LoginMethod.isSaml()) { + // SSO is off (sso.enabled in sso.properties): this endpoint must not authenticate anyone, + // even if a SAML configuration is present + response.sendError(HttpServletResponse.SC_NOT_FOUND); + return; + } + ShepherdLogManager.setRequestIp(request.getRemoteAddr(), request.getHeader("X-Forwarded-For")); - @Override - public void doPost(HttpServletRequest request, HttpServletResponse response) - throws ServletException, IOException { - unavailable(response); + try { + String metadata = SamlSso.getServiceProviderMetadata(); + response.setContentType("application/xml; charset=UTF-8"); + response.getWriter().println(metadata); + } catch (Exception e) { + log.error("Caught exception when initializing SSO: " + e.toString()); + throw new RuntimeException(e); + } } } diff --git a/src/main/java/utils/LoginMethod.java b/src/main/java/utils/LoginMethod.java index 833df8dfb..9ef1a3965 100644 --- a/src/main/java/utils/LoginMethod.java +++ b/src/main/java/utils/LoginMethod.java @@ -73,6 +73,12 @@ public static boolean isSaml() { } } + /** Overrides sso.properties; for tests. Null goes back to reading the file. */ + static void setSaml(Boolean saml) { + isSet = saml != null; + isSaml = saml != null && saml; + } + public static Boolean isLogin() { return (!isSaml()); } diff --git a/src/main/java/utils/SamlRequestStore.java b/src/main/java/utils/SamlRequestStore.java new file mode 100644 index 000000000..002f27080 --- /dev/null +++ b/src/main/java/utils/SamlRequestStore.java @@ -0,0 +1,195 @@ +package utils; + +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.time.Duration; +import java.time.Instant; +import java.util.Base64; +import java.util.Comparator; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import org.opensaml.core.xml.XMLObject; +import org.pac4j.core.context.WebContext; +import org.pac4j.core.context.session.SessionStore; +import org.pac4j.jee.context.JEEContext; +import org.pac4j.saml.store.SAMLMessageStore; +import org.pac4j.saml.store.SAMLMessageStoreFactory; + +/** + * Remembers the SAML requests this SP sends, each bound to the browser that started it, so pac4j + * only accepts a Response whose InResponseTo names a request started in the same browser. This + * stops login CSRF: a valid response for one account can't be replayed through another browser's + * assertion consumer service.
+ * Each request gets its own random token, in its own cookie (named {@value #COOKIE} plus a hash of + * the request ID), so logins started at once in several tabs can't overwrite each other's binding. + * The cookie is HttpOnly, Secure and SameSite=None so it survives the IdP's cross-site POST back, + * scoped to the SAML endpoint directory (the ACS URL's directory, e.g. /saml/), lives as long as + * its request and is deleted once the login completes. The session cookie's policy is left alone. + *
+ * This file is part of the Security Shepherd Project. + * + *

The Security Shepherd project is free software: you can redistribute it and/or modify it under + * the terms of the GNU General Public License as published by the Free Software Foundation, either + * version 3 of the License, or (at your option) any later version.
+ * + *

The Security Shepherd project 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 General Public License for more details.
+ * + *

You should have received a copy of the GNU General Public License along with the Security + * Shepherd project. If not, see . + */ +public class SamlRequestStore implements SAMLMessageStoreFactory { + + /** Prefix of the binding cookies' names */ + static final String COOKIE = "shepherd_saml_request"; + + /** How long a started login may take at the IdP */ + static final Duration LIFETIME = Duration.ofMinutes(10); + + /** Upper bound on pending requests; the oldest are dropped beyond it */ + static int maxPending = 20000; + + private static final SecureRandom random = new SecureRandom(); + + private static final class Pending { + final String token; + final XMLObject message; + final Instant expires; + + Pending(String token, XMLObject message, Instant expires) { + this.token = token; + this.message = message; + this.expires = expires; + } + } + + private final Map pending = new ConcurrentHashMap<>(); + private final String cookiePath; + + /** + * @param assertionConsumerServiceUrl The ACS URL, whose directory (including any context prefix) + * scopes the binding cookie, so both the login start and the ACS receive it + */ + public SamlRequestStore(String assertionConsumerServiceUrl) { + String path = URI.create(assertionConsumerServiceUrl).getPath(); + this.cookiePath = + path == null || path.lastIndexOf('/') < 0 + ? "/" + : path.substring(0, path.lastIndexOf('/') + 1); + } + + /** The binding cookie's Path */ + String cookiePath() { + return cookiePath; + } + + @Override + public SAMLMessageStore getMessageStore(WebContext context, SessionStore sessionStore) { + JEEContext jee = (JEEContext) context; + return new BoundStore(jee.getNativeRequest(), jee.getNativeResponse()); + } + + /** The name of the cookie binding a request to its browser */ + static String cookieName(String requestId) { + try { + byte[] digest = + MessageDigest.getInstance("SHA-256").digest(requestId.getBytes(StandardCharsets.UTF_8)); + return COOKIE + + "_" + + Base64.getUrlEncoder().withoutPadding().encodeToString(digest).substring(0, 22); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 is unavailable", e); + } + } + + /** Forgets a request once its response has been accepted, so each request is used once */ + void forget(String requestId) { + if (requestId != null) { + pending.remove(requestId); + } + } + + /** As {@link #forget(String)}, also deleting the request's binding cookie from the browser */ + void complete(String requestId, HttpServletResponse response) { + forget(requestId); + if (requestId != null) { + response.addCookie(bindingCookie(requestId, "", 0)); + } + } + + private Cookie bindingCookie(String requestId, String token, int maxAge) { + Cookie cookie = new Cookie(cookieName(requestId), token); + cookie.setPath(cookiePath); + cookie.setHttpOnly(true); + cookie.setSecure(true); + cookie.setMaxAge(maxAge); + cookie.setAttribute("SameSite", "None"); + return cookie; + } + + private final class BoundStore implements SAMLMessageStore { + private final HttpServletRequest request; + private final HttpServletResponse response; + + BoundStore(HttpServletRequest request, HttpServletResponse response) { + this.request = request; + this.response = response; + } + + @Override + public void set(String id, XMLObject message) { + Instant now = Instant.now(); + pending.values().removeIf(p -> p.expires.isBefore(now)); + while (pending.size() >= maxPending) { + pending.entrySet().stream() + .min(Comparator.comparing(e -> e.getValue().expires)) + .ifPresent(oldest -> pending.remove(oldest.getKey())); + } + byte[] bytes = new byte[32]; + random.nextBytes(bytes); + String token = Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + response.addCookie(bindingCookie(id, token, (int) LIFETIME.getSeconds())); + pending.put(id, new Pending(token, message, now.plus(LIFETIME))); + } + + @Override + public Optional get(String id) { + Pending entry = id == null ? null : pending.get(id); + String token = id == null ? null : cookieToken(cookieName(id)); + if (entry == null || token == null || entry.expires.isBefore(Instant.now())) { + return Optional.empty(); + } + boolean sameBrowser = + MessageDigest.isEqual( + entry.token.getBytes(StandardCharsets.US_ASCII), + token.getBytes(StandardCharsets.US_ASCII)); + return sameBrowser ? Optional.of(entry.message) : Optional.empty(); + } + + @Override + public void remove(String id) { + forget(id); + } + + private String cookieToken(String name) { + Cookie[] cookies = request.getCookies(); + if (cookies == null) { + return null; + } + for (Cookie cookie : cookies) { + if (name.equals(cookie.getName()) && !cookie.getValue().isEmpty()) { + return cookie.getValue(); + } + } + return null; + } + } +} diff --git a/src/main/java/utils/SamlSessionRegistry.java b/src/main/java/utils/SamlSessionRegistry.java new file mode 100644 index 000000000..1c84ee00b --- /dev/null +++ b/src/main/java/utils/SamlSessionRegistry.java @@ -0,0 +1,134 @@ +package utils; + +import jakarta.servlet.http.HttpSession; +import jakarta.servlet.http.HttpSessionBindingEvent; +import jakarta.servlet.http.HttpSessionBindingListener; +import java.io.Serializable; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +/** + * Tracks which HTTP sessions were created by which SAML login, so an IdP-initiated logout can end + * exactly the sessions it names (by NameID and SessionIndex), whichever browser delivers the logout + * message, and whether or not it carries a session cookie.
+ * Entries are removed when their session ends (logout, invalidation or timeout). The registry is + * held in memory, so sessions restored after a server restart are not tracked.
+ * This file is part of the Security Shepherd Project. + * + *

The Security Shepherd project is free software: you can redistribute it and/or modify it under + * the terms of the GNU General Public License as published by the Free Software Foundation, either + * version 3 of the License, or (at your option) any later version.
+ * + *

The Security Shepherd project 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 General Public License for more details.
+ * + *

You should have received a copy of the GNU General Public License along with the Security + * Shepherd project. If not, see . + */ +public class SamlSessionRegistry { + + private static final Logger log = LogManager.getLogger(SamlSessionRegistry.class); + + static final String REGISTRATION_ATTRIBUTE = "samlSessionRegistration"; + + private static final Map sessions = new ConcurrentHashMap<>(); + + private static class Entry { + final HttpSession session; + final String nameId; + final String sessionIndex; + + Entry(HttpSession session, String nameId, String sessionIndex) { + this.session = session; + this.nameId = nameId; + this.sessionIndex = sessionIndex; + } + } + + /** Session attribute that removes the registry entry when its session ends */ + private static class Registration implements HttpSessionBindingListener, Serializable { + private static final long serialVersionUID = 1L; + private final String key; + + Registration(String key) { + this.key = key; + } + + @Override + public void valueUnbound(HttpSessionBindingEvent event) { + sessions.remove(key); + } + } + + /** + * Records that a session belongs to a SAML login. + * + * @param session The post-login session + * @param nameId The complete NameID identity from the assertion (see + * SamlSso.registerLoginSession) + * @param sessionIndex The SessionIndex from the assertion, if any + */ + public static void register(HttpSession session, String nameId, String sessionIndex) { + String key = UUID.randomUUID().toString(); + sessions.put(key, new Entry(session, nameId, sessionIndex)); + session.setAttribute(REGISTRATION_ATTRIBUTE, new Registration(key)); + } + + /** + * Invalidates the sessions belonging to a SAML login. + * + * @param nameId The complete NameID identity named in the logout request + * @param sessionIndex The SessionIndex named in the logout request, or null to end every session + * for the NameID + * @return How many sessions were invalidated + */ + public static int invalidate(String nameId, String sessionIndex) { + if (nameId == null) { + return 0; + } + int invalidated = 0; + for (Map.Entry item : sessions.entrySet()) { + Entry entry = item.getValue(); + if (!nameId.equals(entry.nameId) + || (sessionIndex != null && !sessionIndex.equals(entry.sessionIndex))) { + continue; + } + sessions.remove(item.getKey()); + try { + entry.session.invalidate(); + invalidated++; + } catch (IllegalStateException alreadyInvalidated) { + log.debug("Session for SAML logout was already invalidated"); + } + } + return invalidated; + } + + /** + * Whether a session is tracked for this login. + * + * @param nameId The complete NameID identity + * @param sessionIndex The SessionIndex + * @return true if a live session was registered for exactly this NameID and SessionIndex + */ + public static boolean isRegistered(String nameId, String sessionIndex) { + if (nameId == null || sessionIndex == null) { + return false; + } + for (Entry entry : sessions.values()) { + if (nameId.equals(entry.nameId) && sessionIndex.equals(entry.sessionIndex)) { + return true; + } + } + return false; + } + + /** Number of tracked sessions; for tests */ + static int size() { + return sessions.size(); + } +} diff --git a/src/main/java/utils/SamlSso.java b/src/main/java/utils/SamlSso.java new file mode 100644 index 000000000..824d550e4 --- /dev/null +++ b/src/main/java/utils/SamlSso.java @@ -0,0 +1,1071 @@ +package utils; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.servlet.http.HttpSession; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.math.BigInteger; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.security.KeyFactory; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.KeyStore; +import java.security.PrivateKey; +import java.security.Signature; +import java.security.cert.Certificate; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; +import java.security.spec.InvalidKeySpecException; +import java.security.spec.PKCS8EncodedKeySpec; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Base64; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Properties; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.bouncycastle.asn1.DERNull; +import org.bouncycastle.asn1.pkcs.PKCSObjectIdentifiers; +import org.bouncycastle.asn1.pkcs.PrivateKeyInfo; +import org.bouncycastle.asn1.pkcs.RSAPrivateKey; +import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.asn1.x509.AlgorithmIdentifier; +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; +import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder; +import org.bouncycastle.operator.OperatorCreationException; +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; +import org.opensaml.saml.saml2.core.LogoutRequest; +import org.opensaml.saml.saml2.core.NameID; +import org.opensaml.saml.saml2.core.NameIDType; +import org.opensaml.saml.saml2.core.Response; +import org.opensaml.saml.saml2.core.SessionIndex; +import org.opensaml.saml.saml2.core.SubjectConfirmationData; +import org.opensaml.saml.saml2.metadata.SPSSODescriptor; +import org.opensaml.xmlsec.encryption.support.DecryptionException; +import org.owasp.encoder.Encode; +import org.pac4j.core.context.CallContext; +import org.pac4j.core.credentials.Credentials; +import org.pac4j.core.exception.http.HttpAction; +import org.pac4j.core.http.callback.NoParameterCallbackUrlResolver; +import org.pac4j.core.keystore.generation.KeystoreGenerator; +import org.pac4j.core.profile.UserProfile; +import org.pac4j.jee.context.JEEContext; +import org.pac4j.jee.context.session.JEESessionStore; +import org.pac4j.jee.http.adapter.JEEHttpActionAdapter; +import org.pac4j.saml.client.SAML2Client; +import org.pac4j.saml.config.SAML2Configuration; +import org.pac4j.saml.context.SAML2MessageContext; +import org.pac4j.saml.credentials.SAML2Credentials; +import org.pac4j.saml.exceptions.SAMLAuthnContextClassRefException; +import org.pac4j.saml.metadata.SAML2FileSystemMetadataGenerator; +import org.pac4j.saml.profile.SAML2Profile; +import org.pac4j.saml.sso.impl.SAML2AuthnResponseValidator; +import org.springframework.core.io.ByteArrayResource; + +/** + * SAML 2.0 single sign-on, backed by pac4j-saml.
+ * The service provider and identity provider are configured from onelogin.saml.properties on the + * classpath, using the same keys the previous java-saml implementation read, so existing + * deployments keep their configuration.
+ * This file is part of the Security Shepherd Project. + * + *

The Security Shepherd project is free software: you can redistribute it and/or modify it under + * the terms of the GNU General Public License as published by the Free Software Foundation, either + * version 3 of the License, or (at your option) any later version.
+ * + *

The Security Shepherd project 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 General Public License for more details.
+ * + *

You should have received a copy of the GNU General Public License along with the Security + * Shepherd project. If not, see . + */ +public class SamlSso { + + private static final Logger log = LogManager.getLogger(SamlSso.class); + + /** Session attribute holding the authenticated user's SAML profile, used for single logout */ + public static final String PROFILE_SESSION_ATTRIBUTE = "samlProfile"; + + static final String PROPERTIES_FILE = "onelogin.saml.properties"; + + static final String SP_ENTITY_ID = "onelogin.saml2.sp.entityid"; + static final String SP_ACS_URL = "onelogin.saml2.sp.assertion_consumer_service.url"; + static final String SP_ACS_BINDING = "onelogin.saml2.sp.assertion_consumer_service.binding"; + static final String SP_SLS_URL = "onelogin.saml2.sp.single_logout_service.url"; + static final String SP_NAMEID_FORMAT = "onelogin.saml2.sp.nameidformat"; + static final String SP_CERT = "onelogin.saml2.sp.x509cert"; + static final String SP_KEY = "onelogin.saml2.sp.privatekey"; + static final String IDP_ENTITY_ID = "onelogin.saml2.idp.entityid"; + static final String IDP_SSO_URL = "onelogin.saml2.idp.single_sign_on_service.url"; + static final String IDP_SSO_BINDING = "onelogin.saml2.idp.single_sign_on_service.binding"; + static final String IDP_SLO_URL = "onelogin.saml2.idp.single_logout_service.url"; + static final String IDP_SLO_RESPONSE_URL = + "onelogin.saml2.idp.single_logout_service.response.url"; + static final String IDP_SLO_BINDING = "onelogin.saml2.idp.single_logout_service.binding"; + static final String IDP_CERT = "onelogin.saml2.idp.x509cert"; + static final String IDP_CERT_FINGERPRINT = "onelogin.saml2.idp.certfingerprint"; + static final String STRICT = "onelogin.saml2.strict"; + static final String AUTHN_REQUEST_SIGNED = "onelogin.saml2.security.authnrequest_signed"; + static final String LOGOUT_REQUEST_SIGNED = "onelogin.saml2.security.logoutrequest_signed"; + static final String WANT_MESSAGES_SIGNED = "onelogin.saml2.security.want_messages_signed"; + static final String WANT_ASSERTIONS_SIGNED = "onelogin.saml2.security.want_assertions_signed"; + static final String SIGN_METADATA = "onelogin.saml2.security.sign_metadata"; + static final String REQUESTED_AUTHN_CONTEXT = "onelogin.saml2.security.requested_authncontext"; + static final String REQUESTED_AUTHN_CONTEXT_COMPARISON = + "onelogin.saml2.security.requested_authncontextcomparison"; + + /** The key as misspelled in the pre-pac4j sample file, still accepted */ + static final String LEGACY_REQUESTED_AUTHN_CONTEXT_COMPARISON = + "onelogin.saml2.security.onelogin.saml2.security.requested_authncontextcomparison"; + + static final String SIGNATURE_ALGORITHM = "onelogin.saml2.security.signature_algorithm"; + + private static final String HTTP_REDIRECT_BINDING = + "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"; + private static final String HTTP_POST_BINDING = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"; + private static final String SP_KEY_ALIAS = "shepherd-sp"; + + /** + * HTTP-Redirect binding signature algorithms accepted on logout messages (no SHA-1). XML-DSig + * ECDSA signatures are the raw r || s concatenation (IEEE P1363), not ASN.1 DER. + */ + private static final Map REDIRECT_SIGNATURE_ALGORITHMS = new HashMap<>(); + + static { + REDIRECT_SIGNATURE_ALGORITHMS.put( + "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256", "SHA256withRSA"); + REDIRECT_SIGNATURE_ALGORITHMS.put( + "http://www.w3.org/2001/04/xmldsig-more#rsa-sha384", "SHA384withRSA"); + REDIRECT_SIGNATURE_ALGORITHMS.put( + "http://www.w3.org/2001/04/xmldsig-more#rsa-sha512", "SHA512withRSA"); + REDIRECT_SIGNATURE_ALGORITHMS.put( + "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha256", "SHA256withECDSAinP1363Format"); + REDIRECT_SIGNATURE_ALGORITHMS.put( + "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha384", "SHA384withECDSAinP1363Format"); + REDIRECT_SIGNATURE_ALGORITHMS.put( + "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha512", "SHA512withECDSAinP1363Format"); + } + + private static final Set REDIRECT_SIGNED_PARAMETERS = + Set.of("SAMLRequest", "SAMLResponse", "RelayState", "SigAlg", "Signature"); + + /** How long after its IssueInstant an IdP LogoutRequest is accepted (plus clock skew) */ + static final Duration LOGOUT_REQUEST_LIFETIME = Duration.ofMinutes(5); + + /** Allowed clock difference between the IdP and this server */ + static final Duration CLOCK_SKEW = Duration.ofMinutes(5); + + /** Upper bound on remembered LogoutRequest IDs; beyond it new requests are refused */ + static int maxRememberedLogoutRequests = 10000; + + /** + * IDs of LogoutRequests already acted on, with when they can be forgotten. An entry outlives the + * window in which its request would be accepted, so a replay is always caught here or by the age + * check. + */ + private static final Map seenLogoutRequests = new ConcurrentHashMap<>(); + + /** The configured SAML client and the settings SamlSso enforces itself */ + static final class Sso { + final SAML2Client client; + final X509Certificate idpCertificate; + + /** want_messages_signed: logout messages must carry a valid IdP signature */ + final boolean requireSignedLogout; + + /** + * This SP's single logout endpoint, which IdP LogoutRequests must be addressed to; null when + * single logout isn't configured + */ + final String singleLogoutUrl; + + /** Defaults for omitted NameQualifier / SPNameQualifier (SAML Core 8.3.7) */ + final String idpEntityId; + + final String spEntityId; + + /** Binds each sent AuthnRequest to the browser that started the login */ + final SamlRequestStore requestStore; + + Sso( + SAML2Client client, + X509Certificate idpCertificate, + boolean requireSignedLogout, + String singleLogoutUrl, + String idpEntityId, + String spEntityId, + SamlRequestStore requestStore) { + this.client = client; + this.idpCertificate = idpCertificate; + this.requireSignedLogout = requireSignedLogout; + this.singleLogoutUrl = singleLogoutUrl; + this.idpEntityId = idpEntityId; + this.spEntityId = spEntityId; + this.requestStore = requestStore; + } + } + + private static volatile Sso sso; + + private static Sso get() { + Sso result = sso; + if (result == null) { + synchronized (SamlSso.class) { + result = sso; + if (result == null) { + result = build(loadProperties()); + sso = result; + } + } + } + return result; + } + + /** Replaces the configuration built from onelogin.saml.properties; for tests. */ + static void set(Sso replacement) { + sso = replacement; + } + + /** + * Returns the SAML client, building it from onelogin.saml.properties on first use. + * + * @return The initialised SAML client + */ + public static SAML2Client getClient() { + return get().client; + } + + /** + * Sends the browser to the identity provider with a SAML authentication request. + * + * @param request The incoming request + * @param response The response the redirect (or auto-submitting form) is written to + */ + public static void login(HttpServletRequest request, HttpServletResponse response) { + CallContext ctx = callContext(request, response); + HttpAction action = + getClient() + .getRedirectionAction(ctx) + .orElseThrow(() -> new IllegalStateException("No SAML redirection action")); + JEEHttpActionAdapter.INSTANCE.adapt(action, ctx.webContext()); + } + + /** + * Validates the SAML response posted to the assertion consumer service. + * + * @param request The ACS request carrying the SAML response + * @param response The ACS response + * @return The authenticated user's profile, or empty if the response did not authenticate a user + */ + public static Optional authenticate( + HttpServletRequest request, HttpServletResponse response) { + Sso config = get(); + SAML2Client saml = config.client; + CallContext ctx = callContext(request, response); + try { + Optional extracted = saml.getCredentials(ctx); + if (extracted.isEmpty()) { + return Optional.empty(); + } + // Only answers to a login started in this browser are accepted (login CSRF): an unsolicited + // (IdP-initiated) Response has no InResponseTo to bind it to a browser, so it is refused; + // otherwise pac4j checks InResponseTo against this browser's pending requests + String inResponseTo = inResponseTo(extracted.get()); + if (inResponseTo == null) { + log.error("SAML response rejected: unsolicited (no InResponseTo)"); + return Optional.empty(); + } + Optional credentials = saml.validateCredentials(ctx, extracted.get()); + if (credentials.isEmpty()) { + return Optional.empty(); + } + config.requestStore.complete(inResponseTo, response); + Optional profile = saml.getUserProfile(ctx, credentials.get()); + return profile.filter(SAML2Profile.class::isInstance).map(SAML2Profile.class::cast); + } catch (RuntimeException e) { + // Signature, issuer, audience, destination and time checks fail with exceptions + log.error("SAML response rejected: " + e.getMessage()); + log.debug("SAML response rejected", e); + return Optional.empty(); + } + } + + private static String inResponseTo(Credentials credentials) { + if (!(credentials instanceof SAML2Credentials)) { + return null; + } + Object message = ((SAML2Credentials) credentials).getContext().getMessageContext().getMessage(); + return message instanceof Response ? ((Response) message).getInResponseTo() : null; + } + + /** + * Starts SP-initiated single logout for the given profile. + * + * @param request The logout request + * @param response The response the redirect (or auto-submitting form) is written to + * @param profile The SAML profile captured at login + * @return true if the browser was sent to the identity provider, false if no logout could be + * started (no IdP logout URL configured) and the caller must respond itself + */ + public static boolean logout( + HttpServletRequest request, HttpServletResponse response, SAML2Profile profile) { + CallContext ctx = callContext(request, response); + Optional action = + getClient().getLogoutAction(ctx, profile, null).map(HttpAction.class::cast); + if (action.isEmpty()) { + return false; + } + JEEHttpActionAdapter.INSTANCE.adapt(action.get(), ctx.webContext()); + return true; + } + + /** + * Records a post-login session against the SAML login that created it, so an IdP-initiated logout + * naming that login can end it. + * + * @param session The post-login session + * @param profile The profile from the validated assertion + */ + public static void registerLoginSession(HttpSession session, SAML2Profile profile) { + SamlSessionRegistry.register( + session, + nameIdKey( + get(), + profile.getId(), + profile.getSamlNameIdFormat(), + profile.getSamlNameIdNameQualifier(), + profile.getSamlNameIdSpNameQualifier()), + profile.getSessionIndex()); + } + + /** The complete identity of a NameID, as {@link SamlSessionRegistry} matches it */ + static String nameIdKey( + String value, String format, String nameQualifier, String spNameQualifier) { + return nameIdKey(get(), value, format, nameQualifier, spNameQualifier); + } + + /** + * Two NameIDs identify the same principal only if value, Format, NameQualifier and + * SPNameQualifier all match. Omitted fields take their SAML defaults: unspecified format, and the + * IdP / SP entity IDs as qualifiers (SAML Core 8.3.7). + */ + private static String nameIdKey( + Sso config, String value, String format, String nameQualifier, String spNameQualifier) { + if (value == null) { + return null; + } + return String.join( + "\u0000", + value, + format == null || format.isEmpty() ? NameIDType.UNSPECIFIED : format, + nameQualifier == null || nameQualifier.isEmpty() ? config.idpEntityId : nameQualifier, + spNameQualifier == null || spNameQualifier.isEmpty() ? config.spEntityId : spNameQualifier); + } + + /** Outcome of a message received on the single logout service */ + public enum LogoutMessage { + /** + * The IdP asked to end a login: the matching sessions were invalidated and a LogoutResponse has + * been sent back to the IdP + */ + IDP_LOGOUT_REQUEST, + /** The IdP confirmed an SP-initiated logout */ + LOGOUT_RESPONSE, + /** The message was missing, unsigned when a signature is required, or failed validation */ + INVALID + } + + /** + * Validates a LogoutRequest or LogoutResponse received on the single logout service. + * + *

When want_messages_signed is set, the message must be signed by the configured IdP: either + * an XML signature (HTTP-POST binding) or a query-string signature (HTTP-Redirect binding). + * + *

For an IdP-initiated LogoutRequest, the sessions created by the login it names (NameID and + * SessionIndex) are invalidated, whichever browser delivered the request, and the LogoutResponse + * is written to the response. A LogoutResponse ends nothing itself: the local session was already + * ended when the SP-initiated logout started. + * + * @param request The SLS request + * @param response The SLS response + * @return What was received + */ + public static LogoutMessage processLogoutMessage( + HttpServletRequest request, HttpServletResponse response) { + Sso config = get(); + SAML2Client saml = config.client; + CallContext ctx = callContext(request, response); + try { + Optional credentials = + saml.getCredentials(ctx).flatMap(c -> saml.validateCredentials(ctx, c)); + if (credentials.isEmpty() || !(credentials.get() instanceof SAML2Credentials)) { + return LogoutMessage.INVALID; + } + SAML2MessageContext messageContext = ((SAML2Credentials) credentials.get()).getContext(); + // pac4j validates an XML signature when one is present, but never requires one and does not + // check HTTP-Redirect query-string signatures + boolean signed = + messageContext.getSAMLPeerEntityContext().isAuthenticated() + || hasValidRedirectSignature(request, config.idpCertificate); + if (config.requireSignedLogout && !signed) { + log.error("SAML logout message rejected: no valid IdP signature"); + return LogoutMessage.INVALID; + } + + Object message = messageContext.getMessageContext().getMessage(); + if (!(message instanceof LogoutRequest)) { + return LogoutMessage.LOGOUT_RESPONSE; + } + LogoutRequest logoutRequest = (LogoutRequest) message; + // pac4j checks none of these for LogoutRequests + String rejection = checkLogoutRequest(logoutRequest, signed, config.singleLogoutUrl); + if (rejection != null) { + log.error("SAML LogoutRequest rejected: " + rejection); + return LogoutMessage.INVALID; + } + NameID nameId = logoutRequest.getNameID(); + if (nameId == null && logoutRequest.getEncryptedID() != null) { + nameId = (NameID) saml.getDecrypter().decrypt(logoutRequest.getEncryptedID()); + } + String nameIdKey = + nameId == null + ? null + : nameIdKey( + config, + nameId.getValue(), + nameId.getFormat(), + nameId.getNameQualifier(), + nameId.getSPNameQualifier()); + List sessionIndexes = new ArrayList<>(); + for (SessionIndex index : logoutRequest.getSessionIndexes()) { + if (index != null && index.getValue() != null) { + sessionIndexes.add(index.getValue()); + } + } + // Only a request that will end something takes a slot in the replay cache: a signed request, + // or an unsigned one naming a login we track. Otherwise unauthenticated no-op requests could + // fill the cache and lock out genuine signed logouts. (A NameID alone is guessable, so only + // a signed request may end all of a user's sessions.) + boolean acts = + signed + || sessionIndexes.stream() + .anyMatch(index -> SamlSessionRegistry.isRegistered(nameIdKey, index)); + int ended = 0; + if (acts) { + String replay = recordLogoutRequest(logoutRequest); + if (replay != null) { + log.error("SAML LogoutRequest rejected: " + replay); + return LogoutMessage.INVALID; + } + if (!sessionIndexes.isEmpty()) { + for (String sessionIndex : sessionIndexes) { + ended += SamlSessionRegistry.invalidate(nameIdKey, sessionIndex); + } + } else { + ended = SamlSessionRegistry.invalidate(nameIdKey, null); + } + } else { + log.error("Unsigned SAML LogoutRequest names no tracked login; no sessions ended"); + } + log.debug("IdP-initiated SAML logout ended " + ended + " session(s)"); + HttpAction action = saml.processLogout(ctx, credentials.get()); + JEEHttpActionAdapter.INSTANCE.adapt(action, ctx.webContext()); + return LogoutMessage.IDP_LOGOUT_REQUEST; + } catch (RuntimeException | DecryptionException e) { + log.error("SAML logout message rejected: " + e.getMessage()); + log.debug("SAML logout message rejected", e); + return LogoutMessage.INVALID; + } + } + + /** + * Checks an IdP LogoutRequest's addressing and freshness before it may end sessions. A signed + * request must name this SP's logout endpoint as its Destination (SAML Bindings 3.4.5.2 / + * 3.5.5.2); any Destination given must match. Replay is checked by {@link #recordLogoutRequest}, + * only for requests that will act. + * + * @return why the request is rejected, or null if it may be acted on + */ + static String checkLogoutRequest(LogoutRequest request, boolean signed, String singleLogoutUrl) { + if (singleLogoutUrl == null) { + return "no single logout endpoint is configured (" + SP_SLS_URL + ")"; + } + String destination = request.getDestination(); + if (destination == null ? signed : !destination.equals(singleLogoutUrl)) { + return "Destination " + destination + " is not " + singleLogoutUrl; + } + Instant now = Instant.now(); + Instant issued = request.getIssueInstant(); + if (issued == null + || issued.isAfter(now.plus(CLOCK_SKEW)) + || issued.isBefore(now.minus(LOGOUT_REQUEST_LIFETIME).minus(CLOCK_SKEW))) { + return "IssueInstant " + issued + " is outside the accepted window"; + } + Instant notOnOrAfter = request.getNotOnOrAfter(); + if (notOnOrAfter != null && !now.minus(CLOCK_SKEW).isBefore(notOnOrAfter)) { + return "expired at " + notOnOrAfter; + } + if (request.getID() == null) { + return "no ID"; + } + return null; + } + + /** + * Records a LogoutRequest's ID (per issuer) before it ends sessions, rejecting a replay. Fails + * closed when the cache is full. + * + * @return why the request is rejected, or null if it was recorded + */ + static String recordLogoutRequest(LogoutRequest request) { + Instant now = Instant.now(); + String issuer = request.getIssuer() == null ? "" : request.getIssuer().getValue(); + seenLogoutRequests.values().removeIf(forgetAt -> forgetAt.isBefore(now)); + if (seenLogoutRequests.size() >= maxRememberedLogoutRequests) { + return "too many recent logout requests to check for replay"; + } + Instant forgetAt = now.plus(LOGOUT_REQUEST_LIFETIME).plus(CLOCK_SKEW.multipliedBy(2)); + if (seenLogoutRequests.putIfAbsent(issuer + "|" + request.getID(), forgetAt) != null) { + return "replay of " + request.getID(); + } + return null; + } + + /** Empties the replay cache; for tests. */ + static void clearRememberedLogoutRequests() { + seenLogoutRequests.clear(); + } + + /** + * Verifies an HTTP-Redirect binding signature (SAML Bindings 3.4.4.1): the signature covers + * SAMLRequest/SAMLResponse, RelayState and SigAlg exactly as they appear, URL-encoded, in the + * query string. + * + *

The signed values must be the ones the servlet container (and so pac4j) reads: only a GET is + * accepted, each signed parameter must appear exactly once under its literal name (no + * percent-encoded aliases, no duplicates, nothing extra in a request body), and its decoded value + * must equal the container's. + */ + static boolean hasValidRedirectSignature(HttpServletRequest request, X509Certificate idpCert) { + String query = request.getQueryString(); + if (!"GET".equalsIgnoreCase(request.getMethod()) || query == null || idpCert == null) { + return false; + } + Map raw = new HashMap<>(); + for (String pair : query.split("&")) { + if (pair.isEmpty()) { + continue; + } + int eq = pair.indexOf('='); + String rawName = eq < 0 ? pair : pair.substring(0, eq); + String name; + try { + name = URLDecoder.decode(rawName, StandardCharsets.UTF_8); + } catch (IllegalArgumentException e) { + return false; + } + if (!REDIRECT_SIGNED_PARAMETERS.contains(name)) { + continue; + } + if (!name.equals(rawName) + || raw.putIfAbsent(name, eq < 0 ? "" : pair.substring(eq + 1)) != null) { + log.error("SAML redirect query has an encoded or repeated " + name + " parameter"); + return false; + } + } + if (raw.containsKey("SAMLRequest") == raw.containsKey("SAMLResponse")) { + return false; + } + for (Map.Entry param : raw.entrySet()) { + String[] values = request.getParameterValues(param.getKey()); + String decoded; + try { + decoded = URLDecoder.decode(param.getValue(), StandardCharsets.UTF_8); + } catch (IllegalArgumentException e) { + return false; + } + if (values == null || values.length != 1 || !decoded.equals(values[0])) { + log.error("SAML redirect parameter " + param.getKey() + " does not match the signed query"); + return false; + } + } + String messageParam = raw.containsKey("SAMLRequest") ? "SAMLRequest" : "SAMLResponse"; + String sigAlg = raw.get("SigAlg"); + String signature = raw.get("Signature"); + if (sigAlg == null || signature == null) { + return false; + } + String algorithm = + REDIRECT_SIGNATURE_ALGORITHMS.get(URLDecoder.decode(sigAlg, StandardCharsets.UTF_8)); + if (algorithm == null) { + log.error("Unsupported SAML redirect signature algorithm: " + sigAlg); + return false; + } + StringBuilder signedContent = new StringBuilder(); + signedContent.append(messageParam).append('=').append(raw.get(messageParam)); + if (raw.containsKey("RelayState")) { + signedContent.append("&RelayState=").append(raw.get("RelayState")); + } + signedContent.append("&SigAlg=").append(sigAlg); + try { + Signature verifier = Signature.getInstance(algorithm); + verifier.initVerify(idpCert.getPublicKey()); + verifier.update(signedContent.toString().getBytes(StandardCharsets.UTF_8)); + return verifier.verify( + Base64.getDecoder().decode(URLDecoder.decode(signature, StandardCharsets.UTF_8))); + } catch (GeneralSecurityException | IllegalArgumentException e) { + log.debug("SAML redirect signature verification failed", e); + return false; + } + } + + /** + * Returns this service provider's SAML metadata. + * + * @return The SP metadata XML + */ + public static String getServiceProviderMetadata() { + return getClient().getServiceProviderMetadataResolver().getMetadata(); + } + + static CallContext callContext(HttpServletRequest request, HttpServletResponse response) { + return new CallContext(new JEEContext(request, response), new JEESessionStore()); + } + + static Properties loadProperties() { + try (InputStream in = SamlSso.class.getClassLoader().getResourceAsStream(PROPERTIES_FILE)) { + if (in == null) { + throw new IllegalStateException( + "SAML properties file '" + PROPERTIES_FILE + "' not found in the classpath"); + } + Properties props = new Properties(); + props.load(in); + return props; + } catch (IOException e) { + throw new IllegalStateException( + "SAML properties file '" + PROPERTIES_FILE + "' cannot be loaded", e); + } + } + + /** + * Builds and initialises the SAML client from java-saml style properties. + * + * @param props onelogin.saml.properties contents + * @return The initialised client and the settings SamlSso enforces itself + */ + static Sso build(Properties props) { + if (!flag(props, STRICT, true)) { + log.warn(STRICT + "=false is ignored: SAML messages are always validated strictly"); + } + + SAML2Configuration config = new SAML2Configuration(); + config.setServiceProviderEntityId(required(props, SP_ENTITY_ID)); + config.setIdentityProviderEntityId(required(props, IDP_ENTITY_ID)); + config.setSingleSignOutServiceUrl(optional(props, SP_SLS_URL)); + config.setResponseBindingType(optional(props, SP_ACS_BINDING, HTTP_POST_BINDING)); + config.setAuthnRequestBindingType(optional(props, IDP_SSO_BINDING, HTTP_REDIRECT_BINDING)); + String sloBinding = optional(props, IDP_SLO_BINDING, HTTP_REDIRECT_BINDING); + config.setSpLogoutRequestBindingType(sloBinding); + config.setSpLogoutResponseBindingType(sloBinding); + config.setNameIdPolicyFormat(optional(props, SP_NAMEID_FORMAT)); + // Send NameQualifier / SPNameQualifier back in LogoutRequests, so IdPs that issue qualified + // NameIDs can match the logout to the login + config.setUseNameQualifier(true); + + config.setAuthnRequestSigned(flag(props, AUTHN_REQUEST_SIGNED, false)); + config.setSpLogoutRequestSigned(flag(props, LOGOUT_REQUEST_SIGNED, false)); + config.setWantsResponsesSigned(flag(props, WANT_MESSAGES_SIGNED, false)); + config.setWantsAssertionsSigned(flag(props, WANT_ASSERTIONS_SIGNED, false)); + config.setSignMetadata(flag(props, SIGN_METADATA, false)); + + String authnContext = optional(props, REQUESTED_AUTHN_CONTEXT); + if (authnContext != null) { + config.setAuthnContextClassRefs(splitList(authnContext)); + // pac4j drops RequestedAuthnContext entirely when no comparison is set + String comparison = + optional( + props, + REQUESTED_AUTHN_CONTEXT_COMPARISON, + optional(props, LEGACY_REQUESTED_AUTHN_CONTEXT_COMPARISON, "exact")); + config.setComparisonType(comparison); + } + String signatureAlgorithm = optional(props, SIGNATURE_ALGORITHM); + if (signatureAlgorithm != null) { + config.setSignatureAlgorithms(List.of(signatureAlgorithm)); + } + + configureKeystore(config, spCredentials(props)); + config.setMetadataGenerator( + new SupportedBindingsMetadataGenerator( + optional(props, SP_SLS_URL) != null, optional(props, SP_CERT) != null)); + config.setIdentityProviderMetadataResource( + new ByteArrayResource( + buildIdentityProviderMetadata(props).getBytes(StandardCharsets.UTF_8), + "identity provider metadata from " + PROPERTIES_FILE)); + + // pac4j's default store keeps nothing, so a Response's InResponseTo was never checked + SamlRequestStore requestStore = new SamlRequestStore(required(props, SP_ACS_URL)); + config.setSamlMessageStoreFactory(requestStore); + + SAML2Client saml = new AlternativeAuthnContextClient(config); + // The ACS URL is registered with the IdP as-is, so don't append ?client_name=... + saml.setCallbackUrlResolver(new NoParameterCallbackUrlResolver()); + saml.setCallbackUrl(required(props, SP_ACS_URL)); + saml.init(); + X509Certificate idpCertificate; + try { + idpCertificate = parseCertificate(required(props, IDP_CERT)); + } catch (GeneralSecurityException | IllegalArgumentException e) { + throw new IllegalStateException("Cannot load " + IDP_CERT + ": " + e.getMessage(), e); + } + // Null when no SLS URL is configured: no logout endpoint is advertised, and IdP + // LogoutRequests are refused + String singleLogoutUrl = optional(props, SP_SLS_URL); + return new Sso( + saml, + idpCertificate, + flag(props, WANT_MESSAGES_SIGNED, false), + singleLogoutUrl, + required(props, IDP_ENTITY_ID), + required(props, SP_ENTITY_ID), + requestStore); + } + + /** + * pac4j requires every requested AuthnContextClassRef to appear in the response, but SAML treats + * the requested classes as alternatives (SAML Core 3.3.2.2.1). With "exact" comparison, at least + * one returned class must be one of those requested. With minimum / better / maximum the IdP + * judges relative strength, which an SP can't check in general (a stronger class may be one that + * wasn't listed), so the returned class is accepted, as java-saml did. + */ + private static final class AlternativeAuthnContextClient extends SAML2Client { + AlternativeAuthnContextClient(SAML2Configuration configuration) { + super(configuration); + } + + @Override + protected void initSAMLResponseValidator(boolean forceReinit) { + if (authnResponseValidator == null || forceReinit) { + String comparison = configuration.getComparisonType(); + authnResponseValidator = + new SAML2AuthnResponseValidator( + signatureTrustEngineProvider, + decrypter, + replayCache, + configuration, + findSessionLogoutHandler()) { + @Override + protected void validateAuthnContextClassRefs( + SAML2MessageContext context, List provided) { + List requested = configuration.getAuthnContextClassRefs(); + if (requested.isEmpty() + || (comparison != null && !"exact".equalsIgnoreCase(comparison))) { + return; + } + if (provided.stream().noneMatch(requested::contains)) { + throw new SAMLAuthnContextClassRefException( + "None of the requested authentication context classes " + + requested + + " is in the IdP's authentication statements " + + provided); + } + } + + /** + * pac4j binds a response to this browser's request only through the Response's + * InResponseTo, which isn't signed unless the whole message is. The bearer + * confirmation sits inside the signed assertion and must name the same request (SAML + * Profiles 4.1.4.3), so an assertion can't be moved into an envelope naming another + * request. Only SP-initiated login is supported, so it must name one. This runs on + * decrypted assertions too. + */ + @Override + protected boolean isValidBearerSubjectConfirmationData( + SubjectConfirmationData data, SAML2MessageContext context) { + if (!super.isValidBearerSubjectConfirmationData(data, context)) { + return false; + } + Object message = context.getMessageContext().getMessage(); + String requestId = + message instanceof Response ? ((Response) message).getInResponseTo() : null; + if (requestId == null || !requestId.equals(data.getInResponseTo())) { + log.error( + "SAML response rejected: the assertion's bearer confirmation doesn't answer" + + " the request named by the response"); + return false; + } + return true; + } + }; + authnResponseValidator.setAcceptedSkew(configuration.getAcceptedSkew()); + } + } + } + + /** + * pac4j advertises HTTP-POST, HTTP-POST-SimpleSign, HTTP-Redirect and SOAP single logout + * endpoints. SamlSso can only verify signatures for HTTP-POST (XML signature) and HTTP-Redirect + * (query-string signature), so advertise just those. With no SLS URL configured pac4j would + * advertise the ACS URL, which doesn't process logout messages, so advertise none. Without a + * configured SP certificate the key is temporary, so publish no KeyDescriptor. The descriptor is + * signed afterwards. + */ + private static final class SupportedBindingsMetadataGenerator + extends SAML2FileSystemMetadataGenerator { + private final boolean advertiseSingleLogout; + private final boolean publishKeys; + + SupportedBindingsMetadataGenerator(boolean advertiseSingleLogout, boolean publishKeys) { + super(null); + this.advertiseSingleLogout = advertiseSingleLogout; + this.publishKeys = publishKeys; + } + + @Override + protected SPSSODescriptor buildSPSSODescriptor() { + SPSSODescriptor descriptor = super.buildSPSSODescriptor(); + descriptor + .getSingleLogoutServices() + .removeIf( + service -> + !advertiseSingleLogout + || (!HTTP_POST_BINDING.equals(service.getBinding()) + && !HTTP_REDIRECT_BINDING.equals(service.getBinding()))); + if (!publishKeys) { + // A temporary key changes on every restart, so an IdP must not sign-verify or encrypt to it + descriptor.getKeyDescriptors().clear(); + } + return descriptor; + } + } + + /** + * The SP's signing/decryption key and certificate. Like java-saml, both settings may be left out + * when the SP signs nothing (authnrequest_signed, logoutrequest_signed and sign_metadata all + * false): pac4j still needs a key pair, so a temporary one is generated in memory. Encrypted + * assertions then can't be used, since the key changes on every restart. + */ + private static KeyStore.PrivateKeyEntry spCredentials(Properties props) { + String certPem = optional(props, SP_CERT); + String keyPem = optional(props, SP_KEY); + try { + if (certPem != null && keyPem != null) { + return new KeyStore.PrivateKeyEntry( + parsePrivateKey(keyPem), new Certificate[] {parseCertificate(certPem)}); + } + if (certPem != null || keyPem != null) { + throw new IllegalStateException("Set both " + SP_CERT + " and " + SP_KEY + ", or neither"); + } + List signingSettings = new ArrayList<>(); + for (String setting : List.of(AUTHN_REQUEST_SIGNED, LOGOUT_REQUEST_SIGNED, SIGN_METADATA)) { + if (flag(props, setting, false)) { + signingSettings.add(setting); + } + } + if (!signingSettings.isEmpty()) { + throw new IllegalStateException( + "Missing SAML setting " + + SP_CERT + + " / " + + SP_KEY + + ", required by " + + String.join(", ", signingSettings)); + } + log.info( + "No SP certificate configured: using a temporary in-memory key pair. SP signing and" + + " encrypted assertions are unavailable."); + return temporaryCredentials(); + } catch (GeneralSecurityException | IOException | OperatorCreationException e) { + throw new IllegalStateException( + "Cannot load " + SP_CERT + " / " + SP_KEY + ": " + e.getMessage(), e); + } + } + + private static KeyStore.PrivateKeyEntry temporaryCredentials() + throws GeneralSecurityException, OperatorCreationException { + KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA"); + generator.initialize(2048); + KeyPair keys = generator.generateKeyPair(); + X500Name name = new X500Name("CN=Security Shepherd SAML SP (temporary)"); + Instant now = Instant.now(); + X509Certificate cert = + new JcaX509CertificateConverter() + .getCertificate( + new JcaX509v3CertificateBuilder( + name, + BigInteger.valueOf(now.toEpochMilli()), + Date.from(now.minus(Duration.ofDays(1))), + Date.from(now.plus(Duration.ofDays(3650))), + name, + keys.getPublic()) + .build(new JcaContentSignerBuilder("SHA256withRSA").build(keys.getPrivate()))); + return new KeyStore.PrivateKeyEntry(keys.getPrivate(), new Certificate[] {cert}); + } + + /** + * Loads the SP's private key and certificate into an in-memory keystore, so pac4j neither reads + * nor generates a keystore file. + */ + private static void configureKeystore( + SAML2Configuration config, KeyStore.PrivateKeyEntry credentials) { + String password = UUID.randomUUID().toString(); + byte[] keystore; + try { + KeyStore ks = KeyStore.getInstance("PKCS12"); + ks.load(null, null); + ks.setKeyEntry( + SP_KEY_ALIAS, + credentials.getPrivateKey(), + password.toCharArray(), + credentials.getCertificateChain()); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + ks.store(out, password.toCharArray()); + keystore = out.toByteArray(); + } catch (GeneralSecurityException | IOException e) { + throw new IllegalStateException("Cannot build the SP keystore: " + e.getMessage(), e); + } + + config.getKeystore().setKeystoreResource(new ByteArrayResource(keystore, "SP keystore")); + config.getKeystore().setKeyStoreType("PKCS12"); + config.getKeystore().setKeyStoreAlias(SP_KEY_ALIAS); + config.getKeystore().setKeystorePassword(password); + config.getKeystore().setPrivateKeyPassword(password); + config + .getKeystore() + .setKeystoreGenerator( + new KeystoreGenerator() { + @Override + public void generate() {} + + @Override + public boolean shouldGenerate() { + return false; + } + + @Override + public InputStream retrieve() { + return new ByteArrayInputStream(keystore); + } + }); + } + + /** + * pac4j reads the IdP from SAML metadata, while onelogin.saml.properties describes it with + * individual keys. Build the equivalent metadata document from those keys. + */ + static String buildIdentityProviderMetadata(Properties props) { + String cert = optional(props, IDP_CERT); + if (cert == null) { + String reason = + optional(props, IDP_CERT_FINGERPRINT) != null + ? " (" + IDP_CERT_FINGERPRINT + " alone is not supported; set the full certificate)" + : ""; + throw new IllegalStateException("Missing SAML setting " + IDP_CERT + reason); + } + StringBuilder xml = new StringBuilder(); + xml.append("") + .append("") + .append("") + .append(pemBody(cert)) + .append(""); + String sloUrl = optional(props, IDP_SLO_URL); + if (sloUrl != null) { + xml.append(""); + } + xml.append("") + .append(""); + return xml.toString(); + } + + static X509Certificate parseCertificate(String pem) throws GeneralSecurityException { + byte[] der = Base64.getDecoder().decode(pemBody(pem)); + return (X509Certificate) + CertificateFactory.getInstance("X.509").generateCertificate(new ByteArrayInputStream(der)); + } + + /** Accepts PKCS#8 ("BEGIN PRIVATE KEY") or PKCS#1 ("BEGIN RSA PRIVATE KEY") RSA keys. */ + static PrivateKey parsePrivateKey(String pem) throws GeneralSecurityException, IOException { + byte[] der = Base64.getDecoder().decode(pemBody(pem)); + KeyFactory rsa = KeyFactory.getInstance("RSA"); + try { + return rsa.generatePrivate(new PKCS8EncodedKeySpec(der)); + } catch (InvalidKeySpecException notPkcs8) { + PrivateKeyInfo pkcs8 = + new PrivateKeyInfo( + new AlgorithmIdentifier(PKCSObjectIdentifiers.rsaEncryption, DERNull.INSTANCE), + RSAPrivateKey.getInstance(der)); + return rsa.generatePrivate(new PKCS8EncodedKeySpec(pkcs8.getEncoded())); + } + } + + /** Strips PEM armour and whitespace, leaving the base64 body. */ + static String pemBody(String pem) { + return pem.replaceAll("-----(BEGIN|END)[^-]*-----", "").replaceAll("\\s", ""); + } + + private static List splitList(String value) { + List items = new ArrayList<>(); + for (String item : value.split(",")) { + if (!item.trim().isEmpty()) { + items.add(item.trim()); + } + } + return items; + } + + private static String required(Properties props, String key) { + String value = optional(props, key); + if (value == null) { + throw new IllegalStateException("Missing SAML setting " + key + " in " + PROPERTIES_FILE); + } + return value; + } + + private static String optional(Properties props, String key) { + String value = props.getProperty(key); + return value == null || value.trim().isEmpty() ? null : value.trim(); + } + + private static String optional(Properties props, String key, String defaultValue) { + String value = optional(props, key); + return value == null ? defaultValue : value; + } + + private static boolean flag(Properties props, String key, boolean defaultValue) { + String value = optional(props, key); + return value == null ? defaultValue : Boolean.parseBoolean(value); + } +} diff --git a/src/main/java/utils/SsoUserMapper.java b/src/main/java/utils/SsoUserMapper.java new file mode 100644 index 000000000..11d80d113 --- /dev/null +++ b/src/main/java/utils/SsoUserMapper.java @@ -0,0 +1,132 @@ +package utils; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Properties; + +/** + * Maps the attributes released by the SAML identity provider to a Security Shepherd user, using the + * attribute names and affiliations configured in sso.properties.
+ * This file is part of the Security Shepherd Project. + * + *

The Security Shepherd project is free software: you can redistribute it and/or modify it under + * the terms of the GNU General Public License as published by the Free Software Foundation, either + * version 3 of the License, or (at your option) any later version.
+ * + *

The Security Shepherd project 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 General Public License for more details.
+ * + *

You should have received a copy of the GNU General Public License along with the Security + * Shepherd project. If not, see . + */ +public class SsoUserMapper { + + /** Result of mapping: either a user or an error message for the login page */ + public static class Result { + public final String ssoName; + public final String userName; + public final String userRole; + public final String error; + + private Result(String ssoName, String userName, String userRole, String error) { + this.ssoName = ssoName; + this.userName = userName; + this.userRole = userRole; + this.error = error; + } + + public boolean isValid() { + return error == null; + } + } + + /** + * Maps IdP attributes to a user. + * + * @param attributes Attributes from the SAML assertion. Values may be single objects or + * collections. + * @param ssoProperties sso.properties contents + * @return The mapped user, or an error if a required attribute is missing or the user has none of + * the configured affiliations + */ + public static Result map(Map attributes, Properties ssoProperties) { + if (attributes == null || attributes.isEmpty()) { + return error("You don't have any attributes"); + } + String ssoName = first(attributes, ssoProperties.getProperty("sso.saml.ssoName")); + String userName = first(attributes, ssoProperties.getProperty("sso.saml.userName")); + if (ssoName == null || userName == null) { + return error("Required SSO attributes are missing"); + } + + List affiliations = + values(attributes, ssoProperties.getProperty("sso.saml.affiliation")); + List adminAffiliations = + splitList(ssoProperties.getProperty("sso.saml.adminAffiliation")); + List playerAffiliations = + splitList(ssoProperties.getProperty("sso.saml.playerAffiliation")); + + if (!Collections.disjoint(affiliations, adminAffiliations)) { + return new Result(ssoName, userName, "admin", null); + } + if (!Collections.disjoint(affiliations, playerAffiliations)) { + return new Result(ssoName, userName, "player", null); + } + + StringBuilder message = + new StringBuilder( + "Authorization failed. Please ensure that you are a member of one of the" + + " following groups: "); + for (String affiliation : adminAffiliations) { + message.append(affiliation).append(", "); + } + for (String affiliation : playerAffiliations) { + message.append(affiliation).append(", "); + } + return error(message.toString()); + } + + private static Result error(String message) { + return new Result(null, null, null, message); + } + + private static String first(Map attributes, String key) { + List values = values(attributes, key); + return values.isEmpty() ? null : values.get(0); + } + + private static List values(Map attributes, String key) { + List values = new ArrayList<>(); + Object value = key == null ? null : attributes.get(key); + if (value instanceof Collection) { + for (Object item : (Collection) value) { + addIfNotBlank(values, item); + } + } else { + addIfNotBlank(values, value); + } + return values; + } + + /** + * Blank values count as missing: a blank ssoName would let different principals share one + * account, since accounts are looked up by it. + */ + private static void addIfNotBlank(List values, Object value) { + if (value != null && !value.toString().trim().isEmpty()) { + values.add(value.toString()); + } + } + + private static List splitList(String value) { + if (value == null || value.trim().isEmpty()) { + return Collections.emptyList(); + } + return Arrays.asList(value.trim().split("\\s*,\\s*")); + } +} diff --git a/src/main/resources/onelogin.saml.properties.sample b/src/main/resources/onelogin.saml.properties.sample index 8d571eee9..d7e417526 100644 --- a/src/main/resources/onelogin.saml.properties.sample +++ b/src/main/resources/onelogin.saml.properties.sample @@ -1,30 +1,49 @@ -# If 'strict' is True, then the Java Toolkit will reject unsigned -# or unencrypted messages if it expects them signed or encrypted -# Also will reject the messages if not strictly follow the SAML -onelogin.saml2.strict = true +# SAML SSO settings, read by utils.SamlSso (pac4j-saml). The key names are +# kept from the java-saml toolkit Security Shepherd used before, so existing +# files keep working. Copy to onelogin.saml.properties and enable SSO with +# sso.enabled=true in sso.properties. +# +# Not supported with pac4j, and ignored: +# onelogin.saml2.strict = false (messages are always validated strictly) +# onelogin.saml2.debug +# onelogin.saml2.security.nameid_encrypted, logoutresponse_signed, +# want_assertions_encrypted, want_nameid_encrypted, want_xml_validation +# (encrypted assertions and NameIDs are still decrypted when received) +# onelogin.saml2.idp.certfingerprint alone is not supported: set +# onelogin.saml2.idp.x509cert to the IdP's full signing certificate. +# +# Only SP-initiated login is supported (users start at /saml/dologin). +# IdP-initiated (unsolicited) responses are refused: they can't be tied to the +# browser that is logging in, which would allow login CSRF. Each login is bound +# to the browser by a cookie of its own, shepherd_saml_request_ (HttpOnly, +# Secure, SameSite=None, path of the ACS URL's directory, e.g. /saml/), deleted +# once the login completes, so the ACS must be served over HTTPS. Logins started +# in several tabs at once all work. +# The assertion's bearer SubjectConfirmationData must carry the AuthnRequest's +# ID in InResponseTo, as the SAML profile requires (standard IdPs do this), so +# the binding holds even when the Response itself is unsigned. -# Enable debug mode (to print errors) -onelogin.saml2.debug = false +onelogin.saml2.strict = true # Service Provider Data that we are deploying # # Identifier of the SP entity (must be a URI) -onelogin.saml2.sp.entityid = http://localhost:8080/owaspSecurityShepherd/ssometadata.xml +onelogin.saml2.sp.entityid = https://localhost/saml/metadata.xml # Specifies info about where and how the message MUST be # returned to the requester, in this case our SP. # URL Location where the from the IdP will be returned -onelogin.saml2.sp.assertion_consumer_service.url = http://localhost:8080/owaspSecurityShepherd/acs +onelogin.saml2.sp.assertion_consumer_service.url = https://localhost/saml/acs # SAML protocol binding to be used when returning the -# message. Onelogin Toolkit supports for this endpoint the -# HTTP-POST binding only +# message. HTTP-POST is the usual choice. onelogin.saml2.sp.assertion_consumer_service.binding = urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST # Specifies info about where and how the message MUST be -# returned to the requester, in this case our SP. -onelogin.saml2.sp.single_logout_service.url = http://localhost:8080/owaspSecurityShepherd/sls +# returned to the requester, in this case our SP. Leave empty to disable +# IdP-initiated single logout: no logout endpoint is then advertised. +onelogin.saml2.sp.single_logout_service.url = https://localhost/saml/sls # SAML protocol binding to be used when returning the or sending the # message. Onelogin Toolkit supports for this endpoint the @@ -36,13 +55,14 @@ onelogin.saml2.sp.single_logout_service.binding = urn:oasis:names:tc:SAML:2.0:bi # Take a look on lib/Saml2/Constants.php to see the NameIdFormat supported onelogin.saml2.sp.nameidformat = urn:oasis:names:tc:SAML:2.0:nameid-format:persistent -# Usually x509cert and privateKey of the SP are provided by files placed at -# the certs folder. But we can also provide them with the following parameters +# The SP's certificate and private key. Both may be left empty if the SP signs +# nothing (authnrequest_signed, logoutrequest_signed and sign_metadata all false): +# a temporary in-memory key is then used, and encrypted assertions can't be used. onelogin.saml2.sp.x509cert = -# Requires Format PKCS#8 BEGIN PRIVATE KEY -# If you have PKCS#1 BEGIN RSA PRIVATE KEY convert it by openssl pkcs8 -topk8 -inform pem -nocrypt -in sp.rsa_key -outform pem -out sp.pem +# RSA private key, PKCS#8 (BEGIN PRIVATE KEY) or PKCS#1 (BEGIN RSA PRIVATE KEY). +# The PEM header/footer and line breaks are optional. onelogin.saml2.sp.privatekey = # Identity Provider Data that we want connect with our SP @@ -74,27 +94,13 @@ onelogin.saml2.idp.single_logout_service.response.url = # HTTP-Redirect binding only onelogin.saml2.idp.single_logout_service.binding = urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect -# Public x509 certificate of the IdP +# Public x509 certificate of the IdP (required) onelogin.saml2.idp.x509cert = -# Instead of use the whole x509cert you can use a fingerprint -# (openssl x509 -noout -fingerprint -in "idp.crt" to generate it, -# or add for example the -sha256 , -sha384 or -sha512 parameter) -# -# If a fingerprint is provided, then the certFingerprintAlgorithm is required in order to -# let the toolkit know which Algorithm was used. Possible values: sha1, sha256, sha384 or sha512 -# 'sha1' is the default value. -onelogin.saml2.idp.certfingerprint = -onelogin.saml2.idp.certfingerprint_algorithm = sha256 - # Security settings # -# Indicates that the nameID of the sent by this SP -# will be encrypted. -onelogin.saml2.security.nameid_encrypted = true - # Indicates whether the messages sent by this SP # will be signed. [The Metadata of the SP will offer this info] onelogin.saml2.security.authnrequest_signed = true @@ -103,10 +109,6 @@ onelogin.saml2.security.authnrequest_signed = true # will be signed. onelogin.saml2.security.logoutrequest_signed = true -# Indicates whether the messages sent by this SP -# will be signed. -onelogin.saml2.security.logoutresponse_signed = true - # Indicates a requirement for the , and # elements received by this SP to be signed. onelogin.saml2.security.want_messages_signed = true @@ -118,23 +120,13 @@ onelogin.saml2.security.want_assertions_signed = true # Right now supported null (in order to not sign) or true (sign using SP private key) onelogin.saml2.security.sign_metadata = true -# Indicates a requirement for the Assertions received by this SP to be encrypted -onelogin.saml2.security.want_assertions_encrypted = true - -# Indicates a requirement for the NameID received by this SP to be encrypted -onelogin.saml2.security.want_nameid_encrypted = true - # Authentication context. # Set Empty and no AuthContext will be sent in the AuthNRequest # You can set multiple values (comma separated them) onelogin.saml2.security.requested_authncontext = # Allows the authn comparison parameter to be set, defaults to 'exact' -onelogin.saml2.security.onelogin.saml2.security.requested_authncontextcomparison = exact - -# Indicates if the SP will validate all received xmls. -# (In order to validate the xml, 'strict' and 'wantXMLValidation' must be true). -onelogin.saml2.security.want_xml_validation = true +onelogin.saml2.security.requested_authncontextcomparison = exact # Algorithm that the toolkit will use on signing process. Options: # 'http://www.w3.org/2000/09/xmldsig#rsa-sha1' diff --git a/src/main/resources/sso.properties.sample b/src/main/resources/sso.properties.sample index e7c075fb5..97c0dca8d 100644 --- a/src/main/resources/sso.properties.sample +++ b/src/main/resources/sso.properties.sample @@ -1,2 +1,16 @@ +# Copy to sso.properties to configure SAML single sign-on. +# SAML endpoint settings live in onelogin.saml.properties. + +# Set to true to replace the username/password login with SAML SSO +sso.enabled=false + +# Names of the SAML attributes holding the user's unique SSO id and display name sso.saml.ssoName= sso.saml.userName= + +# SAML attribute listing the user's groups/affiliations, and the values that +# make a user an admin or a player (comma separated). Users with none of these +# affiliations are refused. +sso.saml.affiliation= +sso.saml.adminAffiliation= +sso.saml.playerAffiliation= diff --git a/src/test/java/servlets/SLSTest.java b/src/test/java/servlets/SLSTest.java new file mode 100644 index 000000000..f1c9e2a41 --- /dev/null +++ b/src/test/java/servlets/SLSTest.java @@ -0,0 +1,104 @@ +package servlets; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.mock.web.MockHttpSession; +import utils.SamlTestSupport; + +public class SLSTest { + + private static SamlTestSupport saml; + + @BeforeAll + public static void installClient() throws Exception { + saml = new SamlTestSupport(); + saml.installClient(); + } + + @AfterAll + public static void uninstallClient() { + SamlTestSupport.uninstallClient(); + } + + private static MockHttpSession loggedInSession() { + MockHttpSession session = new MockHttpSession(); + session.setAttribute("userName", "jdoe"); + session.setAttribute("userRole", "player"); + return session; + } + + private static MockHttpServletRequest logoutResponse() throws Exception { + return SamlTestSupport.slsGet( + SamlTestSupport.redirectQuery( + "SAMLResponse", SamlTestSupport.toXml(saml.logoutResponse(), null, null), null)); + } + + /** The SLS used to only define handleRequest, so the IdP's redirect got a 405 */ + @Test + public void doGet_WithoutSamlMessage_IsBadRequest() throws Exception { + MockHttpServletResponse response = new MockHttpServletResponse(); + + new SLS().doGet(new MockHttpServletRequest("GET", "/saml/sls"), response); + + assertEquals(400, response.getStatus()); + assertNull(response.getRedirectedUrl()); + } + + @Test + public void doPost_WithoutSamlMessage_IsBadRequest() throws Exception { + MockHttpServletResponse response = new MockHttpServletResponse(); + + new SLS().doPost(new MockHttpServletRequest("POST", "/saml/sls"), response); + + assertEquals(400, response.getStatus()); + assertNull(response.getRedirectedUrl()); + } + + /** + * A bogus message must not end the session, directly or by redirecting to login.jsp, which + * invalidates the session it is loaded in + */ + @Test + public void invalidLogoutMessage_KeepsSessionAndDoesNotRedirect() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/saml/sls"); + MockHttpSession session = loggedInSession(); + request.setSession(session); + MockHttpServletResponse response = new MockHttpServletResponse(); + + new SLS().doGet(request, response); + + assertFalse(session.isInvalid()); + assertEquals(400, response.getStatus()); + assertNull(response.getRedirectedUrl()); + } + + @Test + public void logoutResponse_WithoutSession_GoesToLogin() throws Exception { + MockHttpServletResponse response = new MockHttpServletResponse(); + + new SLS().doGet(logoutResponse(), response); + + assertEquals("../login.jsp", response.getRedirectedUrl()); + } + + /** A LogoutResponse delivered to a still-logged-in browser must not end that session */ + @Test + public void logoutResponse_InLoggedInBrowser_KeepsSession() throws Exception { + MockHttpServletRequest request = logoutResponse(); + MockHttpSession session = loggedInSession(); + request.setSession(session); + MockHttpServletResponse response = new MockHttpServletResponse(); + + new SLS().doGet(request, response); + + assertFalse(session.isInvalid()); + assertEquals("../index.jsp", response.getRedirectedUrl()); + } +} diff --git a/src/test/java/servlets/SsoDisabledTest.java b/src/test/java/servlets/SsoDisabledTest.java new file mode 100644 index 000000000..d0f9f5303 --- /dev/null +++ b/src/test/java/servlets/SsoDisabledTest.java @@ -0,0 +1,80 @@ +package servlets; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.mock.web.MockHttpSession; +import utils.SamlTestSupport; + +/** With sso.enabled off, a retained SAML configuration must not make the SSO endpoints usable */ +public class SsoDisabledTest { + + private SamlTestSupport saml; + + @BeforeEach + public void installClientWithSsoDisabled() throws Exception { + saml = new SamlTestSupport(); + saml.installClient(); + SamlTestSupport.setSsoEnabled(false); + } + + @AfterEach + public void uninstallClient() { + SamlTestSupport.uninstallClient(); + } + + @Test + public void acs_ValidAssertion_DoesNotAuthenticate() throws Exception { + Map> attributes = new LinkedHashMap<>(); + attributes.put("uid", Collections.singletonList("jdoe")); + attributes.put("displayName", Collections.singletonList("Jane Doe")); + attributes.put("groups", Arrays.asList("staff")); + String samlResponse = saml.signedResponse("user-123", attributes, saml.idpKeys, saml.idpCert); + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/saml/acs"); + request.setScheme("https"); + request.setSecure(true); + request.setServerName("shepherd.test"); + request.setServerPort(443); + request.addParameter("SAMLResponse", samlResponse); + request.setCookies(saml.browserCookie()); + MockHttpSession session = new MockHttpSession(); + request.setSession(session); + MockHttpServletResponse response = new MockHttpServletResponse(); + + new ACS().doPost(request, response); + + assertEquals(404, response.getStatus()); + assertNull(session.getAttribute("userName")); + } + + @Test + public void ssoLogin_IsNotFound() throws Exception { + MockHttpServletResponse response = new MockHttpServletResponse(); + new SSOLogin().doGet(new MockHttpServletRequest("GET", "/saml/dologin"), response); + assertEquals(404, response.getStatus()); + } + + @Test + public void sls_IsNotFound() throws Exception { + MockHttpServletResponse response = new MockHttpServletResponse(); + new SLS().doGet(new MockHttpServletRequest("GET", "/saml/sls"), response); + assertEquals(404, response.getStatus()); + } + + @Test + public void ssoMetadata_IsNotFound() throws Exception { + MockHttpServletResponse response = new MockHttpServletResponse(); + new SSOMetadata().doGet(new MockHttpServletRequest("GET", "/saml/metadata.xml"), response); + assertEquals(404, response.getStatus()); + } +} diff --git a/src/test/java/utils/SamlLogoutTest.java b/src/test/java/utils/SamlLogoutTest.java new file mode 100644 index 000000000..224cbf86d --- /dev/null +++ b/src/test/java/utils/SamlLogoutTest.java @@ -0,0 +1,481 @@ +package utils; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.startsWith; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.security.KeyPair; +import java.security.cert.X509Certificate; +import java.time.Duration; +import java.time.Instant; +import java.util.Arrays; +import java.util.Properties; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.opensaml.saml.saml2.core.LogoutRequest; +import org.opensaml.saml.saml2.core.NameIDType; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.mock.web.MockHttpSession; + +/** Single logout service: which logout messages are trusted, and which sessions they end */ +public class SamlLogoutTest { + + private static SamlTestSupport saml; + private static KeyPair attackerKeys; + private static X509Certificate attackerCert; + + private MockHttpSession loggedIn; + private MockHttpSession otherUser; + + @BeforeAll + public static void generateKeys() throws Exception { + saml = new SamlTestSupport(); + attackerKeys = SamlTestSupport.newKeyPair(); + attackerCert = SamlTestSupport.selfSigned(attackerKeys, "idp.test"); + } + + /** Installs a client that requires signed logout messages, as the sample configuration does */ + @BeforeEach + public void setUp() throws Exception { + SamlSso.clearRememberedLogoutRequests(); + SamlTestSupport.install(signedLogoutProperties(true)); + loggedIn = new MockHttpSession(); + register(loggedIn, "user-123", "_session-1"); + otherUser = new MockHttpSession(); + register(otherUser, "user-456", "_session-2"); + } + + @AfterEach + public void tearDown() { + for (MockHttpSession session : new MockHttpSession[] {loggedIn, otherUser}) { + if (!session.isInvalid()) { + session.invalidate(); + } + } + SamlTestSupport.uninstallClient(); + SamlSso.maxRememberedLogoutRequests = 10000; + SamlSso.clearRememberedLogoutRequests(); + } + + /** Registers a session for a persistent NameID with default qualifiers, as ACS would */ + private static void register(MockHttpSession session, String nameId, String sessionIndex) { + SamlSessionRegistry.register( + session, SamlSso.nameIdKey(nameId, NameIDType.PERSISTENT, null, null), sessionIndex); + } + + private static Properties signedLogoutProperties(boolean wantMessagesSigned) throws Exception { + Properties props = saml.properties(); + props.setProperty(SamlSso.WANT_MESSAGES_SIGNED, Boolean.toString(wantMessagesSigned)); + return props; + } + + private static SamlSso.LogoutMessage process(MockHttpServletRequest request) { + return SamlSso.processLogoutMessage(request, new MockHttpServletResponse()); + } + + private String logoutResponseXml() throws Exception { + return SamlTestSupport.toXml(saml.logoutResponse(), null, null); + } + + // LogoutResponse (end of an SP-initiated logout) + + @Test + public void redirectLogoutResponse_SignedByIdp_IsAccepted() throws Exception { + String query = SamlTestSupport.redirectQuery("SAMLResponse", logoutResponseXml(), saml.idpKeys); + + assertEquals(SamlSso.LogoutMessage.LOGOUT_RESPONSE, process(SamlTestSupport.slsGet(query))); + } + + @Test + public void redirectLogoutResponse_Unsigned_IsRejected() throws Exception { + String query = SamlTestSupport.redirectQuery("SAMLResponse", logoutResponseXml(), null); + + assertEquals(SamlSso.LogoutMessage.INVALID, process(SamlTestSupport.slsGet(query))); + } + + @Test + public void redirectLogoutResponse_SignedByUnknownKey_IsRejected() throws Exception { + String query = SamlTestSupport.redirectQuery("SAMLResponse", logoutResponseXml(), attackerKeys); + + assertEquals(SamlSso.LogoutMessage.INVALID, process(SamlTestSupport.slsGet(query))); + } + + @Test + public void redirectLogoutResponse_MessageSwappedAfterSigning_IsRejected() throws Exception { + String signed = + SamlTestSupport.redirectQuery("SAMLResponse", logoutResponseXml(), saml.idpKeys); + String otherMessage = SamlTestSupport.redirectQuery("SAMLResponse", logoutResponseXml(), null); + String tampered = otherMessage + signed.substring(signed.indexOf("&SigAlg=")); + + assertEquals(SamlSso.LogoutMessage.INVALID, process(SamlTestSupport.slsGet(tampered))); + } + + @Test + public void postLogoutResponse_XmlSignedByIdp_IsAccepted() throws Exception { + String xml = SamlTestSupport.toXml(saml.logoutResponse(), saml.idpKeys, saml.idpCert); + + assertEquals( + SamlSso.LogoutMessage.LOGOUT_RESPONSE, + process(SamlTestSupport.slsPost("SAMLResponse", xml))); + } + + @Test + public void postLogoutResponse_Unsigned_IsRejected() throws Exception { + assertEquals( + SamlSso.LogoutMessage.INVALID, + process(SamlTestSupport.slsPost("SAMLResponse", logoutResponseXml()))); + } + + @Test + public void postLogoutResponse_XmlSignedByUnknownKey_IsRejected() throws Exception { + String xml = SamlTestSupport.toXml(saml.logoutResponse(), attackerKeys, attackerCert); + + assertEquals( + SamlSso.LogoutMessage.INVALID, process(SamlTestSupport.slsPost("SAMLResponse", xml))); + } + + @Test + public void logoutResponse_EndsNoSession() throws Exception { + String query = SamlTestSupport.redirectQuery("SAMLResponse", logoutResponseXml(), saml.idpKeys); + MockHttpServletRequest request = SamlTestSupport.slsGet(query); + request.setSession(loggedIn); + + process(request); + + assertFalse(loggedIn.isInvalid()); + assertFalse(otherUser.isInvalid()); + } + + @Test + public void unsignedLogoutResponse_AcceptedWhenSignaturesNotRequired() throws Exception { + SamlTestSupport.install(signedLogoutProperties(false)); + String query = SamlTestSupport.redirectQuery("SAMLResponse", logoutResponseXml(), null); + + assertEquals(SamlSso.LogoutMessage.LOGOUT_RESPONSE, process(SamlTestSupport.slsGet(query))); + } + + // IdP-initiated LogoutRequest + + private String logoutRequestQuery(String nameId, String sessionIndex, KeyPair keys) + throws Exception { + String xml = SamlTestSupport.toXml(saml.logoutRequest(nameId, sessionIndex), null, null); + return SamlTestSupport.redirectQuery("SAMLRequest", xml, keys); + } + + @Test + public void idpLogoutRequest_EndsOnlyTheNamedSession_RegardlessOfBrowserSession() + throws Exception { + MockHttpServletRequest request = + SamlTestSupport.slsGet(logoutRequestQuery("user-123", "_session-1", saml.idpKeys)); + // Delivered through a different user's browser + request.setSession(otherUser); + MockHttpServletResponse response = new MockHttpServletResponse(); + + SamlSso.LogoutMessage result = SamlSso.processLogoutMessage(request, response); + + assertEquals(SamlSso.LogoutMessage.IDP_LOGOUT_REQUEST, result); + assertTrue(loggedIn.isInvalid()); + assertFalse(otherUser.isInvalid()); + assertThat(response.getRedirectedUrl(), startsWith(SamlTestSupport.IDP_SLO_URL + "?")); + assertThat(response.getRedirectedUrl(), containsString("SAMLResponse=")); + } + + @Test + public void idpLogoutRequest_WithoutSessionCookie_StillEndsTheNamedSession() throws Exception { + MockHttpServletRequest request = + SamlTestSupport.slsGet(logoutRequestQuery("user-123", "_session-1", saml.idpKeys)); + + assertEquals(SamlSso.LogoutMessage.IDP_LOGOUT_REQUEST, process(request)); + assertTrue(loggedIn.isInvalid()); + } + + @Test + public void idpLogoutRequest_DifferentSessionIndex_EndsNothing() throws Exception { + MockHttpServletRequest request = + SamlTestSupport.slsGet(logoutRequestQuery("user-123", "_other-login", saml.idpKeys)); + request.setSession(loggedIn); + + process(request); + + assertFalse(loggedIn.isInvalid()); + } + + @Test + public void idpLogoutRequest_Unsigned_IsRejectedAndEndsNothing() throws Exception { + MockHttpServletRequest request = + SamlTestSupport.slsGet(logoutRequestQuery("user-123", "_session-1", null)); + + assertEquals(SamlSso.LogoutMessage.INVALID, process(request)); + assertFalse(loggedIn.isInvalid()); + } + + @Test + public void idpLogoutRequest_SignedByUnknownKey_IsRejectedAndEndsNothing() throws Exception { + MockHttpServletRequest request = + SamlTestSupport.slsGet(logoutRequestQuery("user-123", "_session-1", attackerKeys)); + + assertEquals(SamlSso.LogoutMessage.INVALID, process(request)); + assertFalse(loggedIn.isInvalid()); + } + + @Test + public void postIdpLogoutRequest_XmlSignedByIdp_EndsTheNamedSession() throws Exception { + String xml = + SamlTestSupport.toXml( + saml.logoutRequest("user-123", "_session-1"), saml.idpKeys, saml.idpCert); + + assertEquals( + SamlSso.LogoutMessage.IDP_LOGOUT_REQUEST, + process(SamlTestSupport.slsPost("SAMLRequest", xml))); + assertTrue(loggedIn.isInvalid()); + assertFalse(otherUser.isInvalid()); + } + + @Test + public void unsignedIdpLogoutRequestWithoutSessionIndex_EndsNothing() throws Exception { + // Even when signatures are not required, a bare NameID is too guessable to end sessions + SamlTestSupport.install(signedLogoutProperties(false)); + MockHttpServletRequest request = + SamlTestSupport.slsGet(logoutRequestQuery("user-123", null, null)); + + process(request); + + assertFalse(loggedIn.isInvalid()); + } + + @Test + public void idpLogoutRequest_EndsEverySessionIndexItNames() throws Exception { + MockHttpSession secondLogin = new MockHttpSession(); + register(secondLogin, "user-123", "_session-3"); + String xml = + SamlTestSupport.toXml( + saml.logoutRequest("user-123", Arrays.asList("_session-1", "_session-3")), null, null); + String query = SamlTestSupport.redirectQuery("SAMLRequest", xml, saml.idpKeys); + + assertEquals(SamlSso.LogoutMessage.IDP_LOGOUT_REQUEST, process(SamlTestSupport.slsGet(query))); + assertTrue(loggedIn.isInvalid()); + assertTrue(secondLogin.isInvalid()); + assertFalse(otherUser.isInvalid()); + } + + /** + * A captured signed query with an unsigned message prepended under a percent-encoded name: the + * container decodes the alias to SAMLRequest and serves it first, so it must not borrow the + * signature of the real SAMLRequest. + */ + @Test + public void unsignedMessageUnderEncodedAlias_DoesNotBorrowSignature() throws Exception { + String signed = logoutRequestQuery("user-456", "_session-2", saml.idpKeys); + String unsigned = logoutRequestQuery("user-123", null, null); + String smuggled = "%53AMLRequest" + unsigned.substring("SAMLRequest".length()) + "&" + signed; + + assertEquals(SamlSso.LogoutMessage.INVALID, process(SamlTestSupport.slsGet(smuggled))); + assertFalse(loggedIn.isInvalid()); + assertFalse(otherUser.isInvalid()); + } + + @Test + public void repeatedSignedParameter_IsRejected() throws Exception { + String signed = logoutRequestQuery("user-456", "_session-2", saml.idpKeys); + String unsigned = logoutRequestQuery("user-123", null, null); + + assertEquals( + SamlSso.LogoutMessage.INVALID, process(SamlTestSupport.slsGet(unsigned + "&" + signed))); + assertFalse(loggedIn.isInvalid()); + } + + @Test + public void redirectSignatureOnPostRequest_IsNotAccepted() throws Exception { + String signed = logoutRequestQuery("user-456", "_session-2", saml.idpKeys); + String unsignedXml = + SamlTestSupport.toXml(saml.logoutRequest("user-123", (String) null), null, null); + MockHttpServletRequest request = SamlTestSupport.slsPost("SAMLRequest", unsignedXml); + request.setQueryString(signed); + + assertEquals(SamlSso.LogoutMessage.INVALID, process(request)); + assertFalse(loggedIn.isInvalid()); + } + + private static String signedRedirect(LogoutRequest request) throws Exception { + return SamlTestSupport.redirectQuery( + "SAMLRequest", SamlTestSupport.toXml(request, null, null), saml.idpKeys); + } + + @Test + public void signedLogoutRequest_ForAnotherSp_EndsNothing() throws Exception { + LogoutRequest request = saml.logoutRequest("user-123", "_session-1"); + request.setDestination("https://other-sp.test/saml/sls"); + + assertEquals( + SamlSso.LogoutMessage.INVALID, process(SamlTestSupport.slsGet(signedRedirect(request)))); + assertFalse(loggedIn.isInvalid()); + } + + @Test + public void signedLogoutRequest_WithoutDestination_EndsNothing() throws Exception { + LogoutRequest request = saml.logoutRequest("user-123", "_session-1"); + request.setDestination(null); + + assertEquals( + SamlSso.LogoutMessage.INVALID, process(SamlTestSupport.slsGet(signedRedirect(request)))); + assertFalse(loggedIn.isInvalid()); + } + + @Test + public void staleLogoutRequest_EndsNothing() throws Exception { + LogoutRequest request = saml.logoutRequest("user-123", "_session-1"); + request.setIssueInstant(Instant.now().minus(Duration.ofMinutes(20))); + + assertEquals( + SamlSso.LogoutMessage.INVALID, process(SamlTestSupport.slsGet(signedRedirect(request)))); + assertFalse(loggedIn.isInvalid()); + } + + @Test + public void futureDatedLogoutRequest_EndsNothing() throws Exception { + LogoutRequest request = saml.logoutRequest("user-123", "_session-1"); + request.setIssueInstant(Instant.now().plus(Duration.ofMinutes(20))); + + assertEquals( + SamlSso.LogoutMessage.INVALID, process(SamlTestSupport.slsGet(signedRedirect(request)))); + assertFalse(loggedIn.isInvalid()); + } + + @Test + public void expiredLogoutRequest_EndsNothing() throws Exception { + LogoutRequest request = saml.logoutRequest("user-123", "_session-1"); + request.setNotOnOrAfter(Instant.now().minus(Duration.ofMinutes(10))); + + assertEquals( + SamlSso.LogoutMessage.INVALID, process(SamlTestSupport.slsGet(signedRedirect(request)))); + assertFalse(loggedIn.isInvalid()); + } + + @Test + public void replayedLogoutRequest_DoesNotEndALaterLogin() throws Exception { + String query = signedRedirect(saml.logoutRequest("user-123", (String) null)); + assertEquals(SamlSso.LogoutMessage.IDP_LOGOUT_REQUEST, process(SamlTestSupport.slsGet(query))); + assertTrue(loggedIn.isInvalid()); + // The user logs in again + MockHttpSession newLogin = new MockHttpSession(); + register(newLogin, "user-123", "_session-new"); + + assertEquals(SamlSso.LogoutMessage.INVALID, process(SamlTestSupport.slsGet(query))); + assertFalse(newLogin.isInvalid()); + newLogin.invalidate(); + } + + /** XML-DSig ecdsa-sha256 signatures are raw r || s, not the DER that SHA256withECDSA expects */ + @Test + public void ecdsaSignedRedirectLogoutResponse_FromEcIdp_IsAccepted() throws Exception { + KeyPair ecKeys = SamlTestSupport.newEcKeyPair(); + Properties props = signedLogoutProperties(true); + props.setProperty( + SamlSso.IDP_CERT, + SamlTestSupport.certificatePem(SamlTestSupport.selfSigned(ecKeys, "idp.test"))); + SamlTestSupport.install(props); + String query = + SamlTestSupport.redirectQuery( + "SAMLResponse", + logoutResponseXml(), + ecKeys, + "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha256", + "SHA256withECDSAinP1363Format"); + + assertEquals(SamlSso.LogoutMessage.LOGOUT_RESPONSE, process(SamlTestSupport.slsGet(query))); + } + + @Test + public void nameIdOnlyLogout_DoesNotEndSameValueWithDifferentFormatOrQualifier() + throws Exception { + MockHttpSession emailFormat = new MockHttpSession(); + SamlSessionRegistry.register( + emailFormat, SamlSso.nameIdKey("user-123", NameIDType.EMAIL, null, null), "_e"); + MockHttpSession otherIdp = new MockHttpSession(); + SamlSessionRegistry.register( + otherIdp, + SamlSso.nameIdKey("user-123", NameIDType.PERSISTENT, "https://other-idp.test", null), + "_o"); + + assertEquals( + SamlSso.LogoutMessage.IDP_LOGOUT_REQUEST, + process( + SamlTestSupport.slsGet(signedRedirect(saml.logoutRequest("user-123", (String) null))))); + + assertTrue(loggedIn.isInvalid()); + assertFalse(emailFormat.isInvalid()); + assertFalse(otherIdp.isInvalid()); + emailFormat.invalidate(); + otherIdp.invalidate(); + } + + @Test + public void explicitDefaultQualifiers_MatchOmittedOnes() throws Exception { + LogoutRequest request = saml.logoutRequest("user-123", "_session-1"); + request.getNameID().setNameQualifier(SamlTestSupport.IDP_ENTITY_ID); + request.getNameID().setSPNameQualifier(SamlTestSupport.SP_ENTITY_ID); + + assertEquals( + SamlSso.LogoutMessage.IDP_LOGOUT_REQUEST, + process(SamlTestSupport.slsGet(signedRedirect(request)))); + assertTrue(loggedIn.isInvalid()); + } + + @Test + public void withoutSlsUrl_IdpLogoutRequestIsRefusedAndEndsNothing() throws Exception { + Properties props = signedLogoutProperties(true); + props.remove(SamlSso.SP_SLS_URL); + SamlTestSupport.install(props); + LogoutRequest request = saml.logoutRequest("user-123", "_session-1"); + request.setDestination(SamlTestSupport.ACS_URL); + + assertEquals( + SamlSso.LogoutMessage.INVALID, process(SamlTestSupport.slsGet(signedRedirect(request)))); + assertFalse(loggedIn.isInvalid()); + } + + /** + * Unsigned requests that end nothing must not take replay-cache slots; otherwise flooding them + * would make genuine signed logouts be refused as "too many". + */ + @Test + public void floodOfUnsignedNoOpLogouts_DoesNotBlockSignedLogout() throws Exception { + SamlSso.maxRememberedLogoutRequests = 5; + SamlTestSupport.install(signedLogoutProperties(false)); + for (int i = 0; i < 10; i++) { + process(SamlTestSupport.slsGet(logoutRequestQuery("user-" + i, null, null))); + } + + MockHttpServletRequest signed = + SamlTestSupport.slsGet(logoutRequestQuery("user-123", "_session-1", saml.idpKeys)); + + assertEquals(SamlSso.LogoutMessage.IDP_LOGOUT_REQUEST, process(signed)); + assertTrue(loggedIn.isInvalid()); + } + + @Test + public void unsignedLogoutNamingATrackedLogin_StillEndsIt() throws Exception { + SamlTestSupport.install(signedLogoutProperties(false)); + + assertEquals( + SamlSso.LogoutMessage.IDP_LOGOUT_REQUEST, + process(SamlTestSupport.slsGet(logoutRequestQuery("user-123", "_session-1", null)))); + assertTrue(loggedIn.isInvalid()); + assertFalse(otherUser.isInvalid()); + } + + @Test + public void endedSession_IsRemovedFromRegistry() { + int before = SamlSessionRegistry.size(); + + loggedIn.invalidate(); + + assertEquals(before - 1, SamlSessionRegistry.size()); + } +} diff --git a/src/test/java/utils/SamlSsoTest.java b/src/test/java/utils/SamlSsoTest.java new file mode 100644 index 000000000..79c1de579 --- /dev/null +++ b/src/test/java/utils/SamlSsoTest.java @@ -0,0 +1,614 @@ +package utils; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.startsWith; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import jakarta.servlet.http.Cookie; +import java.security.KeyPair; +import java.security.PrivateKey; +import java.security.cert.X509Certificate; +import java.time.Duration; +import java.util.Arrays; +import java.util.Base64; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Properties; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.opensaml.saml.saml2.core.AuthnContext; +import org.pac4j.saml.profile.SAML2Profile; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +public class SamlSsoTest { + + private static SamlTestSupport saml; + + @BeforeAll + public static void generateKeys() throws Exception { + saml = new SamlTestSupport(); + } + + @BeforeEach + public void installClient() throws Exception { + saml.installClient(); + } + + @AfterEach + public void uninstallClient() { + SamlTestSupport.uninstallClient(); + } + + private static Map> studentAttributes() { + Map> attributes = new LinkedHashMap<>(); + attributes.put("uid", Collections.singletonList("jdoe")); + attributes.put("displayName", Collections.singletonList("Jane Doe")); + attributes.put("groups", Arrays.asList("students", "chess-club")); + return attributes; + } + + private static MockHttpServletRequest acsPost(String samlResponse) { + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/saml/acs"); + request.setScheme("https"); + request.setSecure(true); + request.setServerName("shepherd.test"); + request.setServerPort(443); + request.addParameter("SAMLResponse", samlResponse); + // Delivered by the browser that started the login, with the cookies it sends to the ACS + Cookie[] cookies = SamlTestSupport.cookiesFor("/saml/acs"); + if (cookies.length > 0) { + request.setCookies(cookies); + } + return request; + } + + @Test + public void parsePrivateKey_AcceptsPkcs8AndPkcs1() throws Exception { + KeyPair keys = SamlTestSupport.newKeyPair(); + + PrivateKey fromPkcs8 = SamlSso.parsePrivateKey(SamlTestSupport.pkcs8Pem(keys)); + PrivateKey fromPkcs1 = SamlSso.parsePrivateKey(SamlTestSupport.pkcs1Pem(keys)); + + assertEquals(keys.getPrivate(), fromPkcs8); + assertEquals(keys.getPrivate(), fromPkcs1); + } + + @Test + public void parseCertificate_AcceptsBareBase64() throws Exception { + String bare = Base64.getEncoder().encodeToString(saml.idpCert.getEncoded()); + + X509Certificate cert = SamlSso.parseCertificate(bare); + + assertEquals(saml.idpCert, cert); + } + + @Test + public void buildClient_MissingIdpCertificate_NamesTheSetting() throws Exception { + Properties props = saml.properties(); + props.remove(SamlSso.IDP_CERT); + props.setProperty(SamlSso.IDP_CERT_FINGERPRINT, "AB:CD"); + + IllegalStateException e = assertThrows(IllegalStateException.class, () -> SamlSso.build(props)); + + assertThat(e.getMessage(), containsString(SamlSso.IDP_CERT)); + assertThat(e.getMessage(), containsString("not supported")); + } + + @Test + public void buildClient_MissingAcsUrl_NamesTheSetting() throws Exception { + Properties props = saml.properties(); + props.setProperty(SamlSso.SP_ACS_URL, " "); + + IllegalStateException e = assertThrows(IllegalStateException.class, () -> SamlSso.build(props)); + + assertThat(e.getMessage(), containsString(SamlSso.SP_ACS_URL)); + } + + @Test + public void login_RedirectsToIdpWithAuthnRequest() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/saml/dologin"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + SamlSso.login(request, response); + + assertEquals(302, response.getStatus()); + String location = response.getRedirectedUrl(); + assertThat(location, startsWith(SamlTestSupport.IDP_SSO_URL + "?")); + assertThat(location, containsString("Signature=")); + String authnRequest = SamlTestSupport.decodeRedirectRequest(location); + assertThat(authnRequest, containsString("AuthnRequest")); + assertThat(authnRequest, containsString(SamlTestSupport.ACS_URL)); + assertThat(authnRequest, containsString(SamlTestSupport.SP_ENTITY_ID)); + } + + @Test + public void getServiceProviderMetadata_DescribesConfiguredEndpoints() throws Exception { + String metadata = SamlSso.getServiceProviderMetadata(); + + assertThat(metadata, containsString("entityID=\"" + SamlTestSupport.SP_ENTITY_ID + "\"")); + assertThat(metadata, containsString("Location=\"" + SamlTestSupport.ACS_URL + "\"")); + assertThat(metadata, containsString("Location=\"" + SamlTestSupport.SLS_URL + "\"")); + // The ACS URL must be exactly what the IdP has registered + assertFalse(metadata.contains("client_name=")); + String spCertBase64 = Base64.getEncoder().encodeToString(saml.spCert.getEncoded()); + assertThat(metadata.replaceAll("\\s", ""), containsString(spCertBase64)); + } + + @Test + public void authenticate_ValidSignedResponse_ReturnsProfile() throws Exception { + String samlResponse = + saml.signedResponse("user-123", studentAttributes(), saml.idpKeys, saml.idpCert); + + Optional profile = + SamlSso.authenticate(acsPost(samlResponse), new MockHttpServletResponse()); + + assertTrue(profile.isPresent()); + assertEquals("user-123", profile.get().getId()); + assertEquals("_session-user-123", profile.get().getSessionIndex()); + SsoUserMapper.Result user = SsoUserMapper.map(profile.get().getAttributes(), ssoProperties()); + assertTrue(user.isValid()); + assertEquals("jdoe", user.ssoName); + assertEquals("Jane Doe", user.userName); + assertEquals("player", user.userRole); + } + + @Test + public void authenticate_TamperedResponse_IsRejected() throws Exception { + String signed = + saml.signedResponse("user-123", studentAttributes(), saml.idpKeys, saml.idpCert); + String xml = new String(Base64.getDecoder().decode(signed), "UTF-8"); + String tampered = + Base64.getEncoder().encodeToString(xml.replace("students", "teachers").getBytes("UTF-8")); + + Optional profile = + SamlSso.authenticate(acsPost(tampered), new MockHttpServletResponse()); + + assertFalse(profile.isPresent()); + } + + @Test + public void authenticate_SignedByUnknownKey_IsRejected() throws Exception { + KeyPair attackerKeys = SamlTestSupport.newKeyPair(); + X509Certificate attackerCert = SamlTestSupport.selfSigned(attackerKeys, "idp.test"); + String samlResponse = + saml.signedResponse("user-123", studentAttributes(), attackerKeys, attackerCert); + + Optional profile = + SamlSso.authenticate(acsPost(samlResponse), new MockHttpServletResponse()); + + assertFalse(profile.isPresent()); + } + + @Test + public void authenticate_ReplayedResponse_IsRejected() throws Exception { + String samlResponse = + saml.signedResponse("user-123", studentAttributes(), saml.idpKeys, saml.idpCert); + + assertTrue( + SamlSso.authenticate(acsPost(samlResponse), new MockHttpServletResponse()).isPresent()); + assertFalse( + SamlSso.authenticate(acsPost(samlResponse), new MockHttpServletResponse()).isPresent()); + } + + @Test + public void authenticate_NoSamlResponse_ReturnsEmpty() { + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/saml/acs"); + + assertFalse(SamlSso.authenticate(request, new MockHttpServletResponse()).isPresent()); + } + + @Test + public void logout_SendsLogoutRequestWithNameIdAndSessionIndex() throws Exception { + String samlResponse = + saml.signedResponse("user-123", studentAttributes(), saml.idpKeys, saml.idpCert); + SAML2Profile profile = + SamlSso.authenticate(acsPost(samlResponse), new MockHttpServletResponse()).get(); + MockHttpServletResponse response = new MockHttpServletResponse(); + + boolean started = + SamlSso.logout(new MockHttpServletRequest("GET", "/logout"), response, profile); + + assertTrue(started); + String location = response.getRedirectedUrl(); + assertThat(location, startsWith(SamlTestSupport.IDP_SLO_URL + "?")); + String logoutRequest = SamlTestSupport.decodeRedirectRequest(location); + assertThat(logoutRequest, containsString("LogoutRequest")); + assertThat(logoutRequest, containsString(">user-123<")); + assertThat(logoutRequest, containsString("_session-user-123")); + } + + @Test + public void logout_KeepsNameIdQualifiers() throws Exception { + String samlResponse = + saml.signedResponse( + "user-123", + SamlTestSupport.IDP_ENTITY_ID, + SamlTestSupport.SP_ENTITY_ID, + studentAttributes(), + saml.idpKeys, + saml.idpCert); + SAML2Profile profile = + SamlSso.authenticate(acsPost(samlResponse), new MockHttpServletResponse()).get(); + MockHttpServletResponse response = new MockHttpServletResponse(); + + SamlSso.logout(new MockHttpServletRequest("GET", "/logout"), response, profile); + + String logoutRequest = SamlTestSupport.decodeRedirectRequest(response.getRedirectedUrl()); + assertThat( + logoutRequest, containsString("NameQualifier=\"" + SamlTestSupport.IDP_ENTITY_ID + "\"")); + assertThat( + logoutRequest, containsString("SPNameQualifier=\"" + SamlTestSupport.SP_ENTITY_ID + "\"")); + } + + @Test + public void login_RequestedAuthnContextWithoutComparison_DefaultsToExact() throws Exception { + Properties props = saml.properties(); + props.setProperty( + SamlSso.REQUESTED_AUTHN_CONTEXT, + "urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport"); + SamlTestSupport.install(props); + MockHttpServletResponse response = new MockHttpServletResponse(); + + SamlSso.login(new MockHttpServletRequest("GET", "/saml/dologin"), response); + + String authnRequest = SamlTestSupport.decodeRedirectRequest(response.getRedirectedUrl()); + assertThat(authnRequest, containsString("Comparison=\"exact\"")); + assertThat(authnRequest, containsString("PasswordProtectedTransport")); + } + + @Test + public void login_LegacyComparisonKey_IsRead() throws Exception { + Properties props = saml.properties(); + props.setProperty( + SamlSso.REQUESTED_AUTHN_CONTEXT, + "urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport"); + props.setProperty(SamlSso.LEGACY_REQUESTED_AUTHN_CONTEXT_COMPARISON, "minimum"); + SamlTestSupport.install(props); + MockHttpServletResponse response = new MockHttpServletResponse(); + + SamlSso.login(new MockHttpServletRequest("GET", "/saml/dologin"), response); + + String authnRequest = SamlTestSupport.decodeRedirectRequest(response.getRedirectedUrl()); + assertThat(authnRequest, containsString("Comparison=\"minimum\"")); + } + + @Test + public void getServiceProviderMetadata_AdvertisesOnlyVerifiableLogoutBindings() { + String metadata = SamlSso.getServiceProviderMetadata(); + + assertThat(metadata, containsString("bindings:HTTP-POST\"")); + assertThat(metadata, containsString("bindings:HTTP-Redirect\"")); + assertFalse(metadata.contains("HTTP-POST-SimpleSign")); + assertFalse(metadata.contains("bindings:SOAP")); + } + + @Test + public void build_WithoutSpKeyPair_AuthenticatesSignedAssertions() throws Exception { + Properties props = saml.properties(); + props.remove(SamlSso.SP_CERT); + props.remove(SamlSso.SP_KEY); + props.setProperty(SamlSso.AUTHN_REQUEST_SIGNED, "false"); + props.setProperty(SamlSso.LOGOUT_REQUEST_SIGNED, "false"); + SamlTestSupport.install(props); + String samlResponse = + saml.signedResponse("user-123", studentAttributes(), saml.idpKeys, saml.idpCert); + + Optional profile = + SamlSso.authenticate(acsPost(samlResponse), new MockHttpServletResponse()); + + assertTrue(profile.isPresent()); + assertEquals("user-123", profile.get().getId()); + } + + @Test + public void getServiceProviderMetadata_WithoutSpKeyPair_PublishesNoKey() throws Exception { + Properties props = saml.properties(); + props.remove(SamlSso.SP_CERT); + props.remove(SamlSso.SP_KEY); + props.setProperty(SamlSso.AUTHN_REQUEST_SIGNED, "false"); + props.setProperty(SamlSso.LOGOUT_REQUEST_SIGNED, "false"); + SamlTestSupport.install(props); + + String metadata = SamlSso.getServiceProviderMetadata(); + + assertFalse(metadata.contains("KeyDescriptor")); + assertFalse(metadata.contains("X509Certificate")); + } + + @Test + public void getServiceProviderMetadata_WithSpKeyPair_PublishesIt() { + assertThat(SamlSso.getServiceProviderMetadata(), containsString("KeyDescriptor")); + } + + @Test + public void build_WithoutSpKeyPair_WhenSigningRequired_NamesTheSettings() throws Exception { + Properties props = saml.properties(); + props.remove(SamlSso.SP_CERT); + props.remove(SamlSso.SP_KEY); + + IllegalStateException e = assertThrows(IllegalStateException.class, () -> SamlSso.build(props)); + + assertThat(e.getMessage(), containsString(SamlSso.SP_CERT)); + assertThat(e.getMessage(), containsString(SamlSso.AUTHN_REQUEST_SIGNED)); + } + + @Test + public void build_OnlyOneOfSpCertAndKey_IsRejected() throws Exception { + Properties props = saml.properties(); + props.remove(SamlSso.SP_KEY); + + IllegalStateException e = assertThrows(IllegalStateException.class, () -> SamlSso.build(props)); + + assertThat(e.getMessage(), containsString("or neither")); + } + + private Optional authenticateWithRequestedContexts( + String requested, String comparison, String returned) throws Exception { + Properties props = saml.properties(); + props.setProperty(SamlSso.REQUESTED_AUTHN_CONTEXT, requested); + if (comparison != null) { + props.setProperty(SamlSso.REQUESTED_AUTHN_CONTEXT_COMPARISON, comparison); + } + SamlTestSupport.install(props); + saml.authnContextClassRef = returned; + try { + String samlResponse = + saml.signedResponse("user-123", studentAttributes(), saml.idpKeys, saml.idpCert); + return SamlSso.authenticate(acsPost(samlResponse), new MockHttpServletResponse()); + } finally { + saml.authnContextClassRef = AuthnContext.PPT_AUTHN_CTX; + } + } + + @Test + public void authenticate_OneOfSeveralRequestedContexts_IsAccepted() throws Exception { + assertTrue( + authenticateWithRequestedContexts( + AuthnContext.PPT_AUTHN_CTX + ", " + AuthnContext.X509_AUTHN_CTX, + null, + AuthnContext.X509_AUTHN_CTX) + .isPresent()); + } + + @Test + public void authenticate_ExactComparison_UnrequestedContext_IsRejected() throws Exception { + assertFalse( + authenticateWithRequestedContexts( + AuthnContext.PPT_AUTHN_CTX + ", " + AuthnContext.X509_AUTHN_CTX, + "exact", + AuthnContext.KERBEROS_AUTHN_CTX) + .isPresent()); + } + + @Test + public void authenticate_MinimumComparison_StrongerUnlistedContext_IsAccepted() throws Exception { + assertTrue( + authenticateWithRequestedContexts( + AuthnContext.PPT_AUTHN_CTX, "minimum", AuthnContext.X509_AUTHN_CTX) + .isPresent()); + } + + @Test + public void authenticate_NoRequestedContext_AcceptsAnyContext() throws Exception { + String samlResponse = + saml.signedResponse("user-123", studentAttributes(), saml.idpKeys, saml.idpCert); + + assertTrue( + SamlSso.authenticate(acsPost(samlResponse), new MockHttpServletResponse()).isPresent()); + } + + @Test + public void getServiceProviderMetadata_WithoutSlsUrl_AdvertisesNoLogoutEndpoint() + throws Exception { + Properties props = saml.properties(); + props.remove(SamlSso.SP_SLS_URL); + SamlTestSupport.install(props); + + String metadata = SamlSso.getServiceProviderMetadata(); + + assertFalse(metadata.contains("SingleLogoutService")); + assertThat(metadata, containsString("Location=\"" + SamlTestSupport.ACS_URL + "\"")); + } + + /** Two tabs start a login before either finishes: both callbacks must succeed */ + @Test + public void overlappingLogins_InOneBrowser_BothSucceed() throws Exception { + saml.startLogin(); + String first = SamlTestSupport.lastRequestId(); + saml.startLogin(); + String second = SamlTestSupport.lastRequestId(); + + assertTrue( + SamlSso.authenticate( + acsPost(saml.signedResponseTo(first, "user-123", studentAttributes())), + new MockHttpServletResponse()) + .isPresent()); + assertTrue( + SamlSso.authenticate( + acsPost(saml.signedResponseTo(second, "user-123", studentAttributes())), + new MockHttpServletResponse()) + .isPresent()); + } + + /** + * A second tab starts a login just before the first tab's cookie would expire: the cookie must + * last as long as the newest login, or that login's callback arrives without it + */ + @Test + public void overlappingLogins_LaterLoginKeepsTheCookieAlive() throws Exception { + saml.startLogin(); + SamlTestSupport.advanceBrowserClock(Duration.ofMinutes(9)); + saml.startLogin(); + String second = SamlTestSupport.lastRequestId(); + SamlTestSupport.advanceBrowserClock(Duration.ofMinutes(2)); + + assertTrue( + SamlSso.authenticate( + acsPost(saml.signedResponseTo(second, "user-123", studentAttributes())), + new MockHttpServletResponse()) + .isPresent()); + } + + /** Two tabs open the login page before the browser holds any binding cookie: both must work */ + @Test + public void concurrentFirstLogins_InOneBrowser_BothSucceed() throws Exception { + List requests = SamlTestSupport.startConcurrentLogins(2); + + assertTrue( + SamlSso.authenticate( + acsPost(saml.signedResponseTo(requests.get(0), "user-123", studentAttributes())), + new MockHttpServletResponse()) + .isPresent()); + assertTrue( + SamlSso.authenticate( + acsPost(saml.signedResponseTo(requests.get(1), "user-123", studentAttributes())), + new MockHttpServletResponse()) + .isPresent()); + } + + /** A completed login deletes its binding cookie, so cookies don't pile up in the browser */ + @Test + public void authenticate_Success_DeletesThatLoginsCookie() throws Exception { + Cookie cookie = saml.startLogin(); + String samlResponse = + saml.signedResponse("user-123", studentAttributes(), saml.idpKeys, saml.idpCert); + MockHttpServletResponse response = new MockHttpServletResponse(); + + assertTrue(SamlSso.authenticate(acsPost(samlResponse), response).isPresent()); + + Cookie deleted = response.getCookie(cookie.getName()); + assertNotNull(deleted); + assertEquals(0, deleted.getMaxAge()); + assertEquals(cookie.getPath(), deleted.getPath()); + } + + @Test + public void bindingCookie_IsScopedToTheSamlDirectory() throws Exception { + Cookie cookie = saml.startLogin(); + + assertEquals("/saml/", cookie.getPath()); + assertTrue(cookie.isHttpOnly()); + assertTrue(cookie.getSecure()); + assertEquals("None", cookie.getAttribute("SameSite")); + } + + @Test + public void authenticate_UnsolicitedResponse_IsRejected() throws Exception { + saml.unsolicited = true; + try { + String samlResponse = + saml.signedResponse("user-123", studentAttributes(), saml.idpKeys, saml.idpCert); + + assertFalse( + SamlSso.authenticate(acsPost(samlResponse), new MockHttpServletResponse()).isPresent()); + } finally { + saml.unsolicited = false; + } + } + + /** + * Login CSRF: the attacker's own valid assertion, with only the unsigned envelope's InResponseTo + * changed to the victim browser's pending request, must not log the victim's browser in + */ + @Test + public void authenticate_EnvelopeRetargetedToAnotherBrowsersRequest_IsRejected() + throws Exception { + saml.startLogin(); + String attackersRequest = SamlTestSupport.lastRequestId(); + SamlTestSupport.newBrowser(); + saml.startLogin(); + String victimsRequest = SamlTestSupport.lastRequestId(); + + saml.envelopeInResponseTo = victimsRequest; + try { + String forged = saml.signedResponseTo(attackersRequest, "attacker", studentAttributes()); + + assertFalse(SamlSso.authenticate(acsPost(forged), new MockHttpServletResponse()).isPresent()); + } finally { + saml.envelopeInResponseTo = null; + } + } + + /** + * An assertion whose bearer confirmation names no request (as an IdP-initiated one) can't be + * bound to a login, even inside an envelope that names this browser's pending request + */ + @Test + public void authenticate_ConfirmationWithoutInResponseTo_IsRejected() throws Exception { + saml.startLogin(); + String request = SamlTestSupport.lastRequestId(); + + saml.omitConfirmationInResponseTo = true; + try { + String samlResponse = saml.signedResponseTo(request, "user-123", studentAttributes()); + + assertFalse( + SamlSso.authenticate(acsPost(samlResponse), new MockHttpServletResponse()).isPresent()); + } finally { + saml.omitConfirmationInResponseTo = false; + } + } + + /** Login CSRF: a valid response must not log in a browser other than the one that asked */ + @Test + public void authenticate_ResponseDeliveredThroughAnotherBrowser_IsRejected() throws Exception { + saml.startLogin(); + String request = SamlTestSupport.lastRequestId(); + String samlResponse = + saml.signedResponse("attacker", studentAttributes(), saml.idpKeys, saml.idpCert); + MockHttpServletRequest victimWithoutCookie = acsPost(samlResponse); + victimWithoutCookie.setCookies(); + MockHttpServletRequest victimWithOwnCookie = acsPost(samlResponse); + victimWithOwnCookie.setCookies( + new jakarta.servlet.http.Cookie(SamlRequestStore.cookieName(request), "victims-own-token")); + + assertFalse( + SamlSso.authenticate(victimWithoutCookie, new MockHttpServletResponse()).isPresent()); + assertFalse( + SamlSso.authenticate(victimWithOwnCookie, new MockHttpServletResponse()).isPresent()); + } + + @Test + public void authenticate_ResponseToAnotherBrowsersLogin_IsRejected() throws Exception { + saml.startLogin(); + String samlResponse = + saml.signedResponse("attacker", studentAttributes(), saml.idpKeys, saml.idpCert); + // The victim's (different) browser has started a login of its own, with its own cookie + SamlTestSupport.newBrowser(); + jakarta.servlet.http.Cookie victimCookie = saml.startLogin(); + MockHttpServletRequest victim = acsPost(samlResponse); + victim.setCookies(victimCookie); + + assertFalse(SamlSso.authenticate(victim, new MockHttpServletResponse()).isPresent()); + } + + @Test + public void processLogoutMessage_NoMessage_IsInvalid() { + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/saml/sls"); + + assertEquals( + SamlSso.LogoutMessage.INVALID, + SamlSso.processLogoutMessage(request, new MockHttpServletResponse())); + } + + private static Properties ssoProperties() { + Properties props = new Properties(); + props.setProperty("sso.saml.ssoName", "uid"); + props.setProperty("sso.saml.userName", "displayName"); + props.setProperty("sso.saml.affiliation", "groups"); + props.setProperty("sso.saml.adminAffiliation", "staff"); + props.setProperty("sso.saml.playerAffiliation", "students"); + return props; + } +} diff --git a/src/test/java/utils/SamlTestSupport.java b/src/test/java/utils/SamlTestSupport.java new file mode 100644 index 000000000..ae31cce5f --- /dev/null +++ b/src/test/java/utils/SamlTestSupport.java @@ -0,0 +1,647 @@ +package utils; + +import jakarta.servlet.http.Cookie; +import java.io.ByteArrayOutputStream; +import java.math.BigInteger; +import java.net.URLDecoder; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.cert.X509Certificate; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Base64; +import java.util.Date; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Properties; +import java.util.UUID; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.zip.Deflater; +import java.util.zip.Inflater; +import javax.xml.namespace.QName; +import net.shibboleth.shared.xml.SerializeSupport; +import org.bouncycastle.asn1.pkcs.PrivateKeyInfo; +import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; +import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder; +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; +import org.opensaml.core.xml.XMLObject; +import org.opensaml.core.xml.config.XMLObjectProviderRegistrySupport; +import org.opensaml.core.xml.schema.XSString; +import org.opensaml.saml.common.SAMLVersion; +import org.opensaml.saml.common.SignableSAMLObject; +import org.opensaml.saml.saml2.core.Assertion; +import org.opensaml.saml.saml2.core.Attribute; +import org.opensaml.saml.saml2.core.AttributeStatement; +import org.opensaml.saml.saml2.core.AttributeValue; +import org.opensaml.saml.saml2.core.Audience; +import org.opensaml.saml.saml2.core.AudienceRestriction; +import org.opensaml.saml.saml2.core.AuthnContext; +import org.opensaml.saml.saml2.core.AuthnContextClassRef; +import org.opensaml.saml.saml2.core.AuthnStatement; +import org.opensaml.saml.saml2.core.Conditions; +import org.opensaml.saml.saml2.core.Issuer; +import org.opensaml.saml.saml2.core.LogoutRequest; +import org.opensaml.saml.saml2.core.LogoutResponse; +import org.opensaml.saml.saml2.core.NameID; +import org.opensaml.saml.saml2.core.NameIDType; +import org.opensaml.saml.saml2.core.Response; +import org.opensaml.saml.saml2.core.SessionIndex; +import org.opensaml.saml.saml2.core.Status; +import org.opensaml.saml.saml2.core.StatusCode; +import org.opensaml.saml.saml2.core.Subject; +import org.opensaml.saml.saml2.core.SubjectConfirmation; +import org.opensaml.saml.saml2.core.SubjectConfirmationData; +import org.opensaml.security.x509.BasicX509Credential; +import org.opensaml.xmlsec.signature.Signature; +import org.opensaml.xmlsec.signature.support.SignatureConstants; +import org.opensaml.xmlsec.signature.support.Signer; +import org.pac4j.saml.client.SAML2Client; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +/** + * Test fixtures for SAML SSO: generated SP/IdP key pairs, onelogin.saml.properties contents for + * them, and signed SAML responses as an IdP would send them. + */ +public class SamlTestSupport { + + public static final String SP_ENTITY_ID = "https://shepherd.test/saml/metadata.xml"; + public static final String ACS_URL = "https://shepherd.test/saml/acs"; + public static final String SLS_URL = "https://shepherd.test/saml/sls"; + public static final String IDP_ENTITY_ID = "https://idp.test/metadata"; + public static final String IDP_SSO_URL = "https://idp.test/sso"; + public static final String IDP_SLO_URL = "https://idp.test/slo"; + + /** The AuthnContextClassRef that {@link #signedResponse} reports */ + public String authnContextClassRef = AuthnContext.PPT_AUTHN_CTX; + + public final KeyPair spKeys; + public final X509Certificate spCert; + public final KeyPair idpKeys; + public final X509Certificate idpCert; + + public SamlTestSupport() throws Exception { + spKeys = newKeyPair(); + spCert = selfSigned(spKeys, "shepherd.test"); + idpKeys = newKeyPair(); + idpCert = selfSigned(idpKeys, "idp.test"); + } + + /** onelogin.saml.properties as an operator would write it, with PEM armour */ + public Properties properties() throws Exception { + Properties props = new Properties(); + props.setProperty(SamlSso.STRICT, "true"); + props.setProperty(SamlSso.SP_ENTITY_ID, SP_ENTITY_ID); + props.setProperty(SamlSso.SP_ACS_URL, ACS_URL); + props.setProperty(SamlSso.SP_ACS_BINDING, "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"); + props.setProperty(SamlSso.SP_SLS_URL, SLS_URL); + props.setProperty( + SamlSso.SP_NAMEID_FORMAT, "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent"); + props.setProperty(SamlSso.SP_CERT, certificatePem(spCert)); + props.setProperty(SamlSso.SP_KEY, pkcs8Pem(spKeys)); + props.setProperty(SamlSso.IDP_ENTITY_ID, IDP_ENTITY_ID); + props.setProperty(SamlSso.IDP_SSO_URL, IDP_SSO_URL); + props.setProperty( + SamlSso.IDP_SSO_BINDING, "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"); + props.setProperty(SamlSso.IDP_SLO_URL, IDP_SLO_URL); + props.setProperty( + SamlSso.IDP_SLO_BINDING, "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"); + props.setProperty(SamlSso.IDP_CERT, certificatePem(idpCert)); + props.setProperty(SamlSso.AUTHN_REQUEST_SIGNED, "true"); + props.setProperty(SamlSso.LOGOUT_REQUEST_SIGNED, "true"); + props.setProperty(SamlSso.WANT_ASSERTIONS_SIGNED, "true"); + props.setProperty( + SamlSso.SIGNATURE_ALGORITHM, "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"); + return props; + } + + /** Builds a client from {@link #properties()} and makes it the one SamlSso uses */ + public SAML2Client installClient() throws Exception { + return install(properties()); + } + + /** Builds a client from the given properties and makes it the one SamlSso uses */ + public static SAML2Client install(Properties props) { + SamlSso.Sso sso = SamlSso.build(props); + SamlSso.set(sso); + // A fresh client has an empty request store: forget the previous browser's login state + newBrowser(); + LoginMethod.setSaml(true); + return sso.client; + } + + /** Clears the client installed by {@link #installClient()} */ + public static void uninstallClient() { + SamlSso.set(null); + LoginMethod.setSaml(null); + } + + /** Switches SSO (sso.enabled) on or off without touching the installed client */ + public static void setSsoEnabled(boolean enabled) { + LoginMethod.setSaml(enabled); + } + + /** When true, {@link #signedResponse} builds an unsolicited response (no InResponseTo) */ + public boolean unsolicited; + + /** + * When set, {@link #signedResponse} puts this request ID in the Response envelope's InResponseTo + * (which isn't signed unless the whole message is) while the signed assertion's bearer + * confirmation keeps the request it answers: an attacker editing the envelope + */ + public String envelopeInResponseTo; + + /** + * When true, {@link #signedResponse} leaves InResponseTo out of the bearer confirmation, as in an + * assertion issued without a request (IdP-initiated) + */ + public boolean omitConfirmationInResponseTo; + + /* + * One simulated browser, matching the single installed client: the AuthnRequest ID of its most + * recent login, and its cookie jar, where each cookie is dropped when its Max-Age runs out on a + * browser clock that tests can move forward. Reset by install(). + */ + private static String pendingRequestId; + private static final Map cookieJar = new LinkedHashMap<>(); + private static final Map cookieExpires = new HashMap<>(); + private static Cookie lastLoginCookie; + private static Duration browserClock = Duration.ZERO; + + /** + * Starts an SP-initiated login as a browser would, sending the cookies whose Path covers the + * login URL. The next {@link #signedResponse} answers it. + * + * @return the request-binding cookie this login set, which the ACS request must carry + */ + public Cookie startLogin() throws Exception { + startConcurrentLogins(1); + return lastLoginCookie; + } + + /** + * Starts logins in several tabs at once: every request leaves with the cookies the browser held + * before any of them, and only then are the responses' cookies stored, in order. The next {@link + * #signedResponse} answers the last one. + * + * @return the AuthnRequest IDs, in the order the logins were started + */ + public static List startConcurrentLogins(int tabs) throws Exception { + List ids = new ArrayList<>(); + List responses = new ArrayList<>(); + for (int i = 0; i < tabs; i++) { + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/saml/dologin"); + request.setScheme("https"); + request.setSecure(true); + request.setServerName("shepherd.test"); + request.setServerPort(443); + Cookie[] sent = cookiesFor("/saml/dologin"); + if (sent.length > 0) { + request.setCookies(sent); + } + MockHttpServletResponse response = new MockHttpServletResponse(); + SamlSso.login(request, response); + Matcher id = + Pattern.compile(" ID=\"([^\"]+)\"") + .matcher(decodeRedirectRequest(response.getRedirectedUrl())); + if (!id.find()) { + throw new IllegalStateException("No ID in AuthnRequest"); + } + ids.add(id.group(1)); + responses.add(response); + } + for (MockHttpServletResponse response : responses) { + for (Cookie cookie : response.getCookies()) { + storeCookie(cookie); + if (cookie.getName().startsWith(SamlRequestStore.COOKIE) && cookie.getMaxAge() != 0) { + lastLoginCookie = cookie; + } + } + } + pendingRequestId = ids.get(ids.size() - 1); + return ids; + } + + /** Stores a Set-Cookie as a browser does: replacing any cookie of that name, or deleting it */ + private static void storeCookie(Cookie cookie) { + if (cookie.getMaxAge() == 0) { + cookieJar.remove(cookie.getName()); + cookieExpires.remove(cookie.getName()); + return; + } + cookieJar.put(cookie.getName(), cookie); + cookieExpires.put( + cookie.getName(), + cookie.getMaxAge() < 0 ? Instant.MAX : browserNow().plusSeconds(cookie.getMaxAge())); + } + + /** Switches to a different browser, with no cookies and no login in progress */ + public static void newBrowser() { + pendingRequestId = null; + cookieJar.clear(); + cookieExpires.clear(); + lastLoginCookie = null; + browserClock = Duration.ZERO; + } + + /** + * Moves the browser's clock forward, so its cookies expire as their Max-Age says. The server's + * own clock is not moved. + */ + public static void advanceBrowserClock(Duration elapsed) { + browserClock = browserClock.plus(elapsed); + } + + private static Instant browserNow() { + return Instant.now().plus(browserClock); + } + + /** The AuthnRequest ID of the most recently started login */ + public static String lastRequestId() { + return pendingRequestId; + } + + /** The binding cookie set by the most recently started login, if any */ + public Cookie browserCookie() { + return lastLoginCookie; + } + + /** The cookies the browser sends to a URL path, following the cookie Path and Max-Age rules */ + public static Cookie[] cookiesFor(String requestPath) { + cookieJar.keySet().removeIf(name -> !browserNow().isBefore(cookieExpires.get(name))); + return cookieJar.values().stream() + .filter(c -> requestPath.startsWith(c.getPath() == null ? "/" : c.getPath())) + .toArray(Cookie[]::new); + } + + /** As {@link #signedResponse}, answering the given AuthnRequest ID */ + public String signedResponseTo( + String requestId, String nameId, Map> attributes) throws Exception { + pendingRequestId = requestId; + return signedResponse(nameId, attributes, idpKeys, idpCert); + } + + /** + * Builds a base64 SAMLResponse for the HTTP-POST binding, with the assertion signed by the given + * key pair. + */ + public String signedResponse( + String nameId, + Map> attributes, + KeyPair signingKeys, + X509Certificate signingCert) + throws Exception { + return signedResponse(nameId, null, null, attributes, signingKeys, signingCert); + } + + /** As {@link #signedResponse}, with NameQualifier / SPNameQualifier on the NameID */ + public String signedResponse( + String nameId, + String nameQualifier, + String spNameQualifier, + Map> attributes, + KeyPair signingKeys, + X509Certificate signingCert) + throws Exception { + Instant now = Instant.now(); + + Response response = build(Response.DEFAULT_ELEMENT_NAME); + response.setID("_" + UUID.randomUUID()); + response.setIssueInstant(now); + response.setVersion(SAMLVersion.VERSION_20); + response.setDestination(ACS_URL); + String inResponseTo = null; + if (!unsolicited) { + if (pendingRequestId == null) { + startLogin(); + } + inResponseTo = pendingRequestId; + pendingRequestId = null; + response.setInResponseTo(envelopeInResponseTo != null ? envelopeInResponseTo : inResponseTo); + } + response.setIssuer(issuer()); + Status status = build(Status.DEFAULT_ELEMENT_NAME); + StatusCode statusCode = build(StatusCode.DEFAULT_ELEMENT_NAME); + statusCode.setValue(StatusCode.SUCCESS); + status.setStatusCode(statusCode); + response.setStatus(status); + + Assertion assertion = build(Assertion.DEFAULT_ELEMENT_NAME); + assertion.setID("_" + UUID.randomUUID()); + assertion.setIssueInstant(now); + assertion.setVersion(SAMLVersion.VERSION_20); + assertion.setIssuer(issuer()); + + Subject subject = build(Subject.DEFAULT_ELEMENT_NAME); + NameID name = build(NameID.DEFAULT_ELEMENT_NAME); + name.setValue(nameId); + name.setFormat(NameIDType.PERSISTENT); + name.setNameQualifier(nameQualifier); + name.setSPNameQualifier(spNameQualifier); + subject.setNameID(name); + SubjectConfirmation confirmation = build(SubjectConfirmation.DEFAULT_ELEMENT_NAME); + confirmation.setMethod(SubjectConfirmation.METHOD_BEARER); + SubjectConfirmationData confirmationData = build(SubjectConfirmationData.DEFAULT_ELEMENT_NAME); + confirmationData.setRecipient(ACS_URL); + confirmationData.setInResponseTo(omitConfirmationInResponseTo ? null : inResponseTo); + confirmationData.setNotOnOrAfter(now.plus(Duration.ofMinutes(5))); + confirmation.setSubjectConfirmationData(confirmationData); + subject.getSubjectConfirmations().add(confirmation); + assertion.setSubject(subject); + + Conditions conditions = build(Conditions.DEFAULT_ELEMENT_NAME); + conditions.setNotBefore(now.minus(Duration.ofMinutes(1))); + conditions.setNotOnOrAfter(now.plus(Duration.ofMinutes(5))); + AudienceRestriction audienceRestriction = build(AudienceRestriction.DEFAULT_ELEMENT_NAME); + Audience audience = build(Audience.DEFAULT_ELEMENT_NAME); + audience.setURI(SP_ENTITY_ID); + audienceRestriction.getAudiences().add(audience); + conditions.getAudienceRestrictions().add(audienceRestriction); + assertion.setConditions(conditions); + + AuthnStatement authn = build(AuthnStatement.DEFAULT_ELEMENT_NAME); + authn.setAuthnInstant(now); + authn.setSessionIndex("_session-" + nameId); + AuthnContext authnContext = build(AuthnContext.DEFAULT_ELEMENT_NAME); + AuthnContextClassRef classRef = build(AuthnContextClassRef.DEFAULT_ELEMENT_NAME); + classRef.setURI(authnContextClassRef); + authnContext.setAuthnContextClassRef(classRef); + authn.setAuthnContext(authnContext); + assertion.getAuthnStatements().add(authn); + + AttributeStatement attributeStatement = build(AttributeStatement.DEFAULT_ELEMENT_NAME); + for (Map.Entry> entry : attributes.entrySet()) { + Attribute attribute = build(Attribute.DEFAULT_ELEMENT_NAME); + attribute.setName(entry.getKey()); + for (String value : entry.getValue()) { + XSString xsValue = + (XSString) + XMLObjectProviderRegistrySupport.getBuilderFactory() + .getBuilder(XSString.TYPE_NAME) + .buildObject(AttributeValue.DEFAULT_ELEMENT_NAME, XSString.TYPE_NAME); + xsValue.setValue(value); + attribute.getAttributeValues().add(xsValue); + } + attributeStatement.getAttributes().add(attribute); + } + assertion.getAttributeStatements().add(attributeStatement); + + Signature signature = build(Signature.DEFAULT_ELEMENT_NAME); + signature.setSigningCredential(new BasicX509Credential(signingCert, signingKeys.getPrivate())); + signature.setSignatureAlgorithm(SignatureConstants.ALGO_ID_SIGNATURE_RSA_SHA256); + signature.setCanonicalizationAlgorithm(SignatureConstants.ALGO_ID_C14N_EXCL_OMIT_COMMENTS); + assertion.setSignature(signature); + response.getAssertions().add(assertion); + + XMLObjectProviderRegistrySupport.getMarshallerFactory() + .getMarshaller(response) + .marshall(response); + Signer.signObject(signature); + + String xml = SerializeSupport.nodeToString(response.getDOM()); + return Base64.getEncoder().encodeToString(xml.getBytes(StandardCharsets.UTF_8)); + } + + /** An IdP LogoutResponse to an SP-initiated logout */ + public LogoutResponse logoutResponse() { + LogoutResponse response = build(LogoutResponse.DEFAULT_ELEMENT_NAME); + response.setID("_" + UUID.randomUUID()); + response.setIssueInstant(Instant.now()); + response.setVersion(SAMLVersion.VERSION_20); + response.setDestination(SLS_URL); + response.setIssuer(issuer()); + Status status = build(Status.DEFAULT_ELEMENT_NAME); + StatusCode statusCode = build(StatusCode.DEFAULT_ELEMENT_NAME); + statusCode.setValue(StatusCode.SUCCESS); + status.setStatusCode(statusCode); + response.setStatus(status); + return response; + } + + /** An IdP-initiated LogoutRequest naming several logins of one user */ + public LogoutRequest logoutRequest(String nameId, List sessionIndexes) { + LogoutRequest request = logoutRequest(nameId, (String) null); + for (String value : sessionIndexes) { + SessionIndex index = build(SessionIndex.DEFAULT_ELEMENT_NAME); + index.setValue(value); + request.getSessionIndexes().add(index); + } + return request; + } + + /** An IdP-initiated LogoutRequest for a login */ + public LogoutRequest logoutRequest(String nameId, String sessionIndex) { + LogoutRequest request = build(LogoutRequest.DEFAULT_ELEMENT_NAME); + request.setID("_" + UUID.randomUUID()); + request.setIssueInstant(Instant.now()); + request.setVersion(SAMLVersion.VERSION_20); + request.setDestination(SLS_URL); + request.setIssuer(issuer()); + NameID name = build(NameID.DEFAULT_ELEMENT_NAME); + name.setValue(nameId); + name.setFormat(NameIDType.PERSISTENT); + request.setNameID(name); + if (sessionIndex != null) { + SessionIndex index = build(SessionIndex.DEFAULT_ELEMENT_NAME); + index.setValue(sessionIndex); + request.getSessionIndexes().add(index); + } + return request; + } + + /** Serialises a SAML message, with an enveloped XML signature when signing keys are given */ + public static String toXml( + SignableSAMLObject message, KeyPair signingKeys, X509Certificate signingCert) + throws Exception { + Signature signature = null; + if (signingKeys != null) { + signature = build(Signature.DEFAULT_ELEMENT_NAME); + signature.setSigningCredential( + new BasicX509Credential(signingCert, signingKeys.getPrivate())); + signature.setSignatureAlgorithm(SignatureConstants.ALGO_ID_SIGNATURE_RSA_SHA256); + signature.setCanonicalizationAlgorithm(SignatureConstants.ALGO_ID_C14N_EXCL_OMIT_COMMENTS); + message.setSignature(signature); + } + XMLObjectProviderRegistrySupport.getMarshallerFactory() + .getMarshaller(message) + .marshall(message); + if (signature != null) { + Signer.signObject(signature); + } + return SerializeSupport.nodeToString(message.getDOM()); + } + + /** + * Builds an HTTP-Redirect binding query string carrying a SAML message, with a query-string + * signature when signing keys are given. + */ + public static String redirectQuery(String param, String xml, KeyPair signingKeys) + throws Exception { + return redirectQuery( + param, xml, signingKeys, SignatureConstants.ALGO_ID_SIGNATURE_RSA_SHA256, "SHA256withRSA"); + } + + /** As {@link #redirectQuery(String, String, KeyPair)}, with a chosen signature algorithm */ + public static String redirectQuery( + String param, String xml, KeyPair signingKeys, String sigAlgUri, String jcaAlgorithm) + throws Exception { + Deflater deflater = new Deflater(Deflater.DEFLATED, true); + deflater.setInput(xml.getBytes(StandardCharsets.UTF_8)); + deflater.finish(); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + byte[] buffer = new byte[4096]; + while (!deflater.finished()) { + out.write(buffer, 0, deflater.deflate(buffer)); + } + String query = + param + + "=" + + URLEncoder.encode( + Base64.getEncoder().encodeToString(out.toByteArray()), StandardCharsets.UTF_8); + if (signingKeys == null) { + return query; + } + query += "&SigAlg=" + URLEncoder.encode(sigAlgUri, StandardCharsets.UTF_8); + java.security.Signature signer = java.security.Signature.getInstance(jcaAlgorithm); + signer.initSign(signingKeys.getPrivate()); + signer.update(query.getBytes(StandardCharsets.UTF_8)); + return query + + "&Signature=" + + URLEncoder.encode( + Base64.getEncoder().encodeToString(signer.sign()), StandardCharsets.UTF_8); + } + + /** + * A request to the single logout service carrying the given HTTP-Redirect query string. Like a + * servlet container, parameter names and values are both URL-decoded, in order. + */ + public static MockHttpServletRequest slsGet(String query) { + MockHttpServletRequest request = slsRequest("GET"); + request.setQueryString(query); + for (String pair : query.split("&")) { + int eq = pair.indexOf('='); + request.addParameter( + URLDecoder.decode(pair.substring(0, eq), StandardCharsets.UTF_8), + URLDecoder.decode(pair.substring(eq + 1), StandardCharsets.UTF_8)); + } + return request; + } + + /** A request to the single logout service carrying an HTTP-POST binding message */ + public static MockHttpServletRequest slsPost(String param, String xml) { + MockHttpServletRequest request = slsRequest("POST"); + request.addParameter( + param, Base64.getEncoder().encodeToString(xml.getBytes(StandardCharsets.UTF_8))); + return request; + } + + private static MockHttpServletRequest slsRequest(String method) { + MockHttpServletRequest request = new MockHttpServletRequest(method, "/saml/sls"); + request.setScheme("https"); + request.setSecure(true); + request.setServerName("shepherd.test"); + request.setServerPort(443); + return request; + } + + /** Decodes the SAMLRequest parameter of an HTTP-Redirect binding URL */ + public static String decodeRedirectRequest(String location) throws Exception { + String query = location.substring(location.indexOf('?') + 1); + for (String param : query.split("&")) { + if (param.startsWith("SAMLRequest=")) { + byte[] deflated = + Base64.getDecoder() + .decode(URLDecoder.decode(param.substring(12), StandardCharsets.UTF_8)); + Inflater inflater = new Inflater(true); + inflater.setInput(deflated); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + byte[] buffer = new byte[4096]; + while (!inflater.finished()) { + out.write(buffer, 0, inflater.inflate(buffer)); + } + return out.toString(StandardCharsets.UTF_8); + } + } + throw new IllegalArgumentException("No SAMLRequest in " + location); + } + + /** An EC P-256 key pair, as used by IdPs that sign with ecdsa-sha256 */ + public static KeyPair newEcKeyPair() throws Exception { + KeyPairGenerator generator = KeyPairGenerator.getInstance("EC"); + generator.initialize(256); + return generator.generateKeyPair(); + } + + public static KeyPair newKeyPair() throws Exception { + KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA"); + generator.initialize(2048); + return generator.generateKeyPair(); + } + + public static X509Certificate selfSigned(KeyPair keys, String commonName) throws Exception { + X500Name name = new X500Name("CN=" + commonName); + Instant now = Instant.now(); + JcaX509v3CertificateBuilder builder = + new JcaX509v3CertificateBuilder( + name, + BigInteger.valueOf(now.toEpochMilli()), + Date.from(now.minus(Duration.ofDays(1))), + Date.from(now.plus(Duration.ofDays(365))), + name, + keys.getPublic()); + return new JcaX509CertificateConverter() + .getCertificate( + builder.build( + new JcaContentSignerBuilder( + "EC".equals(keys.getPrivate().getAlgorithm()) + ? "SHA256withECDSA" + : "SHA256withRSA") + .build(keys.getPrivate()))); + } + + public static String certificatePem(X509Certificate cert) throws Exception { + return pem("CERTIFICATE", cert.getEncoded()); + } + + public static String pkcs8Pem(KeyPair keys) { + return pem("PRIVATE KEY", keys.getPrivate().getEncoded()); + } + + public static String pkcs1Pem(KeyPair keys) throws Exception { + byte[] pkcs1 = + PrivateKeyInfo.getInstance(keys.getPrivate().getEncoded()) + .parsePrivateKey() + .toASN1Primitive() + .getEncoded(); + return pem("RSA PRIVATE KEY", pkcs1); + } + + private static String pem(String type, byte[] der) { + return "-----BEGIN " + + type + + "-----\n" + + Base64.getMimeEncoder(64, "\n".getBytes(StandardCharsets.US_ASCII)).encodeToString(der) + + "\n-----END " + + type + + "-----"; + } + + private static Issuer issuer() { + Issuer issuer = build(Issuer.DEFAULT_ELEMENT_NAME); + issuer.setValue(IDP_ENTITY_ID); + return issuer; + } + + @SuppressWarnings("unchecked") + private static T build(QName name) { + return (T) + XMLObjectProviderRegistrySupport.getBuilderFactory().getBuilder(name).buildObject(name); + } +} diff --git a/src/test/java/utils/SsoUserMapperTest.java b/src/test/java/utils/SsoUserMapperTest.java new file mode 100644 index 000000000..32267ec27 --- /dev/null +++ b/src/test/java/utils/SsoUserMapperTest.java @@ -0,0 +1,135 @@ +package utils; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.Properties; +import org.junit.jupiter.api.Test; + +public class SsoUserMapperTest { + + private static Properties ssoProperties() { + Properties props = new Properties(); + props.setProperty("sso.saml.ssoName", "uid"); + props.setProperty("sso.saml.userName", "displayName"); + props.setProperty("sso.saml.affiliation", "groups"); + props.setProperty("sso.saml.adminAffiliation", "staff, teachers"); + props.setProperty("sso.saml.playerAffiliation", "students"); + return props; + } + + private static Map attributes(Object groups) { + Map attributes = new HashMap<>(); + attributes.put("uid", Collections.singletonList("jdoe")); + attributes.put("displayName", Collections.singletonList("Jane Doe")); + attributes.put("groups", groups); + return attributes; + } + + @Test + public void map_AdminAffiliation_IsAdmin() { + SsoUserMapper.Result result = + SsoUserMapper.map(attributes(Arrays.asList("students", "teachers")), ssoProperties()); + + assertTrue(result.isValid()); + assertEquals("jdoe", result.ssoName); + assertEquals("Jane Doe", result.userName); + assertEquals("admin", result.userRole); + } + + @Test + public void map_PlayerAffiliation_IsPlayer() { + SsoUserMapper.Result result = + SsoUserMapper.map(attributes(Collections.singletonList("students")), ssoProperties()); + + assertTrue(result.isValid()); + assertEquals("player", result.userRole); + } + + @Test + public void map_SingleValuedAttributes_AreRead() { + Map attributes = new HashMap<>(); + attributes.put("uid", "jdoe"); + attributes.put("displayName", "Jane Doe"); + attributes.put("groups", "staff"); + + SsoUserMapper.Result result = SsoUserMapper.map(attributes, ssoProperties()); + + assertTrue(result.isValid()); + assertEquals("jdoe", result.ssoName); + assertEquals("admin", result.userRole); + } + + @Test + public void map_NoMatchingAffiliation_ListsAllowedGroups() { + SsoUserMapper.Result result = + SsoUserMapper.map(attributes(Collections.singletonList("alumni")), ssoProperties()); + + assertFalse(result.isValid()); + assertEquals( + "Authorization failed. Please ensure that you are a member of one of the following" + + " groups: staff, teachers, students, ", + result.error); + } + + @Test + public void map_NoAffiliationAttribute_IsRejected() { + Map attributes = attributes(null); + attributes.remove("groups"); + + assertFalse(SsoUserMapper.map(attributes, ssoProperties()).isValid()); + } + + @Test + public void map_MissingUserName_IsRejected() { + Map attributes = attributes(Collections.singletonList("staff")); + attributes.remove("displayName"); + + SsoUserMapper.Result result = SsoUserMapper.map(attributes, ssoProperties()); + + assertFalse(result.isValid()); + assertEquals("Required SSO attributes are missing", result.error); + } + + @Test + public void map_BlankSsoName_IsRejected() { + for (String blank : new String[] {"", " "}) { + Map attributes = attributes(Collections.singletonList("staff")); + attributes.put("uid", Collections.singletonList(blank)); + + SsoUserMapper.Result result = SsoUserMapper.map(attributes, ssoProperties()); + + assertFalse(result.isValid()); + assertEquals("Required SSO attributes are missing", result.error); + } + } + + @Test + public void map_BlankUserName_IsRejected() { + Map attributes = attributes(Collections.singletonList("staff")); + attributes.put("displayName", " "); + + assertFalse(SsoUserMapper.map(attributes, ssoProperties()).isValid()); + } + + @Test + public void map_NonBlankValue_IsKeptAsSent() { + Map attributes = attributes(Collections.singletonList("staff")); + attributes.put("uid", Collections.singletonList(" jdoe ")); + + assertEquals(" jdoe ", SsoUserMapper.map(attributes, ssoProperties()).ssoName); + } + + @Test + public void map_NoAttributes_IsRejected() { + SsoUserMapper.Result result = SsoUserMapper.map(Collections.emptyMap(), ssoProperties()); + + assertFalse(result.isValid()); + assertEquals("You don't have any attributes", result.error); + } +} diff --git a/src/test/scripts/pgp-verification-test.sh b/src/test/scripts/pgp-verification-test.sh new file mode 100755 index 000000000..2fe6b9fc7 --- /dev/null +++ b/src/test/scripts/pgp-verification-test.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +# Checks that the Shibboleth repository filter (.mvn/maven.config) is enforced, and that the +# build's PGP verification (pgpverify-maven-plugin, .mvn/pgp-keys.map) rejects a tampered +# OpenSAML artifact and an OpenSAML artifact signed by a key other than the pinned Shibboleth +# release key. Uses a private local repository, so it never touches +# ~/.m2. Run from the project root: bash src/test/scripts/pgp-verification-test.sh +set -euo pipefail + +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT +repo="$work/repository" +mvn_validate() { + mvn -B validate -Dmaven.repo.local="$repo" "$@" >"$work/build.log" 2>&1 +} +fail() { + echo "FAIL: $1" + tail -40 "$work/build.log" + exit 1 +} + +echo "0. The Shibboleth repository may serve only allow-listed groupIds" +# With org.opensaml dropped from the allow-list, OpenSAML must not be fetched from Shibboleth's +# repository (and is not on Central), so resolution has to fail. If it succeeds, the repository +# filter in .mvn/maven.config is not being applied by this Maven. +allowlist=.mvn/rrf/groupId-shibboleth-releases.txt +cp "$allowlist" "$work/allowlist.orig" +grep -v '^org\.opensaml$' "$work/allowlist.orig" >"$allowlist" +restore_allowlist() { cp "$work/allowlist.orig" "$allowlist"; } +trap 'restore_allowlist; rm -rf "$work"' EXIT +if mvn_validate -Dpgpverify.skip -Dmaven.repo.local="$work/filter-repo" dependency:resolve; then + fail "OpenSAML was fetched although org.opensaml is not allow-listed: repository filter not applied" +fi +if grep -q "Downloaded from shibboleth-releases.*org/opensaml" "$work/build.log"; then + fail "OpenSAML was downloaded from Shibboleth although not allow-listed" +fi +grep -E "org\.opensaml:opensaml-core-api:jar:[^ ]* (is missing|was not found)|Could not find artifact org\.opensaml" \ + "$work/build.log" | head -1 || fail "resolution failed for an unrelated reason" +restore_allowlist + +echo "1. Untampered dependencies verify" +mvn_validate || fail "verification failed on genuine artifacts" + +jar=$(find "$repo/org/opensaml/opensaml-core-api" -name 'opensaml-core-api-*.jar' | head -1) +[ -n "$jar" ] || fail "opensaml-core-api jar not found in $repo" + +echo "2. OpenSAML pinned to a different key is rejected" +sed 's/^org\.opensaml = .*/org.opensaml = 0x0000000000000000000000000000000000000001/' \ + .mvn/pgp-keys.map >"$work/wrong-key.map" +if mvn_validate -o -Dpgp.keysMap="$work/wrong-key.map"; then + fail "build passed although OpenSAML is not signed by the pinned key" +fi +grep -E "^\[ERROR\] Not allowed artifact org\.opensaml" "$work/build.log" | head -1 || + fail "failure was not the OpenSAML key check" + +echo "3. A tampered OpenSAML jar is rejected" +cp "$jar" "$work/original.jar" +printf 'tampered' >>"$jar" +if mvn_validate -o; then + fail "build passed with a tampered $(basename "$jar")" +fi +grep -E "^\[ERROR\].*opensaml-core-api" "$work/build.log" | head -1 || + fail "failure was not the opensaml-core-api signature check" + +echo "PASS: PGP verification rejects tampered and wrongly-signed OpenSAML artifacts"