Implement SAML2 login flow (signed AuthnRequest + ACS response validation)
This commit is contained in:
+98
@@ -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();
|
||||
|
||||
+141
@@ -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));
|
||||
}
|
||||
}
|
||||
+18
-3
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user