feat(auth): secure backend authentication modes
This commit is contained in:
@@ -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