From 08b1f15677da7be53e9b69b660406b86120e68bb Mon Sep 17 00:00:00 2001 From: Dario Ghunney Ware Date: Wed, 31 Dec 2025 19:25:11 +0000 Subject: [PATCH] Streamlined config with metadata uri --- .../common/model/ApplicationProperties.java | 15 ++++- .../src/main/resources/settings.yml.template | 3 +- .../security/CustomLogoutSuccessHandler.java | 27 ++------ .../configuration/SecurityConfiguration.java | 29 +++------ .../security/filter/LoginRedirectFilter.java | 64 +++++++++++++++++++ .../security/saml2/Saml2Configuration.java | 41 ++++++++++-- .../CustomLogoutSuccessHandlerTest.java | 3 +- .../proprietary/routes/authShared/auth.css | 31 ++++++--- 8 files changed, 156 insertions(+), 57 deletions(-) create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/security/filter/LoginRedirectFilter.java diff --git a/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java b/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java index 31e34f2468..f85260e029 100644 --- a/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java +++ b/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java @@ -226,9 +226,22 @@ public class ApplicationProperties { private String idpSingleLogoutUrl; private String idpSingleLoginUrl; - private String idpIssuer; + @Deprecated(since = "2.2.1", forRemoval = true) + private String idpIssuer; // Legacy field name, use idpEntityId instead + private String idpEntityId; // IdP Entity ID (preferred field name) private Boolean enableSingleLogout = false; + /** + * Gets the IdP Entity ID, checking both idpEntityId (preferred) and idpIssuer (legacy). + */ + @JsonIgnore + public String getIdpEntityIdOrIssuer() { + if (idpEntityId != null && !idpEntityId.isBlank()) { + return idpEntityId; + } + return idpIssuer; + } + @JsonProperty("idpCert") private String idpCert; diff --git a/app/core/src/main/resources/settings.yml.template b/app/core/src/main/resources/settings.yml.template index fcdb833097..91643aa38a 100644 --- a/app/core/src/main/resources/settings.yml.template +++ b/app/core/src/main/resources/settings.yml.template @@ -55,7 +55,8 @@ security: idpSingleLoginUrl: https://dev-XXXXXXXX.okta.com/app/dev-XXXXXXXX_stirlingpdf_1/externalKey/sso/saml # The URL for initiating SSO. Provided by your IdP enableSingleLogout: true # set to 'true' to enable Single Logout (SP-initiated SLO). Logs the user out from the IdP idpSingleLogoutUrl: https://dev-XXXXXXXX.okta.com/app/dev-XXXXXXXX_stirlingpdf_1/externalKey/slo/saml # The URL for initiating SLO. Provided by your IdP - idpIssuer: "" # The ID of your IdP + idpIssuer: "" # The entity ID of your IdP (Deprecated. Use idpEntityId instead) + idpEntityId: '' # The entity ID of your IdP idpCert: classpath:okta.cert # The certificate your Provider will use to authenticate your app's SAML authentication requests. Provided by your IdP privateKey: classpath:saml-private-key.key # Your private key. Generated from your keypair spCert: classpath:saml-public-cert.crt # Your signing certificate. Generated from your keypair diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/CustomLogoutSuccessHandler.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/CustomLogoutSuccessHandler.java index f96edc173e..17d5b284c5 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/CustomLogoutSuccessHandler.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/CustomLogoutSuccessHandler.java @@ -1,12 +1,9 @@ package stirling.software.proprietary.security; import java.io.IOException; -import java.security.cert.X509Certificate; -import java.security.interfaces.RSAPrivateKey; -import java.util.ArrayList; +import java.util.Collections; import java.util.List; import java.util.Locale; -import java.util.Collections; import java.util.Map; import java.util.Optional; @@ -23,7 +20,6 @@ import jakarta.servlet.http.HttpServletResponse; import lombok.extern.slf4j.Slf4j; -import stirling.software.common.configuration.AppConfig; import stirling.software.common.model.ApplicationProperties; import stirling.software.common.model.ApplicationProperties.Security.OAUTH2; import stirling.software.common.model.oauth2.KeycloakProvider; @@ -41,24 +37,19 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler { public static final String LOGOUT_PATH = "/login?logout=true"; private final ApplicationProperties.Security securityProperties; - private final AppConfig appConfig; private final JwtServiceInterface jwtService; private final LogoutSuccessHandler samlLogoutHandler; public CustomLogoutSuccessHandler( - ApplicationProperties.Security securityProperties, - AppConfig appConfig, - JwtServiceInterface jwtService) { - this(securityProperties, appConfig, jwtService, null); + ApplicationProperties.Security securityProperties, JwtServiceInterface jwtService) { + this(securityProperties, jwtService, null); } public CustomLogoutSuccessHandler( ApplicationProperties.Security securityProperties, - AppConfig appConfig, JwtServiceInterface jwtService, LogoutSuccessHandler samlLogoutHandler) { this.securityProperties = securityProperties; - this.appConfig = appConfig; this.jwtService = jwtService; this.samlLogoutHandler = samlLogoutHandler; } @@ -124,23 +115,20 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler { return true; } - // Try to reconstruct Saml2Authentication from JWT claims for SLO + // Reconstruct Saml2Authentication from JWT claims for SLO Optional reconstructedAuth = reconstructSaml2AuthenticationFromJwt(request); if (reconstructedAuth.isPresent()) { Saml2Authentication samlAuth = reconstructedAuth.get(); - CustomSaml2AuthenticatedPrincipal principal = - (CustomSaml2AuthenticatedPrincipal) samlAuth.getPrincipal(); - log.info( - "SAML user {} logging out via IdP SLO (reconstructed from JWT)", - principal.nameId()); + try { samlLogoutHandler.onLogoutSuccess(request, response, samlAuth); } catch (Exception e) { log.error("SAML SLO failed, falling back to local logout", e); getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH); } + return true; } @@ -182,7 +170,6 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler { return Optional.empty(); } - // Parse session indexes (stored as List in JWT) List sessionIndexes = Collections.emptyList(); if (sessionIndexesObj instanceof List) { sessionIndexes = @@ -214,7 +201,7 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler { return Optional.of(samlAuth); } catch (Exception ex) { - log.debug("Unable to reconstruct Saml2Authentication from JWT", ex); + log.error("Unable to reconstruct Saml2Authentication from JWT", ex); return Optional.empty(); } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java index 8d835fc213..b626fbd6ce 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java @@ -8,11 +8,13 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.DependsOn; import org.springframework.context.annotation.Lazy; +import org.springframework.http.HttpMethod; import org.springframework.security.authentication.ProviderManager; import org.springframework.security.authentication.dao.DaoAuthenticationProvider; import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.config.annotation.web.configurers.CsrfConfigurer; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.core.authority.mapping.GrantedAuthoritiesMapper; @@ -30,14 +32,13 @@ import org.springframework.security.web.authentication.logout.LogoutFilter; import org.springframework.security.web.authentication.logout.LogoutSuccessHandler; import org.springframework.security.web.authentication.rememberme.PersistentTokenRepository; import org.springframework.security.web.savedrequest.NullRequestCache; -import org.springframework.security.web.util.matcher.AntPathRequestMatcher; +import org.springframework.security.web.servlet.util.matcher.PathPatternRequestMatcher; import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.CorsConfigurationSource; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; import lombok.extern.slf4j.Slf4j; -import stirling.software.common.configuration.AppConfig; import stirling.software.common.model.ApplicationProperties; import stirling.software.common.util.RequestUriUtils; import stirling.software.proprietary.security.CustomAuthenticationFailureHandler; @@ -60,7 +61,6 @@ import stirling.software.proprietary.security.service.CustomUserDetailsService; import stirling.software.proprietary.security.service.JwtServiceInterface; import stirling.software.proprietary.security.service.LoginAttemptService; import stirling.software.proprietary.security.service.UserService; -import stirling.software.proprietary.security.session.SessionPersistentRegistry; @Slf4j @Configuration @@ -76,12 +76,10 @@ public class SecurityConfiguration { private final ApplicationProperties applicationProperties; private final ApplicationProperties.Security securityProperties; - private final AppConfig appConfig; private final UserAuthenticationFilter userAuthenticationFilter; private final JwtServiceInterface jwtService; private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint; private final LoginAttemptService loginAttemptService; - private final SessionPersistentRegistry sessionRegistry; private final PersistentLoginRepository persistentLoginRepository; private final GrantedAuthoritiesMapper oAuth2userAuthoritiesMapper; private final RelyingPartyRegistrationRepository saml2RelyingPartyRegistrations; @@ -96,14 +94,12 @@ public class SecurityConfiguration { @Lazy UserService userService, @Qualifier("loginEnabled") boolean loginEnabledValue, @Qualifier("runningProOrHigher") boolean runningProOrHigher, - AppConfig appConfig, ApplicationProperties applicationProperties, ApplicationProperties.Security securityProperties, UserAuthenticationFilter userAuthenticationFilter, JwtServiceInterface jwtService, JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint, LoginAttemptService loginAttemptService, - SessionPersistentRegistry sessionRegistry, @Autowired(required = false) GrantedAuthoritiesMapper oAuth2userAuthoritiesMapper, @Autowired(required = false) RelyingPartyRegistrationRepository saml2RelyingPartyRegistrations, @@ -116,14 +112,12 @@ public class SecurityConfiguration { this.userService = userService; this.loginEnabledValue = loginEnabledValue; this.runningProOrHigher = runningProOrHigher; - this.appConfig = appConfig; this.applicationProperties = applicationProperties; this.securityProperties = securityProperties; this.userAuthenticationFilter = userAuthenticationFilter; this.jwtService = jwtService; this.jwtAuthenticationEntryPoint = jwtAuthenticationEntryPoint; this.loginAttemptService = loginAttemptService; - this.sessionRegistry = sessionRegistry; this.persistentLoginRepository = persistentLoginRepository; this.oAuth2userAuthoritiesMapper = oAuth2userAuthoritiesMapper; this.saml2RelyingPartyRegistrations = saml2RelyingPartyRegistrations; @@ -200,7 +194,7 @@ public class SecurityConfiguration { http.cors(cors -> cors.configurationSource(corsSource)); } else { // Explicitly disable CORS when no origins are configured - http.cors(cors -> cors.disable()); + http.cors(AbstractHttpConfigurer::disable); } http.csrf(CsrfConfigurer::disable); @@ -248,11 +242,11 @@ public class SecurityConfiguration { logout -> // Require POST to prevent logout CSRF attacks logout.logoutRequestMatcher( - new AntPathRequestMatcher("/logout", "POST")) + PathPatternRequestMatcher.withDefaults() + .matcher(HttpMethod.POST, "/logout")) .logoutSuccessHandler( new CustomLogoutSuccessHandler( securityProperties, - appConfig, jwtService, samlLogoutHandler)) .clearAuthentication(true) @@ -391,17 +385,12 @@ public class SecurityConfiguration { // - IdP-initiated logout: IdP sends LogoutRequest to /logout/saml2/slo // - SP-initiated logout response: IdP sends LogoutResponse to /logout/saml2/slo if (Boolean.TRUE.equals(securityProperties.getSaml2().getEnableSingleLogout())) { - log.info("SAML2 Single Logout (SLO) is enabled - configuring SLO endpoints"); - http.saml2Logout( - logout -> - logout.logoutUrl( - "/logout/saml2/slo") // URL that handles both request - // and response - ); + log.debug("SAML2 Single Logout (SLO) is enabled"); + http.saml2Logout(logout -> logout.logoutUrl("/logout/saml2/slo")); } } } else { - log.debug("Login is not enabled."); + log.info("Login is not enabled."); http.authorizeHttpRequests(authz -> authz.anyRequest().permitAll()); } return http.build(); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/LoginRedirectFilter.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/LoginRedirectFilter.java new file mode 100644 index 0000000000..f963d84082 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/LoginRedirectFilter.java @@ -0,0 +1,64 @@ +package stirling.software.proprietary.security.filter; + +import java.io.IOException; + +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.common.model.ApplicationProperties; + +/** + * Filter that redirects browser requests to /login to the frontend URL when configured. This is + * needed for development mode where frontend runs on a different port, and for SAML logout which + * redirects to /login?logout after SLO completes. + */ +@Slf4j +@Component +@Order(Ordered.HIGHEST_PRECEDENCE) +@RequiredArgsConstructor +public class LoginRedirectFilter extends OncePerRequestFilter { + + private final ApplicationProperties applicationProperties; + + @Override + protected void doFilterInternal( + HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + + String path = request.getRequestURI(); + String frontendUrl = applicationProperties.getSystem().getFrontendUrl(); + + // Only process /login requests when frontendUrl is configured + if (path.equals("/login") && frontendUrl != null && !frontendUrl.isBlank()) { + // Check if this is a browser request (Accept: text/html) vs API request + // (Accept: application/json) + String acceptHeader = request.getHeader("Accept"); + boolean isBrowserRequest = + acceptHeader != null + && acceptHeader.contains("text/html") + && !acceptHeader.contains("application/json"); + + if (isBrowserRequest) { + // Preserve query parameters (e.g., ?logout=true, ?error=xxx) + String queryString = request.getQueryString(); + String redirectUrl = + frontendUrl + "/login" + (queryString != null ? "?" + queryString : ""); + log.debug("Redirecting browser request to frontend: {}", redirectUrl); + response.sendRedirect(redirectUrl); + return; + } + } + + filterChain.doFilter(request, response); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/Saml2Configuration.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/Saml2Configuration.java index e7ab748ed5..4e6a1d63aa 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/Saml2Configuration.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/Saml2Configuration.java @@ -108,10 +108,43 @@ public class Saml2Configuration { log.error("Failed to load SAML2 SP credentials: {}", e.getMessage(), e); throw new IllegalStateException("Failed to load SAML2 SP credentials", e); } + // Apply metadata overrides - metadata takes precedence over manual config metadataInfo.ifPresent(info -> applyMetadataOverrides(samlConf, info)); - String idpIssuer = samlConf.getIdpIssuer(); - String idpSingleLoginUrl = samlConf.getIdpSingleLoginUrl(); - String idpSingleLogoutUrl = samlConf.getIdpSingleLogoutUrl(); + + // Get IdP configuration - prefer values from metadata, fall back to manual config + String idpEntityId = + metadataInfo + .map(IdpMetadataInfo::entityId) + .filter(id -> id != null && !id.isBlank()) + .orElseGet(samlConf::getIdpEntityIdOrIssuer); + + String idpSingleLoginUrl = + metadataInfo + .map(IdpMetadataInfo::singleSignOnServiceUrl) + .filter(url -> url != null && !url.isBlank()) + .orElseGet(samlConf::getIdpSingleLoginUrl); + + String idpSingleLogoutUrl = + metadataInfo + .map(IdpMetadataInfo::singleLogoutServiceUrl) + .filter(url -> url != null && !url.isBlank()) + .orElseGet(samlConf::getIdpSingleLogoutUrl); + + // Validate required IdP configuration + if (idpEntityId == null || idpEntityId.isBlank()) { + throw new IllegalStateException( + "SAML2 IdP Entity ID is required. Set security.saml2.idpEntityId or provide idpMetadataUri"); + } + if (idpSingleLoginUrl == null || idpSingleLoginUrl.isBlank()) { + throw new IllegalStateException( + "SAML2 IdP Single Sign-On URL is required. Set security.saml2.idpSingleLoginUrl or provide idpMetadataUri"); + } + + log.info( + "SAML2 IdP configuration: entityId={}, ssoUrl={}, sloUrl={}", + idpEntityId, + idpSingleLoginUrl, + idpSingleLogoutUrl); // Get backend URL from configuration (for SAML endpoints) String backendUrl = applicationProperties.getSystem().getBackendUrl(); @@ -142,7 +175,7 @@ public class Saml2Configuration { .authnRequestsSigned(true) .assertingPartyMetadata( metadata -> - metadata.entityId(idpIssuer) + metadata.entityId(idpEntityId) .verificationX509Credentials( c -> c.add(verificationCredential)) .singleSignOnServiceBinding( diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/CustomLogoutSuccessHandlerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/CustomLogoutSuccessHandlerTest.java index 42c49abb0c..2fd7ba7a18 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/security/CustomLogoutSuccessHandlerTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/CustomLogoutSuccessHandlerTest.java @@ -31,8 +31,7 @@ class CustomLogoutSuccessHandlerTest { @BeforeEach void setUp() { - customLogoutSuccessHandler = - new CustomLogoutSuccessHandler(securityProperties, appConfig, jwtService); + customLogoutSuccessHandler = new CustomLogoutSuccessHandler(securityProperties, jwtService); } @Test diff --git a/frontend/src/proprietary/routes/authShared/auth.css b/frontend/src/proprietary/routes/authShared/auth.css index 37864e5b77..21babaa3d9 100644 --- a/frontend/src/proprietary/routes/authShared/auth.css +++ b/frontend/src/proprietary/routes/authShared/auth.css @@ -142,7 +142,14 @@ .oauth-container-vertical { display: flex; flex-direction: column; + align-items: center; gap: 0.75rem; /* 12px */ + width: 100%; +} + +.oauth-container-vertical > div { + width: 100%; + max-width: 320rem; /* 320px - consistent width for all buttons */ } .oauth-button-icon { @@ -185,22 +192,24 @@ width: 100%; display: flex; align-items: center; - justify-content: flex-start; - padding: 0.75rem 1rem; /* 12px 16px */ - border: 1px solid #d1d5db; + justify-content: center; /* Center icon and text */ + padding: 0.875rem 1.25rem !important; /* 14px 20px - increased for better visibility */ + min-height: 3rem !important; /* 48px - ensure readable height */ + height: auto !important; /* Override Mantine default height */ + border: 1px solid #d1d5db !important; border-radius: 0.75rem; /* 12px */ - background-color: var(--auth-card-bg-light-only); + background-color: var(--auth-card-bg-light-only) !important; font-size: 1rem; /* 16px */ font-weight: 500; - color: var(--auth-text-primary-light-only); + color: var(--auth-text-primary-light-only) !important; cursor: pointer; - gap: 0.75rem; /* 12px */ + gap: 1rem !important; /* 16px - increased spacing between icon and text */ font-family: inherit; transition: background-color 0.2s ease; } .oauth-button-vertical:hover:not(:disabled) { - background-color: #f3f4f6; + background-color: #f3f4f6 !important; } .oauth-button-vertical:disabled { @@ -243,12 +252,16 @@ } .oauth-icon-tiny { - width: 1.25rem; /* 20px */ - height: 1.25rem; /* 20px */ + width: 1.5rem; /* 24px - increased for better visibility */ + height: 1.5rem; /* 24px */ display: block; flex-shrink: 0; } +.oauth-button-vertical span { + margin-left: 0.5rem; /* 8px - additional spacing between icon and text */ +} + /* Login Header Styles */ .login-header { margin-bottom: 1rem; /* 16px */