Skip to content

Commit

Permalink
fix: update JWT cookie at every request (#18994)
Browse files Browse the repository at this point in the history
* fix: update JWT cookie at every request

Fixes stateless authentication, by updating the JWT cookies at every request,
preventing the browser to remove the cookie after initial max-age time is expired.

Fixes #18880

* apply review suggestions

---------

Co-authored-by: Mikhail Shabarov <61410877+mshabarov@users.noreply.github.com>
  • Loading branch information
mcollovati and mshabarov committed Mar 25, 2024
1 parent 458ae86 commit ce9c3d2
Show file tree
Hide file tree
Showing 7 changed files with 452 additions and 43 deletions.
Expand Up @@ -23,7 +23,6 @@
import javax.crypto.SecretKey;

import java.io.IOException;
import java.lang.reflect.Field;
import java.util.LinkedHashMap;
import java.util.Objects;
import java.util.Optional;
Expand All @@ -44,7 +43,6 @@
import org.springframework.security.config.annotation.web.builders.WebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfiguration;
import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer;
import org.springframework.security.config.annotation.web.configurers.SessionManagementConfigurer;
import org.springframework.security.oauth2.jose.jws.MacAlgorithm;
import org.springframework.security.web.DefaultSecurityFilterChain;
import org.springframework.security.web.SecurityFilterChain;
Expand All @@ -56,7 +54,6 @@
import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint;
import org.springframework.security.web.authentication.logout.LogoutHandler;
import org.springframework.security.web.authentication.logout.SimpleUrlLogoutSuccessHandler;
import org.springframework.security.web.context.SecurityContextRepository;
import org.springframework.security.web.csrf.CsrfException;
import org.springframework.security.web.savedrequest.RequestCache;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
Expand Down Expand Up @@ -151,6 +148,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
cfg.invalidateHttpSession(true);
addLogoutHandlers(cfg::addLogoutHandler);
});

DefaultSecurityFilterChain securityFilterChain = http.build();
Optional.ofNullable(vaadinRolePrefixHolder)
.ifPresent(vaadinRolePrefixHolder -> vaadinRolePrefixHolder
Expand Down Expand Up @@ -552,23 +550,9 @@ protected void setStatelessAuthentication(HttpSecurity http,
protected void setStatelessAuthentication(HttpSecurity http,
SecretKey secretKey, String issuer, long expiresIn)
throws Exception {
VaadinStatelessSecurityConfigurer<HttpSecurity> vaadinStatelessSecurityConfigurer = new VaadinStatelessSecurityConfigurer<>();
vaadinStatelessSecurityConfigurer.setSharedObjects(http);
http.apply(vaadinStatelessSecurityConfigurer);

// Workaround
// https://github.com/spring-projects/spring-security/issues/12579 until
// it is released
SessionManagementConfigurer sessionManagementConfigurer = http
.getConfigurer(SessionManagementConfigurer.class);
Field f = SessionManagementConfigurer.class
.getDeclaredField("sessionManagementSecurityContextRepository");
f.setAccessible(true);
f.set(sessionManagementConfigurer,
http.getSharedObject(SecurityContextRepository.class));

vaadinStatelessSecurityConfigurer.withSecretKey().secretKey(secretKey)
.and().issuer(issuer).expiresIn(expiresIn);
VaadinStatelessSecurityConfigurer.apply(http,
cfg -> cfg.withSecretKey().secretKey(secretKey).and()
.issuer(issuer).expiresIn(expiresIn));
}

/**
Expand Down
Expand Up @@ -17,6 +17,7 @@

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;

import java.util.Date;
import java.util.List;
import java.util.Objects;
Expand Down Expand Up @@ -191,7 +192,6 @@ public SecurityContext loadContext(
.convert(jwt);
context.setAuthentication(authentication);
}

return context;
}

Expand Down
Expand Up @@ -92,6 +92,12 @@ void saveSerializedJwt(String serializedJwt, HttpServletRequest request,
}
}

static boolean containsCookie(HttpServletResponse response) {
return response.getHeaders("Set-Cookie").stream()
.anyMatch(cookie -> cookie
.startsWith(JWT_HEADER_AND_PAYLOAD_COOKIE_NAME));
}

/**
* Checks the presence of JWT cookies in request.
*
Expand Down Expand Up @@ -131,20 +137,26 @@ private void setJwtSplitCookies(String serializedJwt,

private void removeJwtSplitCookies(HttpServletRequest request,
HttpServletResponse response) {
Cookie jwtHeaderAndPayloadRemove = new Cookie(
JWT_HEADER_AND_PAYLOAD_COOKIE_NAME, null);
jwtHeaderAndPayloadRemove.setPath(getRequestContextPath(request));
jwtHeaderAndPayloadRemove.setMaxAge(0);
jwtHeaderAndPayloadRemove.setSecure(request.isSecure());
jwtHeaderAndPayloadRemove.setHttpOnly(false);
response.addCookie(jwtHeaderAndPayloadRemove);

Cookie jwtSignatureRemove = new Cookie(JWT_SIGNATURE_COOKIE_NAME, null);
jwtSignatureRemove.setPath(getRequestContextPath(request));
jwtSignatureRemove.setMaxAge(0);
jwtSignatureRemove.setSecure(request.isSecure());
jwtSignatureRemove.setHttpOnly(true);
response.addCookie(jwtSignatureRemove);

// No need to send JWT cookies with max-age 0 if the current request
// does not contain them
if (containsSerializedJwt(request)) {
Cookie jwtHeaderAndPayloadRemove = new Cookie(
JWT_HEADER_AND_PAYLOAD_COOKIE_NAME, null);
jwtHeaderAndPayloadRemove.setPath(getRequestContextPath(request));
jwtHeaderAndPayloadRemove.setMaxAge(0);
jwtHeaderAndPayloadRemove.setSecure(request.isSecure());
jwtHeaderAndPayloadRemove.setHttpOnly(false);
response.addCookie(jwtHeaderAndPayloadRemove);

Cookie jwtSignatureRemove = new Cookie(JWT_SIGNATURE_COOKIE_NAME,
null);
jwtSignatureRemove.setPath(getRequestContextPath(request));
jwtSignatureRemove.setMaxAge(0);
jwtSignatureRemove.setSecure(request.isSecure());
jwtSignatureRemove.setHttpOnly(true);
response.addCookie(jwtSignatureRemove);
}
}

private String getRequestContextPath(HttpServletRequest request) {
Expand Down
Expand Up @@ -15,8 +15,15 @@
*/
package com.vaadin.flow.spring.security.stateless;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;

import javax.crypto.SecretKey;

import java.io.IOException;

import com.nimbusds.jose.JWSAlgorithm;
import com.nimbusds.jose.jwk.JWKSet;
import com.nimbusds.jose.jwk.OctetSequenceKey;
Expand All @@ -29,16 +36,22 @@
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.annotation.web.configurers.CsrfConfigurer;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.jose.jws.JwsAlgorithm;
import org.springframework.security.oauth2.jose.jws.MacAlgorithm;
import org.springframework.security.web.context.DelegatingSecurityContextRepository;
import org.springframework.security.web.context.RequestAttributeSecurityContextRepository;
import org.springframework.security.web.context.SecurityContextRepository;
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
import org.springframework.security.web.csrf.CsrfTokenRepository;
import org.springframework.security.web.csrf.CsrfTokenRequestHandler;
import org.springframework.security.web.csrf.LazyCsrfTokenRepository;
import org.springframework.security.web.csrf.XorCsrfTokenRequestAttributeHandler;
import org.springframework.security.web.header.HeaderWriterFilter;
import org.springframework.security.web.savedrequest.CookieRequestCache;
import org.springframework.security.web.savedrequest.RequestCache;
import org.springframework.security.web.util.OnCommittedResponseWrapper;
import org.springframework.web.filter.OncePerRequestFilter;

import com.vaadin.flow.spring.security.VaadinDefaultRequestCache;
import com.vaadin.flow.spring.security.VaadinSavedRequestAwareAuthenticationSuccessHandler;
Expand Down Expand Up @@ -84,18 +97,66 @@ public final class VaadinStatelessSecurityConfigurer<H extends HttpSecurityBuild

private SecretKeyConfigurer secretKeyConfigurer;

/**
* Sets {@link JwtSecurityContextRepository} as a shared object to be used
* by multiple {@code SecurityConfigurer}.
*
* @param http
* the http security builder to store the shared object.
* @deprecated to be removed. There is no direct replacement for this
* method. Shared object setup must be done along with other
* required configurations by calling
* {@link #apply(HttpSecurity, Customizer)}.
* @see #apply(HttpSecurity, Customizer)
*/
@Deprecated(since = "24.4", forRemoval = true)
public void setSharedObjects(HttpSecurity http) {
JwtSecurityContextRepository jwtSecurityContextRepository = new JwtSecurityContextRepository(
new SerializedJwtSplitCookieRepository());
http.setSharedObject(SecurityContextRepository.class,
jwtSecurityContextRepository);
}

/**
* Applies configuration required to enable stateless security for a Vaadin
* application.
* <p>
* </p>
* Use {@code customizer} to tune {@link VaadinStatelessSecurityConfigurer},
* or {@link Customizer#withDefaults()} to accept the default values.
*
* @param http
* the http security builder
* @param customizer
* the {@link Customizer} to provide more options for the
* {@link VaadinStatelessSecurityConfigurer}
*/
public static void apply(HttpSecurity http,
Customizer<VaadinStatelessSecurityConfigurer<HttpSecurity>> customizer)
throws Exception {

JwtSecurityContextRepository jwtSecurityContextRepository = new JwtSecurityContextRepository(
new SerializedJwtSplitCookieRepository());
http.setSharedObject(JwtSecurityContextRepository.class,
jwtSecurityContextRepository);
http.securityContext(cfg -> {
DelegatingSecurityContextRepository repository = new DelegatingSecurityContextRepository(
jwtSecurityContextRepository,
new RequestAttributeSecurityContextRepository());
cfg.securityContextRepository(repository);
});

VaadinStatelessSecurityConfigurer<HttpSecurity> vaadinStatelessSecurityConfigurer = new VaadinStatelessSecurityConfigurer<>();
http.with(vaadinStatelessSecurityConfigurer, customizer);
}

@Override
@SuppressWarnings("unchecked")
public void init(H http) {

CsrfConfigurer<H> csrf = http.getConfigurer(CsrfConfigurer.class);
if (csrf != null) {

// Use cookie for storing CSRF token, as it does not require a
// session (double-submit cookie pattern)
CsrfTokenRepository csrfTokenRepository = CookieCsrfTokenRepository
Expand All @@ -111,18 +172,15 @@ public void init(H http) {
http.getSharedObject(
VaadinSavedRequestAwareAuthenticationSuccessHandler.class)
.setCsrfTokenRepository(csrfTokenRepository);

}
}

@Override
public void configure(H http) {
SecurityContextRepository securityContextRepository = http
.getSharedObject(SecurityContextRepository.class);

if (securityContextRepository instanceof JwtSecurityContextRepository) {
JwtSecurityContextRepository jwtSecurityContextRepository = (JwtSecurityContextRepository) securityContextRepository;

JwtSecurityContextRepository jwtSecurityContextRepository = http
.getSharedObject(JwtSecurityContextRepository.class);
if (jwtSecurityContextRepository != null) {
jwtSecurityContextRepository
.setJwsAlgorithm(secretKeyConfigurer.getAlgorithm());
jwtSecurityContextRepository
Expand All @@ -136,13 +194,17 @@ public void configure(H http) {
trustResolver = new AuthenticationTrustResolverImpl();
}
jwtSecurityContextRepository.setTrustResolver(trustResolver);
http.addFilterBefore(
new UpdateJwtCookiesFilter(jwtSecurityContextRepository),
HeaderWriterFilter.class);
}

RequestCache requestCache = http.getSharedObject(RequestCache.class);
if (requestCache instanceof VaadinDefaultRequestCache) {
((VaadinDefaultRequestCache) requestCache)
.setDelegateRequestCache(new CookieRequestCache());
}

}

/**
Expand Down Expand Up @@ -262,4 +324,66 @@ JWSAlgorithm getAlgorithm() {
return JWSAlgorithm.parse(jwsAlgorithm.getName());
}
}

// Inspired by Spring HeaderWriterFilter. Updates JWT cookies at every
// request, just before HTTP response is commit.
private static final class UpdateJwtCookiesFilter
extends OncePerRequestFilter {

private final JwtSecurityContextRepository jwtSecurityContextRepository;

private UpdateJwtCookiesFilter(
JwtSecurityContextRepository jwtSecurityContextRepository) {
this.jwtSecurityContextRepository = jwtSecurityContextRepository;
}

@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
UpdateJWTCookieOnCommitResponseWrapper responseWrapper = new UpdateJWTCookieOnCommitResponseWrapper(
request, response, jwtSecurityContextRepository);
try {
filterChain.doFilter(request, responseWrapper);
} finally {
// Force write, in case the response has not been flushed
responseWrapper.writeCookies();
}
}
}

private static final class UpdateJWTCookieOnCommitResponseWrapper
extends OnCommittedResponseWrapper {

private final JwtSecurityContextRepository jwtSecurityContextRepository;

private final HttpServletRequest request;

UpdateJWTCookieOnCommitResponseWrapper(HttpServletRequest request,
HttpServletResponse response,
JwtSecurityContextRepository jwtSecurityContextRepository) {
super(response);
this.request = request;
this.jwtSecurityContextRepository = jwtSecurityContextRepository;
}

@Override
protected void onResponseCommitted() {
writeCookies();
disableOnResponseCommitted();
}

private void writeCookies() {
if (isDisableOnResponseCommitted()) {
return;
}
org.springframework.security.core.context.SecurityContext context = SecurityContextHolder
.getContextHolderStrategy().getContext();
if (context != null && !SerializedJwtSplitCookieRepository
.containsCookie(this)) {
jwtSecurityContextRepository.saveContext(context, request,
this);
}
}
}
}
Expand Up @@ -112,10 +112,11 @@ protected Stream<String> getExcludedPatterns() {
"com\\.vaadin\\.flow\\.spring\\.security\\.VaadinSavedRequestAwareAuthenticationSuccessHandler\\$RedirectStrategy",
"com\\.vaadin\\.flow\\.spring\\.security\\.WebIconsRequestMatcher(\\$.*)?",
"com\\.vaadin\\.flow\\.spring\\.security\\.stateless\\.JwtSecurityContextRepository",
"com\\.vaadin\\.flow\\.spring\\.security\\.stateless\\.JwtSecurityContextRepository\\$UpdateJwtResponseWrapper",
"com\\.vaadin\\.flow\\.spring\\.security\\.stateless\\.SerializedJwtSplitCookieRepository",
"com\\.vaadin\\.flow\\.spring\\.security\\.stateless\\.VaadinStatelessSecurityConfigurer",
"com\\.vaadin\\.flow\\.spring\\.security\\.stateless\\.VaadinStatelessSecurityConfigurer\\$SecretKeyConfigurer",
"com\\.vaadin\\.flow\\.spring\\.security\\.stateless\\.VaadinStatelessSecurityConfigurer\\$UpdateJwtCookiesFilter",
"com\\.vaadin\\.flow\\.spring\\.security\\.stateless\\.VaadinStatelessSecurityConfigurer\\$UpdateJWTCookieOnCommitResponseWrapper",
"com\\.vaadin\\.flow\\.spring\\.VaadinServletContextInitializer\\$ClassPathScanner",
"com\\.vaadin\\.flow\\.spring\\.VaadinServletContextInitializer\\$CustomResourceLoader"),
super.getExcludedPatterns());
Expand Down

0 comments on commit ce9c3d2

Please sign in to comment.