Spring Boot Security : OAuth2, Rate Limiting et Monitoring
Ton API Spring Boot est sécurisée avec JWT. Les utilisateurs peuvent se connecter, les rôles fonctionnent, les erreurs sont gérées proprement.
Mais en production, d'autres questions émergent : comment gérer le login social (Google, GitHub) ? Comment éviter les attaques par force brute ? Comment révoquer un token compromis ? Comment monitorer les tentatives d'accès suspects ?
Cet article couvre les fonctionnalités de sécurité avancées qui transforment une API sécurisée en API production-ready.
Prérequis : Tu as déjà implémenté un système JWT de base. Si ce n'est pas le cas, commence par Spring Boot Security : Authentification JWT.
Contexte technique
- Spring Boot 3.2+
- Java 17+
- Système JWT fonctionnel (voir article précédent)
- Redis (optionnel, pour le rate limiting et la blacklist)
OAuth2 : quand l'utiliser (ou pas)
OAuth2 est devenu un buzzword, mais ce n'est pas toujours la bonne solution.
Quand utiliser OAuth2 ?
- Login social (Google, GitHub, Facebook)
- API publique avec gestion de permissions tierces
- Architecture microservices avec serveur d'autorisation centralisé
- Conformité réglementaire (certains secteurs)
Quand éviter OAuth2 ?
- Application interne simple
- Pas besoin de login social
- Équipe réduite sans expertise OAuth2
- Deadline serrée
En pratique : pour une première version d'API, JWT classique suffit largement. OAuth2 peut venir plus tard si le besoin émerge réellement.
OAuth2 avec Google : implémentation
Si tu as besoin de login social, voici comment l'implémenter proprement.
Configuration Maven
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>Configuration OAuth2
spring:
security:
oauth2:
client:
registration:
google:
client-id: ${GOOGLE_CLIENT_ID}
client-secret: ${GOOGLE_CLIENT_SECRET}
scope:
- email
- profile
github:
client-id: ${GITHUB_CLIENT_ID}
client-secret: ${GITHUB_CLIENT_SECRET}
scope:
- user:email
- read:userImportant : Ne commite JAMAIS les credentials en dur. Utilise des variables d'environnement.
Security Config avec OAuth2
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthFilter;
private final OAuth2AuthenticationSuccessHandler oAuth2SuccessHandler;
public SecurityConfig(JwtAuthenticationFilter jwtAuthFilter,
OAuth2AuthenticationSuccessHandler oAuth2SuccessHandler) {
this.jwtAuthFilter = jwtAuthFilter;
this.oAuth2SuccessHandler = oAuth2SuccessHandler;
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**", "/login/**", "/oauth2/**").permitAll()
.anyRequest().authenticated()
)
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
.oauth2Login(oauth2 -> oauth2
.successHandler(oAuth2SuccessHandler)
)
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}Handler OAuth2 Success
Le handler génère un JWT après une authentification OAuth2 réussie :
@Component
public class OAuth2AuthenticationSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
private final JwtService jwtService;
private final UserService userService;
public OAuth2AuthenticationSuccessHandler(JwtService jwtService, UserService userService) {
this.jwtService = jwtService;
this.userService = userService;
}
@Override
public void onAuthenticationSuccess(HttpServletRequest request,
HttpServletResponse response,
Authentication authentication) throws IOException {
OAuth2User oAuth2User = (OAuth2User) authentication.getPrincipal();
String email = oAuth2User.getAttribute("email");
String name = oAuth2User.getAttribute("name");
// Crée ou récupère l'utilisateur
User user = userService.findOrCreateOAuth2User(email, name);
// Génère les tokens JWT
String accessToken = jwtService.generateToken(user);
String refreshToken = jwtService.generateRefreshToken(user);
// Redirige vers le frontend avec les tokens
String targetUrl = String.format(
"http://localhost:3000/oauth2/redirect?token=%s&refresh=%s",
accessToken,
refreshToken
);
getRedirectStrategy().sendRedirect(request, response, targetUrl);
}
}Service utilisateur OAuth2
@Service
public class UserService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
public UserService(UserRepository userRepository, PasswordEncoder passwordEncoder) {
this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder;
}
public User findOrCreateOAuth2User(String email, String name) {
return userRepository.findByEmail(email)
.orElseGet(() -> createOAuth2User(email, name));
}
private User createOAuth2User(String email, String name) {
User user = new User();
user.setEmail(email);
user.setPassword(passwordEncoder.encode(UUID.randomUUID().toString())); // Password aléatoire
user.setFirstName(name);
user.setRole(Role.USER);
user.setEnabled(true);
user.setOauth2Provider("GOOGLE"); // ou GITHUB
return userRepository.save(user);
}
}Points clés :
- L'utilisateur OAuth2 est créé automatiquement lors du premier login
- Un mot de passe aléatoire est généré (non utilisé, car login via OAuth2)
- Les tokens JWT sont générés pour permettre les appels API suivants
- Le frontend reçoit les tokens via une redirection
Flux complet OAuth2 → JWT
Voici comment s'articule l'authentification OAuth2 avec la génération de tokens JWT :
Points clés du flux :
- Redirection OAuth2 : L'utilisateur est redirigé vers Google pour s'authentifier
- Callback : Google renvoie un code d'autorisation à ton API
- Échange de code : Ton API échange ce code contre les informations utilisateur
- Création/récupération : L'utilisateur est créé s'il n'existe pas
- Génération JWT : Ton API génère ses propres tokens JWT (pas ceux de Google)
- Utilisation normale : Le frontend utilise ensuite les tokens JWT pour toutes les requêtes
Pourquoi JWT après OAuth2 ?
OAuth2 sert uniquement à l'authentification initiale. Ensuite, ton API fonctionne avec ses propres tokens JWT pour rester stateless et indépendante de Google.
Rate limiting : protection contre brute force
Sans rate limiting, un attaquant peut lancer un script qui teste des milliers de combinaisons email/password par minute. En quelques heures, il peut tester des millions de mots de passe courants.
Le principe : limiter le nombre de tentatives de connexion par adresse IP. Après 5 échecs, l'IP est bloquée pendant 15 minutes.
Implémentation avec Guava Cache
Guava Cache permet de stocker en mémoire le nombre de tentatives par IP, avec expiration automatique.
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>33.0.0-jre</version>
</dependency>@Component
public class LoginAttemptService {
private final LoadingCache<String, Integer> attemptsCache;
public LoginAttemptService() {
// Cache qui stocke le nombre de tentatives par IP
attemptsCache = CacheBuilder.newBuilder()
.expireAfterWrite(15, TimeUnit.MINUTES) // Supprime automatiquement après 15 min
.build(new CacheLoader<String, Integer>() {
@Override
public Integer load(String key) {
return 0; // Valeur par défaut : 0 tentative
}
});
}
public void loginSucceeded(String key) {
attemptsCache.invalidate(key); // Supprime l'IP du cache (réinitialise le compteur)
}
public void loginFailed(String key) {
int attempts = attemptsCache.getUnchecked(key); // Récupère le nombre actuel
attemptsCache.put(key, attempts + 1); // Incrémente de 1
}
public boolean isBlocked(String key) {
return attemptsCache.getUnchecked(key) >= 5; // Bloqué si >= 5 tentatives
}
}Comment ça marche :
- Une IP fait une tentative de login → le compteur passe à 1
- Échec → compteur à 2, puis 3, etc.
- Au 5ème échec →
isBlocked()renvoietrue - Après 15 minutes sans tentative → le cache expire automatiquement, l'IP est débloquée
- Login réussi →
invalidate()réinitialise immédiatement le compteur
Visualisation du rate limiting
Voici ce qui se passe concrètement avec les tentatives de login :
Exemple concret :
- 10:00 : Tentative 1 échouée → compteur = 1
- 10:01 : Tentative 2 échouée → compteur = 2
- 10:02 : Tentative 3 échouée → compteur = 3
- 10:03 : Tentative 4 échouée → compteur = 4
- 10:04 : Tentative 5 échouée → compteur = 5, IP BLOQUÉE
- 10:05 : Tentative 6 → Exception immédiate sans vérification DB
- 10:19 : Cache expire (15 min) → IP débloquée, compteur = 0
Protection optimale : le blocage intervient avant toute vérification en base, ce qui protège contre les attaques par force brute massives.
Intégration dans AuthenticationService
Maintenant qu'on a le service de comptage, on l'intègre dans le processus de login pour bloquer les IPs suspectes.
@Service
public class AuthenticationService {
private final AuthenticationManager authenticationManager;
private final LoginAttemptService loginAttemptService;
private final UserRepository userRepository;
private final JwtService jwtService;
public AuthenticationResponse login(LoginRequest request, String ipAddress) {
// ÉTAPE 1 : Vérifie si l'IP est déjà bloquée
if (loginAttemptService.isBlocked(ipAddress)) {
throw new TooManyAttemptsException("Too many failed login attempts. Try again in 15 minutes.");
}
try {
// ÉTAPE 2 : Tente l'authentification
authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(
request.email(),
request.password()
)
);
// ÉTAPE 3 : Login réussi → réinitialise le compteur
loginAttemptService.loginSucceeded(ipAddress);
var user = userRepository.findByEmail(request.email())
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
// ÉTAPE 4 : Génère les tokens JWT
var accessToken = jwtService.generateToken(user);
var refreshToken = jwtService.generateRefreshToken(user);
return new AuthenticationResponse(accessToken, refreshToken);
} catch (BadCredentialsException e) {
// ÉTAPE 5 : Login échoué → incrémente le compteur
loginAttemptService.loginFailed(ipAddress);
throw e; // Relance l'exception pour que le controller renvoie 401
}
}
}Flux de sécurité :
- Avant même de vérifier le mot de passe, on vérifie si l'IP est bloquée
- Si bloquée → exception immédiate, pas de vérification en base
- Si OK → authentification normale
- Login réussi → compteur remis à zéro (l'utilisateur peut se tromper sans être bloqué définitivement)
- Login raté → compteur incrémenté (5 échecs = blocage automatique)
Controller avec IP
Le controller doit extraire l'adresse IP du client pour la passer au service. Attention : si ton API est derrière un proxy (Nginx, load balancer), l'IP réelle est dans le header X-Forwarded-For.
@RestController
@RequestMapping("/api/auth")
public class AuthenticationController {
private final AuthenticationService authenticationService;
@PostMapping("/login")
public ResponseEntity<AuthenticationResponse> login(
@Valid @RequestBody LoginRequest request,
HttpServletRequest httpRequest
) {
String ipAddress = getClientIP(httpRequest);
return ResponseEntity.ok(authenticationService.login(request, ipAddress));
}
private String getClientIP(HttpServletRequest request) {
// Récupère l'IP réelle si derrière un proxy
String xfHeader = request.getHeader("X-Forwarded-For");
if (xfHeader == null || xfHeader.isEmpty()) {
return request.getRemoteAddr(); // Pas de proxy : IP directe
}
// Proxy : prend la première IP de la liste (IP du client)
return xfHeader.split(",")[0];
}
}Pourquoi X-Forwarded-For ?
En production, ton API est souvent derrière un proxy reverse (Nginx, Cloudflare, AWS ALB). Le proxy reçoit la requête du client, puis la transmet à ton API. Du coup, request.getRemoteAddr() renvoie l'IP du proxy, pas celle du client.
Le header X-Forwarded-For contient l'IP réelle du client. Format : IP_CLIENT, IP_PROXY1, IP_PROXY2. On prend la première (celle du client).
En production :
- 5 tentatives échouées = blocage de 15 minutes
- Blocage par IP (pas par email, pour éviter le déni de service)
- Logs des tentatives bloquées pour détecter les patterns d'attaque
Révocation de tokens : blacklist Redis
Le problème : JWT est stateless, donc impossible de le révoquer côté serveur. Si un utilisateur se déconnecte ou si un token est compromis, il reste valide jusqu'à son expiration.
La solution : stocker les tokens révoqués dans Redis (blacklist). À chaque requête, on vérifie si le token est blacklisté avant de l'accepter.
Configuration Redis
Redis est une base de données en mémoire ultra-rapide, parfaite pour stocker temporairement les tokens révoqués.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>spring:
data:
redis:
host: ${REDIS_HOST:localhost}
port: ${REDIS_PORT:6379}
password: ${REDIS_PASSWORD:}Service Blacklist
Ce service gère l'ajout et la vérification des tokens blacklistés.
@Service
public class TokenBlacklistService {
private final RedisTemplate<String, String> redisTemplate;
public TokenBlacklistService(RedisTemplate<String, String> redisTemplate) {
this.redisTemplate = redisTemplate;
}
public void blacklistToken(String token, long expirationMs) {
// Stocke le token dans Redis avec une clé préfixée
redisTemplate.opsForValue().set(
"blacklist:" + token,
"revoked",
expirationMs, // TTL automatique : Redis supprime après expiration
TimeUnit.MILLISECONDS
);
}
public boolean isBlacklisted(String token) {
// Vérifie si la clé existe dans Redis
return Boolean.TRUE.equals(
redisTemplate.hasKey("blacklist:" + token)
);
}
}Points clés :
- Le token est stocké avec un TTL (Time To Live) qui correspond au temps restant avant son expiration naturelle
- Redis supprime automatiquement les entrées expirées → pas de nettoyage manuel
- La clé est préfixée par
"blacklist:"pour éviter les collisions avec d'autres données Redis
Intégration dans le filtre JWT
Le filtre JWT doit vérifier la blacklist avant de valider le token. Si le token est blacklisté, on rejette immédiatement la requête.
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtService jwtService;
private final UserDetailsService userDetailsService;
private final TokenBlacklistService tokenBlacklistService;
@Override
protected void doFilterInternal(
@NonNull HttpServletRequest request,
@NonNull HttpServletResponse response,
@NonNull FilterChain filterChain
) throws ServletException, IOException {
final String authHeader = request.getHeader("Authorization");
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
filterChain.doFilter(request, response);
return;
}
final String jwt = authHeader.substring(7);
// IMPORTANT : Vérifie la blacklist AVANT toute validation
if (tokenBlacklistService.isBlacklisted(jwt)) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json");
response.getWriter().write("{\"error\":\"Token has been revoked\"}");
return; // Stoppe la requête ici, ne continue pas vers le controller
}
// ... reste du code de validation (signature, expiration, etc.)
filterChain.doFilter(request, response);
}
}Ordre des vérifications :
- Header
Authorizationprésent ? → Si non, on continue sans authentification - Token blacklisté ? → Si oui, on rejette immédiatement (401)
- Token valide (signature, expiration) ? → Si oui, on charge l'utilisateur
- Rôles suffisants ? → Si oui, on autorise l'accès
La vérification de la blacklist est avant la validation complète du token pour économiser des ressources (pas besoin de vérifier la signature d'un token déjà révoqué).
Endpoint de logout
Quand un utilisateur se déconnecte, son token doit être révoqué immédiatement. On l'ajoute à la blacklist avec un TTL calculé.
@PostMapping("/logout")
public ResponseEntity<Void> logout(@RequestHeader("Authorization") String authHeader) {
String token = authHeader.substring(7); // Enlève "Bearer "
// Calcule le temps restant avant expiration naturelle du token
long expirationMs = jwtService.getExpirationTime(token);
// Ajoute le token à la blacklist avec ce TTL
tokenBlacklistService.blacklistToken(token, expirationMs);
return ResponseEntity.noContent().build(); // 204 No Content
}Méthode dans JwtService pour calculer le TTL :
public long getExpirationTime(String token) {
Date expiration = extractExpiration(token);
Date now = new Date();
return expiration.getTime() - now.getTime(); // Temps en millisecondes
}Pourquoi calculer le TTL ?
Un token JWT a déjà une date d'expiration (par exemple, 15 minutes après sa création). Inutile de le stocker dans Redis pendant 1 an si il expire dans 5 minutes.
Redis supprimera automatiquement l'entrée quand le TTL sera écoulé. Cela économise de la mémoire et évite d'accumuler des millions de tokens expirés.
Flux complet de révocation
Voici ce qui se passe du logout jusqu'aux requêtes suivantes :
Points clés :
- Logout : Le token est ajouté à Redis avec un TTL calculé
- Vérification : Chaque requête vérifie Redis avant d'accepter le token
- Nettoyage auto : Redis supprime le token après expiration, pas de maintenance manuelle
- Performance : Redis est ultra-rapide (vérification en < 1ms)
Monitoring et traçabilité
En production, tu dois savoir qui accède à quoi et détecter les comportements suspects.
Logs structurés avec MDC
@Slf4j
@Component
public class SecurityAuditFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain
) throws ServletException, IOException {
String username = SecurityContextHolder.getContext().getAuthentication() != null
? SecurityContextHolder.getContext().getAuthentication().getName()
: "anonymous";
MDC.put("user", username);
MDC.put("ip", request.getRemoteAddr());
MDC.put("endpoint", request.getRequestURI());
MDC.put("method", request.getMethod());
try {
filterChain.doFilter(request, response);
} finally {
log.info("API access: {} {} by {} from {}",
request.getMethod(),
request.getRequestURI(),
username,
request.getRemoteAddr()
);
MDC.clear();
}
}
}Métriques Spring Actuator
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-registry-prometheus</artifactId>
</dependency>management:
endpoints:
web:
exposure:
include: health,metrics,prometheus
endpoint:
health:
show-details: when-authorized
metrics:
tags:
application: ${spring.application.name}
distribution:
percentiles-histogram:
http.server.requests: trueMétriques custom de sécurité
@Component
public class SecurityMetrics {
private final Counter loginAttempts;
private final Counter loginFailures;
private final Counter unauthorizedAccess;
private final Counter forbiddenAccess;
public SecurityMetrics(MeterRegistry meterRegistry) {
this.loginAttempts = Counter.builder("security.login.attempts")
.description("Number of login attempts")
.register(meterRegistry);
this.loginFailures = Counter.builder("security.login.failures")
.description("Number of failed login attempts")
.register(meterRegistry);
this.unauthorizedAccess = Counter.builder("security.access.unauthorized")
.description("Number of 401 responses")
.register(meterRegistry);
this.forbiddenAccess = Counter.builder("security.access.forbidden")
.description("Number of 403 responses")
.register(meterRegistry);
}
public void recordLoginAttempt() {
loginAttempts.increment();
}
public void recordLoginFailure() {
loginFailures.increment();
}
public void recordUnauthorizedAccess() {
unauthorizedAccess.increment();
}
public void recordForbiddenAccess() {
forbiddenAccess.increment();
}
}Métriques à surveiller :
- Taux de 401 (tentatives non authentifiées)
- Taux de 403 (tentatives non autorisées)
- Ratio échecs/succès de login
- Latence du filtre JWT
- Nombre de tokens blacklistés
Alertes Prometheus :
# Trop de login échoués
- alert: HighLoginFailureRate
expr: rate(security_login_failures_total[5m]) > 10
for: 5m
annotations:
summary: "High login failure rate detected"
description: "More than 10 failed logins per minute"
# Trop de 401
- alert: HighUnauthorizedRate
expr: rate(security_access_unauthorized_total[5m]) > 50
for: 5m
annotations:
summary: "High unauthorized access rate"
description: "Possible attack in progress"Anti-patterns avancés
1. Charger l'utilisateur à chaque requête
⚠️ Problème : le filtre JWT charge l'user depuis la DB à chaque requête. Pour une API à fort trafic, c'est problématique.
Solution : cache applicatif avec Caffeine ou Redis.
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
</dependency>@Configuration
@EnableCaching
public class CacheConfig {
@Bean
public CacheManager cacheManager() {
CaffeineCacheManager cacheManager = new CaffeineCacheManager("users");
cacheManager.setCaffeine(Caffeine.newBuilder()
.expireAfterWrite(10, TimeUnit.MINUTES)
.maximumSize(1000)
);
return cacheManager;
}
}@Service
public class UserService {
@Cacheable(value = "users", key = "#email")
public User findByEmail(String email) {
return userRepository.findByEmail(email)
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
}
@CacheEvict(value = "users", key = "#user.email")
public void updateUser(User user) {
userRepository.save(user);
}
}2. Mélanger rôles et permissions
❌ Erreur :
user.setRole("ADMIN_READ_ONLY");✅ Correct :
user.setRole(Role.ADMIN);
user.setPermissions(Set.of(Permission.READ));Séparer les concepts permet de gérer finement les droits.
3. Pas de pagination sur les endpoints admin
Si tu exposes /api/admin/users sans pagination, un attaquant peut charger toute la base.
@GetMapping
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<Page<UserDTO>> getAllUsers(Pageable pageable) {
return ResponseEntity.ok(userService.getAllUsers(pageable));
}Checklist de mise en production
Avant de déployer :
- Secret JWT stocké en variable d'environnement
- Secret JWT suffisamment long (256 bits minimum)
- Access token court (15-30 minutes)
- Refresh token implémenté
- HTTPS activé partout
- CORS configuré avec domaines explicites
- Gestion d'erreurs custom (401, 403)
- Logs d'audit en place
- Tests d'intégration de sécurité
- Passwords en BCrypt (jamais en clair)
- Rate limiting sur
/api/auth/login - Blacklist Redis pour révocation de tokens
- Métriques de sécurité activées
- Alertes configurées (Prometheus, Grafana)
- Actuator sécurisé (pas d'accès public)
- Cache utilisateur activé
- Pagination sur tous les endpoints de liste
Conclusion
Tu as maintenant tous les outils pour sécuriser une API Spring Boot en production.
Ce que tu as appris :
- OAuth2 pour login social (Google, GitHub)
- Rate limiting pour protéger contre le brute force
- Révocation de tokens avec Redis
- Monitoring et métriques de sécurité
- Anti-patterns avancés et optimisations
Progression depuis l'article 1 :
- Article 1 : système JWT fonctionnel ✅
- Article 2 : production-ready avec OAuth2, rate limiting, monitoring ✅
La sécurité n'est jamais "terminée". C'est un processus continu : logs, métriques, mises à jour, tests. Mais avec ce système, tu as une base solide et évolutive.
Pour aller plus loin :
- Two-Factor Authentication (2FA) avec Spring Boot - Ajoute une couche de sécurité supplémentaire avec TOTP
Articles liés :