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; } } }