feat(api): login web con sesiones, guard global y freno a fuerza bruta
- 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 <noreply@anthropic.com>
This commit is contained in:
1 parent
bb4acb5510
commit
079cedbaed
18 files changed
+673
-6
No files matched your search
@@ -4,6 +4,8 @@ PORT=3000
|
|||||||
CORS_ORIGINS=http://localhost:4200
|
CORS_ORIGINS=http://localhost:4200
|
||||||
# error | warn | log | debug | verbose
|
# error | warn | log | debug | verbose
|
||||||
LOG_LEVEL=log
|
LOG_LEVEL=log
|
||||||
|
# Inactividad (minutos) tras la cual caduca la sesión web
|
||||||
|
SESSION_TIMEOUT_MINUTES=30
|
||||||
|
|
||||||
# --- SAP Business One Service Layer ---
|
# --- SAP Business One Service Layer ---
|
||||||
SAPB1_SL_URL=https://servidor-sap:50000/b1s/v1
|
SAPB1_SL_URL=https://servidor-sap:50000/b1s/v1
|
||||||
@@ -12,8 +14,8 @@ SAPB1_SL_COMPANYDB=
|
|||||||
SAPB1_SL_USER=
|
SAPB1_SL_USER=
|
||||||
SAPB1_SL_PASSWORD=
|
SAPB1_SL_PASSWORD=
|
||||||
SAPB1_SL_TIMEOUT_MS=15000
|
SAPB1_SL_TIMEOUT_MS=15000
|
||||||
# Peticiones simultáneas hacia el Service Layer (el resto se encola). Con más de
|
# Peticiones simultáneas hacia el Service Layer (el resto se encola). En el servidor
|
||||||
# ~2 en paralelo el Service Layer de prueba retrasaba ~7 s cada petición extra.
|
# 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
|
SAPB1_SL_MAX_CONCURRENCY=1
|
||||||
|
|
||||||
# Confianza TLS (elegir según el caso):
|
# Confianza TLS (elegir según el caso):
|
||||||
|
|||||||
@@ -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.
|
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.
|
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 <token>` 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
|
||||||
|
```
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
import { AuthModule } from './auth/auth.module.js';
|
||||||
import { AppConfigModule } from './config/config.module.js';
|
import { AppConfigModule } from './config/config.module.js';
|
||||||
import { HealthController } from './health/health.controller.js';
|
import { HealthController } from './health/health.controller.js';
|
||||||
import { SapModule } from './sap/sap.module.js';
|
import { SapModule } from './sap/sap.module.js';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [AppConfigModule, SapModule],
|
imports: [AppConfigModule, SapModule, AuthModule],
|
||||||
controllers: [HealthController],
|
controllers: [HealthController],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
@@ -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<typeof loginSchema>;
|
||||||
|
|
||||||
|
@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') };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 <token>` 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<boolean>(IS_PUBLIC_KEY, [
|
||||||
|
context.getHandler(),
|
||||||
|
context.getClass(),
|
||||||
|
]);
|
||||||
|
if (isPublic) return true;
|
||||||
|
|
||||||
|
const req = context.switchToHttp().getRequest<AuthenticatedRequest>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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<string, unknown> = {}) => ({
|
||||||
|
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<typeof vi.fn>;
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<EmployeeRow | undefined> {
|
||||||
|
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];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string, Counter>();
|
||||||
|
private readonly byIp = new Map<string, Counter>();
|
||||||
|
|
||||||
|
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<string, Counter>, 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<string, Counter>, 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<string, Counter>, now: number): void {
|
||||||
|
for (const [k, c] of map) if (now - c.firstAt > WINDOW_MS) map.delete(k);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string, WebSession>();
|
||||||
|
private readonly tokenByUser = new Map<string, string>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<T> implements PipeTransform<unknown, T> {
|
||||||
|
constructor(private readonly schema: ZodType<T>) {}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,8 @@ export const envSchema = z
|
|||||||
.filter(Boolean),
|
.filter(Boolean),
|
||||||
),
|
),
|
||||||
LOG_LEVEL: z.enum(['error', 'warn', 'log', 'debug', 'verbose']).default('log'),
|
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 ---
|
// --- SAP Business One Service Layer ---
|
||||||
SAPB1_SL_URL: z
|
SAPB1_SL_URL: z
|
||||||
@@ -29,8 +31,8 @@ export const envSchema = z
|
|||||||
SAPB1_SL_PASSWORD: z.string().min(1),
|
SAPB1_SL_PASSWORD: z.string().min(1),
|
||||||
SAPB1_SL_TIMEOUT_MS: z.coerce.number().int().positive().default(15000),
|
SAPB1_SL_TIMEOUT_MS: z.coerce.number().int().positive().default(15000),
|
||||||
// Peticiones simultáneas permitidas hacia el Service Layer; el resto se encola.
|
// 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
|
// Medido en SAP10 (12 peticiones): con 1 -> 0.6 s en total; con 2 ya tarda 7.5 s y con
|
||||||
// petición extra, mientras que en serie cada una tarda ~60 ms. Default: 1.
|
// 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),
|
SAPB1_SL_MAX_CONCURRENCY: z.coerce.number().int().positive().default(1),
|
||||||
// CA adicional de confianza (PEM): certificado autofirmado del Service
|
// CA adicional de confianza (PEM): certificado autofirmado del Service
|
||||||
// Layer o CA de un firewall que inspecciona SSL. Opcional.
|
// Layer o CA de un firewall que inspecciona SSL. Opcional.
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Controller, Get } from '@nestjs/common';
|
import { Controller, Get } from '@nestjs/common';
|
||||||
|
import { Public } from '../auth/public.decorator.js';
|
||||||
import { AppConfigService } from '../config/app-config.service.js';
|
import { AppConfigService } from '../config/app-config.service.js';
|
||||||
import { SapSessionService } from '../sap/sap-session.service.js';
|
import { SapSessionService } from '../sap/sap-session.service.js';
|
||||||
|
|
||||||
@@ -9,12 +10,13 @@ export class HealthController {
|
|||||||
private readonly config: AppConfigService,
|
private readonly config: AppConfigService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
@Public()
|
||||||
@Get()
|
@Get()
|
||||||
check() {
|
check() {
|
||||||
return { status: 'ok', uptime: Math.round(process.uptime()) };
|
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')
|
@Get('sap')
|
||||||
async sap() {
|
async sap() {
|
||||||
await this.sessions.getSession();
|
await this.sessions.getSession();
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user