Série « Construire une application Spring Boot complète » : partie 3 sur 3.
Au sommaire de la série :
- Entités, DTO et services
- Sécurité JWT
- Contrôleurs, erreurs et documentation (cette partie)
Partie précédente : Sécurité JWT
Les deux premières parties ont posé le socle (entités, DTO et services) puis la sécurité JWT. Cette dernière partie expose le tout par des contrôleurs, complète la gestion des erreurs, ajoute la documentation Swagger, montre comment tester le parcours d'authentification et liste ce qu'il resterait à faire avant une mise en production. Les exemples ont été compilés et exécutés avec Spring Boot 3.5.
Les contrôleurs : orchestrer sans décider
Le contrôleur reçoit, délègue, renvoie. Jamais de logique métier, jamais d'accès direct au repository. Tous les endpoints partagent le préfixe /api/v1, comme dans la série Spring MVC. Les annotations @Tag et @Operation alimentent la documentation Swagger de la section suivante.
@RestController
@RequestMapping("/api/v1/auth")
@Tag(name = "Authentication", description = "Inscription, connexion et gestion des tokens")
public class AuthController {
private final AuthService authService;
public AuthController(AuthService authService) {
this.authService = authService;
}
@PostMapping("/login")
@Operation(summary = "Connexion avec nom d'utilisateur ou email et mot de passe")
public ResponseEntity<AuthResponse> login(@Valid @RequestBody LoginRequest request) {
return ResponseEntity.ok(authService.login(request));
}
@PostMapping("/register")
@Operation(summary = "Inscription d'un nouvel utilisateur")
public ResponseEntity<UserResponse> register(@Valid @RequestBody RegisterRequest request) {
return ResponseEntity.status(HttpStatus.CREATED).body(authService.register(request));
}
@PostMapping("/refresh")
@Operation(summary = "Obtenir de nouveaux tokens avec un refresh token")
public ResponseEntity<AuthResponse> refresh(@Valid @RequestBody RefreshTokenRequest request) {
return ResponseEntity.ok(authService.refresh(request.refreshToken()));
}
@PostMapping("/logout")
@Operation(summary = "Révoquer l'access token courant et, si fourni, le refresh token")
public ResponseEntity<Void> logout(
@RequestHeader("Authorization") String authorization,
@RequestBody(required = false) RefreshTokenRequest request) {
// L'endpoint exige une authentification : l'en-tête est donc de la forme « Bearer <jeton> »
String accessToken = authorization.substring("Bearer ".length());
authService.logout(accessToken, request == null ? null : request.refreshToken());
return ResponseEntity.noContent().build();
}
}
Le refresh token est envoyé dans le corps plutôt que dans l'en-tête Authorization, qui est réservé à l'access token. Le logout révoque l'access token de l'en-tête et, si le corps contient un refreshToken, celui-ci aussi : sans cela, la session resterait ouverte tant que le refresh token n'a pas expiré. L'endpoint exige une authentification (il n'est pas dans la liste des routes publiques) : l'en-tête est donc forcément de la forme Bearer <jeton> quand le contrôleur est atteint, et le contrôle explicite du préfixe devient inutile.
Le contrôleur utilisateur montre @PreAuthorize pour le contrôle d'accès :
@RestController
@RequestMapping("/api/v1/users")
@Tag(name = "Users", description = "Gestion des utilisateurs")
@SecurityRequirement(name = "bearerAuth")
public class UserController {
private final UserService userService;
public UserController(UserService userService) {
this.userService = userService;
}
@GetMapping("/me")
@Operation(summary = "Profil de l'utilisateur connecté")
public ResponseEntity<UserResponse> findCurrent(@AuthenticationPrincipal UserDetails userDetails) {
return ResponseEntity.ok(userService.findByUsername(userDetails.getUsername()));
}
@GetMapping
@PreAuthorize("hasRole('ADMIN')")
@Operation(summary = "Liste de tous les utilisateurs")
public ResponseEntity<List<UserResponse>> findAll() {
return ResponseEntity.ok(userService.findAll());
}
@GetMapping("/{id}")
@PreAuthorize("hasRole('ADMIN') or @userSecurity.isOwner(#id)")
@Operation(summary = "Détails d'un utilisateur par identifiant")
public ResponseEntity<UserResponse> findById(@PathVariable Long id) {
return ResponseEntity.ok(userService.findById(id));
}
@DeleteMapping("/{id}")
@PreAuthorize("hasRole('ADMIN')")
@Operation(summary = "Supprimer un utilisateur")
public ResponseEntity<Void> delete(@PathVariable Long id) {
userService.delete(id);
return ResponseEntity.noContent().build();
}
}
@AuthenticationPrincipal UserDetails injecte directement l'utilisateur connecté sans passer par SecurityContextHolder.getContext().getAuthentication(). C'est plus propre et plus facile à tester : dans un test unitaire, on passe un UserDetails directement en paramètre.
@PreAuthorize("hasRole('ADMIN') or @userSecurity.isOwner(#id)") est du SpEL (Spring Expression Language). @userSecurity référence un bean Spring nommé userSecurity, et #id le paramètre de la méthode. Ce bean n'est pas fourni par Spring, il faut l'écrire :
@Component("userSecurity")
public class UserSecurity {
private final UserRepository userRepository;
public UserSecurity(UserRepository userRepository) {
this.userRepository = userRepository;
}
public boolean isOwner(Long userId) {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || !auth.isAuthenticated()) {
return false;
}
return userRepository.findByUsername(auth.getName())
.map(user -> user.getId().equals(userId))
.orElse(false);
}
}
Le principal est le UserDetails de Spring Security, qui n'a pas de propriété id : l'expression #id == authentication.principal.id ne fonctionnerait pas sans principal personnalisé. La troisième partie de la série Spring MVC décrit cette alternative (CustomUserDetails). Le bean dédié reste utile pour les règles plus fines que hasRole ne peut pas exprimer.
Enfin, findAll() renvoie ici une List complète : à paginer dès que la table grossit, avec le PageResponse de la série Spring MVC.
La gestion des erreurs : un contrat aussi important que les succès
Quand une API renvoie un 500 avec un stacktrace Java ou un 404 avec la page Whitelabel de Spring, le client ne sait pas quoi faire de cette information. La gestion centralisée des erreurs transforme chaque exception en une réponse cohérente avec le reste de l'API, au format ProblemDetail (RFC 9457).
Les exceptions personnalisées portent un sens métier et sont nommées d'après ce qui s'est passé : UserNotFoundException, UsernameAlreadyUsedException, EmailAlreadyUsedException, InvalidRefreshTokenException. Chacune étend RuntimeException, sans logique propre. Le statut HTTP est choisi dans le GlobalExceptionHandler ci-dessous, qui reprend la structure de la deuxième partie de la série Spring MVC (classe de base ResponseEntityExceptionHandler, champs code et errors) et y ajoute les cas propres à l'authentification :
@RestControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
public record FieldViolation(String field, String message) {
}
@ExceptionHandler(UserNotFoundException.class)
public ProblemDetail handleUserNotFound(UserNotFoundException ex) {
return problem(HttpStatus.NOT_FOUND, "Utilisateur introuvable", ex.getMessage(), "USER_NOT_FOUND");
}
@ExceptionHandler({UsernameAlreadyUsedException.class, EmailAlreadyUsedException.class})
public ProblemDetail handleAlreadyUsed(RuntimeException ex) {
return problem(HttpStatus.CONFLICT, "Conflit", ex.getMessage(), "ALREADY_USED");
}
// Filet de sécurité si deux inscriptions passent la vérification d'unicité en même temps
@ExceptionHandler(DataIntegrityViolationException.class)
public ProblemDetail handleIntegrity(DataIntegrityViolationException ex) {
log.warn("Violation de contrainte : {}", ex.getMostSpecificCause().getMessage());
return problem(HttpStatus.CONFLICT, "Conflit", "Conflit avec une donnée existante.", "DATA_CONFLICT");
}
@ExceptionHandler(InvalidRefreshTokenException.class)
public ProblemDetail handleInvalidRefreshToken(InvalidRefreshTokenException ex) {
return problem(HttpStatus.UNAUTHORIZED, "Refresh token refusé", ex.getMessage(), "INVALID_REFRESH_TOKEN");
}
// BadCredentialsException, DisabledException, LockedException...
@ExceptionHandler(AuthenticationException.class)
public ProblemDetail handleAuthentication(AuthenticationException ex) {
return problem(HttpStatus.UNAUTHORIZED, "Authentification refusée",
"Identifiants incorrects ou compte désactivé.", "BAD_CREDENTIALS");
}
@ExceptionHandler(AccessDeniedException.class)
public ProblemDetail handleAccessDenied(AccessDeniedException ex) {
return problem(HttpStatus.FORBIDDEN, "Accès refusé",
"Permissions insuffisantes pour cette ressource.", "ACCESS_DENIED");
}
@Override
protected ResponseEntity<Object> handleMethodArgumentNotValid(
MethodArgumentNotValidException ex,
HttpHeaders headers,
HttpStatusCode status,
WebRequest request) {
List<FieldViolation> errors = ex.getBindingResult().getFieldErrors().stream()
.map(error -> new FieldViolation(error.getField(), error.getDefaultMessage()))
.toList();
ProblemDetail body = problem(HttpStatus.BAD_REQUEST, "Requête invalide",
"Un ou plusieurs champs sont invalides.", "VALIDATION_FAILED");
body.setProperty("errors", errors);
return createResponseEntity(body, headers, status, request);
}
@ExceptionHandler(Exception.class)
public ProblemDetail handleUnexpected(Exception ex) {
log.error("Erreur non gérée", ex);
return problem(HttpStatus.INTERNAL_SERVER_ERROR, "Erreur interne",
"Une erreur est survenue. Réessayez plus tard.", "INTERNAL_ERROR");
}
private static ProblemDetail problem(HttpStatus status, String title, String detail, String code) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(status, detail);
problem.setTitle(title);
problem.setProperty("code", code);
return problem;
}
}
Le handler Exception.class est le filet de sécurité. Sans lui, une NullPointerException dans un service produirait la page d'erreur par défaut de Spring Boot. Selon la configuration (server.error.include-stacktrace, include-message), celle-ci peut exposer des détails internes : messages, stacktrace, fragments de requêtes SQL. Le handler journalise l'erreur complète côté serveur pour le débogage et renvoie un message générique au client.
Le niveau de journalisation suit la gravité : log.warn pour les conflits, log.error pour les 500. Un 404 relève de l'usage normal de l'API, pas d'une erreur serveur. Un 500 est un bug qui mérite une alerte.
Le handler d'AuthenticationException renvoie volontairement le même message pour un mot de passe incorrect et pour un compte désactivé : la connexion ne révèle ni l'existence d'un compte ni son état. L'inscription, elle, indique forcément qu'un nom ou un e-mail est déjà pris (409) ; une limitation du nombre de tentatives atténue l'énumération de comptes. On ne journalise jamais les identifiants ou les mots de passe tentés : ils se retrouveraient en clair dans les fichiers de journalisation.
Les erreurs levées par les filtres de sécurité avant d'atteindre le contrôleur (token absent sur un endpoint protégé, par exemple) ne passent pas par ce @RestControllerAdvice. C'est le rôle du JwtAuthenticationEntryPoint vu dans la partie précédente.
La documentation Swagger : self-service pour les consommateurs
Swagger (OpenAPI) évite d'écrire un wiki qui sera obsolète quelques semaines après sa création. La documentation est générée depuis le code et reste à jour. Avec Spring Boot 3, on utilise springdoc-openapi-starter-webmvc-ui en branche 2.8 (les anciennes dépendances springfox ne sont pas compatibles).
@Configuration
@OpenAPIDefinition(
info = @Info(
title = "API de gestion des utilisateurs",
version = "1.0",
description = "API REST avec authentification JWT et gestion des rôles",
contact = @Contact(name = "Équipe Backend", email = "backend@example.com")),
servers = {
@Server(url = "http://localhost:8080", description = "Développement"),
@Server(url = "https://api.example.com", description = "Production")
})
@SecurityScheme(
name = "bearerAuth",
type = SecuritySchemeType.HTTP,
scheme = "bearer",
bearerFormat = "JWT")
public class OpenApiConfig {
}
Avec cette configuration et les annotations @Tag, @Operation, @SecurityRequirement sur les contrôleurs, Swagger UI est accessible sur /swagger-ui/index.html et la description OpenAPI sur /v3/api-docs (les deux répondent 200 dans le test d'intégration de l'exemple). Les développeurs frontend peuvent tester les endpoints directement depuis le navigateur, avec le formulaire « Authorize » pour injecter le token JWT.
Ces routes sont ouvertes dans SecurityConfig pour le développement. En production, on les désactive (springdoc.swagger-ui.enabled=false, springdoc.api-docs.enabled=false) ou on les protège.
Tester le parcours d'authentification
Le service d'authentification reçoit tout par son constructeur, ce qui permet de le tester sans contexte Spring : une instance, des doubles Mockito, et on vérifie les règles métier.
class AuthServiceTest {
private final UserRepository userRepository = mock(UserRepository.class);
private final RoleRepository roleRepository = mock(RoleRepository.class);
private final UserMapper userMapper = mock(UserMapper.class);
private final PasswordEncoder passwordEncoder = mock(PasswordEncoder.class);
// Aucun contexte Spring : le constructeur suffit
private final AuthService authService = new AuthService(
userRepository, roleRepository, userMapper, passwordEncoder,
mock(AuthenticationManager.class), mock(JwtService.class));
private static final RegisterRequest REQUEST =
new RegisterRequest("awa", "awa@example.com", "correct-horse-battery", null, null);
@Test
void register_hashesPasswordAndAssignsUserRole() {
User entity = User.builder().username("awa").email("awa@example.com").build();
when(userMapper.toEntity(REQUEST)).thenReturn(entity);
when(passwordEncoder.encode("correct-horse-battery")).thenReturn("{hash}");
when(roleRepository.findByName(RoleName.USER))
.thenReturn(Optional.of(Role.builder().name(RoleName.USER).build()));
when(userRepository.save(any(User.class))).thenAnswer(inv -> inv.getArgument(0));
when(userMapper.toResponse(any(User.class))).thenReturn(
new UserResponse(1L, "awa", "awa@example.com", null, null, true, Set.of("USER"), null));
authService.register(REQUEST);
assertThat(entity.getPassword()).isEqualTo("{hash}");
assertThat(entity.getRoles()).hasSize(1);
}
@Test
void register_withExistingUsername_throwsAndSavesNothing() {
when(userRepository.existsByUsername("awa")).thenReturn(true);
assertThatThrownBy(() -> authService.register(REQUEST))
.isInstanceOf(UsernameAlreadyUsedException.class);
verify(userRepository, never()).save(any());
}
}
Ce test unitaire s'exécute en quelques millisecondes. Il ne dit rien du câblage entre le filtre JWT, la configuration de sécurité, le contrôleur et la base : c'est le rôle d'un test d'intégration, plus lent parce qu'il charge tout le contexte Spring, donc à réserver au câblage. Celui-ci joue le scénario complet avec une base H2 en mémoire :
@SpringBootTest
@AutoConfigureMockMvc
class AuthFlowTest {
private static final String PASSWORD = "correct-horse-battery";
@Autowired
private MockMvc mockMvc;
@Autowired
private RoleRepository roleRepository;
@Autowired
private ObjectMapper objectMapper;
@BeforeEach
void createRoles() {
// En production, une migration Flyway s'en charge
if (roleRepository.count() == 0) {
for (RoleName name : RoleName.values()) {
roleRepository.save(Role.builder().name(name).build());
}
}
}
@Test
void registerLoginRefreshAndLogout() throws Exception {
// inscription
mockMvc.perform(post("/api/v1/auth/register")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"username":"awa","email":"awa@example.com","password":"%s"}
""".formatted(PASSWORD)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.active").value(true))
.andExpect(jsonPath("$.password").doesNotExist());
// connexion avec l'e-mail, puis lecture du profil avec l'access token
JsonNode tokens = objectMapper.readTree(mockMvc.perform(post("/api/v1/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"username":"awa@example.com","password":"%s"}
""".formatted(PASSWORD)))
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString());
String accessToken = tokens.get("accessToken").asText();
String refreshToken = tokens.get("refreshToken").asText();
mockMvc.perform(get("/api/v1/users/me").header("Authorization", "Bearer " + accessToken))
.andExpect(status().isOk())
.andExpect(jsonPath("$.username").value("awa"));
// un refresh token ne remplace pas un access token
mockMvc.perform(get("/api/v1/users/me").header("Authorization", "Bearer " + refreshToken))
.andExpect(status().isUnauthorized());
// rotation : le refresh token ne sert qu'une fois
String refreshBody = "{\"refreshToken\":\"" + refreshToken + "\"}";
mockMvc.perform(post("/api/v1/auth/refresh")
.contentType(MediaType.APPLICATION_JSON).content(refreshBody))
.andExpect(status().isOk());
mockMvc.perform(post("/api/v1/auth/refresh")
.contentType(MediaType.APPLICATION_JSON).content(refreshBody))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.code").value("INVALID_REFRESH_TOKEN"));
// déconnexion : l'access token est révoqué
mockMvc.perform(post("/api/v1/auth/logout").header("Authorization", "Bearer " + accessToken))
.andExpect(status().isNoContent());
mockMvc.perform(get("/api/v1/users/me").header("Authorization", "Bearer " + accessToken))
.andExpect(status().isUnauthorized());
}
@Test
void protectedEndpointWithoutTokenReturnsProblemDetail() throws Exception {
mockMvc.perform(get("/api/v1/users/me"))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.code").value("AUTHENTICATION_REQUIRED"));
}
}
Les rôles sont créés dans le @BeforeEach parce que l'inscription échoue sans USER en base (voir la première partie). Pour une couche seule, @WebMvcTest ou @DataJpaTest suffisent.
Points complémentaires
@Transactional(readOnly = true) sur les méthodes de lecture est une indication transmise au fournisseur JPA et à la connexion JDBC. Le gain dépend du volume chargé et reste à mesurer, mais le coût est nul et l'intention est documentée.
Il ne faut jamais renvoyer un 200 avec "success": false dans le corps. Les codes HTTP sont le contrat : 201 pour une création, 204 pour une suppression, 400 pour une validation échouée, 401 pour un non-authentifié, 403 pour un non-autorisé, 409 pour un conflit. Un client HTTP bien construit prend ses décisions sur le code de statut, pas sur le parsing du JSON.
Ce qu'il resterait à faire pour la production
Cette application n'est pas prête à être exposée telle quelle sur Internet. Le déploiement (conteneurisation, intégration continue, hébergement) n'est pas traité ici. Voici ce qu'il resterait au minimum à mettre en place :
- HTTPS. Terminer TLS sur un reverse proxy ou un répartiteur de charge, rediriger le HTTP, activer HSTS. Derrière ce proxy, configurer la prise en compte des en-têtes
X-Forwarded-*pour que l'application connaisse le schéma réel de la requête. - Limitation du nombre de requêtes sur
/api/v1/auth/loginet/api/v1/auth/register, au niveau du proxy ou dans l'application, contre le brute force et l'énumération de comptes. - Secret JWT hors dépôt, fourni par un gestionnaire de secrets ou par l'environnement, avec une procédure de rotation.
- Profils séparés (
dev,prod) : origines CORS réelles, Swagger désactivé,ddl-auto: validate, niveaux de journalisation adaptés, aucune valeur de développement dans la configuration par défaut. - Révocation partagée (Redis) dès qu'il y a plus d'une instance, et migrations Flyway appliquées avant le démarrage de la nouvelle version.
À retenir de la série
- Le code est rangé par fonctionnalité, avec des suffixes qui disent le rôle de chaque classe.
- Les entités restent derrière des DTO ; MapStruct génère les conversions.
- Les tokens sont typés, identifiés et révocables ; le filtre ne laisse passer que des tokens d'accès valides.
- Toutes les erreurs, y compris celles de la sécurité, sortent au même format
ProblemDetail. - Un test unitaire du service et un test d'intégration du parcours couvrent l'essentiel.
Série « Construire une application Spring Boot complète » : partie 3 sur 3.
Au sommaire de la série :
- Entités, DTO et services
- Sécurité JWT
- Contrôleurs, erreurs et documentation (cette partie)
Partie précédente : Sécurité JWT