feat(auth): secure backend authentication modes

This commit is contained in:
SaidSoighiri94
2026-07-23 12:35:21 +02:00
parent f7ce98343e
commit 313bb1c6ef
8 changed files with 323 additions and 32 deletions
+5 -1
View File
@@ -9,5 +9,9 @@ DB_NAME=eme_db
DB_USER=sa DB_USER=sa
DB_PASSWORD=YourStrongPassword123! DB_PASSWORD=YourStrongPassword123!
AUTH_MODE=demo
AZURE_TENANT_ID= 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
+58 -3
View File
@@ -14,7 +14,8 @@
"cors": "^2.8.6", "cors": "^2.8.6",
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"express": "^5.2.1", "express": "^5.2.1",
"jsonwebtoken": "^9.0.3" "jsonwebtoken": "^9.0.3",
"jwks-rsa": "^4.1.0"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
@@ -1068,7 +1069,6 @@
"version": "9.0.10", "version": "9.0.10",
"resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz",
"integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/ms": "*", "@types/ms": "*",
@@ -1079,7 +1079,6 @@
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/node": { "node_modules/@types/node": {
@@ -3074,6 +3073,15 @@
"jiti": "lib/jiti-cli.mjs" "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": { "node_modules/js-md4": {
"version": "0.3.2", "version": "0.3.2",
"resolved": "https://registry.npmjs.org/js-md4/-/js-md4-0.3.2.tgz", "resolved": "https://registry.npmjs.org/js-md4/-/js-md4-0.3.2.tgz",
@@ -3134,6 +3142,23 @@
"safe-buffer": "^5.0.1" "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": { "node_modules/jws": {
"version": "4.0.1", "version": "4.0.1",
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
@@ -3168,6 +3193,11 @@
"node": ">= 0.8.0" "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": { "node_modules/locate-path": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
@@ -3184,6 +3214,12 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/lodash.includes": {
"version": "4.3.0", "version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
@@ -3233,6 +3269,25 @@
"devOptional": true, "devOptional": true,
"license": "Apache-2.0" "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": { "node_modules/lru.min": {
"version": "1.1.4", "version": "1.1.4",
"resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.4.tgz", "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.4.tgz",
+2 -1
View File
@@ -43,6 +43,7 @@
"cors": "^2.8.6", "cors": "^2.8.6",
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"express": "^5.2.1", "express": "^5.2.1",
"jsonwebtoken": "^9.0.3" "jsonwebtoken": "^9.0.3",
"jwks-rsa": "^4.1.0"
} }
} }
+55 -3
View File
@@ -2,6 +2,8 @@ import dotenv from 'dotenv';
dotenv.config(); dotenv.config();
type AuthMode = 'demo' | 'azure';
function required(name: string): string { function required(name: string): string {
const value = process.env[name]; const value = process.env[name];
@@ -28,9 +30,50 @@ function numberFromEnv(name: string, defaultValue: number): number {
return parsed; 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 denvironnement 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 = { export const env = {
port: numberFromEnv('PORT', 3000), port: numberFromEnv('PORT', 3000),
nodeEnv: process.env.NODE_ENV || 'development', nodeEnv,
frontendUrl: required('FRONTEND_URL'), frontendUrl: required('FRONTEND_URL'),
databaseUrl: required('DATABASE_URL'), databaseUrl: required('DATABASE_URL'),
db: { db: {
@@ -40,8 +83,17 @@ export const env = {
user: required('DB_USER'), user: required('DB_USER'),
password: required('DB_PASSWORD'), password: required('DB_PASSWORD'),
}, },
auth: {
mode: authMode,
azure: { azure: {
tenantId: required('AZURE_TENANT_ID'), tenantId: azureValue('AZURE_TENANT_ID', authMode),
clientId: required('AZURE_CLIENT_ID'), 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',
]),
},
}, },
}; };
+5 -23
View File
@@ -1,28 +1,10 @@
import { Request, Response, NextFunction } from 'express'; import { Request, Response, NextFunction } from 'express';
import { AppError } from '../errors/app-error'; import { authenticateUser } from '../services/authentication.service';
import { findByEmail } from '../repositories/utilisateur.repository';
import { AuthenticatedUser } from '../types/authenticated-user';
/* 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<void> { export async function currentUser(req: Request, _res: Response, next: NextFunction): Promise<void> {
const email = req.header('x-user-email'); req.user = await authenticateUser({
if (!email) { authorization: req.header('authorization'),
throw new AppError(401, 'En-tete x-user-email requis (auth simulee)'); demoEmail: req.header('x-user-email'),
} });
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;
next(); next();
} }
@@ -14,6 +14,13 @@ export function findByEmail(email: string): Promise<UtilisateurAvecRole | null>
}); });
} }
export function findByMicrosoftId(microsoftId: string): Promise<UtilisateurAvecRole | null> {
return prisma.utilisateur.findUnique({
where: { microsoftId },
include: { role: true },
});
}
export function findById(id: number): Promise<UtilisateurProfil | null> { export function findById(id: number): Promise<UtilisateurProfil | null> {
return prisma.utilisateur.findUnique({ return prisma.utilisateur.findUnique({
where: { id }, where: { id },
@@ -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<UtilisateurAvecRole | null> {
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<UtilisateurAvecRole | null> {
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<AuthenticatedUser> {
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);
}
@@ -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<AzureClaims> {
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<AzureIdentity> {
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,
};
}