From b34e2a2a5d143dd01295cf2095e644e07fdd183d Mon Sep 17 00:00:00 2001 From: Sven Mitt Date: Fri, 30 May 2025 15:45:59 +0300 Subject: [PATCH] feat: add spa support for CSRF by setting into cookie WE2-1240 Signed-off-by: Sven Mitt --- example/README.md | 4 +- .../config/ApplicationConfiguration.java | 82 ++++++++++++++++++- .../config/ValidationConfiguration.java | 5 -- .../eu/webeid/example/config/YAMLConfig.java | 7 ++ example/src/main/resources/static/js/csrf.js | 40 +++++++++ .../src/main/resources/templates/index.html | 6 +- .../src/main/resources/templates/welcome.html | 10 +-- .../example/SpaCsrfConfigurationTest.java | 47 +++++++++++ .../eu/webeid/example/WebApplicationTest.java | 11 ++- 9 files changed, 194 insertions(+), 18 deletions(-) create mode 100644 example/src/main/resources/static/js/csrf.js create mode 100644 example/src/test/java/eu/webeid/example/SpaCsrfConfigurationTest.java diff --git a/example/README.md b/example/README.md index b0b0e6a1..11fd9dd7 100644 --- a/example/README.md +++ b/example/README.md @@ -142,7 +142,9 @@ The main configuration file `src/main/resources/application.yaml` is shared by a Besides configuration settings, the trusted certificate authority certificates may need to be configured as described in section [_3. Configure the trusted certificate authority certificates_](#3-configure-the-trusted-certificate-authority-certificates) above. -Spring Security has CSRF protection enabled by default. Web eID requires CSRF protection. +Spring Security has CSRF protection enabled by default. Web eID requires CSRF protection. By default, the frontend reads +CSRF tokens from Thymeleaf meta tags. Set `web-eid-auth-token.csrf.use-spa-configuration=true` to use Spring Security's +SPA-compatible CSRF setup with a JavaScript-readable `XSRF-TOKEN` cookie. ### Integration with Web eID components diff --git a/example/src/main/java/eu/webeid/example/config/ApplicationConfiguration.java b/example/src/main/java/eu/webeid/example/config/ApplicationConfiguration.java index d5d2b584..d79fdb27 100644 --- a/example/src/main/java/eu/webeid/example/config/ApplicationConfiguration.java +++ b/example/src/main/java/eu/webeid/example/config/ApplicationConfiguration.java @@ -5,8 +5,14 @@ import eu.webeid.example.security.AuthTokenDTOAuthenticationProvider; import eu.webeid.example.security.WebEidAjaxLoginProcessingFilter; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; @@ -15,16 +21,38 @@ import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.security.web.authentication.logout.HttpStatusReturningLogoutSuccessHandler; +import org.springframework.security.web.csrf.CookieCsrfTokenRepository; +import org.springframework.security.web.csrf.CsrfToken; +import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler; +import org.springframework.security.web.csrf.CsrfTokenRequestHandler; +import org.springframework.security.web.csrf.XorCsrfTokenRequestAttributeHandler; +import org.springframework.web.servlet.config.annotation.CorsRegistry; import org.springframework.web.servlet.config.annotation.ViewControllerRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; +import java.util.function.Supplier; + @Configuration @EnableWebSecurity @EnableMethodSecurity(securedEnabled = true) public class ApplicationConfiguration implements WebMvcConfigurer { @Bean - public SecurityFilterChain filterChain(HttpSecurity http, AuthTokenDTOAuthenticationProvider authTokenDTOAuthenticationProvider, AuthenticationConfiguration authConfig) throws Exception { + public SecurityFilterChain filterChain( + HttpSecurity http, + AuthTokenDTOAuthenticationProvider authTokenDTOAuthenticationProvider, + AuthenticationConfiguration authConfig, + YAMLConfig config, + @Value("${web-eid-auth-token.csrf.use-spa-configuration:false}") boolean useSpaCsrfConfiguration, + @Value("${web-eid-auth-token.validation.local-origin}") String localOrigin + ) throws Exception { + if (useSpaCsrfConfiguration) { + http + .csrf(csrf -> csrf + .csrfTokenRepository(createDefaultCsrfTokenRepository(localOrigin)) + .csrfTokenRequestHandler(new SpaCsrfTokenRequestHandler())) + .cors(Customizer.withDefaults()); + } return http .authenticationProvider(authTokenDTOAuthenticationProvider) .addFilterBefore(new WebEidAjaxLoginProcessingFilter("/auth/login", authConfig.getAuthenticationManager()), @@ -34,10 +62,62 @@ public SecurityFilterChain filterChain(HttpSecurity http, AuthTokenDTOAuthentica .build(); } + @ConditionalOnProperty(name = "web-eid-auth-token.csrf.use-spa-configuration", havingValue = "true") + @Bean + public WebMvcConfigurer corsConfigurer(YAMLConfig config) { + return new WebMvcConfigurer() { + @Override + public void addCorsMappings(CorsRegistry registry) { + registry.addMapping("/**") + .allowedOrigins(config.getCorsAllowedOrigin()) + .allowCredentials(true); + } + }; + } + @Override public void addViewControllers(ViewControllerRegistry registry) { registry.addViewController("/").setViewName("index"); registry.addViewController("/welcome").setViewName("welcome"); } + private CookieCsrfTokenRepository createDefaultCsrfTokenRepository(String localOrigin) { + CookieCsrfTokenRepository cookieCsrfTokenRepository = CookieCsrfTokenRepository.withHttpOnlyFalse(); + cookieCsrfTokenRepository.setCookieCustomizer(cookie -> cookie + .domain(toApexDomain(localOrigin)) + .sameSite("Lax") + ); + return cookieCsrfTokenRepository; + } + + private String toApexDomain(String localOrigin) { + if (StringUtils.isBlank(localOrigin)) { + return null; + } + + String hostname = StringUtils.substringAfter(localOrigin, "//"); + String[] labels = hostname.split("\\."); + if (labels.length <= 2) { + return hostname; + } + return labels[labels.length - 2] + "." + labels[labels.length - 1]; + } + + private static final class SpaCsrfTokenRequestHandler implements CsrfTokenRequestHandler { + private final CsrfTokenRequestHandler plain = new CsrfTokenRequestAttributeHandler(); + private final CsrfTokenRequestHandler xor = new XorCsrfTokenRequestAttributeHandler(); + + @Override + public void handle(HttpServletRequest request, HttpServletResponse response, Supplier csrfToken) { + xor.handle(request, response, csrfToken); + csrfToken.get(); + } + + @Override + public String resolveCsrfTokenValue(HttpServletRequest request, CsrfToken csrfToken) { + String headerValue = request.getHeader(csrfToken.getHeaderName()); + return (StringUtils.isNotBlank(headerValue) ? plain : xor).resolveCsrfTokenValue(request, csrfToken); + } + } + } diff --git a/example/src/main/java/eu/webeid/example/config/ValidationConfiguration.java b/example/src/main/java/eu/webeid/example/config/ValidationConfiguration.java index 540d97f9..6ea0d13c 100644 --- a/example/src/main/java/eu/webeid/example/config/ValidationConfiguration.java +++ b/example/src/main/java/eu/webeid/example/config/ValidationConfiguration.java @@ -72,11 +72,6 @@ public AuthTokenValidator validator(YAMLConfig yamlConfig) { } } - @Bean - public YAMLConfig yamlConfig() { - return new YAMLConfig(); - } - private X509Certificate[] loadTrustedCACertificatesFromCerFiles() { List caCertificates = new ArrayList<>(); diff --git a/example/src/main/java/eu/webeid/example/config/YAMLConfig.java b/example/src/main/java/eu/webeid/example/config/YAMLConfig.java index 4cf7c660..3785f591 100644 --- a/example/src/main/java/eu/webeid/example/config/YAMLConfig.java +++ b/example/src/main/java/eu/webeid/example/config/YAMLConfig.java @@ -17,6 +17,8 @@ public class YAMLConfig { @Value("local-origin") private String localOrigin; + private String corsAllowedOrigin; + @Value("site-cert-hash") private String siteCertHash; @@ -34,6 +36,11 @@ public String getLocalOrigin() { public void setLocalOrigin(String localOrigin) { this.localOrigin = localOrigin; + this.corsAllowedOrigin = localOrigin; + } + + public String getCorsAllowedOrigin() { + return corsAllowedOrigin; } public String getSiteCertHash() { diff --git a/example/src/main/resources/static/js/csrf.js b/example/src/main/resources/static/js/csrf.js new file mode 100644 index 00000000..78495aa2 --- /dev/null +++ b/example/src/main/resources/static/js/csrf.js @@ -0,0 +1,40 @@ +// SPDX-FileCopyrightText: Estonian Information System Authority +// SPDX-License-Identifier: MIT + +"use strict"; + +const CSRF_COOKIE_NAME = "XSRF-TOKEN"; +const CSRF_COOKIE_HEADER_NAME = "X-XSRF-TOKEN"; + +export function csrfHeader() { + const cookieToken = getCookie(CSRF_COOKIE_NAME); + if (cookieToken) { + return {[CSRF_COOKIE_HEADER_NAME]: cookieToken}; + } + + const metaToken = document.querySelector("#csrftoken")?.content; + const metaHeaderName = document.querySelector("#csrfheadername")?.content; + if (metaToken && metaHeaderName) { + return {[metaHeaderName]: metaToken}; + } + + return {}; +} + +function getCookie(name) { + const encodedName = encodeURIComponent(name) + "="; + return document.cookie + .split(";") + .map(cookie => cookie.trim()) + .filter(cookie => cookie.startsWith(encodedName)) + .map(cookie => decodeCookieValue(cookie.substring(encodedName.length))) + .shift(); +} + +function decodeCookieValue(value) { + try { + return decodeURIComponent(value); + } catch { + return value; + } +} diff --git a/example/src/main/resources/templates/index.html b/example/src/main/resources/templates/index.html index d8acaf78..b8dd989c 100644 --- a/example/src/main/resources/templates/index.html +++ b/example/src/main/resources/templates/index.html @@ -250,15 +250,13 @@

For developers