Implement SAML2 login flow (signed AuthnRequest + ACS response validation)

This commit is contained in:
a
2026-06-13 11:18:30 +01:00
parent 57063c51b5
commit e0b9ef2349
3 changed files with 257 additions and 3 deletions
@@ -123,6 +123,104 @@ public class Saml2Service {
return keyInfo;
}
/**
* Build a signed AuthnRequest and return the IdP SSO URL with the HTTP-Redirect binding query
* (SAMLRequest deflated+base64, plus SigAlg/Signature signed with the SP private key).
*/
public String buildAuthnRequestRedirectUrl(String relayState) throws Exception {
org.opensaml.saml.saml2.core.AuthnRequest authnRequest =
build(org.opensaml.saml.saml2.core.AuthnRequest.DEFAULT_ELEMENT_NAME);
authnRequest.setID("ARQ" + java.util.UUID.randomUUID());
authnRequest.setIssueInstant(java.time.Instant.now());
authnRequest.setDestination(config.idpSingleLoginUrl());
authnRequest.setProtocolBinding(SAMLConstants.SAML2_POST_BINDING_URI);
authnRequest.setAssertionConsumerServiceURL(config.acsUrl());
org.opensaml.saml.saml2.core.Issuer issuer =
build(org.opensaml.saml.saml2.core.Issuer.DEFAULT_ELEMENT_NAME);
issuer.setValue(config.spEntityId());
authnRequest.setIssuer(issuer);
org.opensaml.saml.saml2.core.NameIDPolicy nameIdPolicy =
build(org.opensaml.saml.saml2.core.NameIDPolicy.DEFAULT_ELEMENT_NAME);
nameIdPolicy.setFormat("urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress");
nameIdPolicy.setAllowCreate(true);
authnRequest.setNameIDPolicy(nameIdPolicy);
String xml = SerializeSupport.nodeToString(XMLObjectSupport.marshall(authnRequest));
// HTTP-Redirect binding: DEFLATE (raw) + base64.
java.io.ByteArrayOutputStream baos = new java.io.ByteArrayOutputStream();
try (java.util.zip.DeflaterOutputStream deflater =
new java.util.zip.DeflaterOutputStream(
baos, new java.util.zip.Deflater(java.util.zip.Deflater.DEFLATED, true))) {
deflater.write(xml.getBytes(java.nio.charset.StandardCharsets.UTF_8));
}
String samlRequest = Base64.getEncoder().encodeToString(baos.toByteArray());
String sigAlg = "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256";
StringBuilder query = new StringBuilder();
query.append("SAMLRequest=").append(urlEncode(samlRequest));
if (relayState != null && !relayState.isBlank()) {
query.append("&RelayState=").append(urlEncode(relayState));
}
query.append("&SigAlg=").append(urlEncode(sigAlg));
java.security.Signature signer = java.security.Signature.getInstance("SHA256withRSA");
signer.initSign(spPrivateKey);
signer.update(query.toString().getBytes(java.nio.charset.StandardCharsets.UTF_8));
String signature = Base64.getEncoder().encodeToString(signer.sign());
query.append("&Signature=").append(urlEncode(signature));
return config.idpSingleLoginUrl() + "?" + query;
}
/**
* Validate a base64 SAMLResponse against the IdP signing cert and return the authenticated
* subject's NameID (used as the username).
*/
public String validateResponseAndGetUsername(String samlResponseB64) throws Exception {
byte[] decoded = Base64.getDecoder().decode(samlResponseB64);
org.opensaml.saml.saml2.core.Response response =
(org.opensaml.saml.saml2.core.Response)
XMLObjectSupport.unmarshallFromInputStream(
XMLObjectProviderRegistrySupport.getParserPool(),
new java.io.ByteArrayInputStream(decoded));
org.opensaml.security.x509.BasicX509Credential idpCredential =
new org.opensaml.security.x509.BasicX509Credential(idpCertificate);
boolean responseSigned = response.isSigned();
if (responseSigned) {
validateSignature(response.getSignature(), idpCredential);
}
if (response.getAssertions().isEmpty()) {
throw new IllegalStateException("SAML response contained no assertions");
}
org.opensaml.saml.saml2.core.Assertion assertion = response.getAssertions().get(0);
if (assertion.isSigned()) {
validateSignature(assertion.getSignature(), idpCredential);
} else if (!responseSigned) {
throw new IllegalStateException("Neither SAML response nor assertion was signed");
}
if (assertion.getSubject() == null || assertion.getSubject().getNameID() == null) {
throw new IllegalStateException("SAML assertion has no subject NameID");
}
return assertion.getSubject().getNameID().getValue();
}
private void validateSignature(
org.opensaml.xmlsec.signature.Signature signature,
org.opensaml.security.x509.BasicX509Credential credential)
throws Exception {
new org.opensaml.saml.security.impl.SAMLSignatureProfileValidator().validate(signature);
org.opensaml.xmlsec.signature.support.SignatureValidator.validate(signature, credential);
}
private static String urlEncode(String value) {
return java.net.URLEncoder.encode(value, java.nio.charset.StandardCharsets.UTF_8);
}
@SuppressWarnings("unchecked")
static <T> T build(QName qname) {
XMLObjectBuilderFactory factory = XMLObjectProviderRegistrySupport.getBuilderFactory();
@@ -0,0 +1,141 @@
package stirling.software.proprietary.security.saml2;
import java.io.IOException;
import java.util.Map;
import java.util.Optional;
import jakarta.inject.Inject;
import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.security.model.AuthenticationType;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.service.JwtServiceInterface;
import stirling.software.proprietary.security.service.SaveUserRequest;
import stirling.software.proprietary.security.service.TeamService;
import stirling.software.proprietary.security.service.UserService;
/**
* SAML2 SP login initiation ({@code GET /saml2/authenticate/{registrationId}} - builds + signs an
* AuthnRequest and redirects to the IdP) and Assertion Consumer Service ({@code POST
* /login/saml2/sso/{registrationId}} - validates the IdP SAMLResponse and logs the user in by
* issuing the application JWT cookie). Servlets (not JAX-RS) so quarkus-undertow routes the
* extension-less, {@code /login/*}-prefixed paths reliably.
*/
@Slf4j
@WebServlet(urlPatterns = {"/saml2/authenticate/*", "/login/saml2/sso/*"})
public class SamlSpServlet extends HttpServlet {
private static final String REG_ID = "keycloak";
@Inject Saml2Service samlService;
@Inject UserService userService;
@Inject TeamService teamService;
@Inject JwtServiceInterface jwtService;
@Inject ApplicationProperties applicationProperties;
@Override
protected void doGet(HttpServletRequest request, HttpServletResponse response)
throws IOException {
if (!samlService.isReady()) {
response.sendError(HttpServletResponse.SC_NOT_FOUND, "SAML2 is not enabled");
return;
}
try {
String relayState = request.getParameter("RelayState");
response.sendRedirect(samlService.buildAuthnRequestRedirectUrl(relayState));
} catch (Exception e) {
log.error("Failed to build SAML AuthnRequest", e);
redirectToLogin(request, response, "saml_request_failed");
}
}
@Override
protected void doPost(HttpServletRequest request, HttpServletResponse response)
throws IOException {
if (!samlService.isReady()) {
response.sendError(HttpServletResponse.SC_NOT_FOUND, "SAML2 is not enabled");
return;
}
String samlResponse = request.getParameter("SAMLResponse");
if (samlResponse == null || samlResponse.isBlank()) {
redirectToLogin(request, response, "missing_saml_response");
return;
}
try {
String username = samlService.validateResponseAndGetUsername(samlResponse);
User user = findOrCreateUser(username);
if (user == null) {
redirectToLogin(request, response, "registration_blocked");
return;
}
String jwt =
jwtService.generateToken(
username,
Map.of(
"authType", AuthenticationType.SSO.toString(),
"role", user.getRolesAsString()));
Cookie cookie = new Cookie("stirling_jwt", jwt);
cookie.setPath("/");
cookie.setHttpOnly(true);
response.addCookie(cookie);
response.sendRedirect(baseUrl(request) + "/");
} catch (Exception e) {
log.error("SAML ACS validation failed", e);
redirectToLogin(request, response, "saml_validation_failed");
}
}
private User findOrCreateUser(String username) {
Optional<User> existing = userService.findByUsernameIgnoreCase(username);
if (existing.isPresent()) {
return existing.get();
}
if (!samlService.getConfig().autoCreateUser()) {
log.warn("SAML user '{}' not found and autoCreateUser is disabled", username);
return null;
}
try {
userService.saveUserCore(
SaveUserRequest.builder()
.username(username)
.authenticationType(AuthenticationType.SSO)
.ssoProvider(REG_ID)
.team(teamService.getOrCreateDefaultTeam())
.build());
log.info("Auto-created SAML user: {}", username);
return userService.findByUsernameIgnoreCase(username).orElse(null);
} catch (Exception e) {
log.error("Failed to auto-create SAML user '{}'", username, e);
return null;
}
}
private String baseUrl(HttpServletRequest request) {
String backendUrl = applicationProperties.getSystem().getBackendUrl();
if (backendUrl != null && !backendUrl.isBlank()) {
return backendUrl.replaceAll("/+$", "");
}
String scheme = request.getScheme();
int port = request.getServerPort();
boolean defaultPort =
(scheme.equals("http") && port == 80) || (scheme.equals("https") && port == 443);
return scheme + "://" + request.getServerName() + (defaultPort ? "" : ":" + port);
}
private void redirectToLogin(
HttpServletRequest request, HttpServletResponse response, String reason)
throws IOException {
response.sendRedirect(
baseUrl(request)
+ "/login?error="
+ java.net.URLEncoder.encode(
reason, java.nio.charset.StandardCharsets.UTF_8));
}
}
@@ -20,10 +20,25 @@ public record SamlConfig(
boolean autoCreateUser) {
public static SamlConfig fromConfig(Config mp) {
String registrationId = get(mp, "security.saml2.registrationId", "keycloak");
String backendUrl = get(mp, "system.backendUrl", "http://localhost:8080");
// The SP entityId MUST match the registrationId Keycloak's SAML client is keyed on, which
// is
// the SP metadata URL (this is what Spring Security's Saml2 default used). The compose's
// SECURITY_SAML2_SP_ENTITYID is the bare host and does not match, so derive it here.
String spEntityId =
backendUrl.replaceAll("/+$", "")
+ "/saml2/service-provider-metadata/"
+ registrationId;
String acsUrl =
get(
mp,
"security.saml2.sp.acs",
backendUrl.replaceAll("/+$", "") + "/login/saml2/sso/" + registrationId);
return new SamlConfig(
get(mp, "security.saml2.registrationId", "keycloak"),
get(mp, "security.saml2.sp.entityId", null),
get(mp, "security.saml2.sp.acs", null),
registrationId,
spEntityId,
acsUrl,
get(mp, "security.saml2.idpSingleLoginUrl", null),
get(mp, "security.saml2.idp.issuer", null),
get(mp, "security.saml2.idp.cert", null),