diff --git a/example/README.md b/example/README.md index b0b0e6a1..16b183a6 100644 --- a/example/README.md +++ b/example/README.md @@ -142,7 +142,12 @@ 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 `WEBEID-XSRF-TOKEN` cookie. When +`web-eid-auth-token.csrf.cookie-domain` is not set, the cookie domain is derived from +`web-eid-auth-token.validation.local-origin`. Set `web-eid-auth-token.csrf.cookie-domain` to explicitly configure the +cookie domain. ### 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..93fa1e01 100644 --- a/example/src/main/java/eu/webeid/example/config/ApplicationConfiguration.java +++ b/example/src/main/java/eu/webeid/example/config/ApplicationConfiguration.java @@ -5,39 +5,116 @@ 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.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.autoconfigure.web.ServerProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpStatus; +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; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configurers.HeadersConfigurer; 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.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 { + private static final String CSRF_COOKIE_NAME = "WEBEID-XSRF-TOKEN"; + @Bean - public SecurityFilterChain filterChain(HttpSecurity http, AuthTokenDTOAuthenticationProvider authTokenDTOAuthenticationProvider, AuthenticationConfiguration authConfig) throws Exception { + public SecurityFilterChain filterChain( + HttpSecurity http, + AuthTokenDTOAuthenticationProvider authTokenDTOAuthenticationProvider, + AuthenticationConfiguration authConfig, + YAMLConfig validationConfig, + CsrfConfigurationProperties csrfConfig, + ServerProperties serverConfig + ) throws Exception { + if (csrfConfig.isUseSpaConfiguration()) { + String cookieDomain = StringUtils.defaultIfBlank( + csrfConfig.getCookieDomain(), CookieDomainResolver.toApexDomain(validationConfig.getLocalOrigin())); + http + .csrf(csrf -> csrf + .csrfTokenRepository(createDefaultCsrfTokenRepository(cookieDomain)) + .csrfTokenRequestHandler(new SpaCsrfTokenRequestHandler())) + .cors(Customizer.withDefaults()); + } return http .authenticationProvider(authTokenDTOAuthenticationProvider) .addFilterBefore(new WebEidAjaxLoginProcessingFilter("/auth/login", authConfig.getAuthenticationManager()), UsernamePasswordAuthenticationFilter.class) - .logout(logout -> logout.logoutSuccessHandler(new HttpStatusReturningLogoutSuccessHandler())) + .logout(logout -> logout + .deleteCookies(StringUtils.defaultIfBlank(serverConfig.getServlet().getSession().getCookie().getName(), "JSESSIONID")) + .logoutSuccessHandler(new HttpStatusReturningLogoutSuccessHandler())) .headers(headers -> headers.frameOptions(HeadersConfigurer.FrameOptionsConfig::sameOrigin)) + .exceptionHandling(ex -> ex.authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED))) .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 cookieDomain) { + CookieCsrfTokenRepository cookieCsrfTokenRepository = CookieCsrfTokenRepository.withHttpOnlyFalse(); + cookieCsrfTokenRepository.setCookieName(CSRF_COOKIE_NAME); + cookieCsrfTokenRepository.setCookieCustomizer(cookie -> cookie + .domain(cookieDomain) + .sameSite("Lax") + ); + return cookieCsrfTokenRepository; + } + + 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/CookieDomainResolver.java b/example/src/main/java/eu/webeid/example/config/CookieDomainResolver.java new file mode 100644 index 00000000..692b64e2 --- /dev/null +++ b/example/src/main/java/eu/webeid/example/config/CookieDomainResolver.java @@ -0,0 +1,26 @@ +// SPDX-FileCopyrightText: Estonian Information System Authority +// SPDX-License-Identifier: MIT + +package eu.webeid.example.config; + +import org.apache.commons.lang3.StringUtils; + +public final class CookieDomainResolver { + + public static String toApexDomain(String localOrigin) { + if (StringUtils.isBlank(localOrigin)) { + return null; + } + + String hostnamePort = StringUtils.substringAfter(localOrigin, "//"); + String hostname = StringUtils.substringBefore(hostnamePort, ":"); + String[] labels = hostname.split("\\."); + if (labels.length <= 2) { + return hostname; + } + return labels[labels.length - 2] + "." + labels[labels.length - 1]; + } + + private CookieDomainResolver() { + } +} diff --git a/example/src/main/java/eu/webeid/example/config/CsrfConfigurationProperties.java b/example/src/main/java/eu/webeid/example/config/CsrfConfigurationProperties.java new file mode 100644 index 00000000..b19ecf4b --- /dev/null +++ b/example/src/main/java/eu/webeid/example/config/CsrfConfigurationProperties.java @@ -0,0 +1,31 @@ +// SPDX-FileCopyrightText: Estonian Information System Authority +// SPDX-License-Identifier: MIT + +package eu.webeid.example.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +@Configuration +@ConfigurationProperties(prefix = "web-eid-auth-token.csrf") +public class CsrfConfigurationProperties { + + private boolean useSpaConfiguration; + private String cookieDomain; + + public boolean isUseSpaConfiguration() { + return useSpaConfiguration; + } + + public void setUseSpaConfiguration(boolean useSpaConfiguration) { + this.useSpaConfiguration = useSpaConfiguration; + } + + public String getCookieDomain() { + return cookieDomain; + } + + public void setCookieDomain(String cookieDomain) { + this.cookieDomain = cookieDomain; + } +} 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 65908017..188d2df2 100644 --- a/example/src/main/java/eu/webeid/example/config/ValidationConfiguration.java +++ b/example/src/main/java/eu/webeid/example/config/ValidationConfiguration.java @@ -71,11 +71,6 @@ public AuthTokenValidator validator(YAMLConfig yamlConfig) { } } - @Bean - public YAMLConfig yamlConfig() { - return new YAMLConfig(); - } - 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 b8734c3a..9aad60b4 100644 --- a/example/src/main/java/eu/webeid/example/config/YAMLConfig.java +++ b/example/src/main/java/eu/webeid/example/config/YAMLConfig.java @@ -16,6 +16,8 @@ public class YAMLConfig { @Value("local-origin") private String localOrigin; + private String corsAllowedOrigin; + @Value("site-cert-hash") private String siteCertHash; @@ -31,6 +33,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/java/eu/webeid/example/web/rest/ChallengeController.java b/example/src/main/java/eu/webeid/example/web/rest/ChallengeController.java index 9c161166..6789b8fc 100644 --- a/example/src/main/java/eu/webeid/example/web/rest/ChallengeController.java +++ b/example/src/main/java/eu/webeid/example/web/rest/ChallengeController.java @@ -3,8 +3,12 @@ package eu.webeid.example.web.rest; +import com.fasterxml.jackson.core.JsonProcessingException; +import eu.webeid.example.security.ajax.AjaxAuthenticationSuccessHandler.AuthSuccessDTO; import eu.webeid.example.service.dto.ChallengeDTO; import eu.webeid.security.challenge.ChallengeNonceGenerator; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -23,4 +27,10 @@ public ChallengeController(ChallengeNonceGenerator challengeNonceGenerator) { public ChallengeDTO challenge() { return new ChallengeDTO(challengeNonceGenerator.generateAndStoreNonce().getBase64EncodedNonce()); } + + @PreAuthorize("isAuthenticated()") + @GetMapping("user") + public String user(Authentication authentication) throws JsonProcessingException { + return AuthSuccessDTO.asJson(authentication); + } } 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..3a9c2dd4 --- /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 = "WEBEID-XSRF-TOKEN"; +const CSRF_COOKIE_HEADER_NAME = "X-XSRF-TOKEN"; + +export function csrfHeader() { + const metaToken = document.querySelector("#csrftoken")?.content; + const metaHeaderName = document.querySelector("#csrfheadername")?.content; + if (metaToken && metaHeaderName) { + return {[metaHeaderName]: metaToken}; + } + + const cookieToken = getCookie(CSRF_COOKIE_NAME); + if (cookieToken) { + return {[CSRF_COOKIE_HEADER_NAME]: cookieToken}; + } + + 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..9c35681f 100644 --- a/example/src/main/resources/templates/index.html +++ b/example/src/main/resources/templates/index.html @@ -6,8 +6,10 @@ - - + + + + Web eID: electronic ID smart cards on the Web For developers