Skip to content

Commit be9175b

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 be9175b

12 files changed

Lines changed: 328 additions & 23 deletions

File tree

‎example/README.md‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,10 @@ 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 `WEBEID-XSRF-TOKEN` cookie.
148+
Set `web-eid-auth-token.csrf.cookie-domain` to configure the cookie Domain to one level higher than the local-origin.
146149

147150
### Integration with Web eID components
148151

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

Lines changed: 79 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,39 +5,116 @@
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.slf4j.Logger;
12+
import org.slf4j.LoggerFactory;
13+
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
14+
import org.springframework.boot.autoconfigure.web.ServerProperties;
815
import org.springframework.context.annotation.Bean;
916
import org.springframework.context.annotation.Configuration;
17+
import org.springframework.http.HttpStatus;
18+
import org.springframework.security.config.Customizer;
1019
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
1120
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
1221
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
1322
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
1423
import org.springframework.security.config.annotation.web.configurers.HeadersConfigurer;
1524
import org.springframework.security.web.SecurityFilterChain;
25+
import org.springframework.security.web.authentication.HttpStatusEntryPoint;
1626
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
1727
import org.springframework.security.web.authentication.logout.HttpStatusReturningLogoutSuccessHandler;
28+
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
29+
import org.springframework.security.web.csrf.CsrfToken;
30+
import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler;
31+
import org.springframework.security.web.csrf.CsrfTokenRequestHandler;
32+
import org.springframework.security.web.csrf.XorCsrfTokenRequestAttributeHandler;
33+
import org.springframework.web.servlet.config.annotation.CorsRegistry;
1834
import org.springframework.web.servlet.config.annotation.ViewControllerRegistry;
1935
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
2036

37+
import java.util.function.Supplier;
38+
2139
@Configuration
2240
@EnableWebSecurity
2341
@EnableMethodSecurity(securedEnabled = true)
2442
public class ApplicationConfiguration implements WebMvcConfigurer {
2543

44+
private static final String CSRF_COOKIE_NAME = "WEBEID-XSRF-TOKEN";
45+
2646
@Bean
27-
public SecurityFilterChain filterChain(HttpSecurity http, AuthTokenDTOAuthenticationProvider authTokenDTOAuthenticationProvider, AuthenticationConfiguration authConfig) throws Exception {
47+
public SecurityFilterChain filterChain(
48+
HttpSecurity http,
49+
AuthTokenDTOAuthenticationProvider authTokenDTOAuthenticationProvider,
50+
AuthenticationConfiguration authConfig,
51+
YAMLConfig validationConfig,
52+
CsrfConfigurationProperties csrfConfig,
53+
ServerProperties serverConfig
54+
) throws Exception {
55+
if (csrfConfig.isUseSpaConfiguration()) {
56+
http
57+
.csrf(csrf -> csrf
58+
.csrfTokenRepository(createDefaultCsrfTokenRepository(csrfConfig.getCookieDomain()))
59+
.csrfTokenRequestHandler(new SpaCsrfTokenRequestHandler()))
60+
.cors(Customizer.withDefaults());
61+
}
2862
return http
2963
.authenticationProvider(authTokenDTOAuthenticationProvider)
3064
.addFilterBefore(new WebEidAjaxLoginProcessingFilter("/auth/login", authConfig.getAuthenticationManager()),
3165
UsernamePasswordAuthenticationFilter.class)
32-
.logout(logout -> logout.logoutSuccessHandler(new HttpStatusReturningLogoutSuccessHandler()))
66+
.logout(logout -> logout
67+
.deleteCookies(StringUtils.defaultIfBlank(serverConfig.getServlet().getSession().getCookie().getName(), "JSESSIONID"))
68+
.logoutSuccessHandler(new HttpStatusReturningLogoutSuccessHandler()))
3369
.headers(headers -> headers.frameOptions(HeadersConfigurer.FrameOptionsConfig::sameOrigin))
70+
.exceptionHandling(ex -> ex.authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)))
3471
.build();
3572
}
3673

74+
@ConditionalOnProperty(name = "web-eid-auth-token.csrf.use-spa-configuration", havingValue = "true")
75+
@Bean
76+
public WebMvcConfigurer corsConfigurer(YAMLConfig config) {
77+
return new WebMvcConfigurer() {
78+
@Override
79+
public void addCorsMappings(CorsRegistry registry) {
80+
registry.addMapping("/**")
81+
.allowedOrigins(config.getCorsAllowedOrigin())
82+
.allowCredentials(true);
83+
}
84+
};
85+
}
86+
3787
@Override
3888
public void addViewControllers(ViewControllerRegistry registry) {
3989
registry.addViewController("/").setViewName("index");
4090
registry.addViewController("/welcome").setViewName("welcome");
4191
}
4292

93+
private CookieCsrfTokenRepository createDefaultCsrfTokenRepository(String cookieDomain) {
94+
CookieCsrfTokenRepository cookieCsrfTokenRepository = CookieCsrfTokenRepository.withHttpOnlyFalse();
95+
cookieCsrfTokenRepository.setCookieName(CSRF_COOKIE_NAME);
96+
cookieCsrfTokenRepository.setCookieCustomizer(cookie -> cookie
97+
.domain(cookieDomain)
98+
.sameSite("Lax")
99+
);
100+
return cookieCsrfTokenRepository;
101+
}
102+
103+
private static final class SpaCsrfTokenRequestHandler implements CsrfTokenRequestHandler {
104+
private final CsrfTokenRequestHandler plain = new CsrfTokenRequestAttributeHandler();
105+
private final CsrfTokenRequestHandler xor = new XorCsrfTokenRequestAttributeHandler();
106+
107+
@Override
108+
public void handle(HttpServletRequest request, HttpServletResponse response, Supplier<CsrfToken> csrfToken) {
109+
xor.handle(request, response, csrfToken);
110+
csrfToken.get();
111+
}
112+
113+
@Override
114+
public String resolveCsrfTokenValue(HttpServletRequest request, CsrfToken csrfToken) {
115+
String headerValue = request.getHeader(csrfToken.getHeaderName());
116+
return (StringUtils.isNotBlank(headerValue) ? plain : xor).resolveCsrfTokenValue(request, csrfToken);
117+
}
118+
}
119+
43120
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
// SPDX-FileCopyrightText: Estonian Information System Authority
2+
// SPDX-License-Identifier: MIT
3+
4+
package eu.webeid.example.config;
5+
6+
import org.springframework.boot.context.properties.ConfigurationProperties;
7+
import org.springframework.context.annotation.Configuration;
8+
9+
@Configuration
10+
@ConfigurationProperties(prefix = "web-eid-auth-token.csrf")
11+
public class CsrfConfigurationProperties {
12+
13+
private boolean useSpaConfiguration;
14+
private String cookieDomain;
15+
16+
public boolean isUseSpaConfiguration() {
17+
return useSpaConfiguration;
18+
}
19+
20+
public void setUseSpaConfiguration(boolean useSpaConfiguration) {
21+
this.useSpaConfiguration = useSpaConfiguration;
22+
}
23+
24+
public String getCookieDomain() {
25+
return cookieDomain;
26+
}
27+
28+
public void setCookieDomain(String cookieDomain) {
29+
this.cookieDomain = cookieDomain;
30+
}
31+
}

‎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() {

‎example/src/main/java/eu/webeid/example/web/rest/ChallengeController.java‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,12 @@
33

44
package eu.webeid.example.web.rest;
55

6+
import com.fasterxml.jackson.core.JsonProcessingException;
7+
import eu.webeid.example.security.ajax.AjaxAuthenticationSuccessHandler.AuthSuccessDTO;
68
import eu.webeid.example.service.dto.ChallengeDTO;
79
import eu.webeid.security.challenge.ChallengeNonceGenerator;
10+
import org.springframework.security.access.prepost.PreAuthorize;
11+
import org.springframework.security.core.Authentication;
812
import org.springframework.web.bind.annotation.GetMapping;
913
import org.springframework.web.bind.annotation.RequestMapping;
1014
import org.springframework.web.bind.annotation.RestController;
@@ -23,4 +27,10 @@ public ChallengeController(ChallengeNonceGenerator challengeNonceGenerator) {
2327
public ChallengeDTO challenge() {
2428
return new ChallengeDTO(challengeNonceGenerator.generateAndStoreNonce().getBase64EncodedNonce());
2529
}
30+
31+
@PreAuthorize("isAuthenticated()")
32+
@GetMapping("user")
33+
public String user(Authentication authentication) throws JsonProcessingException {
34+
return AuthSuccessDTO.asJson(authentication);
35+
}
2636
}
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 = "WEBEID-XSRF-TOKEN";
7+
const CSRF_COOKIE_HEADER_NAME = "X-XSRF-TOKEN";
8+
9+
export function csrfHeader() {
10+
const metaToken = document.querySelector("#csrftoken")?.content;
11+
const metaHeaderName = document.querySelector("#csrfheadername")?.content;
12+
if (metaToken && metaHeaderName) {
13+
return {[metaHeaderName]: metaToken};
14+
}
15+
16+
const cookieToken = getCookie(CSRF_COOKIE_NAME);
17+
if (cookieToken) {
18+
return {[CSRF_COOKIE_HEADER_NAME]: cookieToken};
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: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,10 @@
66
<meta charset="UTF-8"/>
77
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"/>
88

9-
<meta id="csrftoken" name="csrftoken" th:content="${_csrf.token}"/>
10-
<meta id="csrfheadername" name="csrfheadername" th:content="${_csrf.headerName}"/>
9+
<th:block th:unless="${@csrfConfigurationProperties.isUseSpaConfiguration()}">
10+
<meta id="csrftoken" name="csrftoken" th:content="${_csrf.token}"/>
11+
<meta id="csrfheadername" name="csrfheadername" th:content="${_csrf.headerName}"/>
12+
</th:block>
1113

1214
<title>Web eID: electronic ID smart cards on the Web</title>
1315
<link
@@ -250,15 +252,13 @@ <h3><a id="for-developers"></a>For developers</h3>
250252
<script type="module">
251253
"use strict";
252254
import * as webeid from "/js/web-eid.js";
255+
import {csrfHeader} from "/js/csrf.js";
253256
import {hideErrorMessage, showErrorMessage, checkHttpError} from "/js/errors.js";
254257

255258
hideErrorMessage();
256259

257260
const authButton = document.querySelector("#webeid-auth-button");
258261

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

264264
authButton.addEventListener("click", async () => {
@@ -279,9 +279,10 @@ <h3><a id="for-developers"></a>For developers</h3>
279279

280280
const authTokenResponse = await fetch("/auth/login", {
281281
method: "POST",
282+
credentials: "include",
282283
headers: {
283284
"Content-Type": "application/json",
284-
[csrfHeaderName]: csrfToken
285+
...csrfHeader()
285286
},
286287
body: `{"auth-token": ${JSON.stringify(authToken)}}`
287288
});

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

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,10 @@
66
<meta charset="UTF-8"/>
77
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"/>
88

9-
<meta id="csrftoken" name="csrftoken" th:content="${_csrf.token}"/>
10-
<meta id="csrfheadername" name="csrfheadername" th:content="${_csrf.headerName}"/>
9+
<th:block th:unless="${@csrfConfigurationProperties.isUseSpaConfiguration()}">
10+
<meta id="csrftoken" name="csrftoken" th:content="${_csrf.token}"/>
11+
<meta id="csrfheadername" name="csrfheadername" th:content="${_csrf.headerName}"/>
12+
</th:block>
1113

1214
<title>Welcome!</title>
1315
<link
@@ -53,6 +55,7 @@ <h2 class="adding-signature">Digital signing</h2>
5355
<script type="module">
5456
"use strict";
5557
import * as webeid from "/js/web-eid.js";
58+
import {csrfHeader} from "/js/csrf.js";
5659
import {hideErrorMessage, showErrorMessage, checkHttpError} from "/js/errors.js";
5760

5861
const signButton = document.querySelector("#webeid-sign-button");
@@ -61,15 +64,13 @@ <h2 class="adding-signature">Digital signing</h2>
6164
const fileNameText = document.querySelector("#file-name");
6265
const exampleDocument = document.querySelector("#example-document");
6366

64-
const csrfToken = document.querySelector('#csrftoken').content;
65-
const csrfHeaderName = document.querySelector('#csrfheadername').content;
66-
6767
document.querySelector("#webeid-logout-button").addEventListener("click", async () => {
6868
await fetch("/logout", {
6969
method: "POST",
70+
credentials: "include",
7071
headers: {
7172
"Content-Type": "application/json",
72-
[csrfHeaderName]: csrfToken
73+
...csrfHeader()
7374
}
7475
});
7576
window.location.href = "/";
@@ -93,9 +94,10 @@ <h2 class="adding-signature">Digital signing</h2>
9394

9495
const prepareSigningResponse = await fetch("/sign/prepare", {
9596
method: "POST",
97+
credentials: "include",
9698
headers: {
9799
"Content-Type": "application/json",
98-
[csrfHeaderName]: csrfToken
100+
...csrfHeader()
99101
},
100102
body: JSON.stringify({certificate, supportedSignatureAlgorithms}),
101103
});
@@ -112,9 +114,10 @@ <h2 class="adding-signature">Digital signing</h2>
112114

113115
const finalizeSigningResponse = await fetch("/sign/sign", {
114116
method: "POST",
117+
credentials: "include",
115118
headers: {
116119
"Content-Type": "application/json",
117-
[csrfHeaderName]: csrfToken
120+
...csrfHeader()
118121
},
119122
body: JSON.stringify({signature, signatureAlgorithm}),
120123
});

0 commit comments

Comments
 (0)