Skip to content

Commit 519bd29

Browse files
committed
feat: add spa support for CSRF by setting into cookie
WE2-1240 Signed-off-by: Sven Mitt <svenzik@users.noreply.github.com>
1 parent d298a7f commit 519bd29

11 files changed

Lines changed: 229 additions & 18 deletions

File tree

‎example/README.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,9 @@ The main configuration file `src/main/resources/application.yaml` is shared by a
142142

143143
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.
144144

145-
Spring Security has CSRF protection enabled by default. Web eID requires CSRF protection.
145+
Spring Security has CSRF protection enabled by default. Web eID requires CSRF protection. By default, the frontend reads
146+
CSRF tokens from Thymeleaf meta tags. Set `web-eid-auth-token.csrf.use-spa-configuration=true` to use Spring Security's
147+
SPA-compatible CSRF setup with a JavaScript-readable `XSRF-TOKEN` cookie.
146148

147149
### Integration with Web eID components
148150

‎example/src/main/java/eu/webeid/example/config/ApplicationConfiguration.java‎

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,18 @@
33

44
package eu.webeid.example.config;
55

6+
import static eu.webeid.example.config.CsrfUtil.toApexDomain;
7+
68
import eu.webeid.example.security.AuthTokenDTOAuthenticationProvider;
79
import eu.webeid.example.security.WebEidAjaxLoginProcessingFilter;
10+
import jakarta.servlet.http.HttpServletRequest;
11+
import jakarta.servlet.http.HttpServletResponse;
12+
import org.apache.commons.lang3.StringUtils;
13+
import org.springframework.beans.factory.annotation.Value;
14+
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
815
import org.springframework.context.annotation.Bean;
916
import org.springframework.context.annotation.Configuration;
17+
import org.springframework.security.config.Customizer;
1018
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
1119
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
1220
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
@@ -15,16 +23,38 @@
1523
import org.springframework.security.web.SecurityFilterChain;
1624
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
1725
import org.springframework.security.web.authentication.logout.HttpStatusReturningLogoutSuccessHandler;
26+
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
27+
import org.springframework.security.web.csrf.CsrfToken;
28+
import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler;
29+
import org.springframework.security.web.csrf.CsrfTokenRequestHandler;
30+
import org.springframework.security.web.csrf.XorCsrfTokenRequestAttributeHandler;
31+
import org.springframework.web.servlet.config.annotation.CorsRegistry;
1832
import org.springframework.web.servlet.config.annotation.ViewControllerRegistry;
1933
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
2034

35+
import java.util.function.Supplier;
36+
2137
@Configuration
2238
@EnableWebSecurity
2339
@EnableMethodSecurity(securedEnabled = true)
2440
public class ApplicationConfiguration implements WebMvcConfigurer {
2541

2642
@Bean
27-
public SecurityFilterChain filterChain(HttpSecurity http, AuthTokenDTOAuthenticationProvider authTokenDTOAuthenticationProvider, AuthenticationConfiguration authConfig) throws Exception {
43+
public SecurityFilterChain filterChain(
44+
HttpSecurity http,
45+
AuthTokenDTOAuthenticationProvider authTokenDTOAuthenticationProvider,
46+
AuthenticationConfiguration authConfig,
47+
YAMLConfig config,
48+
@Value("${web-eid-auth-token.csrf.use-spa-configuration:false}") boolean useSpaCsrfConfiguration,
49+
@Value("${web-eid-auth-token.validation.local-origin}") String localOrigin
50+
) throws Exception {
51+
if (useSpaCsrfConfiguration) {
52+
http
53+
.csrf(csrf -> csrf
54+
.csrfTokenRepository(createDefaultCsrfTokenRepository(localOrigin))
55+
.csrfTokenRequestHandler(new SpaCsrfTokenRequestHandler()))
56+
.cors(Customizer.withDefaults());
57+
}
2858
return http
2959
.authenticationProvider(authTokenDTOAuthenticationProvider)
3060
.addFilterBefore(new WebEidAjaxLoginProcessingFilter("/auth/login", authConfig.getAuthenticationManager()),
@@ -34,10 +64,49 @@ public SecurityFilterChain filterChain(HttpSecurity http, AuthTokenDTOAuthentica
3464
.build();
3565
}
3666

67+
@ConditionalOnProperty(name = "web-eid-auth-token.csrf.use-spa-configuration", havingValue = "true")
68+
@Bean
69+
public WebMvcConfigurer corsConfigurer(YAMLConfig config) {
70+
return new WebMvcConfigurer() {
71+
@Override
72+
public void addCorsMappings(CorsRegistry registry) {
73+
registry.addMapping("/**")
74+
.allowedOrigins(config.getCorsAllowedOrigin())
75+
.allowCredentials(true);
76+
}
77+
};
78+
}
79+
3780
@Override
3881
public void addViewControllers(ViewControllerRegistry registry) {
3982
registry.addViewController("/").setViewName("index");
4083
registry.addViewController("/welcome").setViewName("welcome");
4184
}
4285

86+
private CookieCsrfTokenRepository createDefaultCsrfTokenRepository(String localOrigin) {
87+
CookieCsrfTokenRepository cookieCsrfTokenRepository = CookieCsrfTokenRepository.withHttpOnlyFalse();
88+
cookieCsrfTokenRepository.setCookieCustomizer(cookie -> cookie
89+
.domain(toApexDomain(localOrigin))
90+
.sameSite("Lax")
91+
);
92+
return cookieCsrfTokenRepository;
93+
}
94+
95+
private static final class SpaCsrfTokenRequestHandler implements CsrfTokenRequestHandler {
96+
private final CsrfTokenRequestHandler plain = new CsrfTokenRequestAttributeHandler();
97+
private final CsrfTokenRequestHandler xor = new XorCsrfTokenRequestAttributeHandler();
98+
99+
@Override
100+
public void handle(HttpServletRequest request, HttpServletResponse response, Supplier<CsrfToken> csrfToken) {
101+
xor.handle(request, response, csrfToken);
102+
csrfToken.get();
103+
}
104+
105+
@Override
106+
public String resolveCsrfTokenValue(HttpServletRequest request, CsrfToken csrfToken) {
107+
String headerValue = request.getHeader(csrfToken.getHeaderName());
108+
return (StringUtils.isNotBlank(headerValue) ? plain : xor).resolveCsrfTokenValue(request, csrfToken);
109+
}
110+
}
111+
43112
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
// SPDX-FileCopyrightText: Estonian Information System Authority
2+
// SPDX-License-Identifier: MIT
3+
4+
package eu.webeid.example.config;
5+
6+
import org.apache.commons.lang3.StringUtils;
7+
8+
public class CsrfUtil {
9+
10+
public static String toApexDomain(String localOrigin) {
11+
if (StringUtils.isBlank(localOrigin)) {
12+
return null;
13+
}
14+
15+
String hostnamePort = StringUtils.substringAfter(localOrigin, "//");
16+
String hostname = StringUtils.substringBefore(hostnamePort, ":");
17+
String[] labels = hostname.split("\\.");
18+
if (labels.length <= 2) {
19+
return hostname;
20+
}
21+
return labels[labels.length - 2] + "." + labels[labels.length - 1];
22+
}
23+
24+
25+
private CsrfUtil() {
26+
}
27+
}

‎example/src/main/java/eu/webeid/example/config/ValidationConfiguration.java‎

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -71,11 +71,6 @@ public AuthTokenValidator validator(YAMLConfig yamlConfig) {
7171
}
7272
}
7373

74-
@Bean
75-
public YAMLConfig yamlConfig() {
76-
return new YAMLConfig();
77-
}
78-
7974
X509Certificate[] loadTrustedCACertificatesFromCerFiles() {
8075
List<X509Certificate> caCertificates = new ArrayList<>();
8176

‎example/src/main/java/eu/webeid/example/config/YAMLConfig.java‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ public class YAMLConfig {
1616
@Value("local-origin")
1717
private String localOrigin;
1818

19+
private String corsAllowedOrigin;
20+
1921
@Value("site-cert-hash")
2022
private String siteCertHash;
2123

@@ -31,6 +33,11 @@ public String getLocalOrigin() {
3133

3234
public void setLocalOrigin(String localOrigin) {
3335
this.localOrigin = localOrigin;
36+
this.corsAllowedOrigin = localOrigin;
37+
}
38+
39+
public String getCorsAllowedOrigin() {
40+
return corsAllowedOrigin;
3441
}
3542

3643
public String getSiteCertHash() {
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
// SPDX-FileCopyrightText: Estonian Information System Authority
2+
// SPDX-License-Identifier: MIT
3+
4+
"use strict";
5+
6+
const CSRF_COOKIE_NAME = "XSRF-TOKEN";
7+
const CSRF_COOKIE_HEADER_NAME = "X-XSRF-TOKEN";
8+
9+
export function csrfHeader() {
10+
const cookieToken = getCookie(CSRF_COOKIE_NAME);
11+
if (cookieToken) {
12+
return {[CSRF_COOKIE_HEADER_NAME]: cookieToken};
13+
}
14+
15+
const metaToken = document.querySelector("#csrftoken")?.content;
16+
const metaHeaderName = document.querySelector("#csrfheadername")?.content;
17+
if (metaToken && metaHeaderName) {
18+
return {[metaHeaderName]: metaToken};
19+
}
20+
21+
return {};
22+
}
23+
24+
function getCookie(name) {
25+
const encodedName = encodeURIComponent(name) + "=";
26+
return document.cookie
27+
.split(";")
28+
.map(cookie => cookie.trim())
29+
.filter(cookie => cookie.startsWith(encodedName))
30+
.map(cookie => decodeCookieValue(cookie.substring(encodedName.length)))
31+
.shift();
32+
}
33+
34+
function decodeCookieValue(value) {
35+
try {
36+
return decodeURIComponent(value);
37+
} catch {
38+
return value;
39+
}
40+
}

‎example/src/main/resources/templates/index.html‎

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -250,15 +250,13 @@ <h3><a id="for-developers"></a>For developers</h3>
250250
<script type="module">
251251
"use strict";
252252
import * as webeid from "/js/web-eid.js";
253+
import {csrfHeader} from "/js/csrf.js";
253254
import {hideErrorMessage, showErrorMessage, checkHttpError} from "/js/errors.js";
254255

255256
hideErrorMessage();
256257

257258
const authButton = document.querySelector("#webeid-auth-button");
258259

259-
const csrfToken = document.querySelector('#csrftoken').content;
260-
const csrfHeaderName = document.querySelector('#csrfheadername').content;
261-
262260
const lang = new URLSearchParams(window.location.search).get("lang") || "en";
263261

264262
authButton.addEventListener("click", async () => {
@@ -281,7 +279,7 @@ <h3><a id="for-developers"></a>For developers</h3>
281279
method: "POST",
282280
headers: {
283281
"Content-Type": "application/json",
284-
[csrfHeaderName]: csrfToken
282+
...csrfHeader()
285283
},
286284
body: `{"auth-token": ${JSON.stringify(authToken)}}`
287285
});

‎example/src/main/resources/templates/welcome.html‎

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ <h2 class="adding-signature">Digital signing</h2>
5353
<script type="module">
5454
"use strict";
5555
import * as webeid from "/js/web-eid.js";
56+
import {csrfHeader} from "/js/csrf.js";
5657
import {hideErrorMessage, showErrorMessage, checkHttpError} from "/js/errors.js";
5758

5859
const signButton = document.querySelector("#webeid-sign-button");
@@ -61,15 +62,12 @@ <h2 class="adding-signature">Digital signing</h2>
6162
const fileNameText = document.querySelector("#file-name");
6263
const exampleDocument = document.querySelector("#example-document");
6364

64-
const csrfToken = document.querySelector('#csrftoken').content;
65-
const csrfHeaderName = document.querySelector('#csrfheadername').content;
66-
6765
document.querySelector("#webeid-logout-button").addEventListener("click", async () => {
6866
await fetch("/logout", {
6967
method: "POST",
7068
headers: {
7169
"Content-Type": "application/json",
72-
[csrfHeaderName]: csrfToken
70+
...csrfHeader()
7371
}
7472
});
7573
window.location.href = "/";
@@ -95,7 +93,7 @@ <h2 class="adding-signature">Digital signing</h2>
9593
method: "POST",
9694
headers: {
9795
"Content-Type": "application/json",
98-
[csrfHeaderName]: csrfToken
96+
...csrfHeader()
9997
},
10098
body: JSON.stringify({certificate, supportedSignatureAlgorithms}),
10199
});
@@ -114,7 +112,7 @@ <h2 class="adding-signature">Digital signing</h2>
114112
method: "POST",
115113
headers: {
116114
"Content-Type": "application/json",
117-
[csrfHeaderName]: csrfToken
115+
...csrfHeader()
118116
},
119117
body: JSON.stringify({signature, signatureAlgorithm}),
120118
});
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
// SPDX-FileCopyrightText: Estonian Information System Authority
2+
// SPDX-License-Identifier: MIT
3+
4+
package eu.webeid.example;
5+
6+
import jakarta.servlet.Filter;
7+
import org.junit.jupiter.api.Test;
8+
import org.springframework.beans.factory.annotation.Autowired;
9+
import org.springframework.boot.test.context.SpringBootTest;
10+
import org.springframework.http.HttpHeaders;
11+
import org.springframework.http.HttpStatus;
12+
import org.springframework.mock.web.MockHttpServletResponse;
13+
import org.springframework.test.context.web.WebAppConfiguration;
14+
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
15+
import org.springframework.web.context.WebApplicationContext;
16+
17+
import static org.assertj.core.api.Assertions.assertThat;
18+
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
19+
20+
@SpringBootTest(properties = "web-eid-auth-token.csrf.use-spa-configuration=true")
21+
@WebAppConfiguration
22+
class SpaCsrfConfigurationTest {
23+
24+
@Autowired
25+
private WebApplicationContext context;
26+
27+
@Autowired
28+
private Filter[] springSecurityFilterChain;
29+
30+
@Test
31+
void rootWhenSpaCsrfConfigurationIsEnabledWritesReadableXsrfTokenCookie() throws Exception {
32+
MockHttpServletResponse response = MockMvcBuilders.webAppContextSetup(context)
33+
.addFilters(springSecurityFilterChain)
34+
.build()
35+
.perform(get("/"))
36+
.andReturn()
37+
.getResponse();
38+
39+
assertThat(response.getStatus()).isEqualTo(HttpStatus.OK.value());
40+
assertThat(response.getHeader(HttpHeaders.SET_COOKIE))
41+
.startsWith("XSRF-TOKEN=")
42+
.containsPattern("XSRF-TOKEN=[^;]+")
43+
.contains("Path=/")
44+
.contains("Domain=ria.ee")
45+
.doesNotContain("HttpOnly");
46+
}
47+
}

‎example/src/test/java/eu/webeid/example/WebApplicationTest.java‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,10 +32,13 @@
3232
import eu.webeid.security.util.DateAndTime;
3333

3434
import java.util.Date;
35+
import java.util.regex.Pattern;
3536

3637
import static org.junit.jupiter.api.Assertions.assertEquals;
3738
import static org.mockito.ArgumentMatchers.any;
3839
import static org.mockito.Mockito.mockStatic;
40+
import static org.junit.jupiter.api.Assertions.assertNull;
41+
import static org.junit.jupiter.api.Assertions.assertTrue;
3942
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
4043
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
4144
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
@@ -46,6 +49,8 @@
4649
@WebAppConfiguration
4750
public class WebApplicationTest {
4851

52+
private static final Pattern CSRF_TOKEN_META_TAG = Pattern.compile("<meta id=\"csrftoken\" name=\"csrftoken\" content=\"[^\"]+\"/>");
53+
4954
@Autowired
5055
private WebApplicationContext context;
5156

@@ -69,7 +74,11 @@ public void testRoot() throws Exception {
6974
.getResponse();
7075
// @formatter:on
7176
assertEquals(HttpStatus.OK.value(), response.getStatus());
72-
System.out.println(response.getContentAsString());
77+
assertNull(response.getCookie("XSRF-TOKEN"));
78+
String content = response.getContentAsString();
79+
assertTrue(CSRF_TOKEN_META_TAG.matcher(content).find());
80+
assertTrue(content.contains("<meta id=\"csrfheadername\" name=\"csrfheadername\" content=\"X-CSRF-TOKEN\"/>"));
81+
System.out.println(content);
7382
}
7483

7584
@ParameterizedTest

0 commit comments

Comments
 (0)