A Spring Boot application based on Spring Boot 3.x and Spring Security 6.x shall be setup like this:
This is my current security configuration:
@Order(1)
@Bean
public SecurityFilterChain clientFilterChain(HttpSecurity httpSecurity) throws Exception {
return httpSecurity
.cors(Customizer.withDefaults())
.csrf(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(matcherRegistry -> matcherRegistry.requestMatchers("/api/user/current").authenticated())
.oauth2Login(loginConfigurer -> loginConfigurer.defaultSuccessUrl(applicationConfiguration.getFrontendRedirectUrl(), true))
.exceptionHandling(exceptionHandlingConfigurer -> exceptionHandlingConfigurer.authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)))
.logout(logoutConfigurer -> logoutConfigurer.logoutSuccessHandler(logoutSuccessHandler()))
.build();
}
@Order(2)
@Bean
public SecurityFilterChain resourceServerFilterChain(HttpSecurity httpSecurity) throws Exception {
return httpSecurity
.cors(Customizer.withDefaults())
.csrf(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(matcherRegistry -> matcherRegistry.anyRequest().authenticated())
.oauth2ResourceServer(configurer -> configurer.jwt(Customizer.withDefaults()))
.build();
}
The login itself works, i.e. Keycloak issues a token, the application retrieves it and issues a JSESSION
cookie to the client.
When requesting a resource like /api/test-data
, this should be secured by the 2nd bean but the client always retrieves a 403 (Forbidden) and I don't know why.
Even if I change the 2nd bean to matcherRegistry.anyRequest().permitAll()
the client gets a 403.
As your first security filter-chain does not contain a securityMatcher
to limit which requests it should process, the second is never tried.
The easiest way to fix that is probably to reverse the order (put the resourceServerFilterChain
first) and define a securityMatcher
selecting requests with an Authorization
header starting with Bearer
.
@Order(1)
@Bean
public SecurityFilterChain resourceServerFilterChain(HttpSecurity httpSecurity) throws Exception {
return httpSecurity
.securityMatcher((HttpServletRequest request) -> {
return Optional.ofNullable(request.getHeader(HttpHeaders.AUTHORIZATION)).map(h -> {
return h.toLowerCase().startsWith("bearer ");
}).orElse(false);
})
.cors(Customizer.withDefaults())
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.csrf(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(matcherRegistry -> matcherRegistry.anyRequest().authenticated())
.oauth2ResourceServer(configurer -> configurer.jwt(Customizer.withDefaults()))
.build();
}
@Order(2)
@Bean
public SecurityFilterChain clientFilterChain(HttpSecurity httpSecurity) throws Exception {
return httpSecurity
.cors(Customizer.withDefaults())
// Never disable CSRF protection when sessions are enabled
// Following is the conf for a SPA (like your Vue app)
.csrf(csrf(configurer -> {
// see https://docs.spring.io/spring-security/reference/5.8/migration/servlet/exploits.html#_i_am_using_angularjs_or_another_javascript_framework
final var delegate = new XorCsrfTokenRequestAttributeHandler();
delegate.setCsrfRequestAttributeName("_csrf");
configurer.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()).csrfTokenRequestHandler(delegate::handle);
httpSecurity.addFilterAfter(new CsrfCookieFilter(), BasicAuthenticationFilter.class);
})
.authorizeHttpRequests(matcherRegistry -> matcherRegistry.requestMatchers("/api/user/current").authenticated())
.oauth2Login(loginConfigurer -> loginConfigurer.defaultSuccessUrl(applicationConfiguration.getFrontendRedirectUrl(), true))
.exceptionHandling(exceptionHandlingConfigurer -> exceptionHandlingConfigurer.authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)))
.logout(logoutConfigurer -> logoutConfigurer.logoutSuccessHandler(logoutSuccessHandler()))
.build();
}
private static final class CsrfCookieFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException,
IOException {
CsrfToken csrfToken = (CsrfToken) request.getAttribute(CsrfToken.class.getName());
// Render the token value to a cookie by causing the deferred token to be loaded
csrfToken.getToken();
filterChain.doFilter(request, response);
}
}
Never disable CSRF protection when sessions are enabled: your OAuth2 client filter-chain with oauth2Login
needs sessions and, as a consequence, CSRF protection.
Do not configure OAuth2 client with oauth2Login
and resource server in a single security filter-chain (as advised in another answer): security requirements are too different (oauth2Login
security relies on sessions and need CSRF protection, resource servers rely on tokens and can be stateless)
Are you sure that you need the resource server filter-chain at all? Otherly asked, do you have OAuth2 confidential clients running on server you trust sending requests to your API? All the requests from browser using oauth2Login
will be authorized with sessions (not tokens), and it is perfectly fine like that. Browsers need a framework like Angular, React or Vue, configured as public OAuth2 client to send requests authorized with tokens and this is now discouraged.
I personally configure my REST APIs solely as resource servers. The UI always is in a separate application. In the case of server-side rendered frameworks (Thymeleaf, JSF, etc.), the app serving the UI is configured as OAuth2 client. In the case of single page applications (Angular, React, Vue, etc.) or mobile apps, I use a backend for frontend (spring-cloud-gateway
with oauth2Login
and the TokenRelay
filter to replace the session cookie from the frontend, with the access token in session, before forwarding the request to a resource server).
You could be interested in this starter I wrote which eases OAuth2 security configuration (even in the case where you want both client and resource server filter-chains in the same app). This repo also contains quite a few tutorials, including one with the BFF pattern.