Skip to content

Commit b34e2a2

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 55674bc commit b34e2a2

9 files changed

Lines changed: 194 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: 81 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,14 @@
55

66
import eu.webeid.example.security.AuthTokenDTOAuthenticationProvider;
77
import eu.webeid.example.security.WebEidAjaxLoginProcessingFilter;
8+
import jakarta.servlet.http.HttpServletRequest;
9+
import jakarta.servlet.http.HttpServletResponse;
10+
import org.apache.commons.lang3.StringUtils;
11+
import org.springframework.beans.factory.annotation.Value;
12+
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
813
import org.springframework.context.annotation.Bean;
914
import org.springframework.context.annotation.Configuration;
15+
import org.springframework.security.config.Customizer;
1016
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
1117
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
1218
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
@@ -15,16 +21,38 @@
1521
import org.springframework.security.web.SecurityFilterChain;
1622
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
1723
import org.springframework.security.web.authentication.logout.HttpStatusReturningLogoutSuccessHandler;
24+
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
25+
import org.springframework.security.web.csrf.CsrfToken;
26+
import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler;
27+
import org.springframework.security.web.csrf.CsrfTokenRequestHandler;
28+
import org.springframework.security.web.csrf.XorCsrfTokenRequestAttributeHandler;
29+
import org.springframework.web.servlet.config.annotation.CorsRegistry;
1830
import org.springframework.web.servlet.config.annotation.ViewControllerRegistry;
1931
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
2032

33+
import java.util.function.Supplier;
34+
2135
@Configuration
2236
@EnableWebSecurity
2337
@EnableMethodSecurity(securedEnabled = true)
2438
public class ApplicationConfiguration implements WebMvcConfigurer {
2539

2640
@Bean
27-
public SecurityFilterChain filterChain(HttpSecurity http, AuthTokenDTOAuthenticationProvider authTokenDTOAuthenticationProvider, AuthenticationConfiguration authConfig) throws Exception {
41+
public SecurityFilterChain filterChain(
42+
HttpSecurity http,
43+
AuthTokenDTOAuthenticationProvider authTokenDTOAuthenticationProvider,
44+
AuthenticationConfiguration authConfig,
45+
YAMLConfig config,
46+
@Value("${web-eid-auth-token.csrf.use-spa-configuration:false}") boolean useSpaCsrfConfiguration,
47+
@Value("${web-eid-auth-token.validation.local-origin}") String localOrigin
48+
) throws Exception {
49+
if (useSpaCsrfConfiguration) {
50+
http
51+
.csrf(csrf -> csrf
52+
.csrfTokenRepository(createDefaultCsrfTokenRepository(localOrigin))
53+
.csrfTokenRequestHandler(new SpaCsrfTokenRequestHandler()))
54+
.cors(Customizer.withDefaults());
55+
}
2856
return http
2957
.authenticationProvider(authTokenDTOAuthenticationProvider)
3058
.addFilterBefore(new WebEidAjaxLoginProcessingFilter("/auth/login", authConfig.getAuthenticationManager()),
@@ -34,10 +62,62 @@ public SecurityFilterChain filterChain(HttpSecurity http, AuthTokenDTOAuthentica
3462
.build();
3563
}
3664

65+
@ConditionalOnProperty(name = "web-eid-auth-token.csrf.use-spa-configuration", havingValue = "true")
66+
@Bean
67+
public WebMvcConfigurer corsConfigurer(YAMLConfig config) {
68+
return new WebMvcConfigurer() {
69+
@Override
70+
public void addCorsMappings(CorsRegistry registry) {
71+
registry.addMapping("/**")
72+
.allowedOrigins(config.getCorsAllowedOrigin())
73+
.allowCredentials(true);
74+
}
75+
};
76+
}
77+
3778
@Override
3879
public void addViewControllers(ViewControllerRegistry registry) {
3980
registry.addViewController("/").setViewName("index");
4081
registry.addViewController("/welcome").setViewName("welcome");
4182
}
4283

84+
private CookieCsrfTokenRepository createDefaultCsrfTokenRepository(String localOrigin) {
85+
CookieCsrfTokenRepository cookieCsrfTokenRepository = CookieCsrfTokenRepository.withHttpOnlyFalse();
86+
cookieCsrfTokenRepository.setCookieCustomizer(cookie -> cookie
87+
.domain(toApexDomain(localOrigin))
88+
.sameSite("Lax")
89+
);
90+
return cookieCsrfTokenRepository;
91+
}
92+
93+
private String toApexDomain(String localOrigin) {
94+
if (StringUtils.isBlank(localOrigin)) {
95+
return null;
96+
}
97+
98+
String hostname = StringUtils.substringAfter(localOrigin, "//");
99+
String[] labels = hostname.split("\\.");
100+
if (labels.length <= 2) {
101+
return hostname;
102+
}
103+
return labels[labels.length - 2] + "." + labels[labels.length - 1];
104+
}
105+
106+
private static final class SpaCsrfTokenRequestHandler implements CsrfTokenRequestHandler {
107+
private final CsrfTokenRequestHandler plain = new CsrfTokenRequestAttributeHandler();
108+
private final CsrfTokenRequestHandler xor = new XorCsrfTokenRequestAttributeHandler();
109+
110+
@Override
111+
public void handle(HttpServletRequest request, HttpServletResponse response, Supplier<CsrfToken> csrfToken) {
112+
xor.handle(request, response, csrfToken);
113+
csrfToken.get();
114+
}
115+
116+
@Override
117+
public String resolveCsrfTokenValue(HttpServletRequest request, CsrfToken csrfToken) {
118+
String headerValue = request.getHeader(csrfToken.getHeaderName());
119+
return (StringUtils.isNotBlank(headerValue) ? plain : xor).resolveCsrfTokenValue(request, csrfToken);
120+
}
121+
}
122+
43123
}

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

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

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

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

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

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

@@ -34,6 +36,11 @@ public String getLocalOrigin() {
3436

3537
public void setLocalOrigin(String localOrigin) {
3638
this.localOrigin = localOrigin;
39+
this.corsAllowedOrigin = localOrigin;
40+
}
41+
42+
public String getCorsAllowedOrigin() {
43+
return corsAllowedOrigin;
3744
}
3845

3946
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
@@ -28,14 +28,19 @@
2828
import eu.webeid.security.validator.certvalidators.SubjectCertificateNotRevokedValidator;
2929

3030
import java.security.cert.X509Certificate;
31+
import java.util.regex.Pattern;
3132

3233
import static org.junit.jupiter.api.Assertions.assertEquals;
34+
import static org.junit.jupiter.api.Assertions.assertNull;
35+
import static org.junit.jupiter.api.Assertions.assertTrue;
3336
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
3437

3538
@SpringBootTest
3639
@WebAppConfiguration
3740
public class WebApplicationTest {
3841

42+
private static final Pattern CSRF_TOKEN_META_TAG = Pattern.compile("<meta id=\"csrftoken\" name=\"csrftoken\" content=\"[^\"]+\"/>");
43+
3944
@Autowired
4045
private WebApplicationContext context;
4146

@@ -59,7 +64,11 @@ public void testRoot() throws Exception {
5964
.getResponse();
6065
// @formatter:on
6166
assertEquals(HttpStatus.OK.value(), response.getStatus());
62-
System.out.println(response.getContentAsString());
67+
assertNull(response.getCookie("XSRF-TOKEN"));
68+
String content = response.getContentAsString();
69+
assertTrue(CSRF_TOKEN_META_TAG.matcher(content).find());
70+
assertTrue(content.contains("<meta id=\"csrfheadername\" name=\"csrfheadername\" content=\"X-CSRF-TOKEN\"/>"));
71+
System.out.println(content);
6372
}
6473

6574
@Test

0 commit comments

Comments
 (0)