diff --git a/eme-backend/.env.example b/eme-backend/.env.example index d20cbed..0b9bbd7 100644 --- a/eme-backend/.env.example +++ b/eme-backend/.env.example @@ -9,5 +9,9 @@ DB_NAME=eme_db DB_USER=sa DB_PASSWORD=YourStrongPassword123! +AUTH_MODE=demo AZURE_TENANT_ID= -AZURE_CLIENT_ID= +AZURE_API_CLIENT_ID= +AZURE_API_AUDIENCE= +AZURE_API_SCOPE=access_as_user +AZURE_ALLOWED_EMAIL_DOMAINS=ensup.eu,ensitech.eu diff --git a/eme-backend/package-lock.json b/eme-backend/package-lock.json index 4b6a7f3..7491291 100644 --- a/eme-backend/package-lock.json +++ b/eme-backend/package-lock.json @@ -14,7 +14,8 @@ "cors": "^2.8.6", "dotenv": "^17.4.2", "express": "^5.2.1", - "jsonwebtoken": "^9.0.3" + "jsonwebtoken": "^9.0.3", + "jwks-rsa": "^4.1.0" }, "devDependencies": { "@eslint/js": "^10.0.1", @@ -1068,7 +1069,6 @@ "version": "9.0.10", "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", - "dev": true, "license": "MIT", "dependencies": { "@types/ms": "*", @@ -1079,7 +1079,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", - "dev": true, "license": "MIT" }, "node_modules/@types/node": { @@ -3074,6 +3073,15 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/jose": { + "version": "6.2.4", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.4.tgz", + "integrity": "sha512-N8acGzVsQy6M/fjFcxtysNc4Q379TcM5dM/qKkNtsHFji88yANnXTr7BLeP75iPnFwBfQzM/jg2BZ9+HZrHCZA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-md4": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/js-md4/-/js-md4-0.3.2.tgz", @@ -3134,6 +3142,23 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/jwks-rsa": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/jwks-rsa/-/jwks-rsa-4.1.0.tgz", + "integrity": "sha512-sbkByqyATKYJP5F4RXj03N5TUNC0QLTjCAZvwTzC4BwJZ8e0/cWxN8YROnyUth2g1/ONWi4eSFHeu6oYalrc3Q==", + "license": "MIT", + "dependencies": { + "@types/jsonwebtoken": "^9.0.4", + "debug": "^4.3.4", + "jose": "^6.1.3", + "limiter": "^1.1.5", + "lru-cache": "^11.0.0", + "lru-memoizer": "^3.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >= 23.0.0" + } + }, "node_modules/jws": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", @@ -3168,6 +3193,11 @@ "node": ">= 0.8.0" } }, + "node_modules/limiter": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/limiter/-/limiter-1.1.5.tgz", + "integrity": "sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA==" + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -3184,6 +3214,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash.clonedeep": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", + "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==", + "license": "MIT" + }, "node_modules/lodash.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", @@ -3233,6 +3269,25 @@ "devOptional": true, "license": "Apache-2.0" }, + "node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/lru-memoizer": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/lru-memoizer/-/lru-memoizer-3.0.0.tgz", + "integrity": "sha512-m83w/cYXLdUIboKSPxzPAGfYnk+vqeDYXuoSrQRw1q+yVEd8IXhvMufN8Q5TIPe7e2jyX4SRNrDJI2Skw1yznQ==", + "license": "MIT", + "dependencies": { + "lodash.clonedeep": "^4.5.0", + "lru-cache": "^11.0.1" + } + }, "node_modules/lru.min": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.4.tgz", diff --git a/eme-backend/package.json b/eme-backend/package.json index 2d6661e..7fc73b3 100644 --- a/eme-backend/package.json +++ b/eme-backend/package.json @@ -43,6 +43,7 @@ "cors": "^2.8.6", "dotenv": "^17.4.2", "express": "^5.2.1", - "jsonwebtoken": "^9.0.3" + "jsonwebtoken": "^9.0.3", + "jwks-rsa": "^4.1.0" } } diff --git a/eme-backend/src/config/env.ts b/eme-backend/src/config/env.ts index bf33942..4e537f5 100644 --- a/eme-backend/src/config/env.ts +++ b/eme-backend/src/config/env.ts @@ -2,6 +2,8 @@ import dotenv from 'dotenv'; dotenv.config(); +type AuthMode = 'demo' | 'azure'; + function required(name: string): string { const value = process.env[name]; @@ -28,9 +30,50 @@ function numberFromEnv(name: string, defaultValue: number): number { return parsed; } +function authModeFromEnv(nodeEnv: string): AuthMode { + const value = process.env.AUTH_MODE || (nodeEnv === 'production' ? 'azure' : 'demo'); + + if (value !== 'demo' && value !== 'azure') { + throw new Error('Variable d’environnement invalide: AUTH_MODE'); + } + + if (nodeEnv === 'production' && value === 'demo') { + throw new Error('AUTH_MODE=demo est interdit en production'); + } + + return value; +} + +function azureValue(name: string, authMode: AuthMode): string { + const value = process.env[name]; + + if (authMode === 'azure' && !value) { + throw new Error(`Variable d'environnement manquante en mode Azure: ${name}`); + } + + return value || ''; +} + +function listFromEnv(name: string, defaultValue: string[]): string[] { + const value = process.env[name]; + + if (!value) { + return defaultValue; + } + + return value + .split(',') + .map((item) => item.trim().toLowerCase()) + .filter((item) => item.length > 0); +} + +const nodeEnv = process.env.NODE_ENV || 'development'; +const authMode = authModeFromEnv(nodeEnv); +const azureApiClientId = azureValue('AZURE_API_CLIENT_ID', authMode); + export const env = { port: numberFromEnv('PORT', 3000), - nodeEnv: process.env.NODE_ENV || 'development', + nodeEnv, frontendUrl: required('FRONTEND_URL'), databaseUrl: required('DATABASE_URL'), db: { @@ -40,8 +83,17 @@ export const env = { user: required('DB_USER'), password: required('DB_PASSWORD'), }, - azure: { - tenantId: required('AZURE_TENANT_ID'), - clientId: required('AZURE_CLIENT_ID'), + auth: { + mode: authMode, + azure: { + tenantId: azureValue('AZURE_TENANT_ID', authMode), + apiClientId: azureApiClientId, + audience: process.env.AZURE_API_AUDIENCE || azureApiClientId, + scope: process.env.AZURE_API_SCOPE || 'access_as_user', + allowedEmailDomains: listFromEnv('AZURE_ALLOWED_EMAIL_DOMAINS', [ + 'ensup.eu', + 'ensitech.eu', + ]), + }, }, }; diff --git a/eme-backend/src/middlewares/current-user.ts b/eme-backend/src/middlewares/current-user.ts index 89f90dd..112e82c 100644 --- a/eme-backend/src/middlewares/current-user.ts +++ b/eme-backend/src/middlewares/current-user.ts @@ -1,28 +1,10 @@ import { Request, Response, NextFunction } from 'express'; -import { AppError } from '../errors/app-error'; -import { findByEmail } from '../repositories/utilisateur.repository'; -import { AuthenticatedUser } from '../types/authenticated-user'; +import { authenticateUser } from '../services/authentication.service'; -/* Auth simulée (temporaire) : en attendant Azure AD, l'utilisateur courant est - résolu depuis l'en-tête `x-user-email`. À remplacer par la validation du JWT. */ export async function currentUser(req: Request, _res: Response, next: NextFunction): Promise { - const email = req.header('x-user-email'); - if (!email) { - throw new AppError(401, 'En-tete x-user-email requis (auth simulee)'); - } - - const utilisateur = await findByEmail(email); - if (!utilisateur || !utilisateur.actif) { - throw new AppError(401, 'Utilisateur non reconnu'); - } - - const authenticated: AuthenticatedUser = { - id: utilisateur.id, - email: utilisateur.email, - roleCode: utilisateur.role.code, - campusId: utilisateur.campusId, - }; - - req.user = authenticated; + req.user = await authenticateUser({ + authorization: req.header('authorization'), + demoEmail: req.header('x-user-email'), + }); next(); } diff --git a/eme-backend/src/repositories/utilisateur.repository.ts b/eme-backend/src/repositories/utilisateur.repository.ts index 8a375af..79cb499 100644 --- a/eme-backend/src/repositories/utilisateur.repository.ts +++ b/eme-backend/src/repositories/utilisateur.repository.ts @@ -14,6 +14,13 @@ export function findByEmail(email: string): Promise }); } +export function findByMicrosoftId(microsoftId: string): Promise { + return prisma.utilisateur.findUnique({ + where: { microsoftId }, + include: { role: true }, + }); +} + export function findById(id: number): Promise { return prisma.utilisateur.findUnique({ where: { id }, diff --git a/eme-backend/src/services/authentication.service.ts b/eme-backend/src/services/authentication.service.ts new file mode 100644 index 0000000..00932c0 --- /dev/null +++ b/eme-backend/src/services/authentication.service.ts @@ -0,0 +1,68 @@ +import { env } from '../config/env'; +import { AppError } from '../errors/app-error'; +import { + findByEmail, + findByMicrosoftId, + UtilisateurAvecRole, +} from '../repositories/utilisateur.repository'; +import { AuthenticatedUser } from '../types/authenticated-user'; +import { verifyAzureAccessToken } from './azure-token.service'; + +export interface AuthenticationHeaders { + authorization?: string; + demoEmail?: string; +} + +function toAuthenticatedUser(utilisateur: UtilisateurAvecRole): AuthenticatedUser { + return { + id: utilisateur.id, + email: utilisateur.email, + roleCode: utilisateur.role.code, + campusId: utilisateur.campusId, + }; +} + +function bearerToken(authorization: string | undefined): string { + const match = authorization?.match(/^Bearer\s+(\S+)$/i); + if (!match) { + throw new AppError(401, 'Jeton Bearer requis'); + } + + return match[1]; +} + +async function authenticateDemo(email: string | undefined): Promise { + if (!email) { + throw new AppError(401, 'En-tete x-user-email requis en mode demo'); + } + + return findByEmail(email.trim().toLowerCase()); +} + +async function authenticateAzure( + authorization: string | undefined, +): Promise { + const identity = await verifyAzureAccessToken(bearerToken(authorization)); + const byMicrosoftId = await findByMicrosoftId(identity.microsoftId); + + if (byMicrosoftId) { + return byMicrosoftId; + } + + return findByEmail(identity.email); +} + +export async function authenticateUser( + headers: AuthenticationHeaders, +): Promise { + const utilisateur = + env.auth.mode === 'azure' + ? await authenticateAzure(headers.authorization) + : await authenticateDemo(headers.demoEmail); + + if (!utilisateur || !utilisateur.actif) { + throw new AppError(401, 'Utilisateur non reconnu'); + } + + return toAuthenticatedUser(utilisateur); +} diff --git a/eme-backend/src/services/azure-token.service.ts b/eme-backend/src/services/azure-token.service.ts new file mode 100644 index 0000000..cb7b8db --- /dev/null +++ b/eme-backend/src/services/azure-token.service.ts @@ -0,0 +1,122 @@ +import jwt, { + GetPublicKeyOrSecret, + JwtHeader, + JwtPayload, + SigningKeyCallback, +} from 'jsonwebtoken'; +import jwksClient from 'jwks-rsa'; +import { env } from '../config/env'; +import { AppError } from '../errors/app-error'; + +export interface AzureIdentity { + microsoftId: string; + email: string; +} + +interface AzureClaims extends JwtPayload { + oid?: string; + tid?: string; + scp?: string; + preferred_username?: string; + upn?: string; + email?: string; +} + +const issuer = `https://login.microsoftonline.com/${env.auth.azure.tenantId}/v2.0`; +const client = jwksClient({ + jwksUri: `${issuer}/discovery/v2.0/keys`, + cache: true, + cacheMaxEntries: 5, + cacheMaxAge: 10 * 60 * 1000, + rateLimit: true, + jwksRequestsPerMinute: 10, + timeout: 5000, +}); + +const getSigningKey: GetPublicKeyOrSecret = ( + header: JwtHeader, + callback: SigningKeyCallback, +): void => { + if (!header.kid) { + callback(new Error('Identifiant de cle de signature absent')); + return; + } + + client.getSigningKey(header.kid, (error, key) => { + if (error || !key) { + callback(error || new Error('Cle de signature Microsoft introuvable')); + return; + } + + callback(null, key.getPublicKey()); + }); +}; + +function verifyJwt(token: string): Promise { + return new Promise((resolve, reject) => { + jwt.verify( + token, + getSigningKey, + { + algorithms: ['RS256'], + audience: env.auth.azure.audience, + issuer, + }, + (error, decoded) => { + if (error || !decoded || typeof decoded === 'string') { + reject(error || new Error('Contenu du jeton invalide')); + return; + } + + resolve(decoded); + }, + ); + }); +} + +function emailFromClaims(claims: AzureClaims): string | null { + const email = claims.preferred_username || claims.upn || claims.email; + return email?.trim().toLowerCase() || null; +} + +function hasAllowedEmailDomain(email: string): boolean { + const separatorIndex = email.lastIndexOf('@'); + if (separatorIndex < 1) { + return false; + } + + const domain = email.slice(separatorIndex + 1); + return env.auth.azure.allowedEmailDomains.includes(domain); +} + +export async function verifyAzureAccessToken(token: string): Promise { + if (env.auth.mode !== 'azure') { + throw new AppError(500, 'Validation Azure indisponible hors du mode Azure'); + } + + let claims: AzureClaims; + try { + claims = await verifyJwt(token); + } catch { + throw new AppError(401, 'Jeton Microsoft invalide ou expire'); + } + + if (claims.tid !== env.auth.azure.tenantId) { + throw new AppError(401, 'Tenant Microsoft non autorise'); + } + + const scopes = claims.scp?.split(' ') || []; + if (!scopes.includes(env.auth.azure.scope)) { + throw new AppError(403, 'Permission Microsoft insuffisante'); + } + + const email = emailFromClaims(claims); + if (!claims.oid || !email || !hasAllowedEmailDomain(email)) { + throw new AppError(401, 'Identite Microsoft non autorisee'); + } + + return { + microsoftId: claims.oid, + email, + }; +}