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
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user