chore: import DH V2 D5.6.4 production baseline
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
function positiveInteger(
|
||||
config: ConfigService,
|
||||
key: string,
|
||||
fallback: number,
|
||||
): number {
|
||||
const value = Number(config.get<string>(key) ?? fallback);
|
||||
if (!Number.isInteger(value) || value <= 0) {
|
||||
throw new Error(`${key} must be a positive integer`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function requiredSecret(config: ConfigService, key: string): string {
|
||||
const value = config.get<string>(key);
|
||||
if (!value || value.length < 64) {
|
||||
throw new Error(`${key} must contain at least 64 characters`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function cookieName(config: ConfigService, key: string, fallback: string): string {
|
||||
const value = config.get<string>(key) ?? fallback;
|
||||
if (!/^[A-Za-z0-9_-]{1,80}$/.test(value)) {
|
||||
throw new Error(`${key} must be a valid cookie name`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AuthConfigService {
|
||||
readonly accessTokenSecret: string;
|
||||
readonly refreshTokenPepper: string;
|
||||
readonly accessTokenTtlSeconds: number;
|
||||
readonly refreshTokenTtlSeconds: number;
|
||||
readonly maxLoginAttempts: number;
|
||||
readonly lockoutSeconds: number;
|
||||
readonly webOrigin: string;
|
||||
readonly accessCookieName: string;
|
||||
readonly refreshCookieName: string;
|
||||
readonly csrfCookieName: string;
|
||||
|
||||
constructor(config: ConfigService) {
|
||||
this.accessTokenSecret = requiredSecret(config, 'JWT_ACCESS_SECRET');
|
||||
this.refreshTokenPepper = requiredSecret(config, 'REFRESH_TOKEN_PEPPER');
|
||||
if (this.accessTokenSecret === this.refreshTokenPepper) {
|
||||
throw new Error('JWT_ACCESS_SECRET and REFRESH_TOKEN_PEPPER must differ');
|
||||
}
|
||||
this.accessTokenTtlSeconds = positiveInteger(
|
||||
config,
|
||||
'ACCESS_TOKEN_TTL_SECONDS',
|
||||
900,
|
||||
);
|
||||
this.refreshTokenTtlSeconds = positiveInteger(
|
||||
config,
|
||||
'REFRESH_TOKEN_TTL_SECONDS',
|
||||
604800,
|
||||
);
|
||||
this.maxLoginAttempts = positiveInteger(
|
||||
config,
|
||||
'AUTH_MAX_LOGIN_ATTEMPTS',
|
||||
5,
|
||||
);
|
||||
this.lockoutSeconds = positiveInteger(
|
||||
config,
|
||||
'AUTH_LOCKOUT_SECONDS',
|
||||
900,
|
||||
);
|
||||
|
||||
const webOrigin = config.get<string>('WEB_ORIGIN');
|
||||
if (!webOrigin) throw new Error('Missing required environment variable: WEB_ORIGIN');
|
||||
|
||||
const parsedOrigin = new URL(webOrigin).origin;
|
||||
if (parsedOrigin !== webOrigin || !webOrigin.startsWith('https://')) {
|
||||
throw new Error('WEB_ORIGIN must be an HTTPS origin without a path');
|
||||
}
|
||||
this.webOrigin = parsedOrigin;
|
||||
|
||||
this.accessCookieName = cookieName(
|
||||
config,
|
||||
'ACCESS_COOKIE_NAME',
|
||||
'dhv2_access',
|
||||
);
|
||||
this.refreshCookieName = cookieName(
|
||||
config,
|
||||
'REFRESH_COOKIE_NAME',
|
||||
'dhv2_refresh',
|
||||
);
|
||||
this.csrfCookieName = cookieName(config, 'CSRF_COOKIE_NAME', 'dhv2_csrf');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import {
|
||||
ArgumentsHost,
|
||||
Catch,
|
||||
ExceptionFilter,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
} from '@nestjs/common';
|
||||
import type { Response } from 'express';
|
||||
import type { RequestWithContext } from '../http/request-context';
|
||||
|
||||
interface ErrorBody {
|
||||
code?: unknown;
|
||||
message?: unknown;
|
||||
}
|
||||
|
||||
@Catch()
|
||||
export class ApiExceptionFilter implements ExceptionFilter {
|
||||
catch(exception: unknown, host: ArgumentsHost): void {
|
||||
const context = host.switchToHttp();
|
||||
const request = context.getRequest<RequestWithContext>();
|
||||
const response = context.getResponse<Response>();
|
||||
const status =
|
||||
exception instanceof HttpException
|
||||
? exception.getStatus()
|
||||
: HttpStatus.INTERNAL_SERVER_ERROR;
|
||||
|
||||
const rawBody =
|
||||
exception instanceof HttpException ? exception.getResponse() : undefined;
|
||||
const body =
|
||||
typeof rawBody === 'object' && rawBody !== null
|
||||
? (rawBody as ErrorBody)
|
||||
: undefined;
|
||||
|
||||
let code = typeof body?.code === 'string' ? body.code : undefined;
|
||||
let message = typeof body?.message === 'string' ? body.message : undefined;
|
||||
|
||||
if (status === HttpStatus.BAD_REQUEST && !code) {
|
||||
code = 'VALIDATION_ERROR';
|
||||
message = 'Datos inválidos';
|
||||
}
|
||||
if (status === HttpStatus.UNAUTHORIZED && !code) {
|
||||
code = 'UNAUTHORIZED';
|
||||
message = 'Autenticación requerida';
|
||||
}
|
||||
if (status === HttpStatus.FORBIDDEN && !code) {
|
||||
code = 'FORBIDDEN';
|
||||
message = 'Acceso denegado';
|
||||
}
|
||||
if (status === HttpStatus.TOO_MANY_REQUESTS && !code) {
|
||||
code = 'RATE_LIMITED';
|
||||
message = 'Demasiadas solicitudes';
|
||||
}
|
||||
if (status >= 500) {
|
||||
code = 'INTERNAL_ERROR';
|
||||
message = 'Error interno';
|
||||
}
|
||||
|
||||
response.status(status).json({
|
||||
statusCode: status,
|
||||
code: code ?? 'REQUEST_ERROR',
|
||||
message: message ?? 'La solicitud no pudo procesarse',
|
||||
requestId: request.requestId,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { Request } from 'express';
|
||||
|
||||
export type AuthTransport = 'cookie' | 'bearer';
|
||||
|
||||
export interface AuthPrincipal {
|
||||
userId: string;
|
||||
username: string;
|
||||
sessionId: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string | null;
|
||||
mustChangePassword: boolean;
|
||||
roles: string[];
|
||||
permissions: string[];
|
||||
transport: AuthTransport;
|
||||
}
|
||||
|
||||
export interface RequestWithContext extends Request {
|
||||
requestId: string;
|
||||
auth?: AuthPrincipal;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { NextFunction, Response } from 'express';
|
||||
import type { RequestWithContext } from './request-context';
|
||||
|
||||
const REQUEST_ID_PATTERN = /^[A-Za-z0-9._:-]{8,128}$/;
|
||||
|
||||
export function requestIdMiddleware(
|
||||
request: RequestWithContext,
|
||||
response: Response,
|
||||
next: NextFunction,
|
||||
): void {
|
||||
const supplied = request.header('x-request-id');
|
||||
request.requestId =
|
||||
supplied && REQUEST_ID_PATTERN.test(supplied) ? supplied : randomUUID();
|
||||
response.setHeader('X-Request-ID', request.requestId);
|
||||
next();
|
||||
}
|
||||
Reference in New Issue
Block a user