Skip to main content
David Le Montagner Home Page David Le Montagner

Démarrer un projet full stack avec Spring Boot, Spring Security & React

Introduction #

Nous sommes en septembre et l’été a peut-être été propice à de nouvelles idées 😉 Vous êtes un développeur backend Java et il vous manque le nécessaire pour démarrer un projet full stack et mettre en œuvre ces idées? C’est l’objectif de cet article, un peu différent de d’habitude, qui va décrire les étapes permettant le démarrage d’un projet Spring couplé à un front en React avec une authentification par token JWT.

Alors vous allez me dire pourquoi React? Principalement en raison de sa grosse communauté grâce à laquelle on trouve énormément de ressources et d’outils pour développer rapidement un front.

Au programme:

Ce que nous allons produire #

Voici, en synthèse, le diagramme de séquences représentant le fonctionnel du projet lorsque nous aurons déroulé l’ensemble de l’article:

Diagramme de séquences du projet

Création du projet Spring et écriture des tests #

Pour initialiser notre back, nous allons créer un nouveau projet via start.spring.io avec les dépendances suivantes:

Démarrage du projet avec start.spring.io

Nous allons maintenant écrire les tests visant à couvrir le fonctionnel que nous allons écrire. Pour ce faire, nous allons créer deux classes de test:

package dlemontagner.react_full_stack_sample;

import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.httpBasic;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

import org.hamcrest.Matchers;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.web.servlet.MockMvc;

@SpringBootTest(webEnvironment = RANDOM_PORT)
@AutoConfigureMockMvc
public class JwtTokenTest {
    @Autowired
    private MockMvc mockMvc;

    @Test
    public void givenNoUserPassword_thenStatus401() throws Exception {
        mockMvc.perform(get("/token"))
                .andExpect(status().isUnauthorized());
    }

    @Test
    public void givenInvalidUserPassword_thenStatus401() throws Exception {
        mockMvc.perform(post("/token")
                .with(httpBasic("dlemontagner", "thisIsNotThePassword")))
                .andExpect(status().isUnauthorized());
    }

    @Test
    public void givenValidUserPassword_thenToken() throws Exception {
        mockMvc.perform(post("/token")
                .with(httpBasic("dlemontagner", "password")))
                .andExpect(status().isOk())
                .andExpect(content().string(Matchers.notNullValue()));
    }

}
package dlemontagner.react_full_stack_sample;

import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;

import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.httpBasic;

import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;

@SpringBootTest(webEnvironment = RANDOM_PORT)
@AutoConfigureMockMvc
public class HelloControllerTest {
    @Autowired
    private MockMvc mockMvc;

    @Test
    public void givenNoToken_thenStatus401() throws Exception {
        mockMvc.perform(get("/"))
                .andExpect(status().isUnauthorized());
    }

    @Test
    public void givenInvalidToken_thenStatus401() throws Exception {
        mockMvc.perform(get("/")
                .header("Authorization", "Bearer thisIsNotAValidToken"))
                .andExpect(status().isUnauthorized());
    }

    @Test
    public void givenValidToken_thenHelloUser() throws Exception {
        MvcResult result = mockMvc.perform(post("/token")
                .with(httpBasic("dlemontagner", "password")))
                .andExpect(status().isOk())
                .andReturn();

        String token = result.getResponse().getContentAsString();

        mockMvc.perform(get("/")
                .header("Authorization", "Bearer " + token))
                .andExpect(content().string("Hello dlemontagner!"));
    }

}

A noter qu’afin de pouvoir utiliser la méthode httpBasic, il faut ajouter la dépendance suivante:

  <dependency>
      <groupId>org.springframework.security</groupId>
      <artifactId>spring-security-test</artifactId>
			<scope>test</scope>
  </dependency>

Si nous lançons les tests en l’état, seul le premier de chaque classe sera vert car Spring Security protège déjà tous les endpoints par défaut. Attelons nous à valider les autres tests!

Configuration de l’authentification httpBasic #

Dans un premier temps, nous allons nous concentrer sur JwtTokenTest en commençant par mettre en place le endpoint /token:

package dlemontagner.react_full_stack_sample;

import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.PostMapping;

@RestController
public class AuthController {

    @PostMapping("/token")
    public String token() {
        return "jwtToken";
    }

}

Puis, nous allons configurer Spring Security pour permettre à ce endpoint d’être accessible uniquement dans le cas où un login/password correct est passé en basic auth. Pour ce faire, il faut créer une classe de configuration avec les éléments suivants:

package dlemontagner.react_full_stack_sample.configuration;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.oauth2.server.resource.web.BearerTokenAuthenticationEntryPoint;
import org.springframework.security.oauth2.server.resource.web.access.BearerTokenAccessDeniedHandler;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;

import static org.springframework.security.config.Customizer.withDefaults;

@Configuration
@EnableWebSecurity
public class SecurityConfiguration {
    @Bean
    public InMemoryUserDetailsManager users() {
        return new InMemoryUserDetailsManager(
                User.withUsername("dlemontagner")
                        // NoOpPasswordEncoder est un encodeur qui ne fait aucun chiffrage
                        // A NE PAS UTILISER EN PRODUCTION!
                        .password("{noop}password")
                        .authorities("read")
                        .build());
    }

    @Bean
    // Cette règle de sécurité est prioritaire sur toutes les autres
    // Nous verrons pourquoi dans la suite de l'article
    @Order(Ordered.HIGHEST_PRECEDENCE)
    SecurityFilterChain tokenSecurityFilterChain(HttpSecurity http) throws Exception {
        return http
                // Toutes les requêtes nécessitent authentification sauf /token
                .securityMatcher(new AntPathRequestMatcher("/token"))
                .authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
                // Desactivation totale des sessions
                .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                // Desactivation du Cross-Site Request Forgery (CSRF) devenu inutile puisqu'il
                // n'y a plus de sessions
                .csrf(AbstractHttpConfigurer::disable)
                .exceptionHandling(ex -> {
                    ex.authenticationEntryPoint(new BearerTokenAuthenticationEntryPoint());
                    ex.accessDeniedHandler(new BearerTokenAccessDeniedHandler());
                })
                .httpBasic(withDefaults())
                .build();
    }
}

Relancez les tests JwtTokenTest, ils sont dorénavant verts validant ainsi que l’authentification par http basic sur le endpoint /token fonctionne.

Génération d’un token JWT #

Préparation des clés RSA et de l’encodeur JWT #

Pour pouvoir valider les tests HelloControllerTest, il va d’abord nous falloir générer un token JWT valide.

Pour ce faire, nous allons commencer par créer une clé publique et privée de façon à pouvoir chiffrer le token avec un chiffrage asymétrique:

# Créer le répertoire cible
mkdir src/main/resources/certs
cd src/main/resources/certs

# Créer la paire de clé RSA
openssl genrsa -out keypair.pem 2048

# Extraire la clé publique
openssl rsa -in keypair.pem -pubout -out public.pem

# Créer la clé privée au format PKCS#8
openssl pkcs8 -topk8 -inform PEM -outform PEM -nocrypt -in keypair.pem -out private.pem

Nous devons ensuite rendre ces clés disponibles dans l’application Spring en indiquant le chemin dans le fichier applications.properties et en rendant les clés disponibles en utilisant @ConfigurationProperties:

# Clés à ajouter dans applications.properties
rsa.private-key=classpath:certs/private.pem
rsa.public-key=classpath:certs/public.pem
package dlemontagner.react_full_stack_sample.configuration;

import org.springframework.boot.context.properties.ConfigurationProperties;

import java.security.interfaces.RSAPrivateKey;
import java.security.interfaces.RSAPublicKey;

@ConfigurationProperties(prefix = "rsa")
public record RsaKeyProperties(RSAPublicKey publicKey, RSAPrivateKey privateKey) {
}

Pour que les valeurs soient bien injectées dans RsaKeyProperties, il ne faut pas oublier d’ajouter l’annotation @EnableConfigurationProperties(RsaKeyProperties.class) dans la classe principale de l’application:

package dlemontagner.react_full_stack_sample;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.EnableConfigurationProperties;

import dlemontagner.react_full_stack_sample.configuration.RsaKeyProperties;

@SpringBootApplication
@EnableConfigurationProperties(RsaKeyProperties.class)
public class ReactFullStackSampleApplication {

	public static void main(String[] args) {
		SpringApplication.run(ReactFullStackSampleApplication.class, args);
	}

}

Nous allons ensuite modifier la classe SecurityConfiguration de façon à:

    private final RsaKeyProperties rsaKeys;

    public SecurityConfiguration(RsaKeyProperties rsaKeys) {
        this.rsaKeys = rsaKeys;
    }

    @Bean
    JwtEncoder jwtEncoder() {
        JWK jwk = new RSAKey.Builder(rsaKeys.publicKey()).privateKey(rsaKeys.privateKey()).build();
        JWKSource<SecurityContext> jwks = new ImmutableJWKSet<>(new JWKSet(jwk));
        return new NimbusJwtEncoder(jwks);
    }

Service de création de token #

Nous avons maintenant tout le nécessaire pour générer un token. Nous allons réaliser cela au sein d’une classe dédiée TokenService. Le contrôleur devra passer l’objet Authentication qui contient les données relatives à l’utilisateur authentifié de façon à utiliser ces données pour construire le token JWT.

package dlemontagner.react_full_stack_sample;

import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.stream.Collectors;

import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.oauth2.jwt.JwtClaimsSet;
import org.springframework.security.oauth2.jwt.JwtEncoder;
import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
import org.springframework.stereotype.Service;

@Service
public class TokenService {
    // Notre token sera valable pour une durée d'une heure
    private final static long TOKEN_DURATION = 1;
    private final static ChronoUnit TOKEN_DURATION_UNIT = ChronoUnit.HOURS;

    private final JwtEncoder encoder;

    public TokenService(JwtEncoder encoder) {
        this.encoder = encoder;
    }

    public String generateToken(Authentication authentication) {
        Instant now = Instant.now();
        String scope = authentication.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority)
                .collect(Collectors.joining(" "));
        JwtClaimsSet claims = JwtClaimsSet.builder()
                .issuer("self")
                .issuedAt(now)
                .expiresAt(now.plus(TOKEN_DURATION, TOKEN_DURATION_UNIT))
                .subject(authentication.getName())
                .claim("scope", scope)
                .build();
        return this.encoder.encode(JwtEncoderParameters.from(claims)).getTokenValue();
    }

}

Il ne reste plus qu’à modifier notre classe AuthController pour récupérer l’objet Authentication et utiliser TokenService afin de retourner le token généré:

package dlemontagner.react_full_stack_sample;

import org.springframework.web.bind.annotation.RestController;

import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.PostMapping;

@RestController
public class AuthController {

    private final TokenService tokenService;

    public AuthController(TokenService tokenService) {
        this.tokenService = tokenService;
    }

    @PostMapping("/token")
    public String token(Authentication authentication) {
        return tokenService.generateToken(authentication);
    }

}

Validation du token avec Spring Security #

Avant de procéder à la validation du token, nous allons créer un simple endpoint qui saluera l’utilisateur connecté. L’injection de l’objet java.security.Principal nous permettra de récupérer les informations relatives à l’utilisateur connecté.

package dlemontagner.react_full_stack_sample;

import org.springframework.web.bind.annotation.RestController;

import java.security.Principal;

import org.springframework.web.bind.annotation.GetMapping;

@RestController
public class HelloController {

    @GetMapping("/")
    public String hello(Principal principal) {
        return "Hello %s!".formatted(principal.getName());
    }

}

Si vous relancez les tests HelloControllerTest, ils retourneront tous Cannot invoke "java.security.Principal.getName()" because "principal" is null car le token n’est pas encore décrypté.

Pour réaliser le décryptage, nous allons commencer par ajouter les éléments suivants à notre SecurityConfiguration:

@Bean
JwtDecoder jwtDecoder() {
    return NimbusJwtDecoder.withPublicKey(rsaKeys.publicKey()).build();
}

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    return http
            // Toutes les requêtes nécessitent une authentification
            .authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
            // Desactivation totale des sessions
            .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            // Desactivation du Cross-Site Request Forgery (CSRF) devenu inutile puisqu'il
            // n'y a plus de sessions
            .csrf(AbstractHttpConfigurer::disable)
            // Configuration du serveur de ressources OAuth2 local pour valider les tokens
            // JWT
            .oauth2ResourceServer(oauth2 -> oauth2.jwt(withDefaults()))
            .exceptionHandling(
                    (ex) -> ex.authenticationEntryPoint(new BearerTokenAuthenticationEntryPoint())
                            .accessDeniedHandler(new BearerTokenAccessDeniedHandler()))
            .build();
}

Si vous relancez les tests unitaires, ils doivent dorénavant être verts. Toute la chaine d’authentification est désormais en place.

Initialisation du Front #

Maintenant que nos services Back sont prêts, il est temps de créer l’interface utilisateur. Nous allons nous appuyer sur Vite et React:

cd src/main
# Creation du projet Vite avec le template React
npm create vite@latest client -- --template react-ts
cd client
# Installation de la librairie de Routing permettant de gérer les URLs et les liens
npm install react-router-dom
# Lancement du serveur de développement
npm run dev

Une fois le serveur lancé, vous pouvez accéder à l’url http://localhost:5173/ qui affichera un écran similaire à celui-ci:

Ecran par défaut de Vite/React

Installation de tailwind #

Tailwind est un framework CSS qui va nous faciliter la vie dans la création de pages web en offrant des classes CSS prêtes à l’emploi. L’installation de ce framework se fait de la manière suivante:

# A exécuter depuis src/main/client
npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p

Editez ensuite le fichier tailwind.config.js pour qu’il contienne ceci:

/** @type {import('tailwindcss').Config} */
export default {
	content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
	theme: {
		extend: {},
	},
	plugins: [],
};

Remplacez le contenu de src/main/client/src/index.css par ceci:

@tailwind base;
@tailwind components;
@tailwind utilities;

Enfin, quittez et relancez le serveur de dev front:

npm run dev

Pour vérifier que Tailwind fonctionne, remplacez le contenu de src/main/client/src/App.tsx par ceci (et profitez en pour supprimer App.css et le dossier assets qui ne nous seront pas utiles)

function App() {
	return (
		<div className="flex h-screen">
			<div className="m-auto rounded-lg w-[450px] h-16 bg-[#0037b6] text-[#ffffff]">
				<div className="flex flex-row gap-5 justify-center items-center px-5 w-full h-full">
					<div className="font-bold text-2xl">Hello World!</div>
				</div>
			</div>
		</div>
	);
}

export default App;

L’url http://localhost:5173/ doit désormais afficher un écran de ce type:

Affichage d'un HelloWorld!

Préparation des routes #

Pour notre application de démo, je vous propose deux pages:

Créons la page Login.tsx:

function Login() {
	return <>This is the login page</>;
}

export default Login;

Puis définissons les routes dans main.tsx en remplaçant son contenu par ceci:

import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App.tsx";
import "./index.css";
import { createBrowserRouter, RouterProvider } from "react-router-dom";
import Login from "./Login.tsx";

const router = createBrowserRouter([
  {
    path: "/",
    element: <App />,
  },
  {
    path: "/login",
    element: <Login />,
  },
]);

createRoot(document.getElementById("root")!).render(
  <StrictMode>
    <RouterProvider router={router} />
  </StrictMode>
);

Vous pouvez désormais accéder à http://localhost:5173/login qui affichera le texte suivant: “This is the login page”.

Création de la page de login #

Nous allons désormais enrichir notre page Login.tsx de façon à afficher un formulaire de connexion

function Login() {
	// Nous définissons ici la fonction qui sera appelé lors du clic sur le bouton login
	const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
		// On empeche le navigateur de soumettre le formulaire de façon traditionnelle
		event.preventDefault();

		// On récupère les informations du formulaire dans un objet FormData
		const formData = new FormData(event.currentTarget);
		const username = formData.get("username");
		const password = formData.get("password");

		// On affiche une alerte avec le login et le password saisi par l'utilisateur
		// Par la suite, nous remplacerons ce code par la communication avec le back Spring
		alert(`Let's login with ${username} / ${password}`);
	};

	return (
		<div className="flex h-screen">
			<div className="m-auto flex flex-col w-full md:w-1/2 xl:w-2/5 2xl:w-2/5 3xl:w-1/3 mx-auto p-8 md:p-10 2xl:p-12 3xl:p-14 bg-[#ffffff] rounded-2xl shadow-xl">
				<div className="flex flex-row gap-3 pb-4">
					<h1 className="text-3xl font-bold text-[#4B5563] text-[#4B5563] my-auto">
						Who are you?
					</h1>
				</div>
				<form className="flex flex-col" onSubmit={handleSubmit}>
					<div className="pb-2">
						<label
							htmlFor="username"
							className="block mb-2 text-sm font-medium text-[#111827]"
						>
							Username
						</label>
						<div className="relative text-gray-400">
							<span className="absolute inset-y-0 left-0 flex items-center p-1 pl-3">
								<svg
									xmlns="http://www.w3.org/2000/svg"
									width="24"
									height="24"
									viewBox="0 0 24 24"
									fill="none"
									stroke="currentColor"
									stroke-width="2"
									stroke-linecap="round"
									stroke-linejoin="round"
									className="lucide lucide-user"
								>
									<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2" />
									<circle cx="12" cy="7" r="4" />
								</svg>
							</span>
							<input
								type="text"
								name="username"
								id="username"
								className="pl-12 mb-2 bg-gray-50 text-gray-600 border focus:border-transparent border-gray-300 sm:text-sm rounded-lg ring ring-transparent focus:ring-1 focus:outline-none focus:ring-gray-400 block w-full p-2.5 rounded-l-lg py-3 px-4"
								placeholder="username"
								autoComplete="off"
							/>
						</div>
					</div>
					<div className="pb-6">
						<label
							htmlFor="password"
							className="block mb-2 text-sm font-medium text-[#111827]"
						>
							Password
						</label>
						<div className="relative text-gray-400">
							<span className="absolute inset-y-0 left-0 flex items-center p-1 pl-3">
								<svg
									xmlns="http://www.w3.org/2000/svg"
									width="24"
									height="24"
									viewBox="0 0 24 24"
									fill="none"
									stroke="currentColor"
									stroke-width="2"
									stroke-linecap="round"
									stroke-linejoin="round"
									className="lucide lucide-square-asterisk"
								>
									<rect width="18" height="18" x="3" y="3" rx="2"></rect>
									<path d="M12 8v8"></path>
									<path d="m8.5 14 7-4"></path>
									<path d="m8.5 10 7 4"></path>
								</svg>
							</span>
							<input
								type="password"
								name="password"
								id="password"
								placeholder="••••••••••"
								className="pl-12 mb-2 bg-gray-50 text-gray-600 border focus:border-transparent border-gray-300 sm:text-sm rounded-lg ring ring-transparent focus:ring-1 focus:outline-none focus:ring-gray-400 block w-full p-2.5 rounded-l-lg py-3 px-4"
								autoComplete="new-password"
							/>
						</div>
					</div>
					<button
						type="submit"
						className="w-full text-[#FFFFFF] bg-[#4F46E5] focus:ring-4 focus:outline-none focus:ring-primary-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center mb-6"
					>
						Login
					</button>
				</form>
			</div>
		</div>
	);
}

export default Login;

Le résultat doit s’apparenter à ceci:

Page de connexion

En cliquant sur le bouton Login, l’alerte “Let’s login with / ” doit s’afficher.

Mise en place de l’authentification #

Pour mettre en musique le front et le back, nous allons modifier la page de login pour y intégrer ce code:

// Pour la navigation entre les pages
const navigate = useNavigate();

const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
	// Eviter que le navigateur exécute le formulaire de façon standard
	event.preventDefault();

	// Récupération des données saisies par l'utilisateur
	const formData = new FormData(event.currentTarget);
	const username = formData.get("username");
	const password = formData.get("password");

	try {
		// Encodage du username/password en base 64 pour se conformer au standard
		const encodedCredentials = btoa(`${username}:${password}`);
		// Appel du endpoint token avec l'en tête Basic
		const response = await fetch(`http://localhost:8080/token`, {
			method: "POST",
			headers: {
				Authorization: `Basic ${encodedCredentials}`,
			},
		});

		if (response.ok) {
			// Si retour code 200 alors OK et navigation vers "/"
			const token = await response.text();
			console.log(`Logged in successfully! Token: ${token}`);

			navigate("/");
		} else {
			// Sinon affichage d'une alerte
			console.error("Failed to log in:", response.statusText);
			alert("Failed to login");
		}
	} catch (error) {
		console.error("Error logging in:", error);
		alert(`Error while trying to login ${error}`);
	}
};

Mais lorsque vous allez cliquer sur le bouton login, une erreur va survenir et le navigateur vous indiquera ceci dans la console Javascript: “Blocage d’une requête multiorigines (Cross-Origin Request)”

En effet, il va nous falloir configurer le CORS (Cross-Origin Resource Sharing) de notre serveur Spring pour autoriser les requêtes venant du domaine de notre front (en l’occurence http://localhost:5173). Pour ceci, retour sur le code source Spring pour modifier notre classe SecurityConfiguration:

	@Bean
	CorsConfigurationSource corsConfigurationSource() {
		CorsConfiguration configuration = new CorsConfiguration();
		configuration.setAllowedOriginPatterns(List.of("http://localhost:5173"));
		configuration.setAllowedHeaders(List.of(CorsConfiguration.ALL));
		configuration.setAllowedMethods(List.of(CorsConfiguration.ALL));
		UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
		source.registerCorsConfiguration("/**", configuration);
		return source;
	}

Et en ajoutant cette ligne à nos deux beans SecurityFilterChain:

.cors(withDefaults())

Une fois le serveur Spring rechargé avec cette nouvelle version, vous pouvez cliquer sur le bouton Login après avoir saisi le username/password dlemontagner/password et constater une redirection sur la page racine “/”. Vous devriez également voir une trace de ce style dans la console javascript du navigateur: “Logged in successfully! Token: eyJhbGciOiJ…..”. Nous avons réussi à nous authentifier et à récupérer le token JWT.

Stockage front du token et accès à une page sécurisée #

Maintenant que nous réussissons à nous connecter et récupérer un token, il faut stocker ce token et le réutiliser.

Pour stocker le token, nous allons utiliser le localstorage du navigateur. Dans Login.tsx, remplacez le log du token par son stockage:

-console.log(`Logged in successfully! Token: ${token}`);
+localStorage.setItem("token", token);

Enfin, pour réutiliser le token et afficher le bon message à l’utilisateur sur la page d’accueil, nous allons modifier la page App.tsx dont voici le nouveau contenu:

import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";

function App() {
	const defaultHelloToDisplay = "Hello stranger!";

	// État de l'authentification de l'utilisateur (valeur initiale: false)
	// Lorsque l'utilisateur est authentifié, cette valeur est mise à jour à true par la fonction setAuthenticated()
	const [authenticated, setAuthenticated] = useState(false);

	// État du texte qui sera affiché (valeur initiale: Loading...)
	// Lorsqu'une réponse est reçue du serveur, cette valeur est mise à jour par la fonction setHelloResponse()
	const [helloResponse, setHelloResponse] = useState("Loading...");
	const navigate = useNavigate();

	// Effect qui s'exécute après chaque modification des états authenticated / helloResponse
	useEffect(() => {
		let authenticated = false;
		let helloToDisplay = defaultHelloToDisplay;

		// Récupération du token dans le local storage du navigateur
		const token = localStorage.getItem("token");

		const fetchHelloData = async () => {
			if (token) {
				const helloFetchResponse = await fetch(`http://localhost:8080/`, {
					method: "GET",
					headers: {
						Authorization: `Bearer ${token}`,
					},
				});

				authenticated = helloFetchResponse.ok;
				helloToDisplay = authenticated
					? await helloFetchResponse.text()
					: defaultHelloToDisplay;
			}

			// On met à jour les états
			setAuthenticated(authenticated);
			setHelloResponse(helloToDisplay);
		};

		fetchHelloData().catch((error) => {
			console.error(error);
			setAuthenticated(false);
			setHelloResponse(defaultHelloToDisplay);
		});
	}, [authenticated, helloResponse]);

	function login() {
		navigate("/login");
	}

	function logout() {
		localStorage.removeItem("token");
		setAuthenticated(false);
	}

	return (
		<div className="flex h-screen">
			<div className="m-auto flex flex-col rounded-2xl w-96 bg-[#ffffff] shadow-xl">
				<div className="flex flex-col p-8">
					<div className="text-2xl font-bold   text-[#374151] pb-6">
						{helloResponse}
					</div>
					<div className=" text-lg   text-[#374151]">
						Click the button below to {authenticated ? "log out" : "log in"}!
					</div>
					<div className="flex justify-end pt-6">
						<button
							onClick={authenticated ? logout : login}
							className="bg-blue-700 text-[#ffffff]  font-bold text-base  p-3 rounded-lg hover:bg-blue-800 active:scale-95 transition-transform transform"
						>
							{authenticated ? "Logout" : "Login"}
						</button>
					</div>
				</div>
			</div>
		</div>
	);
}

export default App;

Dorénavant, lors du chargement initial du front, vous aurez l’écran suivant:

Ecran lorsque l'utilisateur n'est pas authentifié

Et si, au préalable, vous avez saisi dlemontagner/password dans le formulaire de connexion, vous aurez l’écran suivant:

Ecran lorsque l'utilisateur est authentifié

Et voilà, toute la cinématique est en place!

Code source #

Retrouvez l'intégralité du code source dans ce repository.

Conclusion #

Cet article n’est qu’un point de départ pour vous permettre de vous lancer dans le développement d’une application Full Stack s’appuyant sur Spring et React.

Pour aller plus loin, vous pouvez faire évoluer le code Spring avec les pistes suivantes:

Coté React, de nombreuses évolutions sont aussi envisageables: utilisation d’une variable d’environnement pour l’url du serveur, mise en place d’un react hook pour le code relatif à la validation du token car il sera souvent utile, gestion fine des cas d’erreurs avec les alertes utilisateur associées etc…

Voilà, j’espère que, grâce à cet article, vous avez désormais tous les éléments nécessaires pour utiliser Spring et React afin de donner vie à vos idées de projets apparues cet été 😎