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