Spring Boot Security : Two-Factor Authentication (2FA)
Ton API est sécurisée avec JWT. Les mots de passe sont chiffrés en BCrypt. Le rate limiting bloque les attaques par force brute.
Mais pour les applications critiques (banque, santé, admin), un mot de passe seul ne suffit pas. Si un mot de passe est compromis (phishing, fuite de données), l'attaquant a accès au compte.
La Two-Factor Authentication (2FA) ajoute une deuxième barrière : même si le mot de passe est volé, l'attaquant ne peut pas se connecter sans le code temporaire généré par l'app de l'utilisateur.
Cet article te montre comment implémenter la 2FA avec TOTP (Time-based One-Time Password) dans Spring Boot.
Prérequis : Tu as déjà un système d'authentification JWT fonctionnel. Si ce n'est pas le cas, consulte Spring Boot Security : Authentification JWT.
Contexte technique
- Spring Boot 3.2+
- Java 17+
- Système JWT fonctionnel
- Bibliothèque TOTP (totp-java)
Quand utiliser la 2FA (ou pas)
Quand l'implémenter
- Applications bancaires et financières
- Portails de santé (données patients)
- Panneaux d'administration
- Comptes avec accès à des données sensibles
- Conformité réglementaire (PSD2, HIPAA, etc.)
Quand l'éviter
- Applications internes non critiques (intranet simple)
- MVP ou prototypes (complexité inutile au démarrage)
- Utilisateurs peu tech-savvy sans support disponible
- Quand le coût d'implémentation dépasse le risque réel
En pratique : propose la 2FA en option pour tous les utilisateurs, mais ne la rends obligatoire que pour les comptes à privilèges élevés (admins, accès données sensibles).
2FA : principe et fonctionnement
Qu'est-ce que TOTP ?
TOTP (Time-based One-Time Password) est un algorithme qui génère des codes à 6 chiffres qui changent toutes les 30 secondes.
Comment ça marche :
- Ton API génère un secret unique pour chaque utilisateur
- Ce secret est partagé avec l'app mobile (Google Authenticator, Authy) via un QR Code
- L'app génère un code à 6 chiffres basé sur :
secret + heure actuelle - À chaque login, l'utilisateur entre ce code
- Ton API calcule le même code (même secret + même heure) et compare
Pourquoi c'est sécurisé :
- Le secret n'est jamais transmis après l'activation
- Les codes changent toutes les 30 secondes
- Impossible de prédire le prochain code sans le secret
- Même si un code est intercepté, il sera expiré quelques secondes plus tard
Flux 2FA complet
Phase 1 : Activation (une seule fois)
- L'utilisateur demande à activer la 2FA
- Un secret unique est généré et stocké en base
- Un QR Code est créé à partir de ce secret
- L'utilisateur scanne le QR avec Google Authenticator (ou Authy)
- L'app génère un code à 6 chiffres basé sur le secret + l'heure actuelle
- L'utilisateur entre le code pour confirmer → 2FA activée
Phase 2 : Login quotidien (à chaque connexion)
- Login classique avec email + mot de passe
- Si 2FA activée → pas de tokens JWT tout de suite
- L'utilisateur ouvre son app 2FA pour lire le code actuel (change toutes les 30 secondes)
- Il envoie le code à l'API
- Si valide → tokens JWT générés, login terminé
Implémentation : dépendances
Nous allons utiliser la bibliothèque totp de Sam Stevens, qui est simple, bien maintenue et compatible Spring Boot.
<dependency>
<groupId>dev.samstevens.totp</groupId>
<artifactId>totp</artifactId>
<version>1.7.1</version>
</dependency>Cette bibliothèque gère :
- Génération de secrets
- Génération de QR Codes
- Vérification des codes TOTP
- Compatibilité avec Google Authenticator, Authy, Microsoft Authenticator
Entité User avec 2FA
Ajoute deux colonnes à ton entité User :
@Entity
@Table(name = "users")
public class User implements UserDetails {
// ... champs existants (id, email, password, role, etc.)
@Column(name = "two_factor_enabled")
private boolean twoFactorEnabled = false;
@Column(name = "two_factor_secret")
private String twoFactorSecret;
// Getters/Setters
public boolean isTwoFactorEnabled() {
return twoFactorEnabled;
}
public void setTwoFactorEnabled(boolean twoFactorEnabled) {
this.twoFactorEnabled = twoFactorEnabled;
}
public String getTwoFactorSecret() {
return twoFactorSecret;
}
public void setTwoFactorSecret(String twoFactorSecret) {
this.twoFactorSecret = twoFactorSecret;
}
}Migration Flyway :
ALTER TABLE users
ADD COLUMN two_factor_enabled BOOLEAN DEFAULT FALSE NOT NULL,
ADD COLUMN two_factor_secret VARCHAR(255);Points clés :
twoFactorEnabled: active ou non pour cet utilisateurtwoFactorSecret: secret unique partagé avec l'app mobile (ne JAMAIS l'exposer dans les DTOs)- Le secret est stocké en clair car il n'est pas sensible comme un mot de passe (inutilisable sans connaître l'algorithme TOTP)
Service 2FA
Ce service gère la génération de secrets, QR Codes et la vérification des codes.
@Service
public class TwoFactorAuthService {
private final SecretGenerator secretGenerator = new DefaultSecretGenerator();
private final QrGenerator qrGenerator = new ZxingPngQrGenerator();
private final CodeVerifier codeVerifier = new DefaultCodeVerifier(
new DefaultCodeGenerator(),
new SystemTimeProvider()
);
public String generateSecret() {
return secretGenerator.generate();
}
public String generateQrCodeDataUri(String secret, String email) {
try {
QrData data = new QrData.Builder()
.label(email)
.secret(secret)
.issuer("SpringCraft API")
.algorithm(HashingAlgorithm.SHA1)
.digits(6)
.period(30)
.build();
return qrGenerator.generate(data);
} catch (QrGenerationException e) {
throw new TwoFactorAuthException("Failed to generate QR code", e);
}
}
public boolean verifyCode(String secret, String code) {
return codeVerifier.isValidCode(secret, code);
}
}Méthodes :
generateSecret(): crée un secret aléatoire uniquegenerateQrCodeDataUri(): génère un QR Code en Data URI (Base64) pour l'afficher dans le frontendverifyCode(): vérifie si un code est valide (tolérance de ±1 fenêtre de 30 secondes pour compenser le décalage d'horloge)
Configuration TOTP :
label: identifie le compte dans l'app (ex: "user@example.com")issuer: nom de ton application (affiché dans l'app)algorithm: SHA1 (standard pour TOTP)digits: 6 chiffres (standard)period: 30 secondes (standard)
DTOs
public record Enable2FAResponse(
String qrCodeDataUri,
String secret,
String message
) {
public Enable2FAResponse(String qrCodeDataUri, String secret) {
this(qrCodeDataUri, secret, "Scan the QR code with Google Authenticator");
}
}
public record Verify2FARequest(
@NotBlank String code
) {}
public record AuthenticationResponse(
String accessToken,
String refreshToken,
boolean requires2FA
) {
// Constructor sans 2FA (login normal)
public AuthenticationResponse(String accessToken, String refreshToken) {
this(accessToken, refreshToken, false);
}
}Activation de la 2FA
Endpoints pour activer et confirmer la 2FA :
@RestController
@RequestMapping("/api/users")
public class UserController {
private final TwoFactorAuthService twoFactorAuthService;
private final UserService userService;
public UserController(TwoFactorAuthService twoFactorAuthService, UserService userService) {
this.twoFactorAuthService = twoFactorAuthService;
this.userService = userService;
}
@PostMapping("/2fa/enable")
public ResponseEntity<Enable2FAResponse> enable2FA(@AuthenticationPrincipal User user) {
// Génère un secret unique pour cet utilisateur
String secret = twoFactorAuthService.generateSecret();
// Génère le QR Code
String qrCodeDataUri = twoFactorAuthService.generateQrCodeDataUri(secret, user.getEmail());
// Stocke le secret (mais n'active pas encore la 2FA)
userService.set2FASecret(user.getId(), secret);
return ResponseEntity.ok(new Enable2FAResponse(qrCodeDataUri, secret));
}
@PostMapping("/2fa/verify")
public ResponseEntity<Void> verify2FA(
@AuthenticationPrincipal User user,
@Valid @RequestBody Verify2FARequest request
) {
// Vérifie le code fourni par l'utilisateur
if (!twoFactorAuthService.verifyCode(user.getTwoFactorSecret(), request.code())) {
throw new InvalidCodeException("Invalid 2FA code");
}
// Code valide → active la 2FA définitivement
userService.enable2FA(user.getId());
return ResponseEntity.ok().build();
}
@DeleteMapping("/2fa")
public ResponseEntity<Void> disable2FA(
@AuthenticationPrincipal User user,
@Valid @RequestBody Verify2FARequest request
) {
// Vérifie le code avant de désactiver (sécurité)
if (!twoFactorAuthService.verifyCode(user.getTwoFactorSecret(), request.code())) {
throw new InvalidCodeException("Invalid 2FA code");
}
userService.disable2FA(user.getId());
return ResponseEntity.noContent().build();
}
}Flux d'activation :
POST /2fa/enable: génère le secret et le QR Code, mais n'active pas encore- Le frontend affiche le QR Code à l'utilisateur
- L'utilisateur scanne avec son app
- L'utilisateur entre le code généré par l'app
POST /2fa/verify: vérifie le code et active définitivement la 2FA
Pourquoi cette approche en 2 étapes ?
Si on activait la 2FA dès la génération du secret, l'utilisateur pourrait perdre l'accès à son compte s'il ne scanne pas correctement le QR Code. La vérification confirme que l'app fonctionne avant d'activer.
Service utilisateur
@Service
public class UserService {
private final UserRepository userRepository;
public UserService(UserRepository userRepository) {
this.userRepository = userRepository;
}
@Transactional
public void set2FASecret(Long userId, String secret) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new UserNotFoundException("User not found"));
user.setTwoFactorSecret(secret);
// 2FA pas encore activée à ce stade
userRepository.save(user);
}
@Transactional
public void enable2FA(Long userId) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new UserNotFoundException("User not found"));
if (user.getTwoFactorSecret() == null) {
throw new IllegalStateException("2FA secret not set");
}
user.setTwoFactorEnabled(true);
userRepository.save(user);
}
@Transactional
public void disable2FA(Long userId) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new UserNotFoundException("User not found"));
user.setTwoFactorEnabled(false);
user.setTwoFactorSecret(null); // Supprime le secret
userRepository.save(user);
}
}Login avec 2FA
Modifie ton AuthenticationService pour gérer la 2FA :
@Service
public class AuthenticationService {
private final AuthenticationManager authenticationManager;
private final UserRepository userRepository;
private final JwtService jwtService;
private final TwoFactorAuthService twoFactorAuthService;
public AuthenticationResponse login(LoginRequest request) {
// ÉTAPE 1 : Vérifie email + mot de passe
authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(request.email(), request.password())
);
var user = userRepository.findByEmail(request.email())
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
// ÉTAPE 2 : Si 2FA activée, ne génère PAS encore les tokens
if (user.isTwoFactorEnabled()) {
return new AuthenticationResponse(null, null, true);
}
// ÉTAPE 3 : Pas de 2FA → génère directement les tokens
var accessToken = jwtService.generateToken(user);
var refreshToken = jwtService.generateRefreshToken(user);
return new AuthenticationResponse(accessToken, refreshToken, false);
}
public AuthenticationResponse verify2FAAndLogin(String email, String code) {
var user = userRepository.findByEmail(email)
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
// Vérifie le code 2FA
if (!twoFactorAuthService.verifyCode(user.getTwoFactorSecret(), code)) {
throw new InvalidCodeException("Invalid 2FA code");
}
// Code valide → génère les tokens JWT
var accessToken = jwtService.generateToken(user);
var refreshToken = jwtService.generateRefreshToken(user);
return new AuthenticationResponse(accessToken, refreshToken, false);
}
}Controller d'authentification
@RestController
@RequestMapping("/api/auth")
public class AuthenticationController {
private final AuthenticationService authenticationService;
@PostMapping("/login")
public ResponseEntity<AuthenticationResponse> login(
@Valid @RequestBody LoginRequest request
) {
return ResponseEntity.ok(authenticationService.login(request));
}
@PostMapping("/2fa/verify")
public ResponseEntity<AuthenticationResponse> verify2FA(
@Valid @RequestBody Verify2FALoginRequest request
) {
return ResponseEntity.ok(
authenticationService.verify2FAAndLogin(request.email(), request.code())
);
}
}public record Verify2FALoginRequest(
@NotBlank @Email String email,
@NotBlank String code
) {}Frontend : gestion de la 2FA
Exemple de flux côté frontend (React/Vue/Angular) :
// Login classique
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password })
});
const data = await response.json();
if (data.requires2FA) {
// Affiche un champ pour saisir le code 2FA
setShow2FAInput(true);
setUserEmail(email);
} else {
// Login réussi, stocke les tokens
localStorage.setItem('accessToken', data.accessToken);
localStorage.setItem('refreshToken', data.refreshToken);
navigate('/dashboard');
}
// Vérification du code 2FA
const verify2FA = async (code) => {
const response = await fetch('/api/auth/2fa/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: userEmail, code })
});
if (response.ok) {
const data = await response.json();
localStorage.setItem('accessToken', data.accessToken);
localStorage.setItem('refreshToken', data.refreshToken);
navigate('/dashboard');
} else {
setError('Code invalide');
}
};Affichage du QR Code
Le QR Code est retourné en Data URI (Base64). Pour l'afficher :
// Activation 2FA
const enable2FA = async () => {
const response = await fetch('/api/users/2fa/enable', {
method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json'
}
});
const data = await response.json();
// Affiche le QR Code (Data URI directement utilisable dans <img>)
setQrCodeDataUri(data.qrCodeDataUri);
setSecret(data.secret); // Pour affichage manuel si scan impossible
};
// Dans le JSX/template
<img src={qrCodeDataUri} alt="QR Code 2FA" />
<p>Ou entrez manuellement ce code : {secret}</p>Backup codes (recommandé)
Pour éviter de perdre l'accès au compte si l'utilisateur perd son téléphone, génère des codes de secours.
@Service
public class BackupCodeService {
public List<String> generateBackupCodes(int count) {
List<String> codes = new ArrayList<>();
SecureRandom random = new SecureRandom();
for (int i = 0; i < count; i++) {
// Génère un code de 8 caractères alphanumériques
String code = String.format("%08d", random.nextInt(100000000));
codes.add(code);
}
return codes;
}
}Stocke ces codes en base (hachés avec BCrypt) lors de l'activation de la 2FA. L'utilisateur peut les utiliser à la place du code TOTP.
Tests
@SpringBootTest
class TwoFactorAuthServiceTest {
@Autowired
private TwoFactorAuthService twoFactorAuthService;
@Test
void shouldGenerateValidSecret() {
String secret = twoFactorAuthService.generateSecret();
assertThat(secret).isNotNull();
assertThat(secret.length()).isGreaterThan(10);
}
@Test
void shouldGenerateQrCode() {
String secret = twoFactorAuthService.generateSecret();
String qrCode = twoFactorAuthService.generateQrCodeDataUri(secret, "test@example.com");
assertThat(qrCode).startsWith("data:image/png;base64,");
}
@Test
void shouldVerifyValidCode() {
String secret = "JBSWY3DPEHPK3PXP"; // Secret de test
CodeGenerator generator = new DefaultCodeGenerator();
String code = generator.generate(secret, System.currentTimeMillis() / 1000 / 30);
boolean valid = twoFactorAuthService.verifyCode(secret, code);
assertThat(valid).isTrue();
}
@Test
void shouldRejectInvalidCode() {
String secret = twoFactorAuthService.generateSecret();
boolean valid = twoFactorAuthService.verifyCode(secret, "000000");
assertThat(valid).isFalse();
}
}Sécurité et bonnes pratiques
1. Rate limiting sur vérification 2FA
Sans rate limiting, un attaquant peut tenter 1 million de combinaisons (000000 → 999999).
@PostMapping("/2fa/verify")
public ResponseEntity<Void> verify2FA(
@AuthenticationPrincipal User user,
@Valid @RequestBody Verify2FARequest request,
HttpServletRequest httpRequest
) {
String ip = getClientIP(httpRequest);
if (rateLimitService.isBlocked(ip + ":" + user.getId())) {
throw new TooManyAttemptsException("Too many failed attempts");
}
if (!twoFactorAuthService.verifyCode(user.getTwoFactorSecret(), request.code())) {
rateLimitService.recordFailure(ip + ":" + user.getId());
throw new InvalidCodeException("Invalid 2FA code");
}
rateLimitService.reset(ip + ":" + user.getId());
userService.enable2FA(user.getId());
return ResponseEntity.ok().build();
}Limite recommandée : 5 tentatives par minute.
2. Logs d'audit
@PostMapping("/2fa/enable")
public ResponseEntity<Enable2FAResponse> enable2FA(@AuthenticationPrincipal User user) {
log.info("2FA activation requested by user: {}", user.getEmail());
String secret = twoFactorAuthService.generateSecret();
String qrCodeDataUri = twoFactorAuthService.generateQrCodeDataUri(secret, user.getEmail());
userService.set2FASecret(user.getId(), secret);
return ResponseEntity.ok(new Enable2FAResponse(qrCodeDataUri, secret));
}
@PostMapping("/2fa/verify")
public ResponseEntity<Void> verify2FA(@AuthenticationPrincipal User user, @Valid @RequestBody Verify2FARequest request) {
if (!twoFactorAuthService.verifyCode(user.getTwoFactorSecret(), request.code())) {
log.warn("Invalid 2FA code attempt for user: {}", user.getEmail());
throw new InvalidCodeException("Invalid 2FA code");
}
userService.enable2FA(user.getId());
log.info("2FA successfully enabled for user: {}", user.getEmail());
return ResponseEntity.ok().build();
}3. Ne jamais exposer le secret
public record UserDTO(
Long id,
String email,
String firstName,
String lastName,
boolean twoFactorEnabled
// ❌ PAS de twoFactorSecret ici
) {
public static UserDTO fromEntity(User user) {
return new UserDTO(
user.getId(),
user.getEmail(),
user.getFirstName(),
user.getLastName(),
user.isTwoFactorEnabled()
);
}
}Le secret ne doit jamais être renvoyé dans les réponses API (sauf lors de l'activation initiale pour l'afficher une seule fois).
4. Désactivation sécurisée
Pour désactiver la 2FA, demande le code actuel pour confirmer que c'est bien le propriétaire du compte.
Conclusion
Tu as maintenant un système de 2FA complet et production-ready.
Ce que tu as appris :
- Principe TOTP et sécurité
- Implémentation avec
totp-java - Flux d'activation et de login
- Génération de QR Codes
- Rate limiting sur vérification
- Backup codes pour récupération
Pourquoi implémenter la 2FA :
- Protection contre le phishing
- Sécurité renforcée pour comptes critiques
- Conformité réglementaire (RGPD, PSD2, etc.)
- Trust utilisateur (apps bancaires, santé)
Pour aller plus loin :
- SMS 2FA (Twilio, AWS SNS)
- Email 2FA
- Push notifications (authentification sans code)
- WebAuthn / Passkeys (FIDO2)
Articles liés :