feat(auth): secure backend authentication modes
This commit is contained in:
@@ -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
|
||||
|
||||
Generated
+58
-3
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
]),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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<void> {
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
return prisma.utilisateur.findUnique({
|
||||
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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user