Protéger incroyablement votre application Angular/Springboot

Coté Springboot

Dans Springboot tous se déroule dans le fichier SecurityConfig, que dont nous avions parlé la dernière fois. Le voici :

package com.raphlys.config;

import java.util.List;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
import org.springframework.security.web.csrf.CsrfToken;
import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
import org.springframework.web.cors.CorsConfiguration;

import jakarta.servlet.http.HttpServletResponse;

@Configuration
@EnableWebSecurity
public class SecurityConfig {

	@Autowired
	private CustomUserDetailService customUserDetailService;

	@Bean
	SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
		CsrfTokenRequestAttributeHandler handler = new CsrfTokenRequestAttributeHandler();

		http.csrf(csrf -> csrf.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
				.csrfTokenRequestHandler(handler).ignoringRequestMatchers(new AntPathRequestMatcher("/login"),
						new AntPathRequestMatcher("/logout")))
		.cors(cors -> cors.configurationSource(request -> {
			CorsConfiguration config = new CorsConfiguration();
			config.setAllowedOrigins(List.of("http://localhost:4200"));
			config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE"));
			config.setAllowedHeaders(List.of("Authorization", "Content-Type", "X-XSRF-TOKEN", "X-Requested-With"));
			config.setAllowCredentials(true);
			return config;
		})).formLogin(form -> form.successHandler((request, response, authentication) -> {
					((CsrfToken) request.getAttribute(CsrfToken.class.getName())).getToken();
					response.setStatus(HttpServletResponse.SC_OK);
				})
				.failureHandler((request, response, exception) -> response
						.setStatus(HttpServletResponse.SC_UNAUTHORIZED)))
				.authorizeHttpRequests(authorizedHttpRequest -> authorizedHttpRequest
						.requestMatchers("/raph/all", "GET").permitAll().requestMatchers("/error/**").permitAll().requestMatchers("/raph/**").authenticated())
				.userDetailsService(customUserDetailService).logout(logout -> logout.logoutUrl("/auth/logout").logoutSuccessHandler(
						(request, response, authentication) -> response.setStatus(HttpServletResponse.SC_OK)));
		return http.build();
	}

	@Bean
	PasswordEncoder passwordEncoder() {
		return new BCryptPasswordEncoder();
	}

}

Voyons cela nouvelle ligne par nouvelle ligne :

CsrfTokenRequestAttributeHandler handler = new CsrfTokenRequestAttributeHandler();
http.csrf(csrf -> csrf.csrfTokenRepository(CookieCsrfTokenRepository .withHttpOnlyFalse()).csrfTokenRequestHandler(handler) .ignoringRequestMatchers( new AntPathRequestMatcher("/login"),
new AntPathRequestMatcher("/logout")))

Cette ligne de 7 lignes concerne le CSRF (Cross-site request forgery). On va instancié un handler, le handler par défaut n’étant pas adapté à notre situation. C’est lui qui va vérifier que la token dans le Header est bien égal à la token qui a été généré par le backend.
Le code concernant csrfTokenRepository va nous permettre de stocker les tokens généré coté backend dans une repository. On lui indique que l’on veut générer et envoyer les tokens même si on n’est pas en https, c’est pareil en production, c’est très probablement une mauvaise idée.
On met le Handler dans la configuration.
On indique ici que pour le login et le logout, on n’utilisera pas le CSRF, pour le login, c’est la première fois que le navigateur va appeler notre backend, il est donc logique qu’il n’est pas encore la token. Pour le logout, on part du principe qu’il faut mieux déconnecter la session plutôt que la garder ouverte si l’on reçoit un appel même sans la token CSRF.

.cors(cors -> cors.configurationSource(request -> {
	CorsConfiguration config = new CorsConfiguration();
	config.setAllowedOrigins(List.of("http://localhost:4200"));
	config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE"));
	config.setAllowedHeaders(List.of("Authorization", "Content-Type", "X-XSRF-TOKEN", "X-Requested-With"));
	config.setAllowCredentials(true);
	return config;
}))

Ici l’on va se protéger des attaques CORS (Cross-origin resource sharing).
On va donc instancier notre configuration.
setAllowedOrigins va nous permettre d’indiquer qu’elles sont les domaines qui vont pouvoir nous appeler. Ici Angular étant sur localhost sur le port 4200, on l’autorise.
setAllowedMethods nous permet d’autoriser uniquement certaines méthodes.
setAllowedHeaders offre la possibilité au client de mettre certains header.
setAllowCredentials là c’est la possibilité de se connecter qui est offerte.

.formLogin(form -> form.successHandler((request, response, authentication) -> {
    ((CsrfToken)request.getAttribute(CsrfToken.class.getName())) .getToken();					   
    response.setStatus(HttpServletResponse.SC_OK);
}).failureHandler((request, response, exception) -> response.setStatus(HttpServletResponse.SC_UNAUTHORIZED)))

On demande poliment à SpringSecurity de générer un token CSRF pour les prochaines requêtes. Sans ça vous devrez appeler la prochaine requête 2 fois. En cas d’échec de la connexion on fait une 403.

.logout(logout -> logout.logoutUrl("/logout").logoutSuccessHandler((request, response, authentication) -> response.setStatus(HttpServletResponse.SC_OK)))

On indique l’url pour se déconnecter et que faire en cas de déconnection. Ici simplement avoir une réponse 200.

Coté Angular

Création du projet

  1. mkdir ~/workspace/somewhere/; cd ~/workspace/somewhere/
  2. ng new FrontDemo;ng add @angular/material
  3. On garde par défaut (CSS et no SSR), en théorie faire un autre choix ne devrait pas poser de problème.

Voilà votre projet Springboot est créé. Le but de cet article est de faire quelque chose de fonctionnel, ça ne sera pas user-friendly, ça ne sera pas particulièrement beau, mais ça fonctionnera. Le but de ces articles est surtout de présenter Springboot, cet article est à la croiser des chemins, c’est peut-être le chaînon manquant pour certains. Je vais partir du principe que vous avez des bases en angular et donc qu’il n’est pas utile de s’attarder sur le sujet, je ne décortiquerais pas ligne par ligne les composants, uniquement la partie qui permet de se connecter.

Prise en compte de CSRF

Voici le fichier app.config.ts le fichier existe déjà, il faut simplement le modifier, je décrirais dessous les lignes que j’ai ajoutées.

import { ApplicationConfig } from '@angular/core';
import { provideRouter } from '@angular/router';

import {
  provideHttpClient,
  withInterceptors,
  withXsrfConfiguration,
} from '@angular/common/http';
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
import { routes } from './app.routes';
import { authInterceptor } from './auth.interceptor';
import { xsrfInterceptor } from './xsrf.interceptor';

export const appConfig: ApplicationConfig = {
  providers: [
    provideHttpClient(
      withInterceptors([authInterceptor, xsrfInterceptor]),
      withXsrfConfiguration({
        cookieName: 'XSRF-TOKEN',
        headerName: 'X-XSRF-TOKEN',
      })
    ),
    provideRouter(routes),
    provideAnimationsAsync(),
  ],
};

Étudions chaque ligne :

  provideHttpClient(

provideHttpClient va vous permettre de fournir tous les providers qui vont modifier les requêtes HTTP.

  withInterceptors([authInterceptor]),

WithInterceptors vous fournit la possibilité de mettre une liste d’interceptors. Ici c’est authInterceptor. Nous l’étudierons ensemble plus tard, il permet de mettre la valeur withCredentials a true.

withXsrfConfiguration({
    cookieName: 'XSRF-TOKEN',
    headerName: 'X-XSRF-TOKEN',
})

C’est ce code qui va faire les modifications afin que le CSRF soit pris en compte par Angular. SpringSecurity va générer et envoyer le cookie XSRF-TOKEN, Angular va prendre ce cookie et le mettre dans l’entête de la requête suivante avec comme nom d’en tête X-XSRF-TOKEN. À noter que CSRF est activé sur toutes les requêtes HTTP à l’exception de GET.
Malheureusement ce code ne fonctionne pas dans notre cas. Car j’ai décidé que notre back-end et notre front-end ne partageraient pas le même port (à l’aide d’un serveur http). Donc si vous regardez la ligne 100 du fichier Angular suivant : https://github.com/angular/angular/blob/main/packages/common/http/src/xsrf.ts, vous remarquerez qu’il y a un filtre sur les urls qui commencent par http ou https en d’autres thermes ce sur une autre url.
On va laisser ce code pour le moment, même s’il est inutile dans notre cas. On va plutôt mettre l’interceptor suivant :

import { HttpInterceptorFn } from '@angular/common/http';

export const xsrfInterceptor: HttpInterceptorFn = (req, next) => {
  const xsrfToken = document.cookie
    .split('; ')
    .find((row) => row.startsWith('XSRF-TOKEN='))
    ?.split('=')[1];
  const clonedReq = req.clone({
    setHeaders: {
      'X-XSRF-TOKEN': xsrfToken || '',
    },
  });
  return next(clonedReq);
};

Celui-ci copie la valeur du cookie dans le Header de toutes les requêtes ( on aurait put ne pas mettre les requêtes GET).

Gestion de la connexion

Pour la gestion de la connexion, on va utiliser notre authInterceptor. À la racine de votre projet voici le fichier auth.interceptor.ts :

import { HttpInterceptorFn } from '@angular/common/http';

export const authInterceptor: HttpInterceptorFn = (req, next) => {
  return next(
    req.clone({
      withCredentials: true,
    })
  );
};

Il n’y a pas besoin d’étudier ligne pas ligne, on ne fait que copier notre requête et y ajouter la valeur withCredentials, en la mettant à true. C’est vrai, on ne peut pas modifier la valeur directement car elle est en read only. En mettant, cette variable à true le cookie JSESSIONID est bien envoyé au backend.

Conclusion

Comme vous pouvez le voir, il est assez simple de faire communiquer Angular et Springboot ensemble. Simplement il faut savoir ce qu’il faut faire, il arrive que ça ne fonctionne pas. Il ne faut alors pas hésiter à faire du débuggage dans le code source (décompiler) de SpringSecurity. De télécharger les sources d’Angular afin de vérifier ce qui était exactement attendu. Ainsi, on peut éviter de faire une usine à gaz. C’est important de fiare les choses le plus simplement et proprement possible dés le début d’un projet.

Sortez couvert avec Spring Security

Vos applications Springboot et Angular dans un Docker

Laisser un commentaire

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *

Ce site utilise Akismet pour réduire les indésirables. En savoir plus sur la façon dont les données de vos commentaires sont traitées.