Initial commit
This commit is contained in:
209
middleware.ts
Normal file
209
middleware.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
|
||||
// Ignorer les requêtes vers /dashboard qui contiennent un code d'autorisation
|
||||
if (pathname === '/dashboard' && request.nextUrl.searchParams.has('code')) {
|
||||
console.log('🔓 Middleware: Autorisant /dashboard avec code d\'autorisation');
|
||||
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;
|
||||
|
||||
let token: string | null = null;
|
||||
|
||||
if (authHeader && authHeader.startsWith('Bearer ')) {
|
||||
token = authHeader.substring(7);
|
||||
} else if (tokenFromCookie) {
|
||||
token = tokenFromCookie;
|
||||
}
|
||||
|
||||
// Si pas de token, rediriger vers l'API d'authentification
|
||||
if (!token) {
|
||||
console.log(`🔒 Middleware: Redirection vers /api/auth/login pour ${pathname}`);
|
||||
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).*)',
|
||||
],
|
||||
};
|
||||
Reference in New Issue
Block a user