Streamlined config with metadata uri
This commit is contained in:
committed by
DarioGii
parent
a75af13304
commit
08b1f15677
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+7
-20
@@ -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<Saml2Authentication> 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<String> 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();
|
||||
}
|
||||
}
|
||||
|
||||
+9
-20
@@ -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();
|
||||
|
||||
+64
@@ -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);
|
||||
}
|
||||
}
|
||||
+37
-4
@@ -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(
|
||||
|
||||
+1
-2
@@ -31,8 +31,7 @@ class CustomLogoutSuccessHandlerTest {
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
customLogoutSuccessHandler =
|
||||
new CustomLogoutSuccessHandler(securityProperties, appConfig, jwtService);
|
||||
customLogoutSuccessHandler = new CustomLogoutSuccessHandler(securityProperties, jwtService);
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -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 */
|
||||
|
||||
Reference in New Issue
Block a user