I have a Sring Boot 3.3.0 backend application with Spring Security 6.3.0. The application is a backend for a website, the website itself is written separately in Angular, including the login form. I wanted to add Spring Security form login, and I managed to do it at first, but whenever I enable the CSRF, it breaks due to a HTTP 302 redirect which I cannot disable for some reason.
Here is the working code:
@Configuration
@EnableWebSecurity
public class MyBackendSecurityConfig {
private static final String[] PERMIT_ALL_INTERFACES = new String[] {
"/login",
"/logout",
"/swagger-ui.html",
"/swagger-ui/**",
"/api-docs/**",
"/webjars/**",
"/swagger-ui/index.html#/",
"/actuator/**"
};
@Value("${ldap.url}")
private String ldapUrl;
@Value("${ldap.domain}")
private String domain;
@Value("${ldap.searchFilter}")
private String searchFilter;
@Value("${ldap.useAuthenticationRequestCredentials}")
private String useAuthenticationRequestCredentials;
@Value("${ldap.convertSubErrorCodesToExceptions}")
private String convertSubErrorCodesToExceptions;
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(CsrfConfigurer::disable)
.requestCache(RequestCacheConfigurer::disable)
.authorizeHttpRequests(auth -> auth
.requestMatchers(PERMIT_ALL_INTERFACES).permitAll()
.anyRequest().authenticated())
.authenticationProvider(ldapAuthenticationProvider())
.formLogin(formLoginConfigurer -> formLoginConfigurer
.loginPage("/sign-in")
.loginProcessingUrl("/login")
.successHandler((req, res, auth) -> res.setStatus(HttpStatus.NO_CONTENT.value()))
.failureHandler((req, res, ex) -> res.setStatus(HttpStatus.UNAUTHORIZED.value()))
.permitAll())
.logout(logoutConfigurer -> logoutConfigurer
.logoutUrl("/logout")
.deleteCookies("JSESSIONID")
.logoutSuccessHandler(new HttpStatusReturningLogoutSuccessHandler(HttpStatus.NO_CONTENT))
.permitAll())
.exceptionHandling(exceptionHandlingConfigurer -> exceptionHandlingConfigurer
.accessDeniedHandler((req, res, ex) -> res.setStatus(HttpStatus.FORBIDDEN.value()))
.authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)))
.sessionManagement(sessionManagementConfigurer -> sessionManagementConfigurer
.invalidSessionUrl("/sign-in"));
return http.build();
}
@Bean
ActiveDirectoryLdapAuthenticationProvider ldapAuthenticationProvider() {
ActiveDirectoryLdapAuthenticationProvider provider = new ActiveDirectoryLdapAuthenticationProvider(domain, ldapUrl);
provider.setConvertSubErrorCodesToExceptions(Boolean.parseBoolean(convertSubErrorCodesToExceptions));
provider.setUseAuthenticationRequestCredentials(Boolean.parseBoolean(useAuthenticationRequestCredentials));
provider.setSearchFilter(searchFilter);
return provider;
}
}
I specifically set the successHandler
to return a HTTP 204 response, because there is additional logic on the frontend after successful login bet before redirecting to the main page, and a HTTP 302 redirect response is not handled well.
This is the frontend code:
@Component({
selector: 'vrf-login',
templateUrl: './login.component.html',
styleUrls: ['./login.component.scss'],
})
export class LoginComponent implements OnInit {
...
signIn() {
this.form.markAllAsTouched();
const formData = this.form.getRawValue();
this.authService.login(formData).subscribe( {
next: () => {
// additional logic here
return this.router.navigateByUrl('/home');
},
error: () => {
this.messageService.add({severity: 'error', summary: 'Error', detail: 'Login Failed'});
}
});
}
...
}
@Injectable()
export class AuthService {
...
login(userData: User) {
const headers = new HttpHeaders(
{
'Accept': 'application/json',
'Content-Type': 'application/x-www-form-urlencoded'
});
const body = new HttpParams()
.set('username', userData.username)
.set('password', userData.password);
return this.http.post<void>(environment.apiUrl + 'login', body.toString(), { 'headers': headers });
}
...
}
So far so good, the backend responds to the /login endpoint request with HTTP 204, and the JSESSIONID cookie is set in my browser as expected. However, when I enable the CSRF in the backend like so (excerpt only, not repeating the full class:
http
.csrf(csrfConfigurer -> csrfConfigurer
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()))
This is not working, because the backend keeps responding with HTTP 302 redirect. Nothing else was changed in the backend at all. The successHandler
for the formLogin()
is ignored.
Is there any way to still force HTTP 2xx response with CSRF token? Or we should modify the frontend itself to accept and work with the HTTP 302 redirect...
Update: right, I actually forgot to show logs and http requests...
When I send the login request, I get a HTTP 302 response, sending me back to the login page in the Location header:
In the server logs I see these messages:
2024.11.14 15:00:00.638 DEBUG [http-nio-9444-exec-5] o.s.s.w.FilterChainProxy - Securing POST /login
2024.11.14 15:00:00.640 DEBUG [http-nio-9444-exec-5] o.s.s.w.c.CsrfFilter - Invalid CSRF token found for https://xxx/login
2024.11.14 15:00:00.640 DEBUG [http-nio-9444-exec-5] o.s.s.w.s.SimpleRedirectInvalidSessionStrategy - Starting new session (if required) and redirecting to '/sign-in'
2024.11.14 15:00:00.641 DEBUG [http-nio-9444-exec-5] o.s.s.w.DefaultRedirectStrategy - Redirecting to /sign-in
In the response I get the XSRF-TOKEN cookie, but when I re-try the login request with the cookie value in the X-XSRF-TOKEN, I get a 403 Forbidden response. The backend server logs contain the exact same messages for the second request too.
If I add the /login endpoint as ignored in the CSRF configuration (the PERMIT_ALL_INTERFACES array contains the /login endpoint with some others too):
http
.csrf(csrfConfigurer -> csrfConfigurer
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
.ignoringRequestMatchers(PERMIT_ALL_INTERFACES))
Then the login works, but I don't get a CSRF token in the response, so the next POST request that I do from the web app will fail in the same way (getting HTTP 302 redirecting me to the /sign-in page).
One more detail which may or may not be relevant, I'm not sure. The frontend is served from an Apache HTTPD and the backend is running as a Spring Boot app, but they are available from the browser on the same FQDN, but different URL path. The requests go to a HAProxy, which separates the requests, the frontend requests are /web-gui/xxx, the backend requests are /web-gui/backend/xxx. The HAProxy remove the /web-gui/backend prefix from the requests and sends only the remaining part to the Spring Boot app. So e.g. /web-gui/backend/login becomes /login.
Well, thanks again for the help @Toerktumlare, the solution basically is the following, based on the content linked in your comments:
handle
and resolveCsrfTokenValue
methods) and the deferred token opt-out (calling setCsrfRequestAttributeName
with null) solutions from the linked documentationcsrfTokenRequestHandler
to the HttpSecurity CSRF configurationThis way when the /login is invoked with POST, it is ignored by the CSRF check so it is allowed, but due to the deferred opt-out config, a CSRF token is being generated and returned in the HTTP response. Also I get the expected HTTP 204 response for the login, instead of a 302 redirect. I can use the token from the response in the subsequent POST calls (I made the appropriate changes on our front-end too).