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.
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
- 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