-
Notifications
You must be signed in to change notification settings - Fork 0
Add SAML-based SSO to UI & secure API calls #54
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
6bdcd66
5fd361d
9827b3c
8756f07
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,30 @@ | ||
| package com.opendatamask.infrastructure.config | ||
|
|
||
| import org.springframework.beans.factory.annotation.Value | ||
| import org.springframework.context.annotation.Bean | ||
| import org.springframework.context.annotation.Configuration | ||
| import org.springframework.web.cors.CorsConfiguration | ||
| import org.springframework.web.cors.CorsConfigurationSource | ||
| import org.springframework.web.cors.UrlBasedCorsConfigurationSource | ||
|
|
||
| @Configuration | ||
| class CorsConfig( | ||
| @Value("\${opendatamask.cors.allowed-origins:http://localhost:5173}") | ||
| private val allowedOrigins: List<String> | ||
| ) { | ||
|
|
||
| @Bean | ||
| fun corsConfigurationSource(): CorsConfigurationSource { | ||
| val config = CorsConfiguration() | ||
| config.allowedOrigins = allowedOrigins | ||
| config.allowedMethods = listOf("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH") | ||
| config.allowedHeaders = listOf("*") | ||
| config.exposedHeaders = listOf("X-XSRF-TOKEN") | ||
| config.allowCredentials = true | ||
| config.maxAge = 3600L | ||
|
|
||
| val source = UrlBasedCorsConfigurationSource() | ||
| source.registerCorsConfiguration("/**", config) | ||
| return source | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,72 @@ | ||
| package com.opendatamask.infrastructure.config | ||
|
|
||
| import org.springframework.boot.autoconfigure.condition.ConditionalOnClass | ||
| import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty | ||
| import org.springframework.context.annotation.Bean | ||
| import org.springframework.context.annotation.Configuration | ||
| import org.springframework.core.annotation.Order | ||
| import org.springframework.security.config.Customizer | ||
| import org.springframework.security.config.annotation.web.builders.HttpSecurity | ||
| import org.springframework.security.web.SecurityFilterChain | ||
| import org.springframework.security.web.csrf.CookieCsrfTokenRepository | ||
| import org.springframework.security.web.util.matcher.AntPathRequestMatcher | ||
|
|
||
| // SAML2 Single Sign-On security filter chain. | ||
| // | ||
| // This configuration is activated only when BOTH conditions are true: | ||
| // 1. The `spring-security-saml2-service-provider` library is on the classpath, AND | ||
| // 2. The env var SAML_IDP_METADATA_URI is set to a non-empty value, which binds | ||
| // `spring.security.saml2.relyingparty.registration.default.assertingparty.metadata-uri`. | ||
| // The property is NOT defined in application.yml with an empty default, so it is | ||
| // absent from the Spring Environment unless the env var is explicitly provided. | ||
| // This ensures `@ConditionalOnProperty` does not activate on empty/blank values. | ||
| // | ||
| // To enable SAML SSO: | ||
| // 1. Add the Shibboleth repository and the SAML SP dependency to build.gradle.kts: | ||
| // repositories { | ||
| // maven { url = uri("https://build.shibboleth.net/nexus/content/repositories/releases/") } | ||
| // } | ||
| // dependencies { | ||
| // implementation("org.springframework.security:spring-security-saml2-service-provider") | ||
| // } | ||
| // 2. Set the required environment variables (see application.yml comments): | ||
| // SAML_IDP_METADATA_URI=https://idp.example.com/metadata | ||
| // SAML_SP_ENTITY_ID=https://your-app.example.com | ||
| // SAML_SP_PRIVATE_KEY=classpath:saml/sp-private-key.pem | ||
| // SAML_SP_CERTIFICATE=classpath:saml/sp-certificate.pem | ||
| // SAML_IDP_SSO_URL=https://idp.example.com/sso | ||
| @Configuration | ||
| @ConditionalOnClass(name = ["org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistrationRepository"]) | ||
| @ConditionalOnProperty( | ||
| name = ["spring.security.saml2.relyingparty.registration.default.assertingparty.metadata-uri"], | ||
| matchIfMissing = false | ||
| ) | ||
| class SamlSecurityConfig( | ||
|
Comment on lines
+38
to
+44
|
||
| private val corsConfig: CorsConfig | ||
| ) { | ||
|
|
||
| // SAML2 web security filter chain (order=2): intercepts browser routes before the | ||
| // fallback webSecurityFilterChain (order=3) in SecurityConfig. | ||
| // CSRF protection is enabled via CookieCsrfTokenRepository so the SPA can read the | ||
| // XSRF-TOKEN cookie and send it back in an X-XSRF-TOKEN header on mutating requests. | ||
| @Bean | ||
| @Order(2) | ||
| fun samlSecurityFilterChain(http: HttpSecurity): SecurityFilterChain { | ||
| http | ||
| .cors { it.configurationSource(corsConfig.corsConfigurationSource()) } | ||
| .csrf { it.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()) } | ||
| .authorizeHttpRequests { auth -> | ||
| auth | ||
| .requestMatchers( | ||
| AntPathRequestMatcher("/saml2/**"), | ||
| AntPathRequestMatcher("/login/**"), | ||
| AntPathRequestMatcher("/error") | ||
| ).permitAll() | ||
| .anyRequest().authenticated() | ||
| } | ||
| .saml2Login(Customizer.withDefaults()) | ||
| .saml2Logout(Customizer.withDefaults()) | ||
|
|
||
| return http.build() | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,6 +3,8 @@ package com.opendatamask.infrastructure.config | |
| import com.opendatamask.infrastructure.security.JwtAuthenticationFilter | ||
| import org.springframework.context.annotation.Bean | ||
| import org.springframework.context.annotation.Configuration | ||
| import org.springframework.core.annotation.Order | ||
| import org.springframework.http.HttpStatus | ||
| import org.springframework.security.authentication.AuthenticationManager | ||
| import org.springframework.security.authentication.dao.DaoAuthenticationProvider | ||
| import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration | ||
|
|
@@ -14,14 +16,18 @@ import org.springframework.security.core.userdetails.UserDetailsService | |
| import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder | ||
| import org.springframework.security.crypto.password.PasswordEncoder | ||
| import org.springframework.security.web.SecurityFilterChain | ||
| import org.springframework.security.web.authentication.HttpStatusEntryPoint | ||
| import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter | ||
| import org.springframework.security.web.csrf.CookieCsrfTokenRepository | ||
| import org.springframework.security.web.util.matcher.AntPathRequestMatcher | ||
|
|
||
| @Configuration | ||
| @EnableWebSecurity | ||
| @EnableMethodSecurity | ||
| class SecurityConfig( | ||
| private val jwtAuthenticationFilter: JwtAuthenticationFilter, | ||
| private val userDetailsService: UserDetailsService | ||
| private val userDetailsService: UserDetailsService, | ||
| private val corsConfig: CorsConfig | ||
| ) { | ||
|
|
||
| @Bean | ||
|
|
@@ -39,21 +45,60 @@ class SecurityConfig( | |
| fun authenticationManager(config: AuthenticationConfiguration): AuthenticationManager = | ||
| config.authenticationManager | ||
|
|
||
| // API security filter chain (highest priority, order=1): handles /api/** endpoints. | ||
| // Uses JWT Bearer tokens. Returns HTTP 401 for unauthenticated requests instead of | ||
| // redirecting to an IdP. CSRF is disabled since JWT-only requests use Bearer tokens. | ||
| // Session policy is NEVER: does not create new sessions but will read an existing one | ||
| // (e.g. a SAML session initiated by the browser-based flow), allowing SAML-authenticated | ||
| // users to call API endpoints using their session cookie. | ||
| @Bean | ||
| fun securityFilterChain(http: HttpSecurity): SecurityFilterChain { | ||
| @Order(1) | ||
| fun apiSecurityFilterChain(http: HttpSecurity): SecurityFilterChain { | ||
| http | ||
| .securityMatcher("/api/**") | ||
| .cors { it.configurationSource(corsConfig.corsConfigurationSource()) } | ||
| .csrf { it.disable() } | ||
| .sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) } | ||
| .sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.NEVER) } | ||
| .authorizeHttpRequests { auth -> | ||
|
Comment on lines
+58
to
62
|
||
| auth | ||
| .requestMatchers("/api/auth/**").permitAll() | ||
| .requestMatchers("/actuator/health").permitAll() | ||
| .requestMatchers("/api/**").authenticated() | ||
| .anyRequest().authenticated() | ||
| } | ||
| .exceptionHandling { exceptions -> | ||
| exceptions.authenticationEntryPoint(HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)) | ||
| } | ||
| .authenticationProvider(authenticationProvider()) | ||
| .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter::class.java) | ||
|
|
||
| return http.build() | ||
| } | ||
|
|
||
| // Fallback web security filter chain (lowest priority, order=3): handles all non-API routes. | ||
| // Protects against CSRF using a cookie-based token repository so the SPA can read the | ||
| // XSRF-TOKEN cookie and send it back via X-XSRF-TOKEN on mutating requests. | ||
| // When SamlSecurityConfig is active (order=2) it intercepts browser routes before this chain. | ||
| // The /saml2/**, /login/**, /error matchers here are the fallback for when SAML is NOT | ||
| // active; they mirror the SamlSecurityConfig matchers intentionally so those paths are | ||
| // always accessible regardless of whether the SAML SP library is on the classpath. | ||
| @Bean | ||
| @Order(3) | ||
| fun webSecurityFilterChain(http: HttpSecurity): SecurityFilterChain { | ||
| http | ||
| .cors { it.configurationSource(corsConfig.corsConfigurationSource()) } | ||
| .csrf { it.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()) } | ||
| .authorizeHttpRequests { auth -> | ||
| auth | ||
| .requestMatchers("/actuator/health").permitAll() | ||
| .requestMatchers( | ||
| AntPathRequestMatcher("/saml2/**"), | ||
| AntPathRequestMatcher("/login/**"), | ||
| AntPathRequestMatcher("/error") | ||
| ).permitAll() | ||
| .anyRequest().authenticated() | ||
| } | ||
| .formLogin { it.disable() } | ||
|
|
||
| return http.build() | ||
| } | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -22,3 +22,5 @@ spring: | |
| opendatamask: | ||
| encryption: | ||
| key: 0123456789abcdef | ||
| cors: | ||
| allowed-origins: http://localhost:5173 | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@AuthenticationPrincipal principal: UserDetails?will only be populated for authentication types whose principal implementsUserDetails(e.g., your JWT flow). For SAML2, the principal is typically aSaml2AuthenticatedPrincipal, so this parameter will be null and the endpoint will incorrectly return 401 even when the user is authenticated via SAML. Use a more general type (e.g.,Principal,Authentication, orAuthenticatedPrincipal) and extract the name/attributes accordingly.