feat: auditoría (audit_log en SQLite, endpoint /api/audit y pantalla /admin/auditoria)

Registra sesiones, creaciones y modificaciones en SAP (solo nombres de campos), denegados y cambios de permisos.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
GeorgeandClaude Sonnet 5.5 committed 2026-10-16 12:35:46 -05:00
1 parent 4e9d6a757d
commit fe8c080471
26 files changed
+993 -28

No files matched your search

+2 -2
View File
@@ -34,8 +34,8 @@ export class AuthController {
@Post('logout')
@HttpCode(204)
logout(@Req() req: AuthenticatedRequest): void {
this.auth.logout(req.sessionToken);
async logout(@Req() req: AuthenticatedRequest): Promise<void> {
await this.auth.logout(req.sessionToken, req.user, req.ip ?? 'desconocida');
}
@Get('me')
+2 -1
View File
@@ -1,5 +1,6 @@
import { Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { AuditModule } from '../audit/audit.module.js';
import { SapModule } from '../sap/sap.module.js';
import { AuthController } from './auth.controller.js';
import { AuthGuard } from './auth.guard.js';
@@ -8,7 +9,7 @@ import { LoginAttemptsService } from './login-attempts.service.js';
import { SessionStoreService } from './session-store.service.js';
@Module({
imports: [SapModule],
imports: [SapModule, AuditModule],
controllers: [AuthController],
providers: [
AuthService,
+45 -1
View File
@@ -3,6 +3,7 @@ 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 type { AuditService } from '../audit/audit.service.js';
import { LoginAttemptsService } from './login-attempts.service.js';
import { SessionStoreService } from './session-store.service.js';
@@ -21,12 +22,14 @@ describe('AuthService', () => {
let get: ReturnType<typeof vi.fn>;
let sessions: SessionStoreService;
let service: AuthService;
let record: ReturnType<typeof vi.fn>;
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());
record = vi.fn(async () => undefined);
service = new AuthService({ get } as unknown as SapClientService, sessions, new LoginAttemptsService(), { record } as unknown as AuditService);
});
it('login correcto devuelve token y usuario', async () => {
@@ -72,4 +75,45 @@ describe('AuthService', () => {
await expect(service.login('ana@acme.com', 'secreto', '1.1.1.1')).rejects.toThrow(/Demasiados intentos/);
expect(get).not.toHaveBeenCalled();
});
describe('auditoría', () => {
it('un login correcto queda registrado con usuario e ip', async () => {
await service.login('ana@acme.com', 'secreto', '9.9.9.9');
expect(record).toHaveBeenCalledWith({ action: 'login', webUser: 'ana@acme.com', ip: '9.9.9.9' });
});
it('un login fallido queda registrado sin distinguir usuario inexistente de clave errónea, y sin la clave', async () => {
await service.login('ana@acme.com', 'mala-clave', '9.9.9.9').catch(() => undefined);
rows = [];
await service.login('nadie', 'otra-clave', '9.9.9.9').catch(() => undefined);
const calls = record.mock.calls.map((c) => c[0]);
expect(calls).toEqual([
{ action: 'login_failed', webUser: 'ana@acme.com', ip: '9.9.9.9', outcome: 'error', detail: { reason: 'credenciales' } },
{ action: 'login_failed', webUser: 'nadie', ip: '9.9.9.9', outcome: 'error', detail: { reason: 'credenciales' } },
]);
expect(JSON.stringify(calls)).not.toContain('clave');
expect(JSON.stringify(calls)).not.toContain('secreto');
});
it('un usuario inactivo queda registrado como denegado', async () => {
rows = [employee({ Active: 'tNO' })];
await service.login('ana@acme.com', 'secreto', '9.9.9.9').catch(() => undefined);
expect(record).toHaveBeenCalledWith({ action: 'login_failed', webUser: 'ana@acme.com', ip: '9.9.9.9', outcome: 'denied', detail: { reason: 'usuario inactivo' } });
});
it('el bloqueo por demasiados intentos queda registrado', async () => {
for (let i = 0; i < 5; i++) await service.login('ana@acme.com', 'mala', '9.9.9.9').catch(() => undefined);
record.mockClear();
await service.login('ana@acme.com', 'secreto', '9.9.9.9').catch(() => undefined);
expect(record).toHaveBeenCalledWith({ action: 'login_failed', webUser: 'ana@acme.com', ip: '9.9.9.9', outcome: 'denied', detail: { reason: 'demasiados intentos' } });
});
it('el cierre de sesión queda registrado', async () => {
const { token, user } = await service.login('ana@acme.com', 'secreto', '9.9.9.9');
record.mockClear();
await service.logout(token, user, '9.9.9.9');
expect(sessions.validate(token)).toBeUndefined();
expect(record).toHaveBeenCalledWith({ action: 'logout', webUser: 'ana@acme.com', ip: '9.9.9.9' });
});
});
});
+13 -2
View File
@@ -1,5 +1,6 @@
import { createHash, timingSafeEqual } from 'node:crypto';
import { Injectable, Logger, UnauthorizedException } from '@nestjs/common';
import { AuditService } from '../audit/audit.service.js';
import { SapClientService } from '../sap/sap-client.service.js';
import type { AuthUser } from './auth.types.js';
import { LoginAttemptsService } from './login-attempts.service.js';
@@ -34,10 +35,16 @@ export class AuthService {
private readonly sap: SapClientService,
private readonly sessions: SessionStoreService,
private readonly attempts: LoginAttemptsService,
private readonly audit: AuditService,
) {}
async login(username: string, password: string, ip: string): Promise<{ token: string; user: AuthUser }> {
this.attempts.assertAllowed(username, ip);
try {
this.attempts.assertAllowed(username, ip);
} catch (e) {
await this.audit.record({ action: 'login_failed', webUser: username, ip, outcome: 'denied', detail: { reason: 'demasiados intentos' } });
throw e;
}
const employee = await this.findEmployee(username);
// Se compara siempre (aunque no exista el usuario) para no filtrar su existencia por tiempo.
@@ -47,10 +54,12 @@ export class AuthService {
if (!employee || !passwordOk) {
this.attempts.recordFailure(username, ip);
this.logger.warn(`Login fallido para "${username}"`);
await this.audit.record({ action: 'login_failed', webUser: username, ip, outcome: 'error', detail: { reason: 'credenciales' } });
throw new UnauthorizedException(INVALID_CREDENTIALS);
}
if (employee.Active !== 'tYES') {
this.logger.warn(`Login rechazado: empleado ${employee.EmployeeID} inactivo`);
await this.audit.record({ action: 'login_failed', webUser: username, ip, outcome: 'denied', detail: { reason: 'usuario inactivo' } });
throw new UnauthorizedException('Usuario inactivo');
}
@@ -63,11 +72,13 @@ export class AuthService {
};
const token = this.sessions.create(user);
this.logger.log(`Login exitoso: ${user.webUser} (empleado ${user.empId})`);
await this.audit.record({ action: 'login', webUser: user.webUser, ip });
return { token, user };
}
logout(token: string): void {
async logout(token: string, user: AuthUser, ip: string): Promise<void> {
this.sessions.remove(token);
await this.audit.record({ action: 'logout', webUser: user.webUser, ip });
}
private async findEmployee(username: string): Promise<EmployeeRow | undefined> {