diff --git a/app/api/auth/login/route.ts b/app/api/auth/login/route.ts new file mode 100644 index 0000000..bf1020b --- /dev/null +++ b/app/api/auth/login/route.ts @@ -0,0 +1,29 @@ +import { NextRequest, NextResponse } from 'next/server'; + +const KEYCLOAK_URL = process.env.NEXT_PUBLIC_KEYCLOAK_URL || 'https://security.lions.dev'; +const KEYCLOAK_REALM = process.env.NEXT_PUBLIC_KEYCLOAK_REALM || 'btpxpress'; +const CLIENT_ID = process.env.NEXT_PUBLIC_KEYCLOAK_CLIENT_ID || 'btpxpress-frontend'; +const REDIRECT_URI = process.env.NEXT_PUBLIC_APP_URL + ? `${process.env.NEXT_PUBLIC_APP_URL}/auth/callback` + : 'https://btpxpress.lions.dev/auth/callback'; + +export async function GET(request: NextRequest) { + console.log('🔐 Login API called'); + + // GĂ©nĂ©rer un state alĂ©atoire pour CSRF protection + const state = Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15); + + // Construire l'URL d'autorisation Keycloak + const authUrl = new URL(`${KEYCLOAK_URL}/realms/${KEYCLOAK_REALM}/protocol/openid-connect/auth`); + + authUrl.searchParams.set('client_id', CLIENT_ID); + authUrl.searchParams.set('redirect_uri', REDIRECT_URI); + authUrl.searchParams.set('response_type', 'code'); + authUrl.searchParams.set('scope', 'openid profile email'); + authUrl.searchParams.set('state', state); + + console.log('✅ Redirecting to Keycloak:', authUrl.toString()); + + // Rediriger vers Keycloak pour l'authentification + return NextResponse.redirect(authUrl.toString()); +} diff --git a/app/api/auth/logout/route.ts b/app/api/auth/logout/route.ts new file mode 100644 index 0000000..a88389a --- /dev/null +++ b/app/api/auth/logout/route.ts @@ -0,0 +1,48 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { cookies } from 'next/headers'; + +const KEYCLOAK_URL = process.env.NEXT_PUBLIC_KEYCLOAK_URL || 'https://security.lions.dev'; +const KEYCLOAK_REALM = process.env.NEXT_PUBLIC_KEYCLOAK_REALM || 'btpxpress'; +const CLIENT_ID = process.env.NEXT_PUBLIC_KEYCLOAK_CLIENT_ID || 'btpxpress-frontend'; +const POST_LOGOUT_REDIRECT_URI = process.env.NEXT_PUBLIC_APP_URL || 'https://btpxpress.lions.dev'; + +export async function GET(request: NextRequest) { + console.log('đŸšȘ Logout API called'); + + const cookieStore = cookies(); + + // RĂ©cupĂ©rer l'id_token avant de supprimer les cookies + const idToken = cookieStore.get('id_token')?.value; + + // Supprimer tous les cookies d'authentification + cookieStore.delete('access_token'); + cookieStore.delete('refresh_token'); + cookieStore.delete('id_token'); + cookieStore.delete('token_expires_at'); + + console.log('✅ Authentication cookies deleted'); + + // Si on a un id_token, on fait un logout Keycloak + if (idToken) { + const logoutUrl = new URL( + `${KEYCLOAK_URL}/realms/${KEYCLOAK_REALM}/protocol/openid-connect/logout` + ); + + logoutUrl.searchParams.set('client_id', CLIENT_ID); + logoutUrl.searchParams.set('post_logout_redirect_uri', POST_LOGOUT_REDIRECT_URI); + logoutUrl.searchParams.set('id_token_hint', idToken); + + console.log('✅ Redirecting to Keycloak logout'); + + return NextResponse.redirect(logoutUrl.toString()); + } + + // Sinon, rediriger directement vers la page d'accueil + console.log('✅ Redirecting to home page'); + return NextResponse.redirect(POST_LOGOUT_REDIRECT_URI); +} + +export async function POST(request: NextRequest) { + // MĂȘme logique pour POST + return GET(request); +} diff --git a/app/api/auth/token/route.ts b/app/api/auth/token/route.ts new file mode 100644 index 0000000..19ce7ff --- /dev/null +++ b/app/api/auth/token/route.ts @@ -0,0 +1,138 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { cookies } from 'next/headers'; + +const KEYCLOAK_URL = process.env.NEXT_PUBLIC_KEYCLOAK_URL || 'https://security.lions.dev'; +const KEYCLOAK_REALM = process.env.NEXT_PUBLIC_KEYCLOAK_REALM || 'btpxpress'; +const CLIENT_ID = process.env.NEXT_PUBLIC_KEYCLOAK_CLIENT_ID || 'btpxpress-frontend'; +const REDIRECT_URI = process.env.NEXT_PUBLIC_APP_URL + ? `${process.env.NEXT_PUBLIC_APP_URL}/auth/callback` + : 'https://btpxpress.lions.dev/auth/callback'; + +const TOKEN_ENDPOINT = `${KEYCLOAK_URL}/realms/${KEYCLOAK_REALM}/protocol/openid-connect/token`; + +export async function POST(request: NextRequest) { + console.log('🔐 Token exchange API called'); + + try { + const body = await request.json(); + const { code, state } = body; + + if (!code) { + console.error('❌ No authorization code provided'); + return NextResponse.json( + { error: 'Code d\'autorisation manquant' }, + { status: 400 } + ); + } + + console.log('✅ Exchanging code with Keycloak...'); + console.log('📍 Token endpoint:', TOKEN_ENDPOINT); + console.log('📍 Client ID:', CLIENT_ID); + console.log('📍 Redirect URI:', REDIRECT_URI); + + // PrĂ©parer les paramĂštres pour l'Ă©change de code + const params = new URLSearchParams({ + grant_type: 'authorization_code', + client_id: CLIENT_ID, + code: code, + redirect_uri: REDIRECT_URI, + }); + + // Échanger le code contre des tokens + const tokenResponse = await fetch(TOKEN_ENDPOINT, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: params.toString(), + }); + + if (!tokenResponse.ok) { + const errorText = await tokenResponse.text(); + console.error('❌ Keycloak token exchange failed:', tokenResponse.status, errorText); + + let errorData; + try { + errorData = JSON.parse(errorText); + } catch { + errorData = { error: 'Token exchange failed', details: errorText }; + } + + return NextResponse.json( + { + error: errorData.error || 'Échec de l\'Ă©change de token', + error_description: errorData.error_description || 'Erreur lors de la communication avec Keycloak', + details: errorData + }, + { status: tokenResponse.status } + ); + } + + const tokens = await tokenResponse.json(); + console.log('✅ Tokens received from Keycloak'); + + // Stocker les tokens dans des cookies HttpOnly sĂ©curisĂ©s + const cookieStore = cookies(); + const isProduction = process.env.NODE_ENV === 'production'; + + // Access token (durĂ©e: expires_in secondes) + cookieStore.set('access_token', tokens.access_token, { + httpOnly: true, + secure: isProduction, + sameSite: 'lax', + maxAge: tokens.expires_in || 300, // Par dĂ©faut 5 minutes + path: '/', + }); + + // Refresh token (durĂ©e plus longue) + if (tokens.refresh_token) { + cookieStore.set('refresh_token', tokens.refresh_token, { + httpOnly: true, + secure: isProduction, + sameSite: 'lax', + maxAge: tokens.refresh_expires_in || 1800, // Par dĂ©faut 30 minutes + path: '/', + }); + } + + // ID token + if (tokens.id_token) { + cookieStore.set('id_token', tokens.id_token, { + httpOnly: true, + secure: isProduction, + sameSite: 'lax', + maxAge: tokens.expires_in || 300, + path: '/', + }); + } + + // Stocker aussi le temps d'expiration + cookieStore.set('token_expires_at', String(Date.now() + (tokens.expires_in * 1000)), { + httpOnly: true, + secure: isProduction, + sameSite: 'lax', + maxAge: tokens.expires_in || 300, + path: '/', + }); + + console.log('✅ Tokens stored in HttpOnly cookies'); + + // Retourner une rĂ©ponse de succĂšs (sans les tokens) + return NextResponse.json({ + success: true, + message: 'Authentification rĂ©ussie', + expires_in: tokens.expires_in, + }); + + } catch (error) { + console.error('❌ Error in token exchange API:', error); + + return NextResponse.json( + { + error: 'Erreur serveur', + message: error instanceof Error ? error.message : 'Erreur inconnue' + }, + { status: 500 } + ); + } +} diff --git a/app/api/auth/userinfo/route.ts b/app/api/auth/userinfo/route.ts new file mode 100644 index 0000000..0b037ea --- /dev/null +++ b/app/api/auth/userinfo/route.ts @@ -0,0 +1,82 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { cookies } from 'next/headers'; + +const KEYCLOAK_URL = process.env.NEXT_PUBLIC_KEYCLOAK_URL || 'https://security.lions.dev'; +const KEYCLOAK_REALM = process.env.NEXT_PUBLIC_KEYCLOAK_REALM || 'btpxpress'; + +const USERINFO_ENDPOINT = `${KEYCLOAK_URL}/realms/${KEYCLOAK_REALM}/protocol/openid-connect/userinfo`; + +export async function GET(request: NextRequest) { + console.log('đŸ‘€ Userinfo API called'); + + try { + const cookieStore = cookies(); + const accessToken = cookieStore.get('access_token')?.value; + + if (!accessToken) { + console.error('❌ No access token found'); + return NextResponse.json( + { error: 'Non authentifiĂ©', authenticated: false }, + { status: 401 } + ); + } + + console.log('✅ Access token found, fetching user info from Keycloak'); + + // RĂ©cupĂ©rer les informations utilisateur depuis Keycloak + const userinfoResponse = await fetch(USERINFO_ENDPOINT, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + }); + + if (!userinfoResponse.ok) { + const errorText = await userinfoResponse.text(); + console.error('❌ Keycloak userinfo failed:', userinfoResponse.status, errorText); + + // Si le token est invalide ou expirĂ© + if (userinfoResponse.status === 401) { + // Supprimer les cookies invalides + cookieStore.delete('access_token'); + cookieStore.delete('refresh_token'); + cookieStore.delete('id_token'); + cookieStore.delete('token_expires_at'); + + return NextResponse.json( + { error: 'Token expirĂ© ou invalide', authenticated: false }, + { status: 401 } + ); + } + + return NextResponse.json( + { + error: 'Erreur lors de la rĂ©cupĂ©ration des informations utilisateur', + authenticated: false + }, + { status: userinfoResponse.status } + ); + } + + const userinfo = await userinfoResponse.json(); + console.log('✅ User info retrieved:', userinfo.preferred_username || userinfo.sub); + + return NextResponse.json({ + authenticated: true, + user: userinfo, + }); + + } catch (error) { + console.error('❌ Error in userinfo API:', error); + + return NextResponse.json( + { + error: 'Erreur serveur', + authenticated: false, + message: error instanceof Error ? error.message : 'Erreur inconnue' + }, + { status: 500 } + ); + } +} diff --git a/app/auth/login/page.tsx b/app/auth/login/page.tsx new file mode 100644 index 0000000..b9f3f26 --- /dev/null +++ b/app/auth/login/page.tsx @@ -0,0 +1,70 @@ +'use client'; + +import React from 'react'; +import { useSearchParams } from 'next/navigation'; +import { Button } from 'primereact/button'; +import { Card } from 'primereact/card'; + +export default function LoginPage() { + const searchParams = useSearchParams(); + const returnUrl = searchParams.get('returnUrl') || '/dashboard'; + + const handleLogin = () => { + // Stocker l'URL de retour dans le sessionStorage + if (returnUrl) { + sessionStorage.setItem('returnUrl', returnUrl); + } + + // Rediriger vers l'API de login qui initiera le flux OAuth + window.location.href = '/api/auth/login'; + }; + + return ( +
+
+
+
+ BTP Xpress +
+ + Plateforme de gestion pour le secteur du BTP + +
+ + +
+ +

+ Connexion requise +

+

+ Veuillez vous connecter pour accéder à l'application +

+ +
+
+ +
+ + © 2025 BTP Xpress - Tous droits réservés + +
+
+
+ ); +} diff --git a/middleware.ts b/middleware.ts index 99306e3..caac750 100644 --- a/middleware.ts +++ b/middleware.ts @@ -1,25 +1,97 @@ /** - * Middleware Next.js simplifiĂ© + * Middleware Next.js pour l'authentification OAuth avec Keycloak * - * L'authentification est entiĂšrement gĂ©rĂ©e par le backend Quarkus avec Keycloak OIDC. - * Le middleware frontend laisse passer toutes les requĂȘtes. - * - * Flux d'authentification: - * 1. User accĂšde Ă  une page protĂ©gĂ©e du frontend (ex: /dashboard) - * 2. Frontend appelle l'API backend (ex: http://localhost:8080/api/v1/dashboard) - * 3. Backend dĂ©tecte absence de session -> redirige vers Keycloak (security.lions.dev) - * 4. User se connecte sur Keycloak - * 5. Keycloak redirige vers le backend avec le code OAuth - * 6. Backend Ă©change le code, crĂ©e une session, renvoie un cookie - * 7. Frontend reçoit le cookie et peut maintenant appeler l'API + * Ce middleware protĂšge les routes privĂ©es en vĂ©rifiant la prĂ©sence + * d'un access_token dans les cookies HttpOnly. */ import { NextResponse } from 'next/server'; import type { NextRequest } from 'next/server'; +// Routes publiques accessibles sans authentification +const PUBLIC_ROUTES = [ + '/', + '/auth/login', + '/auth/callback', + '/api/auth/login', + '/api/auth/callback', + '/api/auth/token', + '/api/health', +]; + +// Routes API publiques (patterns) +const PUBLIC_API_PATTERNS = [ + /^\/api\/auth\/.*/, + /^\/api\/health/, +]; + +// VĂ©rifie si une route est publique +function isPublicRoute(pathname: string): boolean { + // VĂ©rifier les routes exactes + if (PUBLIC_ROUTES.includes(pathname)) { + return true; + } + + // VĂ©rifier les patterns + return PUBLIC_API_PATTERNS.some(pattern => pattern.test(pathname)); +} + export function middleware(request: NextRequest) { - // Le middleware ne fait plus rien - l'authentification est gĂ©rĂ©e par le backend - // Toutes les requĂȘtes sont autorisĂ©es cĂŽtĂ© frontend + const { pathname } = request.nextUrl; + + console.log('🔒 Middleware - Checking:', pathname); + + // Laisser passer les routes publiques + if (isPublicRoute(pathname)) { + console.log('✅ Middleware - Public route, allowing'); + return NextResponse.next(); + } + + // Laisser passer les fichiers statiques + if ( + pathname.startsWith('/_next') || + pathname.startsWith('/static') || + pathname.includes('.') + ) { + return NextResponse.next(); + } + + // VĂ©rifier la prĂ©sence du token d'authentification + const accessToken = request.cookies.get('access_token'); + const tokenExpiresAt = request.cookies.get('token_expires_at'); + + if (!accessToken) { + console.log('❌ Middleware - No access token, redirecting to login'); + + // Rediriger vers la page de login avec l'URL de retour + const loginUrl = new URL('/auth/login', request.url); + loginUrl.searchParams.set('returnUrl', pathname); + + return NextResponse.redirect(loginUrl); + } + + // VĂ©rifier si le token est expirĂ© + if (tokenExpiresAt) { + const expiresAt = parseInt(tokenExpiresAt.value, 10); + const now = Date.now(); + + if (now >= expiresAt) { + console.log('❌ Middleware - Token expired, redirecting to login'); + + // Supprimer les cookies expirĂ©s + const response = NextResponse.redirect(new URL('/auth/login', request.url)); + response.cookies.delete('access_token'); + response.cookies.delete('refresh_token'); + response.cookies.delete('id_token'); + response.cookies.delete('token_expires_at'); + + return response; + } + } + + console.log('✅ Middleware - Authenticated, allowing'); + + // L'utilisateur est authentifiĂ©, laisser passer return NextResponse.next(); } @@ -33,6 +105,6 @@ export const config = { * - favicon.ico (favicon file) * - public files (images, etc.) */ - '/((?!_next/static|_next/image|favicon.ico|.*\..*|public).*)', + '/((?!_next/static|_next/image|favicon.ico|.*\\..*|public).*)', ], };