Back-end angularforall.com

- JWT Spring Boot & Angular : auth complète

Springboot Jwt Angular Spring-Security Authentication Java Token Security Httpclient
JWT Spring Boot & Angular : auth complète

Sécurisez votre API Spring Boot avec JWT : Spring Security, JwtUtil, JwtAuthFilter, refresh tokens, HttpInterceptor Angular et AuthGuard.

1. Comprendre JWT : comment ça marche ?

JWT (JSON Web Token) est un standard open source (RFC 7519) qui permet d'échanger des informations de manière sécurisée entre deux parties sous forme de token signé. C'est la méthode d'authentification la plus utilisée pour les APIs REST.

🧠 Pour les débutants : la métaphore du badge

JWT fonctionne comme un badge d'accès. Quand vous vous connectez (login), Spring Boot vous donne un badge signé (JWT). À chaque requête suivante, Angular présente ce badge. Spring Boot vérifie sa signature — si valide, il traite la requête. Pas besoin de stocker de session côté serveur.

Anatomie d'un token JWT

Un JWT est composé de 3 parties séparées par des points :


eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9    ← Header (algorithme + type)
.eyJzdWIiOiJ1c2VyQGV4YW1wbGUuY29tIiwidXNlcklkIjoxLCJyb2xlIjoiVVNFUiIsImlhdCI6MTYyMDAwMDAwMCwiZXhwIjoxNjIwMDA2MDAwfQ==  ← Payload (données)
.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c    ← Signature (vérification)

Flux d'authentification JWT complet


1. Angular        →  POST /api/auth/login {email, password}     → Spring Boot
2. Spring Boot    →  vérifie password (BCrypt)                  →
3. Spring Boot    →  génère access_token (15 min) + refresh_token (7j) →
4. Spring Boot    →  retourne {access_token, refresh_token}     → Angular
5. Angular        →  stocke tokens (localStorage)               →
6. Angular        →  GET /api/todos + Authorization: Bearer {token}  → Spring Boot
7. Spring Boot    →  JwtAuthFilter vérifie le token             →
8. Spring Boot    →  si valide → traite la requête              → Angular
9. Angular        →  si 401 → utilise refresh_token pour renouveler →
Token Durée recommandée Stockage Utilisation
Access Token 15-60 minutes Mémoire / localStorage Header Authorization: Bearer
Refresh Token 7-30 jours HTTP-only Cookie (sécurisé) Renouveler l'access token

2. Prérequis et dépendances

🟦 Dépendances Maven (pom.xml)


<dependencies>
    <!-- Spring Boot Security (authentification, autorisation) -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-security</artifactId>
    </dependency>

    <!-- JJWT : bibliothèque Java pour créer et vérifier les JWT -->
    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt-api</artifactId>
        <version>0.12.3</version>
    </dependency>
    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt-impl</artifactId>
        <version>0.12.3</version>
        <scope>runtime</scope>
    </dependency>
    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt-jackson</artifactId>
        <version>0.12.3</version>
        <scope>runtime</scope>
    </dependency>

    <!-- Spring Web, JPA, MySQL, Lombok (déjà présents si vous suivez l'article CRUD) -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-jpa</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-validation</artifactId>
    </dependency>
    <dependency>
        <groupId>com.mysql</groupId>
        <artifactId>mysql-connector-j</artifactId>
        <scope>runtime</scope>
    </dependency>
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <optional>true</optional>
    </dependency>
</dependencies>

Configuration application.properties


# Base de données
spring.datasource.url=jdbc:mysql://localhost:3306/tododb?createDatabaseIfNotExist=true
spring.datasource.username=root
spring.datasource.password=votre_mot_de_passe
spring.jpa.hibernate.ddl-auto=update

# JWT — Clé secrète (utilisez une vraie clé aléatoire en production !)
# Minimum 256 bits (32 caractères) pour HMAC-SHA256
app.jwt.secret=votre_cle_secrete_super_longue_et_aleatoire_minimum_32_chars
# Durée de vie de l'access token en millisecondes (15 minutes)
app.jwt.expiration=900000
# Durée de vie du refresh token en millisecondes (7 jours)
app.jwt.refresh-expiration=604800000

3. 🟦 Back-end : configurer Spring Security

Spring Security est le framework de sécurité intégré à Spring Boot. Par défaut, il bloque toutes les requêtes non authentifiées. On va le configurer pour autoriser le login/register et protéger le reste.


package com.example.todo.config;

import com.example.todo.security.JwtAuthFilter;
import com.example.todo.service.UserDetailsServiceImpl;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
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.authentication.UsernamePasswordAuthenticationFilter;

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final JwtAuthFilter jwtAuthFilter;
    private final UserDetailsServiceImpl userDetailsService;

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            // Désactiver CSRF (inutile avec JWT stateless)
            .csrf(csrf -> csrf.disable())

            // Configurer les autorisations par route
            .authorizeHttpRequests(auth -> auth
                // Routes publiques : login et register sont accessibles sans token
                .requestMatchers("/api/auth/**").permitAll()
                // Swagger/OpenAPI (si utilisé en dev)
                .requestMatchers("/v3/api-docs/**", "/swagger-ui/**").permitAll()
                // Toutes les autres routes nécessitent une authentification
                .anyRequest().authenticated()
            )

            // Mode stateless : Spring Security ne gère PAS de session HTTP
            // Chaque requête doit contenir un JWT valide
            .sessionManagement(session ->
                session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))

            // Ajouter notre filtre JWT AVANT le filtre d'authentification standard
            .authenticationProvider(authenticationProvider())
            .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }

    // Bean qui encode les mots de passe avec BCrypt (hachage sécurisé)
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    // AuthenticationManager : gère la vérification des credentials (email/password)
    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
        return config.getAuthenticationManager();
    }

    // Configurer comment Spring vérifie les utilisateurs en base de données
    @Bean
    public AuthenticationProvider authenticationProvider() {
        DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
        provider.setUserDetailsService(userDetailsService);
        provider.setPasswordEncoder(passwordEncoder());
        return provider;
    }
}

4. 🟦 Back-end : entité User et repository

Entité User.java


package com.example.todo.model;

import jakarta.persistence.*;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import java.time.LocalDateTime;
import java.util.Collection;
import java.util.List;

@Entity
@Table(name = "users",
    // Contrainte unicité sur l'email
    uniqueConstraints = @UniqueConstraint(columnNames = "email"))
@Data
// UserDetails : interface Spring Security — obligatoire pour l'intégration JWT
public class User implements UserDetails {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @NotBlank
    @Column(nullable = false, length = 100)
    private String firstName;

    @NotBlank
    @Column(nullable = false, length = 100)
    private String lastName;

    @Email
    @NotBlank
    @Column(nullable = false, unique = true)
    private String email;

    // Mot de passe hashé BCrypt (jamais stocké en clair !)
    @NotBlank
    @Column(nullable = false)
    private String password;

    // Rôle de l'utilisateur (USER ou ADMIN)
    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    private Role role = Role.USER;

    private LocalDateTime createdAt;

    @PrePersist
    protected void onCreate() {
        createdAt = LocalDateTime.now();
    }

    // ===== Méthodes de UserDetails (contrat Spring Security) =====

    // Retourne les rôles/permissions de l'utilisateur
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return List.of(new SimpleGrantedAuthority("ROLE_" + role.name()));
    }

    // Spring Security utilise getUsername() comme identifiant unique → on retourne l'email
    @Override
    public String getUsername() {
        return email;
    }

    @Override public boolean isAccountNonExpired()     { return true; }
    @Override public boolean isAccountNonLocked()      { return true; }
    @Override public boolean isCredentialsNonExpired() { return true; }
    @Override public boolean isEnabled()               { return true; }
}

// Enum Role séparé dans Role.java
// public enum Role { USER, ADMIN }

UserRepository.java


package com.example.todo.repository;

import com.example.todo.model.User;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;

public interface UserRepository extends JpaRepository<User, Long> {

    // Trouver un utilisateur par email (pour la vérification lors du login)
    Optional<User> findByEmail(String email);

    // Vérifier si un email est déjà utilisé (pour l'inscription)
    boolean existsByEmail(String email);
}

UserDetailsServiceImpl.java


package com.example.todo.service;

import com.example.todo.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

// Spring Security appelle cette classe lors de chaque authentification
@Service
@RequiredArgsConstructor
public class UserDetailsServiceImpl implements UserDetailsService {

    private final UserRepository userRepository;

    // Spring Security appelle loadUserByUsername avec l'email fourni par le client
    @Override
    public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
        return userRepository.findByEmail(email)
            .orElseThrow(() -> new UsernameNotFoundException(
                "Aucun utilisateur trouvé avec l'email : " + email));
    }
}

5. 🟦 Back-end : utilitaire JWT

La classe JwtUtil centralise toute la logique de création et validation des tokens JWT.


package com.example.todo.security;

import io.jsonwebtoken.*;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;
import javax.crypto.SecretKey;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.function.Function;

@Component
@Slf4j
public class JwtUtil {

    // Lire la clé secrète et les durées depuis application.properties
    @Value("${app.jwt.secret}")
    private String secretKey;

    @Value("${app.jwt.expiration}")
    private long jwtExpiration; // 15 minutes en ms

    @Value("${app.jwt.refresh-expiration}")
    private long refreshExpiration; // 7 jours en ms

    // ===== GÉNÉRATION DES TOKENS =====

    // Générer un access token pour un utilisateur
    public String generateToken(UserDetails userDetails) {
        return generateToken(new HashMap<>(), userDetails);
    }

    // Générer un access token avec des claims personnalisés
    public String generateToken(Map<String, Object> extraClaims, UserDetails userDetails) {
        return buildToken(extraClaims, userDetails, jwtExpiration);
    }

    // Générer un refresh token (durée plus longue)
    public String generateRefreshToken(UserDetails userDetails) {
        return buildToken(new HashMap<>(), userDetails, refreshExpiration);
    }

    private String buildToken(Map<String, Object> extraClaims, UserDetails userDetails, long expiration) {
        return Jwts.builder()
            .claims(extraClaims)
            // Subject = identifiant unique = email de l'utilisateur
            .subject(userDetails.getUsername())
            // Quand le token a été émis
            .issuedAt(new Date(System.currentTimeMillis()))
            // Quand le token expire
            .expiration(new Date(System.currentTimeMillis() + expiration))
            // Signer avec la clé secrète HMAC-SHA256
            .signWith(getSigningKey())
            .compact();
    }

    // ===== VALIDATION DU TOKEN =====

    // Vérifier que le token appartient à cet utilisateur et n'est pas expiré
    public boolean isTokenValid(String token, UserDetails userDetails) {
        final String username = extractUsername(token);
        return username.equals(userDetails.getUsername()) && !isTokenExpired(token);
    }

    // ===== EXTRACTION DES INFORMATIONS =====

    // Extraire l'email (subject) du token
    public String extractUsername(String token) {
        return extractClaim(token, Claims::getSubject);
    }

    // Extraire la date d'expiration
    public Date extractExpiration(String token) {
        return extractClaim(token, Claims::getExpiration);
    }

    // Méthode générique pour extraire n'importe quel claim
    public <T> T extractClaim(String token, Function<Claims, T> claimsResolver) {
        final Claims claims = extractAllClaims(token);
        return claimsResolver.apply(claims);
    }

    private boolean isTokenExpired(String token) {
        return extractExpiration(token).before(new Date());
    }

    private Claims extractAllClaims(String token) {
        return Jwts.parser()
            .verifyWith(getSigningKey())
            .build()
            .parseSignedClaims(token)
            .getPayload();
    }

    // Construire la clé de signature à partir de la clé secrète Base64
    private SecretKey getSigningKey() {
        byte[] keyBytes = Decoders.BASE64.decode(secretKey);
        return Keys.hmacShaKeyFor(keyBytes);
    }
}

6. 🟦 Back-end : filtre d'authentification JWT

Le filtre JWT est exécuté à chaque requête HTTP. Il lit le token dans le header Authorization, le valide, et s'il est valide, il authentifie l'utilisateur pour cette requête.


package com.example.todo.security;

import com.example.todo.service.UserDetailsServiceImpl;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;

// OncePerRequestFilter garantit que ce filtre est exécuté une seule fois par requête
@Component
@RequiredArgsConstructor
@Slf4j
public class JwtAuthFilter extends OncePerRequestFilter {

    private final JwtUtil jwtUtil;
    private final UserDetailsServiceImpl userDetailsService;

    @Override
    protected void doFilterInternal(
            HttpServletRequest request,
            HttpServletResponse response,
            FilterChain filterChain) throws ServletException, IOException {

        // 1. Lire le header Authorization de la requête HTTP
        final String authHeader = request.getHeader("Authorization");

        // 2. Si pas de header ou header ne commence pas par "Bearer ", passer au filtre suivant
        if (authHeader == null || !authHeader.startsWith("Bearer ")) {
            filterChain.doFilter(request, response);
            return;
        }

        // 3. Extraire le token (supprimer le préfixe "Bearer ")
        final String jwt = authHeader.substring(7);

        try {
            // 4. Extraire l'email du token
            final String userEmail = jwtUtil.extractUsername(jwt);

            // 5. Si l'email est extrait et qu'il n'y a pas d'authentification en cours
            if (userEmail != null && SecurityContextHolder.getContext().getAuthentication() == null) {

                // 6. Charger l'utilisateur depuis la base de données
                UserDetails userDetails = userDetailsService.loadUserByUsername(userEmail);

                // 7. Vérifier que le token est valide (email correspond + pas expiré)
                if (jwtUtil.isTokenValid(jwt, userDetails)) {

                    // 8. Créer un objet d'authentification Spring Security
                    UsernamePasswordAuthenticationToken authToken =
                        new UsernamePasswordAuthenticationToken(
                            userDetails,
                            null, // Pas de credentials (on a déjà le JWT)
                            userDetails.getAuthorities() // Rôles de l'utilisateur
                        );
                    authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));

                    // 9. Mettre à jour le SecurityContext : Spring sait que l'utilisateur est authentifié
                    SecurityContextHolder.getContext().setAuthentication(authToken);
                    log.debug("Utilisateur authentifié via JWT : {}", userEmail);
                }
            }
        } catch (Exception e) {
            // Token invalide ou expiré — on laisse le filtre de sécurité gérer le 401
            log.warn("Token JWT invalide : {}", e.getMessage());
        }

        // 10. Passer au filtre suivant (ou au contrôleur si c'est le dernier filtre)
        filterChain.doFilter(request, response);
    }
}

7. 🟦 Back-end : contrôleur Auth (login/register)

DTOs d'authentification


// RegisterRequest.java
@Data
public class RegisterRequest {
    @NotBlank private String firstName;
    @NotBlank private String lastName;
    @Email @NotBlank private String email;
    @NotBlank @Size(min = 8, message = "Le mot de passe doit contenir au moins 8 caractères")
    private String password;
}

// LoginRequest.java
@Data
public class LoginRequest {
    @Email @NotBlank private String email;
    @NotBlank private String password;
}

// AuthResponse.java — réponse retournée à Angular
@Data
@Builder
public class AuthResponse {
    private String accessToken;    // Token à courte durée
    private String refreshToken;   // Token à longue durée
    private String tokenType = "Bearer";
    private long expiresIn;        // Durée de vie en secondes
    private UserDto user;          // Infos de l'utilisateur (sans le mot de passe !)
}

// UserDto.java — données utilisateur safe à exposer
@Data
public class UserDto {
    private Long id;
    private String firstName;
    private String lastName;
    private String email;
    private String role;
}

AuthController.java


package com.example.todo.controller;

import com.example.todo.dto.*;
import com.example.todo.model.Role;
import com.example.todo.model.User;
import com.example.todo.repository.UserRepository;
import com.example.todo.security.JwtUtil;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/auth")
// Route publique : pas de JWT requis (configuré dans SecurityConfig)
@CrossOrigin(origins = "http://localhost:4200")
@RequiredArgsConstructor
@Slf4j
public class AuthController {

    private final AuthenticationManager authManager;
    private final UserRepository userRepository;
    private final PasswordEncoder passwordEncoder;
    private final JwtUtil jwtUtil;

    // POST /api/auth/register — créer un compte
    @PostMapping("/register")
    public ResponseEntity<?> register(@Valid @RequestBody RegisterRequest request) {

        // Vérifier que l'email n'est pas déjà utilisé
        if (userRepository.existsByEmail(request.getEmail())) {
            return ResponseEntity.badRequest()
                .body(Map.of("message", "Cet email est déjà utilisé"));
        }

        // Créer l'utilisateur avec le mot de passe hashé
        User user = new User();
        user.setFirstName(request.getFirstName());
        user.setLastName(request.getLastName());
        user.setEmail(request.getEmail());
        // IMPORTANT : ne jamais stocker le mot de passe en clair !
        user.setPassword(passwordEncoder.encode(request.getPassword()));
        user.setRole(Role.USER);

        userRepository.save(user);
        log.info("Nouvel utilisateur enregistré : {}", request.getEmail());

        // Générer les tokens immédiatement après l'inscription
        return ResponseEntity.status(HttpStatus.CREATED).body(buildAuthResponse(user));
    }

    // POST /api/auth/login — connexion
    @PostMapping("/login")
    public ResponseEntity<?> login(@Valid @RequestBody LoginRequest request) {
        try {
            // Spring Security vérifie email + mot de passe via UserDetailsService
            authManager.authenticate(
                new UsernamePasswordAuthenticationToken(request.getEmail(), request.getPassword())
            );
        } catch (BadCredentialsException e) {
            log.warn("Tentative de connexion échouée pour : {}", request.getEmail());
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
                .body(Map.of("message", "Email ou mot de passe incorrect"));
        }

        User user = userRepository.findByEmail(request.getEmail())
            .orElseThrow(() -> new RuntimeException("Utilisateur introuvable après authentification"));

        log.info("Connexion réussie pour : {}", request.getEmail());
        return ResponseEntity.ok(buildAuthResponse(user));
    }

    // Construire la réponse avec les tokens et les infos utilisateur
    private AuthResponse buildAuthResponse(User user) {
        String accessToken  = jwtUtil.generateToken(user);
        String refreshToken = jwtUtil.generateRefreshToken(user);

        UserDto userDto = new UserDto();
        userDto.setId(user.getId());
        userDto.setFirstName(user.getFirstName());
        userDto.setLastName(user.getLastName());
        userDto.setEmail(user.getEmail());
        userDto.setRole(user.getRole().name());

        return AuthResponse.builder()
            .accessToken(accessToken)
            .refreshToken(refreshToken)
            .expiresIn(900) // 15 minutes en secondes
            .user(userDto)
            .build();
    }
}

8. 🟦 Back-end : renouveler le token (refresh)


// Ajouter dans AuthController.java

// POST /api/auth/refresh — renouveler l'access token avec le refresh token
@PostMapping("/refresh")
public ResponseEntity<?> refreshToken(@RequestBody Map<String, String> request) {
    String refreshToken = request.get("refreshToken");

    if (refreshToken == null || refreshToken.isBlank()) {
        return ResponseEntity.badRequest().body(Map.of("message", "Refresh token manquant"));
    }

    try {
        // Extraire l'email depuis le refresh token
        String email = jwtUtil.extractUsername(refreshToken);

        UserDetails userDetails = userDetailsService.loadUserByUsername(email);

        // Valider le refresh token
        if (!jwtUtil.isTokenValid(refreshToken, userDetails)) {
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
                .body(Map.of("message", "Refresh token invalide ou expiré"));
        }

        // Générer un nouvel access token
        String newAccessToken = jwtUtil.generateToken(userDetails);

        return ResponseEntity.ok(Map.of(
            "accessToken", newAccessToken,
            "expiresIn", 900
        ));

    } catch (Exception e) {
        return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
            .body(Map.of("message", "Refresh token invalide"));
    }
}

// POST /api/auth/logout — déconnexion (invalider côté client uniquement avec JWT stateless)
@PostMapping("/logout")
public ResponseEntity<?> logout() {
    // Avec JWT stateless, il n'y a pas de session à invalider côté serveur
    // Le client doit supprimer ses tokens (Angular s'en charge)
    // Pour une sécurité renforcée : stocker les tokens révoqués dans Redis
    return ResponseEntity.ok(Map.of("message", "Déconnexion réussie"));
}

9. 🟧 Front-end : service d'authentification


import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { BehaviorSubject, Observable, tap, catchError, throwError } from 'rxjs';
import { Router } from '@angular/router';
import { environment } from '../../environments/environment';

// Interfaces pour typer les données reçues de Spring Boot
export interface User {
  id: number;
  firstName: string;
  lastName: string;
  email: string;
  role: string;
}

export interface AuthResponse {
  accessToken: string;
  refreshToken: string;
  tokenType: string;
  expiresIn: number;
  user: User;
}

export interface LoginRequest {
  email: string;
  password: string;
}

export interface RegisterRequest {
  firstName: string;
  lastName: string;
  email: string;
  password: string;
}

@Injectable({
  providedIn: 'root'
})
export class AuthService {

  private readonly apiUrl = `${environment.apiUrl}/auth`;

  // BehaviorSubject : stocke l'utilisateur actuel et notifie les composants abonnés
  // null = pas connecté
  private currentUserSubject = new BehaviorSubject<User | null>(this.getStoredUser());

  // Observable public pour que les composants puissent s'abonner
  public currentUser$ = this.currentUserSubject.asObservable();

  constructor(private http: HttpClient, private router: Router) { }

  // Inscription
  register(data: RegisterRequest): Observable<AuthResponse> {
    return this.http.post<AuthResponse>(`${this.apiUrl}/register`, data)
      .pipe(
        tap(response => this.storeAuthData(response)),
        catchError(this.handleError)
      );
  }

  // Connexion
  login(credentials: LoginRequest): Observable<AuthResponse> {
    return this.http.post<AuthResponse>(`${this.apiUrl}/login`, credentials)
      .pipe(
        tap(response => this.storeAuthData(response)),
        catchError(this.handleError)
      );
  }

  // Renouveler l'access token via le refresh token
  refreshToken(): Observable<{ accessToken: string; expiresIn: number }> {
    const refreshToken = this.getRefreshToken();

    if (!refreshToken) {
      this.logout();
      return throwError(() => new Error('Pas de refresh token'));
    }

    return this.http.post<any>(`${this.apiUrl}/refresh`, { refreshToken })
      .pipe(
        tap(response => {
          // Stocker uniquement le nouvel access token
          localStorage.setItem('access_token', response.accessToken);
        }),
        catchError((error) => {
          // Refresh token expiré → déconnexion forcée
          this.logout();
          return throwError(() => error);
        })
      );
  }

  // Déconnexion
  logout(): void {
    // Informer Spring Boot (optionnel avec JWT stateless)
    this.http.post(`${this.apiUrl}/logout`, {}).subscribe();
    // Supprimer tous les tokens du localStorage
    localStorage.removeItem('access_token');
    localStorage.removeItem('refresh_token');
    localStorage.removeItem('current_user');
    // Réinitialiser l'état de l'utilisateur
    this.currentUserSubject.next(null);
    // Rediriger vers la page de login
    this.router.navigate(['/login']);
  }

  // ===== MÉTHODES UTILITAIRES =====

  // Vérifie si l'utilisateur est connecté
  isLoggedIn(): boolean {
    const token = this.getAccessToken();
    if (!token) return false;
    // Vérifier que le token n'est pas expiré
    return !this.isTokenExpired(token);
  }

  getAccessToken(): string | null {
    return localStorage.getItem('access_token');
  }

  getRefreshToken(): string | null {
    return localStorage.getItem('refresh_token');
  }

  getCurrentUser(): User | null {
    return this.currentUserSubject.value;
  }

  // Stocker les données après login/register
  private storeAuthData(response: AuthResponse): void {
    localStorage.setItem('access_token',  response.accessToken);
    localStorage.setItem('refresh_token', response.refreshToken);
    localStorage.setItem('current_user',  JSON.stringify(response.user));
    this.currentUserSubject.next(response.user);
  }

  private getStoredUser(): User | null {
    const stored = localStorage.getItem('current_user');
    return stored ? JSON.parse(stored) : null;
  }

  // Décoder le token JWT pour lire la date d'expiration (sans vérifier la signature)
  private isTokenExpired(token: string): boolean {
    try {
      const payload = JSON.parse(atob(token.split('.')[1]));
      // exp est en secondes (Unix timestamp)
      return payload.exp * 1000 < Date.now();
    } catch {
      return true; // Token malformé → considéré expiré
    }
  }

  private handleError(error: any): Observable<never> {
    const message = error.error?.message || 'Erreur d\'authentification';
    return throwError(() => new Error(message));
  }
}

10. 🟧 Front-end : intercepteur HTTP JWT

L'intercepteur s'exécute automatiquement avant chaque requête HTTP Angular. Il ajoute le token JWT dans le header et gère le renouvellement automatique en cas d'expiration.


import { Injectable } from '@angular/core';
import {
  HttpInterceptor, HttpRequest, HttpHandler,
  HttpEvent, HttpErrorResponse
} from '@angular/common/http';
import { Observable, throwError, BehaviorSubject } from 'rxjs';
import { catchError, switchMap, filter, take } from 'rxjs/operators';
import { AuthService } from '../services/auth.service';

@Injectable()
export class JwtInterceptor implements HttpInterceptor {

  // Flag pour éviter plusieurs requêtes de refresh simultanées
  private isRefreshing = false;
  // Subject qui émet le nouveau token une fois obtenu
  private refreshTokenSubject = new BehaviorSubject<string | null>(null);

  constructor(private authService: AuthService) { }

  intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
    // Ne pas ajouter le token sur les routes auth (login, register)
    if (req.url.includes('/auth/')) {
      return next.handle(req);
    }

    // Ajouter le token JWT dans le header Authorization
    const token = this.authService.getAccessToken();
    const authReq = token ? this.addToken(req, token) : req;

    return next.handle(authReq).pipe(
      catchError((error: HttpErrorResponse) => {
        if (error.status === 401) {
          // Token expiré → essayer de le renouveler automatiquement
          return this.handle401Error(req, next);
        }
        return throwError(() => error);
      })
    );
  }

  // Cloner la requête avec le token dans le header
  private addToken(req: HttpRequest<any>, token: string): HttpRequest<any> {
    return req.clone({
      setHeaders: { Authorization: `Bearer ${token}` }
    });
  }

  // Gérer le token expiré (401) : refresh puis relancer la requête
  private handle401Error(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
    if (!this.isRefreshing) {
      this.isRefreshing = true;
      this.refreshTokenSubject.next(null);

      return this.authService.refreshToken().pipe(
        switchMap((response) => {
          this.isRefreshing = false;
          this.refreshTokenSubject.next(response.accessToken);
          // Relancer la requête originale avec le nouveau token
          return next.handle(this.addToken(req, response.accessToken));
        }),
        catchError((error) => {
          this.isRefreshing = false;
          // Refresh échoué → déconnexion
          this.authService.logout();
          return throwError(() => error);
        })
      );
    }

    // D'autres requêtes attendent le nouveau token → attendre et relancer
    return this.refreshTokenSubject.pipe(
      filter(token => token !== null),
      take(1),
      switchMap(token => next.handle(this.addToken(req, token!)))
    );
  }
}

Enregistrer l'intercepteur dans AppModule


import { HTTP_INTERCEPTORS } from '@angular/common/http';
import { JwtInterceptor } from './interceptors/jwt.interceptor';

@NgModule({
  providers: [
    // Enregistrer l'intercepteur JWT
    { provide: HTTP_INTERCEPTORS, useClass: JwtInterceptor, multi: true }
  ]
})
export class AppModule { }

11. 🟧 Front-end : AuthGuard et routes protégées


// src/app/guards/auth.guard.ts
import { Injectable } from '@angular/core';
import { CanActivate, Router, UrlTree } from '@angular/router';
import { AuthService } from '../services/auth.service';

@Injectable({
  providedIn: 'root'
})
export class AuthGuard implements CanActivate {

  constructor(private authService: AuthService, private router: Router) { }

  canActivate(): boolean | UrlTree {
    if (this.authService.isLoggedIn()) {
      // Utilisateur connecté → autoriser l'accès
      return true;
    }
    // Utilisateur non connecté → rediriger vers /login
    return this.router.createUrlTree(['/login']);
  }
}

// Guard pour les pages "invitées" uniquement (ex: /login ne doit pas être accessible si connecté)
@Injectable({ providedIn: 'root' })
export class GuestGuard implements CanActivate {
  constructor(private authService: AuthService, private router: Router) { }

  canActivate(): boolean | UrlTree {
    if (!this.authService.isLoggedIn()) {
      return true;
    }
    // Déjà connecté → rediriger vers le tableau de bord
    return this.router.createUrlTree(['/todos']);
  }
}

Configuration des routes Angular


// src/app/app-routing.module.ts
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { AuthGuard, GuestGuard } from './guards/auth.guard';

const routes: Routes = [
  // Route publique — accessible uniquement si NON connecté
  { path: 'login',    component: LoginComponent,    canActivate: [GuestGuard] },
  { path: 'register', component: RegisterComponent, canActivate: [GuestGuard] },

  // Routes protégées — nécessitent d'être connecté
  { path: 'todos',    component: TodoListComponent, canActivate: [AuthGuard] },
  { path: 'todo/new', component: TodoFormComponent, canActivate: [AuthGuard] },
  { path: 'todo/edit/:id', component: TodoFormComponent, canActivate: [AuthGuard] },
  { path: 'profile',  component: ProfileComponent,  canActivate: [AuthGuard] },

  // Redirection par défaut
  { path: '',   redirectTo: '/todos', pathMatch: 'full' },
  { path: '**', redirectTo: '/todos' }
];

@NgModule({
  imports: [RouterModule.forRoot(routes)],
  exports: [RouterModule]
})
export class AppRoutingModule { }

12. 🟧 Front-end : composant Login

LoginComponent — TypeScript


import { Component } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { Router } from '@angular/router';
import { AuthService } from '../../services/auth.service';

@Component({
  selector: 'app-login',
  templateUrl: './login.component.html'
})
export class LoginComponent {

  form: FormGroup;
  loading = false;
  error: string | null = null;
  showPassword = false; // Basculer visibilité mot de passe

  constructor(
    private fb: FormBuilder,
    private authService: AuthService,
    private router: Router
  ) {
    this.form = this.fb.group({
      email:    ['', [Validators.required, Validators.email]],
      password: ['', [Validators.required, Validators.minLength(8)]]
    });
  }

  onSubmit(): void {
    if (this.form.invalid) {
      this.form.markAllAsTouched();
      return;
    }

    this.loading = true;
    this.error = null;

    this.authService.login(this.form.value).subscribe({
      next: () => {
        // Rediriger vers la page principale après connexion réussie
        this.router.navigate(['/todos']);
      },
      error: (err: Error) => {
        this.error = err.message;
        this.loading = false;
      }
    });
  }

  get f() { return this.form.controls; }
}

LoginComponent — Template HTML


<div class="container mt-5">
  <div class="row justify-content-center">
    <div class="col-md-5 col-lg-4">
      <div class="card shadow">
        <div class="card-body p-4">
          <h1 class="h4 fw-bold text-center mb-4">Connexion</h1>

          <div *ngIf="error" class="alert alert-danger">{{ error }}</div>

          <form [formGroup]="form" (ngSubmit)="onSubmit()">

            <div class="mb-3">
              <label class="form-label fw-semibold">Email</label>
              <input type="email" class="form-control"
                     formControlName="email"
                     [class.is-invalid]="f['email'].invalid && f['email'].touched"
                     placeholder="votre@email.com">
              <div class="invalid-feedback">
                <span *ngIf="f['email'].errors?.['required']">L'email est obligatoire.</span>
                <span *ngIf="f['email'].errors?.['email']">Email invalide.</span>
              </div>
            </div>

            <div class="mb-4">
              <label class="form-label fw-semibold">Mot de passe</label>
              <div class="input-group">
                <input [type]="showPassword ? 'text' : 'password'"
                       class="form-control"
                       formControlName="password"
                       [class.is-invalid]="f['password'].invalid && f['password'].touched"
                       placeholder="Votre mot de passe">
                <button type="button" class="btn btn-outline-secondary"
                        (click)="showPassword = !showPassword">
                  {{ showPassword ? '🙈' : '👁️' }}
                </button>
              </div>
              <div class="invalid-feedback d-block"
                   *ngIf="f['password'].invalid && f['password'].touched">
                Mot de passe requis (minimum 8 caractères).
              </div>
            </div>

            <button type="submit" class="btn btn-primary w-100" [disabled]="loading">
              <span *ngIf="loading" class="spinner-border spinner-border-sm me-2"></span>
              {{ loading ? 'Connexion...' : 'Se connecter' }}
            </button>
          </form>

          <hr>
          <p class="text-center mb-0">
            Pas de compte ?
            <a routerLink="/register" class="text-primary fw-semibold">S'inscrire</a>
          </p>
        </div>
      </div>
    </div>
  </div>
</div>

13. Sécurité avancée et bonnes pratiques

🔐 Côté Spring Boot

  • Clé secrète JWT : minimum 256 bits, stockée dans les variables d'environnement (pas dans application.properties)
  • BCrypt pour les mots de passe : jamais MD5, SHA1, ou texte clair
  • HTTPS obligatoire en production (TLS/SSL)
  • Rate limiting sur /api/auth/login pour limiter les attaques brute-force
  • Validation des inputs avec @Valid sur tous les endpoints
  • Claims minimaux dans le JWT : ne jamais inclure le mot de passe ou données sensibles

🔐 Côté Angular

  • HTTP-only cookies pour les refresh tokens (protection XSS)
  • Ne jamais déchiffrer le JWT côté client : utilisez uniquement le payload Base64 (sans vérifier la signature)
  • Effacer les tokens lors du logout
  • Expirer automatiquement la session si l'access token est expiré et le refresh échoue
  • AuthGuard sur toutes les routes protégées

Variables d'environnement en production


# Générer une clé secrète JWT aléatoire (Linux/Mac)
openssl rand -base64 64

# application-prod.properties
app.jwt.secret=${JWT_SECRET}    # Lire depuis variable d'environnement
app.jwt.expiration=900000

# Lancer Spring Boot avec la variable d'environnement
JWT_SECRET=votre_cle_generee_ici java -jar todo-api.jar

14. Conclusion et checklist finale

Vous avez maintenant une authentification JWT complète entre Spring Boot et Angular. Le flux est sécurisé : le back-end valide chaque requête, Angular renouvelle automatiquement les tokens expirés, et les routes sont protégées des deux côtés.

✅ Checklist back-end Spring Boot

  • Spring Security configuré en mode stateless (STATELESS)
  • Entité User implémente UserDetails
  • UserDetailsServiceImpl charge l'utilisateur par email
  • JwtUtil génère et valide les tokens
  • JwtAuthFilter lit le token à chaque requête
  • AuthController expose /register, /login, /refresh, /logout
  • Mots de passe hashés avec BCryptPasswordEncoder
  • CORS configuré pour Angular
  • Clé JWT dans variable d'environnement

✅ Checklist front-end Angular

  • AuthService gère login, register, logout, refresh
  • Tokens stockés dans localStorage
  • JwtInterceptor ajoute le token à chaque requête
  • Renouvellement automatique du token (401 → refresh → retry)
  • AuthGuard protège les routes privées
  • GuestGuard redirige les utilisateurs connectés depuis /login
  • LoginComponent avec Reactive Forms et validation
  • currentUser$ Observable pour les composants qui affichent l'utilisateur
🚀 Pour aller plus loin :
  • Implémenter les rôles RBAC (ADMIN vs USER) avec @PreAuthorize en Spring
  • Stocker les refresh tokens en Redis pour pouvoir les révoquer
  • Ajouter OAuth2 (login Google, GitHub) avec Spring Security OAuth2
  • Mettre en place 2FA (double authentification) avec TOTP
  • Configurer des tests d'intégration Spring Security avec @WithMockUser

Partager