Testing saml login/logout
# Conflicts: # app/proprietary/src/main/java/stirling/software/proprietary/security/CustomLogoutSuccessHandler.java # Conflicts: # frontend/src/proprietary/routes/AuthCallback.tsx # Conflicts: # app/core/src/main/java/stirling/software/SPDF/controller/api/SettingsController.java # app/proprietary/src/main/java/stirling/software/proprietary/security/config/AccountWebController.java
This commit is contained in:
committed by
DarioGii
parent
c77cd73deb
commit
bf51895b8e
@@ -241,6 +241,9 @@ public class ApplicationProperties {
|
||||
|
||||
@JsonIgnore
|
||||
public InputStream getIdpMetadataUri() throws IOException {
|
||||
if (idpMetadataUri == null || idpMetadataUri.isBlank()) {
|
||||
throw new IOException("security.saml2.idpMetadataUri is not configured");
|
||||
}
|
||||
if (idpMetadataUri.startsWith("classpath:")) {
|
||||
return new ClassPathResource(idpMetadataUri.substring("classpath:".length()))
|
||||
.getInputStream();
|
||||
@@ -256,6 +259,11 @@ public class ApplicationProperties {
|
||||
}
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
public String getIdpMetadataUriLocation() {
|
||||
return idpMetadataUri;
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
public Resource getSpCert() {
|
||||
if (spCert == null) return null;
|
||||
|
||||
@@ -29,7 +29,7 @@ public class RequestUriUtils {
|
||||
|| normalizedUri.startsWith("/pdfium/")
|
||||
|| normalizedUri.startsWith("/assets/")
|
||||
|| normalizedUri.startsWith("/locales/")
|
||||
|| normalizedUri.startsWith("/Login/")
|
||||
|| normalizedUri.startsWith("/login/")
|
||||
|| normalizedUri.startsWith("/samples/")
|
||||
|| normalizedUri.startsWith("/classic-logo/")
|
||||
|| normalizedUri.startsWith("/modern-logo/")
|
||||
|
||||
@@ -4,9 +4,9 @@ logging.level.org.springframework.security=WARN
|
||||
logging.level.org.hibernate=WARN
|
||||
logging.level.org.eclipse.jetty=WARN
|
||||
#logging.level.org.springframework.security.oauth2=DEBUG
|
||||
#logging.level.org.springframework.security=DEBUG
|
||||
logging.level.org.springframework.security=DEBUG
|
||||
#logging.level.org.opensaml=DEBUG
|
||||
#logging.level.stirling.software.proprietary.security=DEBUG
|
||||
logging.level.stirling.software.proprietary.security=DEBUG
|
||||
logging.level.com.zaxxer.hikari=WARN
|
||||
logging.level.stirling.software.SPDF.service.PdfJsonConversionService=INFO
|
||||
logging.level.stirling.software.common.service.JobExecutorService=INFO
|
||||
|
||||
+1
-1
@@ -210,7 +210,7 @@ public class ProprietaryUIDataController {
|
||||
SAML2 saml2 = securityProps.getSaml2();
|
||||
// Only add SAML2 providers if loginMethod allows it
|
||||
if (securityProps.isSaml2Active() && applicationProperties.getPremium().isEnabled()) {
|
||||
String samlIdp = saml2.getProvider();
|
||||
String samlIdp = saml2.getIdpIssuer();
|
||||
String saml2AuthenticationPath = "/saml2/authenticate/" + saml2.getRegistrationId();
|
||||
|
||||
// For SAML, we need to use the backend URL directly, not a relative path
|
||||
|
||||
+57
-13
@@ -6,6 +6,8 @@ import java.security.interfaces.RSAPrivateKey;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
@@ -56,11 +58,14 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler {
|
||||
throws IOException {
|
||||
|
||||
if (!response.isCommitted()) {
|
||||
Optional<String> samlNameId = resolveSamlNameId(authentication, request);
|
||||
if (samlNameId.isPresent()) {
|
||||
getRedirect_saml2(request, response, samlNameId.get());
|
||||
return;
|
||||
}
|
||||
|
||||
if (authentication != null) {
|
||||
if (authentication instanceof Saml2Authentication samlAuthentication) {
|
||||
// Handle SAML2 logout redirection
|
||||
getRedirect_saml2(request, response, samlAuthentication);
|
||||
} else if (authentication instanceof OAuth2AuthenticationToken oAuthToken) {
|
||||
if (authentication instanceof OAuth2AuthenticationToken oAuthToken) {
|
||||
// Handle OAuth2 logout redirection
|
||||
getRedirect_oauth2(request, response, oAuthToken);
|
||||
} else if (authentication instanceof UsernamePasswordAuthenticationToken) {
|
||||
@@ -90,19 +95,26 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler {
|
||||
|
||||
// Redirect for SAML2 authentication logout
|
||||
private void getRedirect_saml2(
|
||||
HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
Saml2Authentication samlAuthentication)
|
||||
HttpServletRequest request, HttpServletResponse response, Saml2Authentication samlAuth)
|
||||
throws IOException {
|
||||
CustomSaml2AuthenticatedPrincipal principal =
|
||||
(CustomSaml2AuthenticatedPrincipal) samlAuth.getPrincipal();
|
||||
getRedirect_saml2(request, response, principal.nameId());
|
||||
}
|
||||
|
||||
private void getRedirect_saml2(
|
||||
HttpServletRequest request, HttpServletResponse response, String nameIdValue)
|
||||
throws IOException {
|
||||
|
||||
if (nameIdValue == null || nameIdValue.isBlank()) {
|
||||
log.warn("Unable to perform SAML logout due to missing NameID");
|
||||
getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH);
|
||||
return;
|
||||
}
|
||||
|
||||
SAML2 samlConf = securityProperties.getSaml2();
|
||||
String registrationId = samlConf.getRegistrationId();
|
||||
|
||||
CustomSaml2AuthenticatedPrincipal principal =
|
||||
(CustomSaml2AuthenticatedPrincipal) samlAuthentication.getPrincipal();
|
||||
|
||||
String nameIdValue = principal.name();
|
||||
|
||||
try {
|
||||
// Read certificate from the resource
|
||||
Resource certificateResource = samlConf.getSpCert();
|
||||
@@ -130,13 +142,45 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler {
|
||||
} catch (Exception e) {
|
||||
log.error(
|
||||
"Error retrieving logout URL from Provider {} for user {}",
|
||||
samlConf.getProvider(),
|
||||
samlConf.getIdpIssuer(),
|
||||
nameIdValue,
|
||||
e);
|
||||
getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH);
|
||||
}
|
||||
}
|
||||
|
||||
private Optional<String> resolveSamlNameId(
|
||||
Authentication authentication, HttpServletRequest request) {
|
||||
if (authentication instanceof Saml2Authentication samlAuthentication) {
|
||||
CustomSaml2AuthenticatedPrincipal principal =
|
||||
(CustomSaml2AuthenticatedPrincipal) samlAuthentication.getPrincipal();
|
||||
String nameId = principal.nameId();
|
||||
if (nameId != null && !nameId.isBlank()) {
|
||||
return Optional.of(nameId);
|
||||
}
|
||||
}
|
||||
|
||||
if (jwtService != null) {
|
||||
try {
|
||||
String token = jwtService.extractToken(request);
|
||||
if (token != null && !token.isBlank()) {
|
||||
Map<String, Object> claims = jwtService.extractClaims(token);
|
||||
Object authType = claims.get("authType");
|
||||
if (authType != null && "SAML2".equalsIgnoreCase(authType.toString())) {
|
||||
Object nameId = claims.get("samlNameId");
|
||||
if (nameId instanceof String && !((String) nameId).isBlank()) {
|
||||
return Optional.of((String) nameId);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
log.debug("Unable to resolve SAML NameID from JWT during logout", ex);
|
||||
}
|
||||
}
|
||||
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
// Redirect for OAuth2 authentication logout
|
||||
private void getRedirect_oauth2(
|
||||
HttpServletRequest request,
|
||||
|
||||
+5
-4
@@ -24,6 +24,7 @@ import org.springframework.security.saml2.provider.service.registration.RelyingP
|
||||
import org.springframework.security.saml2.provider.service.web.authentication.OpenSaml4AuthenticationRequestResolver;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
||||
import org.springframework.security.web.authentication.logout.LogoutFilter;
|
||||
import org.springframework.security.web.authentication.rememberme.PersistentTokenRepository;
|
||||
import org.springframework.security.web.savedrequest.NullRequestCache;
|
||||
import org.springframework.security.web.servlet.util.matcher.PathPatternRequestMatcher;
|
||||
@@ -203,10 +204,10 @@ public class SecurityConfiguration {
|
||||
|
||||
if (loginEnabledValue) {
|
||||
|
||||
http.addFilterBefore(
|
||||
userAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
|
||||
.addFilterBefore(rateLimitingFilter, UsernamePasswordAuthenticationFilter.class)
|
||||
.addFilterBefore(jwtAuthenticationFilter, UserAuthenticationFilter.class);
|
||||
http.addFilterBefore(rateLimitingFilter, UsernamePasswordAuthenticationFilter.class)
|
||||
.addFilterBefore(jwtAuthenticationFilter, LogoutFilter.class)
|
||||
.addFilterAfter(
|
||||
userAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
|
||||
|
||||
http.sessionManagement(
|
||||
sessionManagement ->
|
||||
|
||||
+9
@@ -61,6 +61,11 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
String requestURI = request.getRequestURI();
|
||||
String contextPath = request.getContextPath();
|
||||
|
||||
if (isPublicAuthEndpoint(requestURI, contextPath)) {
|
||||
filterChain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isStaticResource(contextPath, requestURI)) {
|
||||
filterChain.doFilter(request, response);
|
||||
return;
|
||||
@@ -174,7 +179,11 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
userDetails, null, userDetails.getAuthorities());
|
||||
|
||||
authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
|
||||
log.debug("Setting authentication for user: {}", username);
|
||||
SecurityContextHolder.getContext().setAuthentication(authToken);
|
||||
log.debug(
|
||||
"Authentication set successfully: {}",
|
||||
SecurityContextHolder.getContext().getAuthentication());
|
||||
} else {
|
||||
throw new UsernameNotFoundException("User not found: " + username);
|
||||
}
|
||||
|
||||
+39
-3
@@ -4,6 +4,7 @@ import static stirling.software.common.util.RequestUriUtils.isPublicAuthEndpoint
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
@@ -32,6 +33,7 @@ import stirling.software.common.util.RequestUriUtils;
|
||||
import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrincipal;
|
||||
import stirling.software.proprietary.security.service.JwtServiceInterface;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
import stirling.software.proprietary.security.session.SessionPersistentRegistry;
|
||||
|
||||
@@ -43,16 +45,19 @@ public class UserAuthenticationFilter extends OncePerRequestFilter {
|
||||
private final UserService userService;
|
||||
private final SessionPersistentRegistry sessionPersistentRegistry;
|
||||
private final boolean loginEnabledValue;
|
||||
private final JwtServiceInterface jwtService;
|
||||
|
||||
public UserAuthenticationFilter(
|
||||
@Lazy ApplicationProperties.Security securityProp,
|
||||
@Lazy UserService userService,
|
||||
SessionPersistentRegistry sessionPersistentRegistry,
|
||||
@Qualifier("loginEnabled") boolean loginEnabledValue) {
|
||||
@Qualifier("loginEnabled") boolean loginEnabledValue,
|
||||
@Lazy JwtServiceInterface jwtService) {
|
||||
this.securityProp = securityProp;
|
||||
this.userService = userService;
|
||||
this.sessionPersistentRegistry = sessionPersistentRegistry;
|
||||
this.loginEnabledValue = loginEnabledValue;
|
||||
this.jwtService = jwtService;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -135,7 +140,7 @@ public class UserAuthenticationFilter extends OncePerRequestFilter {
|
||||
}
|
||||
|
||||
// Check if the authenticated user is disabled and invalidate their session if so
|
||||
if (authentication != null && authentication.isAuthenticated()) {
|
||||
if (authentication.isAuthenticated()) {
|
||||
|
||||
UserLoginType loginMethod = UserLoginType.UNKNOWN;
|
||||
|
||||
@@ -146,7 +151,14 @@ public class UserAuthenticationFilter extends OncePerRequestFilter {
|
||||
String username = null;
|
||||
if (principal instanceof UserDetails detailsUser) {
|
||||
username = detailsUser.getUsername();
|
||||
loginMethod = UserLoginType.USERDETAILS;
|
||||
loginMethod = resolveLoginTypeFromJwt(request);
|
||||
if (loginMethod == UserLoginType.SAML2USER) {
|
||||
SAML2 saml2 = securityProp.getSaml2();
|
||||
blockRegistration = saml2 != null && saml2.getBlockRegistration();
|
||||
} else if (loginMethod == UserLoginType.OAUTH2USER) {
|
||||
OAUTH2 oAuth = securityProp.getOauth2();
|
||||
blockRegistration = oAuth != null && oAuth.getBlockRegistration();
|
||||
}
|
||||
} else if (principal instanceof OAuth2User oAuth2User) {
|
||||
username = oAuth2User.getName();
|
||||
loginMethod = UserLoginType.OAUTH2USER;
|
||||
@@ -241,6 +253,30 @@ public class UserAuthenticationFilter extends OncePerRequestFilter {
|
||||
filterChain.doFilter(request, response);
|
||||
}
|
||||
|
||||
private UserLoginType resolveLoginTypeFromJwt(HttpServletRequest request) {
|
||||
if (jwtService == null) {
|
||||
return UserLoginType.USERDETAILS;
|
||||
}
|
||||
try {
|
||||
String token = jwtService.extractToken(request);
|
||||
if (token != null && !token.isBlank()) {
|
||||
Map<String, Object> claims = jwtService.extractClaims(token);
|
||||
Object authType = claims.get("authType");
|
||||
if (authType != null) {
|
||||
String type = authType.toString().toUpperCase();
|
||||
if ("SAML2".equals(type)) {
|
||||
return UserLoginType.SAML2USER;
|
||||
} else if ("OAUTH2".equals(type)) {
|
||||
return UserLoginType.OAUTH2USER;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.debug("Unable to resolve login type from JWT claims", e);
|
||||
}
|
||||
return UserLoginType.USERDETAILS;
|
||||
}
|
||||
|
||||
private enum UserLoginType {
|
||||
USERDETAILS("UserDetails"),
|
||||
OAUTH2USER("OAuth2User"),
|
||||
|
||||
+23
-8
@@ -7,6 +7,8 @@ import java.net.URI;
|
||||
import java.net.URLDecoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.sql.SQLException;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
@@ -33,6 +35,7 @@ import stirling.software.proprietary.audit.AuditEventType;
|
||||
import stirling.software.proprietary.audit.AuditLevel;
|
||||
import stirling.software.proprietary.audit.Audited;
|
||||
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.LoginAttemptService;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
@@ -70,8 +73,7 @@ public class CustomSaml2AuthenticationSuccessHandler
|
||||
|
||||
// Check if user is eligible for SAML (grandfathered or system has ENTERPRISE license)
|
||||
if (userExists) {
|
||||
stirling.software.proprietary.security.model.User user =
|
||||
userService.findByUsernameIgnoreCase(username).orElse(null);
|
||||
User user = userService.findByUsernameIgnoreCase(username).orElse(null);
|
||||
|
||||
if (user != null && !licenseSettingsService.isSamlEligible(user)) {
|
||||
// User is not grandfathered and no ENTERPRISE license - block SAML login
|
||||
@@ -172,7 +174,11 @@ public class CustomSaml2AuthenticationSuccessHandler
|
||||
|
||||
// Extract SSO provider information from SAML2 assertion
|
||||
String ssoProviderId = saml2Principal.nameId();
|
||||
String ssoProvider = "saml2"; // fixme
|
||||
String ssoProvider =
|
||||
(saml2Properties.getIdpIssuer() != null
|
||||
&& !saml2Properties.getIdpIssuer().isBlank())
|
||||
? saml2Properties.getIdpIssuer()
|
||||
: saml2Properties.getRegistrationId();
|
||||
|
||||
log.debug(
|
||||
"Processing SSO post-login for user: {} (Provider: {}, ProviderId: {})",
|
||||
@@ -188,12 +194,21 @@ public class CustomSaml2AuthenticationSuccessHandler
|
||||
SAML2);
|
||||
log.debug("Successfully processed authentication for user: {}", username);
|
||||
|
||||
// Generate JWT if v2 is enabled
|
||||
if (jwtService.isJwtEnabled()) {
|
||||
String jwt =
|
||||
jwtService.generateToken(
|
||||
authentication,
|
||||
Map.of("authType", AuthenticationType.SAML2));
|
||||
Map<String, Object> claims = new HashMap<>();
|
||||
claims.put("authType", AuthenticationType.SAML2);
|
||||
claims.put("samlNameId", saml2Principal.nameId());
|
||||
List<String> sessionIndexes = saml2Principal.sessionIndexes();
|
||||
if (sessionIndexes != null && !sessionIndexes.isEmpty()) {
|
||||
claims.put("samlSessionIndexes", sessionIndexes);
|
||||
}
|
||||
if (ssoProvider != null) {
|
||||
claims.put("samlProvider", ssoProvider);
|
||||
}
|
||||
if (saml2Properties.getRegistrationId() != null) {
|
||||
claims.put("samlRegistrationId", saml2Properties.getRegistrationId());
|
||||
}
|
||||
String jwt = jwtService.generateToken(authentication, claims);
|
||||
|
||||
// Build context-aware redirect URL based on the original request
|
||||
String redirectUrl =
|
||||
|
||||
+1
-1
@@ -94,7 +94,7 @@ public class JwtSaml2AuthenticationRequestRepository
|
||||
|
||||
if (token != null) {
|
||||
tokenStore.remove(authnRequestId);
|
||||
log.debug("Retrieved SAMLRequest token for RelayState ID: {}", authnRequestId);
|
||||
log.info("Retrieved SAMLRequest token for RelayState ID: {}", authnRequestId);
|
||||
return token;
|
||||
} else {
|
||||
log.warn("No SAMLRequest token found for RelayState ID: {}", authnRequestId);
|
||||
|
||||
+181
-26
@@ -1,9 +1,22 @@
|
||||
package stirling.software.proprietary.security.saml2;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.security.cert.CertificateException;
|
||||
import java.security.cert.CertificateFactory;
|
||||
import java.security.cert.X509Certificate;
|
||||
import java.util.Base64;
|
||||
import java.util.Collections;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import javax.xml.XMLConstants;
|
||||
import javax.xml.parsers.DocumentBuilder;
|
||||
import javax.xml.parsers.DocumentBuilderFactory;
|
||||
import javax.xml.parsers.ParserConfigurationException;
|
||||
|
||||
import org.opensaml.saml.saml2.core.AuthnRequest;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
@@ -19,6 +32,10 @@ import org.springframework.security.saml2.provider.service.registration.RelyingP
|
||||
import org.springframework.security.saml2.provider.service.registration.Saml2MessageBinding;
|
||||
import org.springframework.security.saml2.provider.service.web.Saml2AuthenticationRequestRepository;
|
||||
import org.springframework.security.saml2.provider.service.web.authentication.OpenSaml4AuthenticationRequestResolver;
|
||||
import org.w3c.dom.Document;
|
||||
import org.w3c.dom.Element;
|
||||
import org.w3c.dom.NodeList;
|
||||
import org.xml.sax.SAXException;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
@@ -35,37 +52,26 @@ import stirling.software.proprietary.security.service.JwtServiceInterface;
|
||||
@RequiredArgsConstructor
|
||||
public class Saml2Configuration {
|
||||
|
||||
private static final String SAML_METADATA_NS = "urn:oasis:names:tc:SAML:2.0:metadata";
|
||||
private static final String XML_DSIG_NS = "http://www.w3.org/2000/09/xmldsig#";
|
||||
|
||||
private final ApplicationProperties applicationProperties;
|
||||
|
||||
@Bean
|
||||
@ConditionalOnProperty(name = "security.saml2.enabled", havingValue = "true")
|
||||
public RelyingPartyRegistrationRepository relyingPartyRegistrations() throws Exception {
|
||||
SAML2 samlConf = applicationProperties.getSecurity().getSaml2();
|
||||
Optional<IdpMetadataInfo> metadataInfo = loadIdpMetadata(samlConf);
|
||||
|
||||
log.info(
|
||||
"Initializing SAML2 configuration with registration ID: {}",
|
||||
samlConf.getRegistrationId());
|
||||
|
||||
// Load IdP certificate
|
||||
X509Certificate idpCert;
|
||||
try {
|
||||
Resource idpCertResource = samlConf.getIdpCert();
|
||||
log.info("Loading IdP certificate from: {}", idpCertResource.getDescription());
|
||||
if (!idpCertResource.exists()) {
|
||||
log.error(
|
||||
"SAML2 IdP certificate not found at: {}", idpCertResource.getDescription());
|
||||
throw new IllegalStateException(
|
||||
"SAML2 IdP certificate file does not exist: "
|
||||
+ idpCertResource.getDescription());
|
||||
}
|
||||
idpCert = CertificateUtils.readCertificate(idpCertResource);
|
||||
log.info(
|
||||
"Successfully loaded IdP certificate. Subject: {}",
|
||||
idpCert.getSubjectX500Principal().getName());
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to load SAML2 IdP certificate: {}", e.getMessage(), e);
|
||||
throw new IllegalStateException("Failed to load SAML2 IdP certificate", e);
|
||||
}
|
||||
// Load IdP certificate either from metadata or fallback resource
|
||||
X509Certificate idpCert =
|
||||
metadataInfo
|
||||
.map(IdpMetadataInfo::signingCertificate)
|
||||
.orElseGet(() -> loadIdpCertificateFromResource(samlConf));
|
||||
|
||||
Saml2X509Credential verificationCredential = Saml2X509Credential.verification(idpCert);
|
||||
|
||||
@@ -102,6 +108,10 @@ public class Saml2Configuration {
|
||||
log.error("Failed to load SAML2 SP credentials: {}", e.getMessage(), e);
|
||||
throw new IllegalStateException("Failed to load SAML2 SP credentials", e);
|
||||
}
|
||||
metadataInfo.ifPresent(info -> applyMetadataOverrides(samlConf, info));
|
||||
String idpIssuer = samlConf.getIdpIssuer();
|
||||
String idpSingleLoginUrl = samlConf.getIdpSingleLoginUrl();
|
||||
String idpSingleLogoutUrl = samlConf.getIdpSingleLogoutUrl();
|
||||
|
||||
// Get backend URL from configuration (for SAML endpoints)
|
||||
String backendUrl = applicationProperties.getSystem().getBackendUrl();
|
||||
@@ -123,24 +133,22 @@ public class Saml2Configuration {
|
||||
.signingX509Credentials(c -> c.add(signingCredential))
|
||||
.entityId(entityId)
|
||||
.singleLogoutServiceBinding(Saml2MessageBinding.POST)
|
||||
.singleLogoutServiceLocation(samlConf.getIdpSingleLogoutUrl())
|
||||
.singleLogoutServiceLocation(idpSingleLogoutUrl)
|
||||
.singleLogoutServiceResponseLocation(sloResponseLocation)
|
||||
.assertionConsumerServiceBinding(Saml2MessageBinding.POST)
|
||||
.assertionConsumerServiceLocation(acsLocation)
|
||||
.authnRequestsSigned(true)
|
||||
.assertingPartyMetadata(
|
||||
metadata ->
|
||||
metadata.entityId(samlConf.getIdpIssuer())
|
||||
metadata.entityId(idpIssuer)
|
||||
.verificationX509Credentials(
|
||||
c -> c.add(verificationCredential))
|
||||
.singleSignOnServiceBinding(
|
||||
Saml2MessageBinding.POST)
|
||||
.singleSignOnServiceLocation(
|
||||
samlConf.getIdpSingleLoginUrl())
|
||||
.singleSignOnServiceLocation(idpSingleLoginUrl)
|
||||
.singleLogoutServiceBinding(
|
||||
Saml2MessageBinding.POST)
|
||||
.singleLogoutServiceLocation(
|
||||
samlConf.getIdpSingleLogoutUrl())
|
||||
.singleLogoutServiceLocation(idpSingleLogoutUrl)
|
||||
.singleLogoutServiceResponseLocation(
|
||||
sloResponseLocation)
|
||||
.wantAuthnRequestsSigned(true))
|
||||
@@ -206,6 +214,153 @@ public class Saml2Configuration {
|
||||
return resolver;
|
||||
}
|
||||
|
||||
private X509Certificate loadIdpCertificateFromResource(SAML2 samlConf) {
|
||||
try {
|
||||
Resource idpCertResource = samlConf.getIdpCert();
|
||||
if (idpCertResource == null) {
|
||||
throw new IllegalStateException("SAML2 IdP certificate resource is not defined");
|
||||
}
|
||||
log.info("Loading IdP certificate from: {}", idpCertResource.getDescription());
|
||||
if (!idpCertResource.exists()) {
|
||||
throw new IllegalStateException(
|
||||
"SAML2 IdP certificate file does not exist: "
|
||||
+ idpCertResource.getDescription());
|
||||
}
|
||||
X509Certificate certificate = CertificateUtils.readCertificate(idpCertResource);
|
||||
log.info(
|
||||
"Successfully loaded IdP certificate. Subject: {}",
|
||||
certificate.getSubjectX500Principal().getName());
|
||||
return certificate;
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to load SAML2 IdP certificate: {}", e.getMessage(), e);
|
||||
throw new IllegalStateException("Failed to load SAML2 IdP certificate", e);
|
||||
}
|
||||
}
|
||||
|
||||
private void applyMetadataOverrides(SAML2 samlConf, IdpMetadataInfo metadataInfo) {
|
||||
log.info(
|
||||
"Applying IdP metadata overrides for registration: {}",
|
||||
samlConf.getRegistrationId());
|
||||
overrideIfPresent(metadataInfo.entityId(), samlConf::setIdpIssuer);
|
||||
overrideIfPresent(metadataInfo.singleSignOnServiceUrl(), samlConf::setIdpSingleLoginUrl);
|
||||
overrideIfPresent(metadataInfo.singleLogoutServiceUrl(), samlConf::setIdpSingleLogoutUrl);
|
||||
}
|
||||
|
||||
private Optional<IdpMetadataInfo> loadIdpMetadata(SAML2 samlConf) {
|
||||
String metadataLocation = samlConf.getIdpMetadataUriLocation();
|
||||
if (metadataLocation == null || metadataLocation.isBlank()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
try (InputStream metadataStream = samlConf.getIdpMetadataUri()) {
|
||||
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
|
||||
factory.setNamespaceAware(true);
|
||||
factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
|
||||
try {
|
||||
factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, "");
|
||||
factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_SCHEMA, "");
|
||||
} catch (IllegalArgumentException ignored) {
|
||||
log.debug("XML parser does not support external entity restrictions");
|
||||
}
|
||||
|
||||
DocumentBuilder builder = factory.newDocumentBuilder();
|
||||
Document doc = builder.parse(metadataStream);
|
||||
|
||||
Element entityDescriptor = doc.getDocumentElement();
|
||||
if (entityDescriptor == null) {
|
||||
log.warn("No EntityDescriptor found in SAML metadata: {}", metadataLocation);
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
String entityId = entityDescriptor.getAttribute("entityID");
|
||||
NodeList idpDescriptors =
|
||||
entityDescriptor.getElementsByTagNameNS(SAML_METADATA_NS, "IDPSSODescriptor");
|
||||
if (idpDescriptors.getLength() == 0) {
|
||||
log.warn("No IDPSSODescriptor found in SAML metadata: {}", metadataLocation);
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
Element idpDescriptor = (Element) idpDescriptors.item(0);
|
||||
String ssoUrl = extractServiceLocation(idpDescriptor, "SingleSignOnService");
|
||||
String sloUrl = extractServiceLocation(idpDescriptor, "SingleLogoutService");
|
||||
X509Certificate signingCert = extractSigningCertificate(idpDescriptor);
|
||||
|
||||
log.info("Loaded IdP metadata from: {}", metadataLocation);
|
||||
return Optional.of(new IdpMetadataInfo(entityId, ssoUrl, sloUrl, signingCert));
|
||||
} catch (IOException
|
||||
| ParserConfigurationException
|
||||
| SAXException
|
||||
| CertificateException e) {
|
||||
log.warn("Failed to parse SAML metadata from {}: {}", metadataLocation, e.getMessage());
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
private String extractServiceLocation(Element descriptor, String tagName) {
|
||||
NodeList services = descriptor.getElementsByTagNameNS(SAML_METADATA_NS, tagName);
|
||||
String fallback = null;
|
||||
for (int i = 0; i < services.getLength(); i++) {
|
||||
Element service = (Element) services.item(i);
|
||||
String location = service.getAttribute("Location");
|
||||
String binding = service.getAttribute("Binding");
|
||||
if (!hasText(location)) {
|
||||
continue;
|
||||
}
|
||||
if (Saml2MessageBinding.POST.getUrn().equals(binding)) {
|
||||
return location;
|
||||
}
|
||||
if (fallback == null) {
|
||||
fallback = location;
|
||||
}
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
private X509Certificate extractSigningCertificate(Element descriptor)
|
||||
throws CertificateException {
|
||||
NodeList keyDescriptors =
|
||||
descriptor.getElementsByTagNameNS(SAML_METADATA_NS, "KeyDescriptor");
|
||||
for (int i = 0; i < keyDescriptors.getLength(); i++) {
|
||||
Element keyDescriptor = (Element) keyDescriptors.item(i);
|
||||
String use = keyDescriptor.getAttribute("use");
|
||||
if (hasText(use) && !"signing".equalsIgnoreCase(use)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
NodeList certificateNodes =
|
||||
keyDescriptor.getElementsByTagNameNS(XML_DSIG_NS, "X509Certificate");
|
||||
if (certificateNodes.getLength() == 0) {
|
||||
continue;
|
||||
}
|
||||
String certificateValue = certificateNodes.item(0).getTextContent();
|
||||
if (!hasText(certificateValue)) {
|
||||
continue;
|
||||
}
|
||||
byte[] decoded =
|
||||
Base64.getMimeDecoder().decode(certificateValue.replaceAll("\\s+", ""));
|
||||
CertificateFactory certificateFactory = CertificateFactory.getInstance("X.509");
|
||||
return (X509Certificate)
|
||||
certificateFactory.generateCertificate(new ByteArrayInputStream(decoded));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private boolean hasText(String value) {
|
||||
return value != null && !value.trim().isEmpty();
|
||||
}
|
||||
|
||||
private void overrideIfPresent(String value, Consumer<String> setter) {
|
||||
if (hasText(value)) {
|
||||
setter.accept(value.trim());
|
||||
}
|
||||
}
|
||||
|
||||
private record IdpMetadataInfo(
|
||||
String entityId,
|
||||
String singleSignOnServiceUrl,
|
||||
String singleLogoutServiceUrl,
|
||||
X509Certificate signingCertificate) {}
|
||||
|
||||
private static void logAuthnRequestDetails(AuthnRequest authnRequest) {
|
||||
String message =
|
||||
"""
|
||||
|
||||
@@ -134,10 +134,15 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
// Listen for jwt-available event (triggered by desktop auth or other sources)
|
||||
const handleJwtAvailable = () => {
|
||||
console.debug('[Auth] JWT available event received, refreshing session');
|
||||
console.log('[Auth] JWT available event received, refreshing session');
|
||||
// Set loading true while we re-validate
|
||||
setLoading(true);
|
||||
void initializeAuth();
|
||||
};
|
||||
|
||||
// Log that we're setting up the listener
|
||||
console.debug('[Auth] Setting up jwt-available event listener');
|
||||
|
||||
window.addEventListener('jwt-available', handleJwtAvailable);
|
||||
|
||||
// Subscribe to auth state changes
|
||||
|
||||
@@ -1,76 +1,175 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { springAuth } from '@app/auth/springAuthClient';
|
||||
import { handleAuthCallbackSuccess } from '@app/extensions/authCallback';
|
||||
import styles from '@app/routes/AuthCallback.module.css';
|
||||
import { useAuth } from '@app/auth/UseSession';
|
||||
|
||||
// Use sessionStorage to track callback handling across React StrictMode double-renders
|
||||
// Timestamp-based to allow retry after 10 seconds (e.g., if user tries again)
|
||||
const AUTH_CALLBACK_TIMESTAMP_KEY = 'stirling_auth_callback_ts';
|
||||
|
||||
/**
|
||||
* OAuth Callback Handler
|
||||
* OAuth/SAML Callback Handler
|
||||
*
|
||||
* This component is rendered after OAuth providers (GitHub, Google, etc.) redirect back.
|
||||
* This component is rendered after OAuth/SAML providers redirect back.
|
||||
* The JWT is passed in the URL fragment (#access_token=...) by the Spring backend.
|
||||
* We extract it, store in localStorage, and redirect to the home page.
|
||||
*
|
||||
* Flow:
|
||||
* 1. Extract JWT from URL hash
|
||||
* 2. Store in localStorage
|
||||
* 3. Fire jwt-available event (AuthProvider will validate)
|
||||
* 4. Wait for AuthProvider to confirm session
|
||||
* 5. Navigate to home only after session is confirmed
|
||||
*/
|
||||
export default function AuthCallback() {
|
||||
const navigate = useNavigate();
|
||||
const { session, loading: authLoading } = useAuth();
|
||||
const [status, setStatus] = useState<'extracting' | 'validating' | 'error'>('extracting');
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [tokenStored, setTokenStored] = useState(false);
|
||||
const processingRef = useRef(false);
|
||||
|
||||
// Step 1: Extract and store the token
|
||||
useEffect(() => {
|
||||
const handleCallback = async () => {
|
||||
try {
|
||||
console.log('[AuthCallback] Handling OAuth callback...');
|
||||
// Check if we've already handled this callback recently (within 10 seconds)
|
||||
const handledTimestamp = sessionStorage.getItem(AUTH_CALLBACK_TIMESTAMP_KEY);
|
||||
const now = Date.now();
|
||||
if (handledTimestamp && (now - parseInt(handledTimestamp, 10)) < 10000) {
|
||||
console.debug('[AuthCallback] Already handled recently, checking session state');
|
||||
setTokenStored(true);
|
||||
setStatus('validating');
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract JWT from URL fragment (#access_token=...)
|
||||
const hash = window.location.hash.substring(1); // Remove '#'
|
||||
const params = new URLSearchParams(hash);
|
||||
const token = params.get('access_token');
|
||||
// Use ref to prevent concurrent processing
|
||||
if (processingRef.current) {
|
||||
console.debug('[AuthCallback] Already processing, skipping');
|
||||
return;
|
||||
}
|
||||
processingRef.current = true;
|
||||
|
||||
if (!token) {
|
||||
console.error('[AuthCallback] No access_token in URL fragment');
|
||||
console.log('[AuthCallback] Handling OAuth/SAML callback...');
|
||||
console.debug('[AuthCallback] Current URL:', window.location.href);
|
||||
console.debug('[AuthCallback] Hash:', window.location.hash);
|
||||
|
||||
// Extract JWT from URL fragment (#access_token=...)
|
||||
const hash = window.location.hash.substring(1); // Remove '#'
|
||||
const params = new URLSearchParams(hash);
|
||||
const token = params.get('access_token');
|
||||
|
||||
if (!token) {
|
||||
console.error('[AuthCallback] No access_token in URL fragment');
|
||||
console.debug('[AuthCallback] Full hash was:', hash);
|
||||
setStatus('error');
|
||||
setErrorMessage('Authentication failed - no token received from server.');
|
||||
|
||||
setTimeout(() => {
|
||||
navigate('/login', {
|
||||
replace: true,
|
||||
state: { error: 'OAuth login failed - no token received.' }
|
||||
});
|
||||
}, 2000);
|
||||
return;
|
||||
}
|
||||
|
||||
// Mark as handled BEFORE storing token
|
||||
sessionStorage.setItem(AUTH_CALLBACK_TIMESTAMP_KEY, now.toString());
|
||||
|
||||
// Store JWT in localStorage
|
||||
localStorage.setItem('stirling_jwt', token);
|
||||
console.log('[AuthCallback] JWT stored in localStorage');
|
||||
|
||||
// Clear the hash from URL to prevent re-processing on page refresh
|
||||
if (window.history.replaceState) {
|
||||
window.history.replaceState(null, '', window.location.pathname);
|
||||
}
|
||||
|
||||
// Update status
|
||||
setStatus('validating');
|
||||
|
||||
// Dispatch jwt-available event - AuthProvider will validate the token
|
||||
console.log('[AuthCallback] Firing jwt-available event for AuthProvider to validate');
|
||||
window.dispatchEvent(new CustomEvent('jwt-available'));
|
||||
|
||||
// Small delay to allow AuthProvider to receive the event and start loading
|
||||
// before we set tokenStored (which triggers the session watching effect)
|
||||
setTimeout(() => {
|
||||
console.log('[AuthCallback] Setting tokenStored=true to start watching session');
|
||||
setTokenStored(true);
|
||||
}, 50);
|
||||
|
||||
return () => {
|
||||
processingRef.current = false;
|
||||
};
|
||||
}, [navigate]);
|
||||
|
||||
// Step 2: Wait for AuthProvider to validate and provide session
|
||||
useEffect(() => {
|
||||
if (!tokenStored) {
|
||||
return; // Token not stored yet, wait
|
||||
}
|
||||
|
||||
console.debug('[AuthCallback] Checking auth state:', {
|
||||
authLoading,
|
||||
hasSession: !!session,
|
||||
tokenStored
|
||||
});
|
||||
|
||||
// Still loading - wait for AuthProvider to finish validation
|
||||
if (authLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
// AuthProvider finished loading
|
||||
if (session) {
|
||||
// Session validated successfully!
|
||||
console.log('[AuthCallback] Session confirmed, user:', session.user?.email);
|
||||
console.log('[AuthCallback] Redirecting to home...');
|
||||
navigate('/', { replace: true });
|
||||
} else {
|
||||
// No session after AuthProvider finished - token was invalid
|
||||
console.error('[AuthCallback] No session after validation - token may be invalid');
|
||||
|
||||
// Check if JWT is still in localStorage (AuthProvider removes it on 401)
|
||||
const jwtStillExists = !!localStorage.getItem('stirling_jwt');
|
||||
if (!jwtStillExists) {
|
||||
setStatus('error');
|
||||
setErrorMessage('Authentication failed - invalid or expired token.');
|
||||
|
||||
setTimeout(() => {
|
||||
navigate('/login', {
|
||||
replace: true,
|
||||
state: { error: 'OAuth login failed - no token received.' }
|
||||
state: { error: 'Authentication failed - please try again.' }
|
||||
});
|
||||
return;
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
// If JWT still exists but no session, AuthProvider might still be initializing
|
||||
// Wait a bit more before giving up
|
||||
}
|
||||
}, [tokenStored, authLoading, session, navigate]);
|
||||
|
||||
// Store JWT in localStorage
|
||||
localStorage.setItem('stirling_jwt', token);
|
||||
console.log('[AuthCallback] JWT stored in localStorage');
|
||||
|
||||
// Dispatch custom event for other components to react to JWT availability
|
||||
window.dispatchEvent(new CustomEvent('jwt-available'));
|
||||
|
||||
// Validate the token and load user info
|
||||
// This calls /api/v1/auth/me with the JWT to get user details
|
||||
const { data, error } = await springAuth.getSession();
|
||||
|
||||
if (error || !data.session) {
|
||||
console.error('[AuthCallback] Failed to validate token:', error);
|
||||
localStorage.removeItem('stirling_jwt');
|
||||
navigate('/login', {
|
||||
replace: true,
|
||||
state: { error: 'OAuth login failed - invalid token.' }
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Timeout: If we've been validating for too long, show error
|
||||
useEffect(() => {
|
||||
if (status !== 'validating' || !tokenStored) {
|
||||
return;
|
||||
}
|
||||
|
||||
await handleAuthCallbackSuccess(token);
|
||||
|
||||
console.log('[AuthCallback] Token validated, redirecting to home');
|
||||
|
||||
// Clear the hash from URL and redirect to home page
|
||||
navigate('/', { replace: true });
|
||||
} catch (error) {
|
||||
console.error('[AuthCallback] Error:', error);
|
||||
navigate('/login', {
|
||||
replace: true,
|
||||
state: { error: 'OAuth login failed. Please try again.' }
|
||||
});
|
||||
setTimeout(() => {
|
||||
navigate('/login', {
|
||||
replace: true,
|
||||
state: { error: 'Authentication timed out. Please try again.' }
|
||||
});
|
||||
}, 2000);
|
||||
}
|
||||
};
|
||||
}, 10000); // 10 second timeout
|
||||
|
||||
handleCallback();
|
||||
}, [navigate]);
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [status, tokenStored, session, navigate]);
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
|
||||
@@ -31,10 +31,14 @@ export function setupApiInterceptors(client: AxiosInstance): void {
|
||||
(config) => {
|
||||
const jwtToken = getJwtTokenFromStorage();
|
||||
const xsrfToken = getXsrfToken();
|
||||
const url = config.url || 'unknown';
|
||||
|
||||
if (jwtToken && !config.headers.Authorization) {
|
||||
config.headers.Authorization = `Bearer ${jwtToken}`;
|
||||
console.debug('[API Client] Added JWT token from localStorage to Authorization header');
|
||||
console.debug('[API Client] Added JWT to request:', url);
|
||||
} else if (!jwtToken) {
|
||||
// Log when JWT is missing - helps debug auth issues
|
||||
console.debug('[API Client] No JWT in localStorage for request:', url);
|
||||
}
|
||||
|
||||
if (xsrfToken && !config.headers['X-XSRF-TOKEN']) {
|
||||
|
||||
Reference in New Issue
Block a user