diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/Saml2Service.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/Saml2Service.java index 6ed196b6ef..c9fe1f54b3 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/Saml2Service.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/Saml2Service.java @@ -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 build(QName qname) { XMLObjectBuilderFactory factory = XMLObjectProviderRegistrySupport.getBuilderFactory(); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/SamlSpServlet.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/SamlSpServlet.java new file mode 100644 index 0000000000..f10d5b81b5 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/SamlSpServlet.java @@ -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 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)); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/config/SamlConfig.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/config/SamlConfig.java index 85213670ae..75435af912 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/config/SamlConfig.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/config/SamlConfig.java @@ -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),