- Stockage des tokens dans des cookies HttpOnly côté serveur - Suppression du stockage localStorage côté client - Modification du middleware pour vérifier les cookies HttpOnly - Redirection propre après authentification - Suppression du nettoyage précoce des paramètres URL Cela corrige le problème où le dashboard se rafraîchissait en boucle après l'authentification Keycloak. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
220 lines
7.2 KiB
TypeScript
220 lines
7.2 KiB
TypeScript
/**
|
|
* Middleware Next.js pour la protection des routes avec Keycloak
|
|
* Gère l'authentification et l'autorisation au niveau des routes
|
|
*/
|
|
|
|
import { NextRequest, NextResponse } from 'next/server';
|
|
import { jwtVerify, JWTPayload } from 'jose';
|
|
|
|
// Configuration des routes protégées
|
|
const PROTECTED_ROUTES = [
|
|
'/dashboard',
|
|
'/chantiers',
|
|
'/clients',
|
|
'/devis',
|
|
'/factures',
|
|
'/materiels',
|
|
'/employes',
|
|
'/equipes',
|
|
'/planning',
|
|
'/reports',
|
|
'/admin',
|
|
'/profile',
|
|
] as const;
|
|
|
|
// Configuration des routes publiques (toujours accessibles)
|
|
const PUBLIC_ROUTES = [
|
|
'/',
|
|
'/api/health',
|
|
'/api/auth/login',
|
|
'/api/auth/logout',
|
|
'/api/auth/token',
|
|
] as const;
|
|
|
|
// Configuration des routes par rôle
|
|
const ROLE_BASED_ROUTES = {
|
|
'/admin': ['super_admin', 'admin'],
|
|
'/reports': ['super_admin', 'admin', 'directeur', 'manager'],
|
|
'/employes': ['super_admin', 'admin', 'directeur', 'manager', 'chef_chantier'],
|
|
'/equipes': ['super_admin', 'admin', 'directeur', 'manager', 'chef_chantier'],
|
|
'/materiels/manage': ['super_admin', 'admin', 'logisticien'],
|
|
'/clients/manage': ['super_admin', 'admin', 'commercial'],
|
|
'/devis/manage': ['super_admin', 'admin', 'commercial'],
|
|
'/factures/manage': ['super_admin', 'admin', 'comptable'],
|
|
} as const;
|
|
|
|
// Interface pour le token JWT décodé
|
|
interface KeycloakToken extends JWTPayload {
|
|
preferred_username?: string;
|
|
email?: string;
|
|
given_name?: string;
|
|
family_name?: string;
|
|
realm_access?: {
|
|
roles: string[];
|
|
};
|
|
resource_access?: {
|
|
[key: string]: {
|
|
roles: string[];
|
|
};
|
|
};
|
|
}
|
|
|
|
// Fonction pour vérifier si une route est protégée
|
|
function isProtectedRoute(pathname: string): boolean {
|
|
return PROTECTED_ROUTES.some(route => pathname.startsWith(route));
|
|
}
|
|
|
|
// Fonction pour vérifier si une route est publique
|
|
function isPublicRoute(pathname: string): boolean {
|
|
return PUBLIC_ROUTES.some(route => pathname === route || pathname.startsWith(route));
|
|
}
|
|
|
|
// Fonction pour extraire les rôles du token
|
|
function extractRoles(token: KeycloakToken): string[] {
|
|
const realmRoles = token.realm_access?.roles || [];
|
|
const clientRoles = token.resource_access?.['btpxpress-frontend']?.roles || [];
|
|
return [...realmRoles, ...clientRoles];
|
|
}
|
|
|
|
// Fonction pour vérifier les permissions de rôle
|
|
function hasRequiredRole(userRoles: string[], requiredRoles: string[]): boolean {
|
|
return requiredRoles.some(role => userRoles.includes(role));
|
|
}
|
|
|
|
// Fonction pour vérifier et décoder le token JWT
|
|
async function verifyToken(token: string): Promise<KeycloakToken | null> {
|
|
try {
|
|
// Configuration de la clé publique Keycloak
|
|
const keycloakUrl = process.env.NEXT_PUBLIC_KEYCLOAK_URL || 'https://security.lions.dev';
|
|
const realm = process.env.NEXT_PUBLIC_KEYCLOAK_REALM || 'btpxpress';
|
|
|
|
// Récupérer la clé publique depuis Keycloak
|
|
const jwksUrl = `${keycloakUrl}/realms/${realm}/protocol/openid_connect/certs`;
|
|
|
|
// Pour la vérification côté serveur, nous utilisons une approche simplifiée
|
|
// En production, il faudrait implémenter une vérification complète avec JWKS
|
|
|
|
// Décoder le token sans vérification pour le middleware (vérification côté client)
|
|
const payload = JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString());
|
|
|
|
// Vérifier l'expiration
|
|
if (payload.exp && payload.exp < Date.now() / 1000) {
|
|
return null;
|
|
}
|
|
|
|
return payload as KeycloakToken;
|
|
} catch (error) {
|
|
console.error('Erreur lors de la vérification du token:', error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// Fonction principale du middleware
|
|
export async function middleware(request: NextRequest) {
|
|
const { pathname } = request.nextUrl;
|
|
|
|
// Ignorer les fichiers statiques et les API routes internes
|
|
if (
|
|
pathname.startsWith('/_next') ||
|
|
pathname.startsWith('/api') ||
|
|
pathname.includes('.') ||
|
|
pathname.startsWith('/favicon')
|
|
) {
|
|
return NextResponse.next();
|
|
}
|
|
|
|
// Permettre l'accès aux routes publiques
|
|
if (isPublicRoute(pathname)) {
|
|
return NextResponse.next();
|
|
}
|
|
|
|
// Vérifier si la route nécessite une authentification
|
|
if (isProtectedRoute(pathname)) {
|
|
// Récupérer le token depuis les cookies ou headers
|
|
const authHeader = request.headers.get('authorization');
|
|
const tokenFromCookie = request.cookies.get('keycloak-token')?.value;
|
|
const pkceVerifier = request.cookies.get('pkce_code_verifier')?.value;
|
|
const hasAuthCode = request.nextUrl.searchParams.has('code');
|
|
|
|
console.log(`🔍 Middleware: Vérification de ${pathname}:`, {
|
|
hasAuthHeader: !!authHeader,
|
|
hasTokenCookie: !!tokenFromCookie,
|
|
hasPkceVerifier: !!pkceVerifier,
|
|
hasCode: hasAuthCode
|
|
});
|
|
|
|
let token: string | null = null;
|
|
|
|
if (authHeader && authHeader.startsWith('Bearer ')) {
|
|
token = authHeader.substring(7);
|
|
} else if (tokenFromCookie) {
|
|
token = tokenFromCookie;
|
|
}
|
|
|
|
// Si pas de token, vérifier si un processus d'authentification est en cours
|
|
if (!token) {
|
|
// Autoriser l'accès SEULEMENT si on a un code d'autorisation ET un PKCE verifier
|
|
// Cela permet le premier passage pour l'échange du code
|
|
if (hasAuthCode && pkceVerifier && pathname === '/dashboard') {
|
|
console.log('🔓 Middleware: Autorisant /dashboard pour l\'échange du code d\'autorisation');
|
|
return NextResponse.next();
|
|
}
|
|
|
|
console.log(`🔒 Middleware: Redirection vers /api/auth/login pour ${pathname} (pas de token)`);
|
|
const loginUrl = new URL('/api/auth/login', request.url);
|
|
loginUrl.searchParams.set('redirect', pathname);
|
|
return NextResponse.redirect(loginUrl);
|
|
}
|
|
|
|
// Vérifier et décoder le token
|
|
const decodedToken = await verifyToken(token);
|
|
|
|
if (!decodedToken) {
|
|
// Token invalide ou expiré
|
|
const loginUrl = new URL('/api/auth/login', request.url);
|
|
loginUrl.searchParams.set('redirect', pathname);
|
|
return NextResponse.redirect(loginUrl);
|
|
}
|
|
|
|
// Extraire les rôles de l'utilisateur
|
|
const userRoles = extractRoles(decodedToken);
|
|
|
|
// Vérifier les permissions basées sur les rôles pour des routes spécifiques
|
|
for (const [route, requiredRoles] of Object.entries(ROLE_BASED_ROUTES)) {
|
|
if (pathname.startsWith(route)) {
|
|
if (!hasRequiredRole(userRoles, [...requiredRoles])) {
|
|
// Utilisateur n'a pas les rôles requis
|
|
return NextResponse.redirect(new URL('/api/auth/login', request.url));
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Ajouter les informations utilisateur aux headers pour les composants
|
|
const response = NextResponse.next();
|
|
response.headers.set('x-user-id', decodedToken.sub || '');
|
|
response.headers.set('x-user-email', decodedToken.email || '');
|
|
response.headers.set('x-user-roles', JSON.stringify(userRoles));
|
|
|
|
return response;
|
|
}
|
|
|
|
// Par défaut, permettre l'accès
|
|
return NextResponse.next();
|
|
}
|
|
|
|
// Configuration du matcher pour spécifier quelles routes le middleware doit traiter
|
|
export const config = {
|
|
matcher: [
|
|
/*
|
|
* Match all request paths except for the ones starting with:
|
|
* - api (API routes)
|
|
* - _next/static (static files)
|
|
* - _next/image (image optimization files)
|
|
* - favicon.ico (favicon file)
|
|
* - public files (images, etc.)
|
|
*/
|
|
'/((?!api|_next/static|_next/image|favicon.ico|.*\\..*|public).*)',
|
|
],
|
|
};
|