Skip to content

Commit 2010988

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 4c2cc54 commit 2010988

8 files changed

Lines changed: 165 additions & 12 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: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@
2424

2525
import eu.webeid.example.security.AuthTokenDTOAuthenticationProvider;
2626
import eu.webeid.example.security.WebEidAjaxLoginProcessingFilter;
27+
import jakarta.servlet.http.HttpServletRequest;
28+
import jakarta.servlet.http.HttpServletResponse;
29+
import org.springframework.beans.factory.annotation.Value;
2730
import org.springframework.context.annotation.Bean;
2831
import org.springframework.context.annotation.Configuration;
2932
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
@@ -34,17 +37,38 @@
3437
import org.springframework.security.web.SecurityFilterChain;
3538
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
3639
import org.springframework.security.web.authentication.logout.HttpStatusReturningLogoutSuccessHandler;
40+
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
41+
import org.springframework.security.web.csrf.CsrfToken;
42+
import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler;
43+
import org.springframework.security.web.csrf.CsrfTokenRequestHandler;
44+
import org.springframework.security.web.csrf.XorCsrfTokenRequestAttributeHandler;
45+
import org.springframework.util.StringUtils;
3746
import org.springframework.web.servlet.config.annotation.ViewControllerRegistry;
3847
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
3948

49+
import java.util.function.Supplier;
50+
4051
@Configuration
4152
@EnableWebSecurity
4253
@EnableMethodSecurity(securedEnabled = true)
4354
public class ApplicationConfiguration implements WebMvcConfigurer {
4455

56+
private final boolean useSpaCsrfConfiguration;
57+
58+
public ApplicationConfiguration(@Value("${web-eid-auth-token.csrf.use-spa-configuration:false}") String useSpaCsrfConfiguration) {
59+
this.useSpaCsrfConfiguration = Boolean.TRUE.toString().equalsIgnoreCase(useSpaCsrfConfiguration);
60+
}
61+
4562
@Bean
4663
public SecurityFilterChain filterChain(HttpSecurity http, AuthTokenDTOAuthenticationProvider authTokenDTOAuthenticationProvider, AuthenticationConfiguration authConfig) throws Exception {
4764
return http
65+
.csrf(csrf -> {
66+
if (useSpaCsrfConfiguration) {
67+
csrf
68+
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
69+
.csrfTokenRequestHandler(new SpaCsrfTokenRequestHandler());
70+
}
71+
})
4872
.authenticationProvider(authTokenDTOAuthenticationProvider)
4973
.addFilterBefore(new WebEidAjaxLoginProcessingFilter("/auth/login", authConfig.getAuthenticationManager()),
5074
UsernamePasswordAuthenticationFilter.class)
@@ -59,4 +83,21 @@ public void addViewControllers(ViewControllerRegistry registry) {
5983
registry.addViewController("/welcome").setViewName("welcome");
6084
}
6185

86+
private static final class SpaCsrfTokenRequestHandler implements CsrfTokenRequestHandler {
87+
private final CsrfTokenRequestHandler plain = new CsrfTokenRequestAttributeHandler();
88+
private final CsrfTokenRequestHandler xor = new XorCsrfTokenRequestAttributeHandler();
89+
90+
@Override
91+
public void handle(HttpServletRequest request, HttpServletResponse response, Supplier<CsrfToken> csrfToken) {
92+
xor.handle(request, response, csrfToken);
93+
csrfToken.get();
94+
}
95+
96+
@Override
97+
public String resolveCsrfTokenValue(HttpServletRequest request, CsrfToken csrfToken) {
98+
String headerValue = request.getHeader(csrfToken.getHeaderName());
99+
return (StringUtils.hasText(headerValue) ? plain : xor).resolveCsrfTokenValue(request, csrfToken);
100+
}
101+
}
102+
62103
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
web-eid-auth-token:
2+
csrf:
3+
use-spa-configuration: true
24
validation:
35
use-digidoc4j-prod-configuration: false
46
local-origin: "https://test.web-eid.eu"
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
"use strict";
2+
3+
const CSRF_COOKIE_NAME = "XSRF-TOKEN";
4+
const CSRF_COOKIE_HEADER_NAME = "X-XSRF-TOKEN";
5+
6+
export function csrfHeader() {
7+
const cookieToken = getCookie(CSRF_COOKIE_NAME);
8+
if (cookieToken) {
9+
return {[CSRF_COOKIE_HEADER_NAME]: cookieToken};
10+
}
11+
12+
const metaToken = document.querySelector("#csrftoken")?.content;
13+
const metaHeaderName = document.querySelector("#csrfheadername")?.content;
14+
if (metaToken && metaHeaderName) {
15+
return {[metaHeaderName]: metaToken};
16+
}
17+
18+
return {};
19+
}
20+
21+
function getCookie(name) {
22+
const encodedName = encodeURIComponent(name) + "=";
23+
return document.cookie
24+
.split(";")
25+
.map(cookie => cookie.trim())
26+
.filter(cookie => cookie.startsWith(encodedName))
27+
.map(cookie => decodeCookieValue(cookie.substring(encodedName.length)))
28+
.shift();
29+
}
30+
31+
function decodeCookieValue(value) {
32+
try {
33+
return decodeURIComponent(value);
34+
} catch {
35+
return value;
36+
}
37+
}

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

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

253254
hideErrorMessage();
254255

255256
const authButton = document.querySelector("#webeid-auth-button");
256257

257-
const csrfToken = document.querySelector('#csrftoken').content;
258-
const csrfHeaderName = document.querySelector('#csrfheadername').content;
259-
260258
const lang = new URLSearchParams(window.location.search).get("lang") || "en";
261259

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

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

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

5657
const signButton = document.querySelector("#webeid-sign-button");
@@ -59,15 +60,12 @@ <h2 class="adding-signature">Digital signing</h2>
5960
const fileNameText = document.querySelector("#file-name");
6061
const exampleDocument = document.querySelector("#example-document");
6162

62-
const csrfToken = document.querySelector('#csrftoken').content;
63-
const csrfHeaderName = document.querySelector('#csrfheadername').content;
64-
6563
document.querySelector("#webeid-logout-button").addEventListener("click", async () => {
6664
await fetch("/logout", {
6765
method: "POST",
6866
headers: {
6967
"Content-Type": "application/json",
70-
[csrfHeaderName]: csrfToken
68+
...csrfHeader()
7169
}
7270
});
7371
window.location.href = "/";
@@ -93,7 +91,7 @@ <h2 class="adding-signature">Digital signing</h2>
9391
method: "POST",
9492
headers: {
9593
"Content-Type": "application/json",
96-
[csrfHeaderName]: csrfToken
94+
...csrfHeader()
9795
},
9896
body: JSON.stringify({certificate, supportedSignatureAlgorithms}),
9997
});
@@ -112,7 +110,7 @@ <h2 class="adding-signature">Digital signing</h2>
112110
method: "POST",
113111
headers: {
114112
"Content-Type": "application/json",
115-
[csrfHeaderName]: csrfToken
113+
...csrfHeader()
116114
},
117115
body: JSON.stringify({signature, signatureAlgorithm}),
118116
});
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
/*
2+
* Copyright (c) 2020-2026 Estonian Information System Authority
3+
*
4+
* Permission is hereby granted, free of charge, to any person obtaining a copy
5+
* of this software and associated documentation files (the "Software"), to deal
6+
* in the Software without restriction, including without limitation the rights
7+
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
8+
* copies of the Software, and to permit persons to whom the Software is
9+
* furnished to do so, subject to the following conditions:
10+
*
11+
* The above copyright notice and this permission notice shall be included in all
12+
* copies or substantial portions of the Software.
13+
*
14+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15+
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16+
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17+
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18+
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
19+
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
20+
* SOFTWARE.
21+
*/
22+
23+
package eu.webeid.example;
24+
25+
import jakarta.servlet.Filter;
26+
import jakarta.servlet.http.Cookie;
27+
import org.junit.jupiter.api.Test;
28+
import org.springframework.beans.factory.annotation.Autowired;
29+
import org.springframework.boot.test.context.SpringBootTest;
30+
import org.springframework.http.HttpStatus;
31+
import org.springframework.mock.web.MockHttpServletResponse;
32+
import org.springframework.test.context.web.WebAppConfiguration;
33+
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
34+
import org.springframework.web.context.WebApplicationContext;
35+
36+
import static org.assertj.core.api.Assertions.assertThat;
37+
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
38+
39+
@SpringBootTest(properties = "web-eid-auth-token.csrf.use-spa-configuration=true")
40+
@WebAppConfiguration
41+
class SpaCsrfConfigurationTest {
42+
43+
@Autowired
44+
private WebApplicationContext context;
45+
46+
@Autowired
47+
private Filter[] springSecurityFilterChain;
48+
49+
@Test
50+
void rootWhenSpaCsrfConfigurationIsEnabledWritesReadableXsrfTokenCookie() throws Exception {
51+
MockHttpServletResponse response = MockMvcBuilders.webAppContextSetup(context)
52+
.addFilters(springSecurityFilterChain)
53+
.build()
54+
.perform(get("/"))
55+
.andReturn()
56+
.getResponse();
57+
58+
Cookie csrfCookie = response.getCookie("XSRF-TOKEN");
59+
60+
assertThat(response.getStatus()).isEqualTo(HttpStatus.OK.value());
61+
assertThat(csrfCookie).isNotNull();
62+
assertThat(csrfCookie.isHttpOnly()).isFalse();
63+
assertThat(csrfCookie.getValue()).isNotBlank();
64+
assertThat(response.getContentAsString()).contains("id=\"csrftoken\"", "id=\"csrfheadername\"");
65+
}
66+
}

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

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,14 +47,19 @@
4747
import eu.webeid.security.validator.certvalidators.SubjectCertificateNotRevokedValidator;
4848

4949
import java.security.cert.X509Certificate;
50+
import java.util.regex.Pattern;
5051

5152
import static org.junit.jupiter.api.Assertions.assertEquals;
53+
import static org.junit.jupiter.api.Assertions.assertNull;
54+
import static org.junit.jupiter.api.Assertions.assertTrue;
5255
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
5356

5457
@SpringBootTest
5558
@WebAppConfiguration
5659
public class WebApplicationTest {
5760

61+
private static final Pattern CSRF_TOKEN_META_TAG = Pattern.compile("<meta id=\"csrftoken\" name=\"csrftoken\" content=\"[^\"]+\"/>");
62+
5863
@Autowired
5964
private WebApplicationContext context;
6065

@@ -78,7 +83,11 @@ public void testRoot() throws Exception {
7883
.getResponse();
7984
// @formatter:on
8085
assertEquals(HttpStatus.OK.value(), response.getStatus());
81-
System.out.println(response.getContentAsString());
86+
assertNull(response.getCookie("XSRF-TOKEN"));
87+
String content = response.getContentAsString();
88+
assertTrue(CSRF_TOKEN_META_TAG.matcher(content).find());
89+
assertTrue(content.contains("<meta id=\"csrfheadername\" name=\"csrfheadername\" content=\"X-CSRF-TOKEN\"/>"));
90+
System.out.println(content);
8291
}
8392

8493
@Test

0 commit comments

Comments
 (0)