From 079cedbaed80bf68bb1adc906aa5fb9bcdc3d7e1 Mon Sep 17 00:00:00 2001 From: George Date: Tue, 13 Oct 2026 18:38:11 -0500 Subject: [PATCH] feat(api): login web con sesiones, guard global y freno a fuerza bruta MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - POST /api/auth/login (U_WEB_USER/U_WEB_PASS de EmployeesInfo), logout y me. - Token opaco (32 bytes) en Authorization: Bearer; una sesión por usuario, timeout deslizante configurable (SESSION_TIMEOUT_MINUTES, 30 por defecto). - AuthGuard global con @Public(); /health/sap ahora exige sesión. - Usuario inexistente y contraseña errónea devuelven el mismo mensaje; comparación en tiempo constante; filtro OData escapado; se rechazan U_WEB_USER duplicados. - LoginAttemptsService: 429 tras 5 fallos por usuario o 20 por IP (15 min). - Tests unitarios y e2e con SAP simulado. Co-Authored-By: Claude Sonnet 5.5 --- apps/api/.env.example | 6 +- apps/api/README.md | 19 +++++ apps/api/src/app.module.ts | 3 +- apps/api/src/auth/auth.controller.ts | 40 +++++++++ apps/api/src/auth/auth.guard.ts | 38 +++++++++ apps/api/src/auth/auth.module.ts | 21 +++++ apps/api/src/auth/auth.service.spec.ts | 75 ++++++++++++++++ apps/api/src/auth/auth.service.ts | 85 +++++++++++++++++++ apps/api/src/auth/auth.types.ts | 14 +++ .../src/auth/login-attempts.service.spec.ts | 38 +++++++++ apps/api/src/auth/login-attempts.service.ts | 72 ++++++++++++++++ apps/api/src/auth/public.decorator.ts | 6 ++ .../src/auth/session-store.service.spec.ts | 65 ++++++++++++++ apps/api/src/auth/session-store.service.ts | 84 ++++++++++++++++++ apps/api/src/common/zod-validation.pipe.ts | 19 +++++ apps/api/src/config/env.schema.ts | 6 +- apps/api/src/health/health.controller.ts | 4 +- apps/api/test/auth.e2e-spec.ts | 84 ++++++++++++++++++ 18 files changed, 673 insertions(+), 6 deletions(-) create mode 100644 apps/api/src/auth/auth.controller.ts create mode 100644 apps/api/src/auth/auth.guard.ts create mode 100644 apps/api/src/auth/auth.module.ts create mode 100644 apps/api/src/auth/auth.service.spec.ts create mode 100644 apps/api/src/auth/auth.service.ts create mode 100644 apps/api/src/auth/auth.types.ts create mode 100644 apps/api/src/auth/login-attempts.service.spec.ts create mode 100644 apps/api/src/auth/login-attempts.service.ts create mode 100644 apps/api/src/auth/public.decorator.ts create mode 100644 apps/api/src/auth/session-store.service.spec.ts create mode 100644 apps/api/src/auth/session-store.service.ts create mode 100644 apps/api/src/common/zod-validation.pipe.ts create mode 100644 apps/api/test/auth.e2e-spec.ts diff --git a/apps/api/.env.example b/apps/api/.env.example index cb986d3..bd94c30 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -4,6 +4,8 @@ PORT=3000 CORS_ORIGINS=http://localhost:4200 # error | warn | log | debug | verbose LOG_LEVEL=log +# Inactividad (minutos) tras la cual caduca la sesión web +SESSION_TIMEOUT_MINUTES=30 # --- SAP Business One Service Layer --- SAPB1_SL_URL=https://servidor-sap:50000/b1s/v1 @@ -12,8 +14,8 @@ SAPB1_SL_COMPANYDB= SAPB1_SL_USER= SAPB1_SL_PASSWORD= SAPB1_SL_TIMEOUT_MS=15000 -# Peticiones simultáneas hacia el Service Layer (el resto se encola). Con más de -# ~2 en paralelo el Service Layer de prueba retrasaba ~7 s cada petición extra. +# Peticiones simultáneas hacia el Service Layer (el resto se encola). En el servidor +# de prueba, 1 era lo óptimo (~20 req/s); con 2 o más se degradaba. Medir en cada cliente. SAPB1_SL_MAX_CONCURRENCY=1 # Confianza TLS (elegir según el caso): diff --git a/apps/api/README.md b/apps/api/README.md index 3c97eda..144215b 100644 --- a/apps/api/README.md +++ b/apps/api/README.md @@ -13,3 +13,22 @@ npm run lint Configuración: `src/config/env.schema.ts` valida el `.env` al arrancar (zod); si algo es inválido, el proceso no inicia. Nota: instalar dependencias con npm 11 (`npx npm@11 install`); npm 10 falla al resolver peers con TypeScript 6. + +## Autenticación + +Login web contra `EmployeesInfo` (`U_WEB_USER` / `U_WEB_PASS`); el backend usa la sesión SAP compartida. +Guard global: todo `/api/*` exige `Authorization: Bearer ` salvo rutas `@Public()`. + +| Endpoint | Descripción | +|---|---| +| `POST /api/auth/login` `{username, password}` | Devuelve `{token, user, company}` | +| `GET /api/auth/me` | Usuario de la sesión | +| `POST /api/auth/logout` | Cierra la sesión (204) | + +- Una sesión por usuario (un nuevo login invalida el token anterior); timeout deslizante `SESSION_TIMEOUT_MINUTES` (30). +- Tras 5 fallos por usuario (o 20 por IP) en 15 min responde 429. +- Sesiones en memoria: ejecutar un solo proceso. + +```bash +curl -s -H 'Content-Type: application/json' -d '{"username":"USUARIO","password":"CLAVE"}' localhost:3000/api/auth/login +``` diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 197b75c..168e702 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -1,10 +1,11 @@ import { Module } from '@nestjs/common'; +import { AuthModule } from './auth/auth.module.js'; import { AppConfigModule } from './config/config.module.js'; import { HealthController } from './health/health.controller.js'; import { SapModule } from './sap/sap.module.js'; @Module({ - imports: [AppConfigModule, SapModule], + imports: [AppConfigModule, SapModule, AuthModule], controllers: [HealthController], }) export class AppModule {} diff --git a/apps/api/src/auth/auth.controller.ts b/apps/api/src/auth/auth.controller.ts new file mode 100644 index 0000000..097f5ec --- /dev/null +++ b/apps/api/src/auth/auth.controller.ts @@ -0,0 +1,40 @@ +import { Body, Controller, Get, HttpCode, Post, Req } from '@nestjs/common'; +import { z } from 'zod'; +import { ZodValidationPipe } from '../common/zod-validation.pipe.js'; +import { AppConfigService } from '../config/app-config.service.js'; +import type { AuthenticatedRequest } from './auth.types.js'; +import { AuthService } from './auth.service.js'; +import { Public } from './public.decorator.js'; + +const loginSchema = z.object({ + username: z.string().trim().min(1).max(100), + password: z.string().min(1).max(200), +}); +type LoginDto = z.infer; + +@Controller('auth') +export class AuthController { + constructor( + private readonly auth: AuthService, + private readonly config: AppConfigService, + ) {} + + @Public() + @Post('login') + @HttpCode(200) + async login(@Body(new ZodValidationPipe(loginSchema)) body: LoginDto, @Req() req: AuthenticatedRequest) { + const { token, user } = await this.auth.login(body.username, body.password, req.ip ?? 'desconocida'); + return { token, user, company: this.config.get('SAPB1_SL_COMPANYDB') }; + } + + @Post('logout') + @HttpCode(204) + logout(@Req() req: AuthenticatedRequest): void { + this.auth.logout(req.sessionToken); + } + + @Get('me') + me(@Req() req: AuthenticatedRequest) { + return { user: req.user, company: this.config.get('SAPB1_SL_COMPANYDB') }; + } +} diff --git a/apps/api/src/auth/auth.guard.ts b/apps/api/src/auth/auth.guard.ts new file mode 100644 index 0000000..cbd51fb --- /dev/null +++ b/apps/api/src/auth/auth.guard.ts @@ -0,0 +1,38 @@ +import { type CanActivate, type ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import type { AuthenticatedRequest } from './auth.types.js'; +import { IS_PUBLIC_KEY } from './public.decorator.js'; +import { SessionStoreService } from './session-store.service.js'; + +/** Guard global: exige `Authorization: Bearer ` salvo en rutas @Public(). */ +@Injectable() +export class AuthGuard implements CanActivate { + constructor( + private readonly reflector: Reflector, + private readonly sessions: SessionStoreService, + ) {} + + canActivate(context: ExecutionContext): boolean { + const isPublic = this.reflector.getAllAndOverride(IS_PUBLIC_KEY, [ + context.getHandler(), + context.getClass(), + ]); + if (isPublic) return true; + + const req = context.switchToHttp().getRequest(); + const token = bearerToken(req.headers.authorization); + if (!token) throw new UnauthorizedException('Se requiere autenticación. Inicie sesión primero.'); + + const user = this.sessions.validate(token); + if (!user) throw new UnauthorizedException('Sesión expirada. Debe iniciar sesión nuevamente.'); + + req.user = user; + req.sessionToken = token; + return true; + } +} + +function bearerToken(header: string | undefined): string | undefined { + const [scheme, token] = header?.split(' ') ?? []; + return scheme?.toLowerCase() === 'bearer' && token ? token : undefined; +} diff --git a/apps/api/src/auth/auth.module.ts b/apps/api/src/auth/auth.module.ts new file mode 100644 index 0000000..39a06a3 --- /dev/null +++ b/apps/api/src/auth/auth.module.ts @@ -0,0 +1,21 @@ +import { Module } from '@nestjs/common'; +import { APP_GUARD } from '@nestjs/core'; +import { SapModule } from '../sap/sap.module.js'; +import { AuthController } from './auth.controller.js'; +import { AuthGuard } from './auth.guard.js'; +import { AuthService } from './auth.service.js'; +import { LoginAttemptsService } from './login-attempts.service.js'; +import { SessionStoreService } from './session-store.service.js'; + +@Module({ + imports: [SapModule], + controllers: [AuthController], + providers: [ + AuthService, + SessionStoreService, + LoginAttemptsService, + { provide: APP_GUARD, useClass: AuthGuard }, + ], + exports: [SessionStoreService], +}) +export class AuthModule {} diff --git a/apps/api/src/auth/auth.service.spec.ts b/apps/api/src/auth/auth.service.spec.ts new file mode 100644 index 0000000..47c43e2 --- /dev/null +++ b/apps/api/src/auth/auth.service.spec.ts @@ -0,0 +1,75 @@ +import { UnauthorizedException } from '@nestjs/common'; +import { vi } from 'vitest'; +import type { AppConfigService } from '../config/app-config.service.js'; +import type { SapClientService } from '../sap/sap-client.service.js'; +import { AuthService, escapeODataString } from './auth.service.js'; +import { LoginAttemptsService } from './login-attempts.service.js'; +import { SessionStoreService } from './session-store.service.js'; + +const employee = (over: Record = {}) => ({ + EmployeeID: 7, + FirstName: 'Ana', + LastName: 'Pérez', + Active: 'tYES', + U_WEB_USER: 'ana@acme.com', + U_WEB_PASS: 'secreto', + ...over, +}); + +describe('AuthService', () => { + let rows: unknown[]; + let get: ReturnType; + let sessions: SessionStoreService; + let service: AuthService; + + beforeEach(() => { + rows = [employee()]; + get = vi.fn(async () => ({ data: { value: rows } })); + sessions = new SessionStoreService({ get: () => 30 } as unknown as AppConfigService); + service = new AuthService({ get } as unknown as SapClientService, sessions, new LoginAttemptsService()); + }); + + it('login correcto devuelve token y usuario', async () => { + const { token, user } = await service.login('ana@acme.com', 'secreto', '1.1.1.1'); + expect(user).toEqual({ webUser: 'ana@acme.com', empId: 7, firstName: 'Ana', lastName: 'Pérez' }); + expect(sessions.validate(token)?.empId).toBe(7); + }); + + it('usuario inexistente y contraseña errónea dan el MISMO mensaje', async () => { + const wrongPass = await service.login('ana@acme.com', 'mala', '1.1.1.1').catch((e) => e); + rows = []; + const unknown = await service.login('nadie', 'x', '1.1.1.1').catch((e) => e); + expect(wrongPass).toBeInstanceOf(UnauthorizedException); + expect(unknown).toBeInstanceOf(UnauthorizedException); + expect(wrongPass.message).toBe(unknown.message); + }); + + it('empleado inactivo con contraseña correcta -> "Usuario inactivo"', async () => { + rows = [employee({ Active: 'tNO' })]; + await expect(service.login('ana@acme.com', 'secreto', '1.1.1.1')).rejects.toThrow('Usuario inactivo'); + }); + + it('rechaza si el empleado no tiene contraseña web guardada', async () => { + rows = [employee({ U_WEB_PASS: null })]; + await expect(service.login('ana@acme.com', 'x', '1.1.1.1')).rejects.toThrow(UnauthorizedException); + }); + + it('rechaza si hay dos empleados con el mismo U_WEB_USER', async () => { + rows = [employee(), employee({ EmployeeID: 8 })]; + await expect(service.login('ana@acme.com', 'secreto', '1.1.1.1')).rejects.toThrow(UnauthorizedException); + }); + + it('escapa las comillas del usuario en el $filter (sin inyección OData)', async () => { + await service.login("x' or U_WEB_USER ne '", 'p', '1.1.1.1').catch(() => undefined); + const query = get.mock.calls[0][1] as { $filter: string }; + expect(query.$filter).toBe("U_WEB_USER eq 'x'' or U_WEB_USER ne '''"); + expect(escapeODataString("o'neil")).toBe("o''neil"); + }); + + it('bloquea tras 5 fallos y ya no consulta a SAP', async () => { + for (let i = 0; i < 5; i++) await service.login('ana@acme.com', 'mala', '1.1.1.1').catch(() => undefined); + get.mockClear(); + await expect(service.login('ana@acme.com', 'secreto', '1.1.1.1')).rejects.toThrow(/Demasiados intentos/); + expect(get).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/auth/auth.service.ts b/apps/api/src/auth/auth.service.ts new file mode 100644 index 0000000..f4c22b7 --- /dev/null +++ b/apps/api/src/auth/auth.service.ts @@ -0,0 +1,85 @@ +import { createHash, timingSafeEqual } from 'node:crypto'; +import { Injectable, Logger, UnauthorizedException } from '@nestjs/common'; +import { SapClientService } from '../sap/sap-client.service.js'; +import type { AuthUser } from './auth.types.js'; +import { LoginAttemptsService } from './login-attempts.service.js'; +import { SessionStoreService } from './session-store.service.js'; + +interface EmployeeRow { + EmployeeID: number; + FirstName: string | null; + LastName: string | null; + Active: string; + U_WEB_USER: string | null; + U_WEB_PASS: string | null; +} + +const INVALID_CREDENTIALS = 'Usuario o contraseña incorrectos'; + +/** Escapa una cadena para usarla entre comillas simples en un $filter OData. */ +export const escapeODataString = (s: string) => s.replace(/'/g, "''"); + +/** Comparación en tiempo constante (se comparan hashes para igualar longitudes). */ +function safeEqual(a: string, b: string): boolean { + const ha = createHash('sha256').update(a).digest(); + const hb = createHash('sha256').update(b).digest(); + return timingSafeEqual(ha, hb); +} + +@Injectable() +export class AuthService { + private readonly logger = new Logger(AuthService.name); + + constructor( + private readonly sap: SapClientService, + private readonly sessions: SessionStoreService, + private readonly attempts: LoginAttemptsService, + ) {} + + async login(username: string, password: string, ip: string): Promise<{ token: string; user: AuthUser }> { + this.attempts.assertAllowed(username, ip); + + const employee = await this.findEmployee(username); + // Se compara siempre (aunque no exista el usuario) para no filtrar su existencia por tiempo. + const stored = employee?.U_WEB_PASS ?? ''; + const passwordOk = safeEqual(stored, password) && stored !== ''; + + if (!employee || !passwordOk) { + this.attempts.recordFailure(username, ip); + this.logger.warn(`Login fallido para "${username}"`); + throw new UnauthorizedException(INVALID_CREDENTIALS); + } + if (employee.Active !== 'tYES') { + this.logger.warn(`Login rechazado: empleado ${employee.EmployeeID} inactivo`); + throw new UnauthorizedException('Usuario inactivo'); + } + + this.attempts.recordSuccess(username); + const user: AuthUser = { + webUser: employee.U_WEB_USER ?? username, + empId: employee.EmployeeID, + firstName: employee.FirstName ?? '', + lastName: employee.LastName ?? '', + }; + const token = this.sessions.create(user); + this.logger.log(`Login exitoso: ${user.webUser} (empleado ${user.empId})`); + return { token, user }; + } + + logout(token: string): void { + this.sessions.remove(token); + } + + private async findEmployee(username: string): Promise { + const { data } = await this.sap.get<{ value: EmployeeRow[] }>('EmployeesInfo', { + $select: 'EmployeeID,FirstName,LastName,Active,U_WEB_USER,U_WEB_PASS', + $filter: `U_WEB_USER eq '${escapeODataString(username)}'`, + }); + if (data.value.length > 1) { + // Dos empleados con el mismo U_WEB_USER: ambiguo, no se autentica a ninguno. + this.logger.error(`U_WEB_USER duplicado en EmployeesInfo: "${username}"`); + return undefined; + } + return data.value[0]; + } +} diff --git a/apps/api/src/auth/auth.types.ts b/apps/api/src/auth/auth.types.ts new file mode 100644 index 0000000..6c68bc2 --- /dev/null +++ b/apps/api/src/auth/auth.types.ts @@ -0,0 +1,14 @@ +import type { Request } from 'express'; + +/** Empleado SAP autenticado en la web (campos estándar de EmployeesInfo). */ +export interface AuthUser { + webUser: string; + empId: number; + firstName: string; + lastName: string; +} + +export interface AuthenticatedRequest extends Request { + user: AuthUser; + sessionToken: string; +} diff --git a/apps/api/src/auth/login-attempts.service.spec.ts b/apps/api/src/auth/login-attempts.service.spec.ts new file mode 100644 index 0000000..c84d4cb --- /dev/null +++ b/apps/api/src/auth/login-attempts.service.spec.ts @@ -0,0 +1,38 @@ +import { afterEach, beforeEach, vi } from 'vitest'; +import { LoginAttemptsService } from './login-attempts.service.js'; + +describe('LoginAttemptsService', () => { + let svc: LoginAttemptsService; + beforeEach(() => { + vi.useFakeTimers(); + svc = new LoginAttemptsService(); + }); + afterEach(() => vi.useRealTimers()); + + it('bloquea al usuario tras 5 fallos (sin distinguir mayúsculas)', () => { + for (let i = 0; i < 4; i++) svc.recordFailure('Ana', `10.0.0.${i}`); + expect(() => svc.assertAllowed('ana', '10.0.0.99')).not.toThrow(); + svc.recordFailure('ANA', '10.0.0.5'); + expect(() => svc.assertAllowed('ana', '10.0.0.99')).toThrow(/Demasiados intentos/); + }); + + it('un login exitoso limpia el contador del usuario', () => { + for (let i = 0; i < 4; i++) svc.recordFailure('ana', '10.0.0.1'); + svc.recordSuccess('ana'); + svc.recordFailure('ana', '10.0.0.1'); + expect(() => svc.assertAllowed('ana', '10.0.0.1')).not.toThrow(); + }); + + it('bloquea una IP que prueba muchos usuarios distintos', () => { + for (let i = 0; i < 20; i++) svc.recordFailure(`user${i}`, '10.0.0.7'); + expect(() => svc.assertAllowed('otro', '10.0.0.7')).toThrow(/Demasiados intentos/); + expect(() => svc.assertAllowed('otro', '10.0.0.8')).not.toThrow(); + }); + + it('el bloqueo se levanta tras 15 minutos', () => { + for (let i = 0; i < 5; i++) svc.recordFailure('ana', '10.0.0.1'); + expect(() => svc.assertAllowed('ana', '10.0.0.1')).toThrow(); + vi.advanceTimersByTime(16 * 60_000); + expect(() => svc.assertAllowed('ana', '10.0.0.1')).not.toThrow(); + }); +}); diff --git a/apps/api/src/auth/login-attempts.service.ts b/apps/api/src/auth/login-attempts.service.ts new file mode 100644 index 0000000..ef8633b --- /dev/null +++ b/apps/api/src/auth/login-attempts.service.ts @@ -0,0 +1,72 @@ +import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; + +const WINDOW_MS = 15 * 60_000; +const MAX_FAILURES_PER_USER = 5; +const MAX_FAILURES_PER_IP = 20; + +interface Counter { + count: number; + firstAt: number; +} + +/** + * Freno a la fuerza bruta en el login: bloquea temporalmente tras varios + * intentos fallidos, por usuario y por IP. En memoria (proceso único). + */ +@Injectable() +export class LoginAttemptsService { + private readonly byUser = new Map(); + private readonly byIp = new Map(); + + assertAllowed(username: string, ip: string): void { + const retry = Math.max( + this.retryAfterMs(this.byUser, username.toLowerCase(), MAX_FAILURES_PER_USER), + this.retryAfterMs(this.byIp, ip, MAX_FAILURES_PER_IP), + ); + if (retry > 0) { + throw new HttpException( + { + statusCode: 429, + message: 'Demasiados intentos fallidos. Intente de nuevo más tarde.', + retryAfterSeconds: Math.ceil(retry / 1000), + }, + HttpStatus.TOO_MANY_REQUESTS, + ); + } + } + + recordFailure(username: string, ip: string): void { + this.bump(this.byUser, username.toLowerCase()); + this.bump(this.byIp, ip); + } + + recordSuccess(username: string): void { + this.byUser.delete(username.toLowerCase()); + } + + private bump(map: Map, key: string): void { + const now = Date.now(); + const current = map.get(key); + if (!current || now - current.firstAt > WINDOW_MS) { + map.set(key, { count: 1, firstAt: now }); + } else { + current.count++; + } + if (map.size > 10_000) this.prune(map, now); + } + + private retryAfterMs(map: Map, key: string, max: number): number { + const c = map.get(key); + if (!c) return 0; + const remaining = c.firstAt + WINDOW_MS - Date.now(); + if (remaining <= 0) { + map.delete(key); + return 0; + } + return c.count >= max ? remaining : 0; + } + + private prune(map: Map, now: number): void { + for (const [k, c] of map) if (now - c.firstAt > WINDOW_MS) map.delete(k); + } +} diff --git a/apps/api/src/auth/public.decorator.ts b/apps/api/src/auth/public.decorator.ts new file mode 100644 index 0000000..b18189a --- /dev/null +++ b/apps/api/src/auth/public.decorator.ts @@ -0,0 +1,6 @@ +import { SetMetadata } from '@nestjs/common'; + +export const IS_PUBLIC_KEY = 'isPublic'; + +/** Marca un endpoint como accesible sin sesión (el guard es global). */ +export const Public = () => SetMetadata(IS_PUBLIC_KEY, true); diff --git a/apps/api/src/auth/session-store.service.spec.ts b/apps/api/src/auth/session-store.service.spec.ts new file mode 100644 index 0000000..92ec72f --- /dev/null +++ b/apps/api/src/auth/session-store.service.spec.ts @@ -0,0 +1,65 @@ +import { afterEach, beforeEach, vi } from 'vitest'; +import type { AppConfigService } from '../config/app-config.service.js'; +import type { AuthUser } from './auth.types.js'; +import { SessionStoreService } from './session-store.service.js'; + +const config = { get: () => 30 } as unknown as AppConfigService; +const user = (webUser: string): AuthUser => ({ webUser, empId: 1, firstName: 'Ana', lastName: 'Pérez' }); +const MIN = 60_000; + +describe('SessionStoreService', () => { + let store: SessionStoreService; + + beforeEach(() => { + vi.useFakeTimers(); + store = new SessionStoreService(config); + }); + afterEach(() => vi.useRealTimers()); + + it('crea un token que valida al usuario', () => { + const token = store.create(user('ana')); + expect(token.length).toBeGreaterThanOrEqual(43); + expect(store.validate(token)?.webUser).toBe('ana'); + }); + + it('rechaza tokens desconocidos', () => { + expect(store.validate('nope')).toBeUndefined(); + }); + + it('un nuevo login invalida el token anterior del mismo usuario', () => { + const t1 = store.create(user('ana')); + const t2 = store.create(user('ana')); + expect(store.validate(t1)).toBeUndefined(); + expect(store.validate(t2)).toBeDefined(); + expect(store.size).toBe(1); + }); + + it('el timeout es deslizante: la actividad lo renueva', () => { + const token = store.create(user('ana')); + vi.advanceTimersByTime(25 * MIN); + expect(store.validate(token)).toBeDefined(); + vi.advanceTimersByTime(25 * MIN); + expect(store.validate(token)).toBeDefined(); // 50 min en total, pero con actividad + }); + + it('expira tras 30 min sin actividad', () => { + const token = store.create(user('ana')); + vi.advanceTimersByTime(31 * MIN); + expect(store.validate(token)).toBeUndefined(); + expect(store.size).toBe(0); + }); + + it('sweep elimina las sesiones vencidas', () => { + store.create(user('ana')); + store.create(user('luis')); + vi.advanceTimersByTime(31 * MIN); + store.sweep(); + expect(store.size).toBe(0); + }); + + it('remove cierra la sesión', () => { + const token = store.create(user('ana')); + store.remove(token); + expect(store.validate(token)).toBeUndefined(); + }); +}); diff --git a/apps/api/src/auth/session-store.service.ts b/apps/api/src/auth/session-store.service.ts new file mode 100644 index 0000000..8feabd6 --- /dev/null +++ b/apps/api/src/auth/session-store.service.ts @@ -0,0 +1,84 @@ +import { randomBytes } from 'node:crypto'; +import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; +import { AppConfigService } from '../config/app-config.service.js'; +import type { AuthUser } from './auth.types.js'; + +interface WebSession { + user: AuthUser; + lastActivity: number; +} + +/** + * Sesiones web en memoria (un único proceso). Una sesión por usuario: un nuevo + * login invalida el token anterior. El timeout es deslizante: cada petición + * válida renueva la actividad. + */ +@Injectable() +export class SessionStoreService implements OnModuleInit, OnModuleDestroy { + private readonly logger = new Logger(SessionStoreService.name); + private readonly byToken = new Map(); + private readonly tokenByUser = new Map(); + private sweeper?: NodeJS.Timeout; + + constructor(private readonly config: AppConfigService) {} + + onModuleInit() { + this.sweeper = setInterval(() => this.sweep(), 60_000); + this.sweeper.unref(); + } + + onModuleDestroy() { + clearInterval(this.sweeper); + } + + create(user: AuthUser): string { + const previous = this.tokenByUser.get(user.webUser); + if (previous) { + this.byToken.delete(previous); + this.logger.log(`Sesión anterior de ${user.webUser} invalidada (nuevo login)`); + } + const token = randomBytes(32).toString('base64url'); + this.byToken.set(token, { user, lastActivity: Date.now() }); + this.tokenByUser.set(user.webUser, token); + return token; + } + + /** Devuelve el usuario y renueva la actividad; `undefined` si no existe o expiró. */ + validate(token: string): AuthUser | undefined { + const session = this.byToken.get(token); + if (!session) return undefined; + const now = Date.now(); + if (now - session.lastActivity > this.timeoutMs()) { + this.remove(token); + this.logger.log(`Sesión de ${session.user.webUser} expirada por inactividad`); + return undefined; + } + session.lastActivity = now; + return session.user; + } + + remove(token: string): void { + const session = this.byToken.get(token); + if (!session) return; + this.byToken.delete(token); + if (this.tokenByUser.get(session.user.webUser) === token) { + this.tokenByUser.delete(session.user.webUser); + } + } + + /** Elimina las sesiones vencidas que nadie volvió a usar. */ + sweep(): void { + const limit = Date.now() - this.timeoutMs(); + for (const [token, s] of this.byToken) { + if (s.lastActivity < limit) this.remove(token); + } + } + + get size(): number { + return this.byToken.size; + } + + private timeoutMs(): number { + return this.config.get('SESSION_TIMEOUT_MINUTES') * 60_000; + } +} diff --git a/apps/api/src/common/zod-validation.pipe.ts b/apps/api/src/common/zod-validation.pipe.ts new file mode 100644 index 0000000..4cf3b73 --- /dev/null +++ b/apps/api/src/common/zod-validation.pipe.ts @@ -0,0 +1,19 @@ +import { BadRequestException, type PipeTransform } from '@nestjs/common'; +import type { ZodType } from 'zod'; + +/** Valida el cuerpo/parámetros con un esquema zod y devuelve el valor ya tipado. */ +export class ZodValidationPipe implements PipeTransform { + constructor(private readonly schema: ZodType) {} + + transform(value: unknown): T { + const result = this.schema.safeParse(value); + if (!result.success) { + throw new BadRequestException({ + statusCode: 400, + message: 'Datos inválidos', + errors: result.error.issues.map((i) => ({ path: i.path.join('.'), message: i.message })), + }); + } + return result.data; + } +} diff --git a/apps/api/src/config/env.schema.ts b/apps/api/src/config/env.schema.ts index f04d963..a0b5b3e 100644 --- a/apps/api/src/config/env.schema.ts +++ b/apps/api/src/config/env.schema.ts @@ -19,6 +19,8 @@ export const envSchema = z .filter(Boolean), ), LOG_LEVEL: z.enum(['error', 'warn', 'log', 'debug', 'verbose']).default('log'), + // Inactividad tras la cual caduca la sesión web (deslizante). + SESSION_TIMEOUT_MINUTES: z.coerce.number().int().positive().default(30), // --- SAP Business One Service Layer --- SAPB1_SL_URL: z @@ -29,8 +31,8 @@ export const envSchema = z SAPB1_SL_PASSWORD: z.string().min(1), SAPB1_SL_TIMEOUT_MS: z.coerce.number().int().positive().default(15000), // Peticiones simultáneas permitidas hacia el Service Layer; el resto se encola. - // Medido en SAP10: con más de ~2 en paralelo el Service Layer retrasa ~7 s cada - // petición extra, mientras que en serie cada una tarda ~60 ms. Default: 1. + // Medido en SAP10 (12 peticiones): con 1 -> 0.6 s en total; con 2 ya tarda 7.5 s y con + // 4, 22 s (el Service Layer retrasa ~7 s por cada petición paralela extra). Default: 1. SAPB1_SL_MAX_CONCURRENCY: z.coerce.number().int().positive().default(1), // CA adicional de confianza (PEM): certificado autofirmado del Service // Layer o CA de un firewall que inspecciona SSL. Opcional. diff --git a/apps/api/src/health/health.controller.ts b/apps/api/src/health/health.controller.ts index 2c27631..5c8d269 100644 --- a/apps/api/src/health/health.controller.ts +++ b/apps/api/src/health/health.controller.ts @@ -1,4 +1,5 @@ import { Controller, Get } from '@nestjs/common'; +import { Public } from '../auth/public.decorator.js'; import { AppConfigService } from '../config/app-config.service.js'; import { SapSessionService } from '../sap/sap-session.service.js'; @@ -9,12 +10,13 @@ export class HealthController { private readonly config: AppConfigService, ) {} + @Public() @Get() check() { return { status: 'ok', uptime: Math.round(process.uptime()) }; } - // TODO(auth): proteger con el guard de sesión cuando exista el login. + /** Requiere sesión web (guard global). */ @Get('sap') async sap() { await this.sessions.getSession(); diff --git a/apps/api/test/auth.e2e-spec.ts b/apps/api/test/auth.e2e-spec.ts new file mode 100644 index 0000000..ef81921 --- /dev/null +++ b/apps/api/test/auth.e2e-spec.ts @@ -0,0 +1,84 @@ +import { Test } from '@nestjs/testing'; +import type { INestApplication } from '@nestjs/common'; +import request from 'supertest'; + +process.env.SAPB1_SL_URL ??= 'https://sap.invalid:50000/b1s/v1'; +process.env.SAPB1_SL_COMPANYDB ??= 'DB'; +process.env.SAPB1_SL_USER ??= 'u'; +process.env.SAPB1_SL_PASSWORD ??= 'p'; + +const EMPLOYEES = [ + { EmployeeID: 7, FirstName: 'Ana', LastName: 'Pérez', Active: 'tYES', U_WEB_USER: 'ana', U_WEB_PASS: 'secreto' }, +]; + +describe('Auth (e2e, SAP simulado)', () => { + let app: INestApplication; + + beforeEach(async () => { + const { AppModule } = await import('../src/app.module.js'); + const { SapClientService } = await import('../src/sap/sap-client.service.js'); + const { SapSessionService } = await import('../src/sap/sap-session.service.js'); + const moduleRef = await Test.createTestingModule({ imports: [AppModule] }) + .overrideProvider(SapClientService) + .useValue({ + get: async (_path: string, q: { $filter: string }) => ({ + data: { value: q.$filter === "U_WEB_USER eq 'ana'" ? EMPLOYEES : [] }, + }), + }) + .overrideProvider(SapSessionService) + .useValue({ getSession: async () => ({}), status: () => ({ connected: true }), onModuleDestroy: () => undefined }) + .compile(); + app = moduleRef.createNestApplication(); + app.setGlobalPrefix('api'); + await app.init(); + }); + + afterEach(async () => { + await app.close(); + }); + + const login = (username: string, password: string) => + request(app.getHttpServer()).post('/api/auth/login').send({ username, password }); + + it('/health es público, /health/sap exige sesión', async () => { + await request(app.getHttpServer()).get('/api/health').expect(200); + await request(app.getHttpServer()).get('/api/health/sap').expect(401); + }); + + it('flujo completo: login -> me -> ruta protegida -> logout -> 401', async () => { + const res = await login('ana', 'secreto').expect(200); + expect(res.body.user).toMatchObject({ webUser: 'ana', empId: 7, firstName: 'Ana' }); + expect(JSON.stringify(res.body)).not.toContain('secreto'); + const auth = { Authorization: `Bearer ${res.body.token}` }; + + const me = await request(app.getHttpServer()).get('/api/auth/me').set(auth).expect(200); + expect(me.body.user.webUser).toBe('ana'); + await request(app.getHttpServer()).get('/api/health/sap').set(auth).expect(200); + + await request(app.getHttpServer()).post('/api/auth/logout').set(auth).expect(204); + await request(app.getHttpServer()).get('/api/auth/me').set(auth).expect(401); + }); + + it('un segundo login invalida el token del primero', async () => { + const t1 = (await login('ana', 'secreto').expect(200)).body.token; + const t2 = (await login('ana', 'secreto').expect(200)).body.token; + await request(app.getHttpServer()).get('/api/auth/me').set('Authorization', `Bearer ${t1}`).expect(401); + await request(app.getHttpServer()).get('/api/auth/me').set('Authorization', `Bearer ${t2}`).expect(200); + }); + + it('credenciales inválidas -> 401 y cuerpo inválido -> 400', async () => { + await login('ana', 'mala').expect(401); + await login('nadie', 'x').expect(401); + await request(app.getHttpServer()).post('/api/auth/login').send({ username: 'ana' }).expect(400); + }); + + it('sin token o con token falso -> 401', async () => { + await request(app.getHttpServer()).get('/api/auth/me').expect(401); + await request(app.getHttpServer()).get('/api/auth/me').set('Authorization', 'Bearer falso').expect(401); + }); + + it('tras 5 intentos fallidos responde 429', async () => { + for (let i = 0; i < 5; i++) await login('ana', 'mala').expect(401); + await login('ana', 'secreto').expect(429); + }); +});