Authentification JWT avec backend centralisé
Dans mes projets, j'ai souvent besoin de la même infrastructure d'authentification : inscription, connexion, vérification email, réinitialisation de mot de passe, gestion des tokens. Plutôt que de réimplémenter ça à chaque fois, j'ai construit un backend d'authentification centralisé qui sert toutes mes applications.
Le problème : L'authentification redondante
Chaque projet Symfony/React nécessitait :
- Un système de login/register
- La gestion des tokens JWT
- La vérification des emails
- La réinitialisation des mots de passe
- La protection des routes
- Le refresh token
Résultat : du code dupliqué, des failles potentielles, et une maintenance complexe.
La solution : Un backend centralisé
J'ai créé une API d'authentification unique qui gère tout. Toutes mes applications (portfolio, baccalaureat.sn, SyllVS) s'y connectent.
L'architecture côté backend
1. Le JWT Authenticator
Le cœur de la sécurité : un authenticator Symfony qui valide chaque requête.
// src/Security/JwtAuthenticator.php
class JwtAuthenticator extends AbstractAuthenticator
{
public function __construct(
private readonly UserRepository $userRepository,
private readonly string $jwtSecret,
) {}
public function authenticate(Request $request): SelfValidatingPassport
{
$authHeader = $request->headers->get('Authorization');
if (!str_starts_with($authHeader, 'Bearer ')) {
throw new CustomUserMessageAuthenticationException('Missing token');
}
$jwt = trim(substr($authHeader, 7));
try {
$payload = JWT::decode($jwt, new Key($this->jwtSecret, 'HS256'));
} catch (Throwable $e) {
throw new CustomUserMessageAuthenticationException('Invalid JWT token');
}
if ($payload->exp < time()) {
throw new CustomUserMessageAuthenticationException('Token expired');
}
$user = $this->userRepository->findOneBy(['email' => $payload->email]);
if (!$user) {
throw new CustomUserMessageAuthenticationException('User not found');
}
if ($user->isBlocked()) {
throw new CustomUserMessageAuthenticationException('Account disabled');
}
return new SelfValidatingPassport(
new UserBadge($payload->email, fn () => $user)
);
}
}2. Le TokenService
// src/Service/TokenService.php
class TokenService
{
public function generateToken(User $user, int $ttl = 3600): string
{
$payload = [
'email' => $user->getEmail(),
'roles' => $user->getRoles(),
'exp' => time() + $ttl,
'iat' => time(),
'jti' => bin2hex(random_bytes(16)),
];
return JWT::encode($payload, $this->jwtSecret, 'HS256');
}
public function getUserFromToken(Request $request): ?User
{
$authHeader = $request->headers->get('Authorization');
if (!$authHeader || !str_starts_with($authHeader, 'Bearer ')) {
return null;
}
$jwt = trim(substr($authHeader, 7));
try {
$payload = JWT::decode($jwt, new Key($this->jwtSecret, 'HS256'));
return $this->userRepository->findOneBy(['email' => $payload->email]);
} catch (Throwable $e) {
return null;
}
}
public function refreshToken(Request $request): ?string
{
$user = $this->getUserFromToken($request);
if (!$user) return null;
return $this->generateToken($user);
}
}3. Le contrôleur d'authentification
// src/Controller/Security/AuthController.php
#[Route('/api/security', name: 'security_')]
class AuthController extends AbstractController
{
#[Route('/login', name: 'login', methods: ['POST'])]
public function login(
Request $request,
UserPasswordHasherInterface $hasher,
TokenService $tokenService
): JsonResponse {
$data = json_decode($request->getContent(), true);
$user = $this->userRepository->findOneBy(['email' => $data['email']]);
if (!$user || !$hasher->isPasswordValid($user, $data['password'])) {
return new JsonResponse([
'success' => false,
'error' => 'Invalid credentials'
], 401);
}
$token = $tokenService->generateToken($user);
return new JsonResponse([
'success' => true,
'token' => $token,
'token_type' => 'Bearer',
'expires_in' => 3600,
'user' => [
'id' => $user->getId(),
'email' => $user->getEmail(),
'firstname' => $user->getPrenom(),
'lastname' => $user->getNom(),
'roles' => $user->getRoles(),
'isVerified' => $user->isVerified()
]
]);
}
#[Route('/refresh-token', name: 'refresh_token', methods: ['POST'])]
public function refreshToken(Request $request, TokenService $tokenService): JsonResponse
{
try {
$newToken = $tokenService->refreshToken($request);
if (!$newToken) {
return new JsonResponse([
'success' => false,
'error' => 'Unable to refresh token'
], 401);
}
return new JsonResponse([
'success' => true,
'token' => $newToken,
'expires_in' => 3600
]);
} catch (Exception $e) {
return new JsonResponse([
'success' => false,
'error' => 'Token refresh failed'
], 401);
}
}
}Côté frontend : L'intégration Redux
L'intercepteur Axios avec refresh automatique
// store/slices/authSlice.js
let isRefreshing = false;
let failedQueue = [];
const processQueue = (error, token = null) => {
failedQueue.forEach(prom => {
if (error) prom.reject(error);
else prom.resolve(token);
});
failedQueue = [];
};
api.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
if (error.response?.status === 401 && !originalRequest._retry) {
if (isRefreshing) {
return new Promise((resolve, reject) => {
failedQueue.push({ resolve, reject });
})
.then(token => {
originalRequest.headers.Authorization = `Bearer ${token}`;
return api(originalRequest);
})
.catch(err => Promise.reject(err));
}
originalRequest._retry = true;
isRefreshing = true;
try {
const token = localStorage.getItem('token');
if (!token) throw new Error('No token');
const response = await axios.post(`${API_URL}/user/refresh-token`, {}, {
headers: { Authorization: `Bearer ${token}` }
});
if (response.data.success) {
const newToken = response.data.token;
localStorage.setItem('token', newToken);
processQueue(null, newToken);
originalRequest.headers.Authorization = `Bearer ${newToken}`;
return api(originalRequest);
}
} catch (refreshError) {
processQueue(refreshError, null);
localStorage.removeItem('token');
localStorage.removeItem('user');
window.location.href = '/auth';
return Promise.reject(refreshError);
} finally {
isRefreshing = false;
}
}
return Promise.reject(error);
}
);Les avantages de cette architecture
- ✅ Centralisation : Une seule base de données utilisateurs
- ✅ Sécurité : Une seule clé JWT à protéger
- ✅ Maintenance : Les mises à jour de sécurité sont faites une fois
- ✅ Expérience utilisateur : L'utilisateur est connecté partout
- ✅ Monitoring : Les logs de connexion sont centralisés
- ✅ Scalabilité : Le backend peut servir des milliers d'applications
- ✅ Refresh automatique : L'intercepteur gère tout
Ce que je retiens
- Centraliser l'authentification n'est pas centraliser toutes les données utilisateurs
- Le refresh token avec queue évite les appels multiples en parallèle
- Le blocage après 5 tentatives est efficace contre le brute force
- Les logs de connexion sont essentiels pour le monitoring