import { isIP } from 'node:net'; import { BadRequestException, Injectable, UnauthorizedException, } from '@nestjs/common'; import type { Response } from 'express'; import { DataSource, EntityManager } from 'typeorm'; import { AuditService } from '../audit/audit.service'; import type { AuthPrincipal, RequestWithContext, } from '../common/http/request-context'; import { AuthSessionsRepository } from '../core-data/repositories/auth-sessions.repository'; import { RolesRepository } from '../core-data/repositories/roles.repository'; import { UsersRepository } from '../core-data/repositories/users.repository'; import { AuditAction, AuditSource, AuthSession, User, UserStatus, } from '../database/entities'; import type { ChangePasswordDto } from './dto/change-password.dto'; import type { LoginDto } from './dto/login.dto'; import { CookieService } from './services/cookie.service'; import { PasswordService } from './services/password.service'; import { IssuedRefreshToken, TokenService, } from './services/token.service'; interface RequestMetadata { ip: string | null; userAgent: string | null; } export interface PublicUser { id: string; username: string; firstName: string; lastName: string; email: string | null; mustChangePassword: boolean; roles: string[]; permissions: string[]; } interface SuccessfulAuthentication { user: User; accessToken: string; refreshToken: IssuedRefreshToken; } type LoginOutcome = SuccessfulAuthentication | null; type RefreshOutcome = | ({ kind: 'ok' } & SuccessfulAuthentication) | { kind: 'reuse' } | { kind: 'invalid' }; function invalidCredentials(): UnauthorizedException { return new UnauthorizedException({ code: 'INVALID_CREDENTIALS', message: 'Credenciales inválidas', }); } function invalidSession(): UnauthorizedException { return new UnauthorizedException({ code: 'INVALID_SESSION', message: 'Sesión inválida o vencida', }); } function requestMetadata(request: RequestWithContext): RequestMetadata { const candidateIp = request.ip || request.socket.remoteAddress || ''; const ip = isIP(candidateIp) ? candidateIp : null; const rawUserAgent = request.header('user-agent')?.trim(); return { ip, userAgent: rawUserAgent ? rawUserAgent.slice(0, 2048) : null, }; } @Injectable() export class AuthService { constructor( private readonly dataSource: DataSource, private readonly users: UsersRepository, private readonly sessions: AuthSessionsRepository, private readonly roles: RolesRepository, private readonly passwords: PasswordService, private readonly tokens: TokenService, private readonly cookies: CookieService, private readonly audit: AuditService, ) {} async login( dto: LoginDto, request: RequestWithContext, response: Response, ) { const identifier = dto.identifier.trim().toLowerCase(); const candidate = await this.users.findForAuthentication(identifier); const metadata = requestMetadata(request); if (!candidate) { await this.passwords.verifyUnknown(dto.password); await this.audit.record({ action: AuditAction.AUTH_LOGIN_FAILED, requestId: request.requestId, source: AuditSource.WEB, ...metadata, metadata: { identifier, reason: 'UNKNOWN_IDENTIFIER', }, }); throw invalidCredentials(); } const outcome = await this.dataSource.transaction( async (manager) => { const user = await manager .getRepository(User) .createQueryBuilder('user') .addSelect('user.passwordHash') .where('user.id = :id', { id: candidate.id }) .setLock('pessimistic_write') .getOne(); if (!user) return null; const now = new Date(); const passwordMatches = await this.passwords.verify( user.passwordHash, dto.password, ); const locked = Boolean(user.lockedUntil && user.lockedUntil > now); const active = user.status === UserStatus.ACTIVE; if (!passwordMatches || locked || !active) { if (!passwordMatches && active && !locked) { await manager.query( ` UPDATE users SET failed_login_attempts = failed_login_attempts + 1, locked_until = CASE WHEN failed_login_attempts + 1 >= $2 THEN CURRENT_TIMESTAMP + ($3 * INTERVAL '1 second') ELSE locked_until END, updated_at = CURRENT_TIMESTAMP WHERE id = $1 `, [ user.id, this.tokens.maxLoginAttempts, this.tokens.lockoutSeconds, ], ); } await this.audit.record( { actorUserId: user.id, actorUsername: user.username, action: AuditAction.AUTH_LOGIN_FAILED, entityType: 'user', entityId: user.id, requestId: request.requestId, source: AuditSource.WEB, ...metadata, metadata: { reason: !active ? 'INACTIVE_USER' : locked ? 'LOCKED_USER' : 'INVALID_PASSWORD', }, }, manager, ); return null; } if (this.passwords.needsRehash(user.passwordHash)) { user.passwordHash = await this.passwords.hash(dto.password); } user.failedLoginAttempts = 0; user.lockedUntil = null; user.lastLoginAt = now; await manager.getRepository(User).save(user); const refreshToken = this.tokens.issueRefreshToken(); const session = manager.getRepository(AuthSession).create({ id: refreshToken.sessionId, userId: user.id, refreshTokenHash: refreshToken.tokenHash, expiresAt: this.tokens.refreshExpiresAt(now), lastUsedAt: now, revokedAt: null, replacedBySessionId: null, ip: metadata.ip, userAgent: metadata.userAgent, deviceLabel: dto.deviceLabel?.trim() || null, }); await manager.getRepository(AuthSession).save(session); const accessToken = await this.tokens.issueAccessToken({ userId: user.id, sessionId: session.id, username: user.username, }); await this.audit.record( { actorUserId: user.id, actorUsername: user.username, action: AuditAction.AUTH_LOGIN_SUCCESS, entityType: 'auth_session', entityId: session.id, requestId: request.requestId, source: AuditSource.WEB, ...metadata, metadata: { deviceLabel: session.deviceLabel }, }, manager, ); return { user, accessToken, refreshToken }; }, ); if (!outcome) throw invalidCredentials(); return this.completeAuthentication(outcome, response); } async refresh(request: RequestWithContext, response: Response) { const rawToken = this.cookies.getRefreshToken(request); const parsed = rawToken ? this.tokens.parseRefreshToken(rawToken) : null; if (!rawToken || !parsed) { this.cookies.clearAuthCookies(response); throw invalidSession(); } const metadata = requestMetadata(request); const outcome = await this.dataSource.transaction( async (manager) => { const session = await manager .getRepository(AuthSession) .createQueryBuilder('session') .addSelect('session.refreshTokenHash') .where('session.id = :id', { id: parsed.sessionId }) .setLock('pessimistic_write') .getOne(); if ( !session || !this.tokens.verifyRefreshToken(rawToken, session.refreshTokenHash) ) { return { kind: 'invalid' }; } const user = await manager.getRepository(User).findOne({ where: { id: session.userId }, }); if (session.revokedAt) { await this.sessions.revokeSessionFamily(session.id, manager); await this.audit.record( { actorUserId: user?.id ?? null, actorUsername: user?.username ?? null, action: AuditAction.AUTH_REFRESH_REUSE_DETECTED, entityType: 'auth_session', entityId: session.id, requestId: request.requestId, source: AuditSource.WEB, ...metadata, }, manager, ); return { kind: 'reuse' }; } const now = new Date(); if (!user || user.status !== UserStatus.ACTIVE || session.expiresAt <= now) { session.revokedAt = now; session.lastUsedAt = now; await manager.getRepository(AuthSession).save(session); if (user?.status === UserStatus.INACTIVE) { await this.sessions.revokeUserSessions(user.id, undefined, manager); } return { kind: 'invalid' }; } const refreshToken = this.tokens.issueRefreshToken(); const replacement = manager.getRepository(AuthSession).create({ id: refreshToken.sessionId, userId: user.id, refreshTokenHash: refreshToken.tokenHash, expiresAt: this.tokens.refreshExpiresAt(now), lastUsedAt: now, revokedAt: null, replacedBySessionId: null, ip: metadata.ip, userAgent: metadata.userAgent, deviceLabel: session.deviceLabel, }); await manager.getRepository(AuthSession).save(replacement); session.revokedAt = now; session.lastUsedAt = now; session.replacedBySessionId = replacement.id; await manager.getRepository(AuthSession).save(session); const accessToken = await this.tokens.issueAccessToken({ userId: user.id, sessionId: replacement.id, username: user.username, }); await this.audit.record( { actorUserId: user.id, actorUsername: user.username, action: AuditAction.AUTH_REFRESH, entityType: 'auth_session', entityId: replacement.id, requestId: request.requestId, source: AuditSource.WEB, ...metadata, metadata: { replacedSessionId: session.id }, }, manager, ); return { kind: 'ok', user, accessToken, refreshToken }; }, ); if (outcome.kind !== 'ok') { this.cookies.clearAuthCookies(response); throw invalidSession(); } return this.completeAuthentication(outcome, response); } async logout( principal: AuthPrincipal, request: RequestWithContext, response: Response, ) { const metadata = requestMetadata(request); await this.dataSource.transaction(async (manager) => { await this.sessions.revokeSession( principal.sessionId, principal.userId, manager, ); await this.audit.record( { actorUserId: principal.userId, actorUsername: principal.username, action: AuditAction.AUTH_LOGOUT, entityType: 'auth_session', entityId: principal.sessionId, requestId: request.requestId, source: AuditSource.WEB, ...metadata, }, manager, ); }); this.cookies.clearAuthCookies(response); return { status: 'ok' }; } me(principal: AuthPrincipal) { return { user: this.publicPrincipal(principal) }; } async changePassword( principal: AuthPrincipal, dto: ChangePasswordDto, request: RequestWithContext, ) { const metadata = requestMetadata(request); await this.dataSource.transaction(async (manager) => { const user = await manager .getRepository(User) .createQueryBuilder('user') .addSelect('user.passwordHash') .where('user.id = :id', { id: principal.userId }) .setLock('pessimistic_write') .getOne(); if ( !user || !(await this.passwords.verify(user.passwordHash, dto.currentPassword)) ) { throw invalidCredentials(); } if (await this.passwords.verify(user.passwordHash, dto.newPassword)) { throw new BadRequestException({ code: 'PASSWORD_UNCHANGED', message: 'La nueva contraseña debe ser diferente', }); } const now = new Date(); user.passwordHash = await this.passwords.hash(dto.newPassword); user.passwordChangedAt = now; user.mustChangePassword = false; user.failedLoginAttempts = 0; user.lockedUntil = null; user.updatedBy = user.id; await manager.getRepository(User).save(user); await this.sessions.revokeUserSessions( user.id, principal.sessionId, manager, ); await this.audit.record( { actorUserId: user.id, actorUsername: user.username, action: AuditAction.AUTH_PASSWORD_CHANGED, entityType: 'user', entityId: user.id, requestId: request.requestId, source: AuditSource.WEB, ...metadata, metadata: { otherSessionsRevoked: true }, }, manager, ); }); return { status: 'ok', mustChangePassword: false }; } private async completeAuthentication( authentication: SuccessfulAuthentication, response: Response, ) { const [roleCodes, permissionCodes] = await Promise.all([ this.roles.findRoleCodesForUser(authentication.user.id), this.roles.findPermissionCodesForUser(authentication.user.id), ]); const csrfToken = this.cookies.setAuthCookies( response, authentication.accessToken, authentication.refreshToken.token, ); return { user: this.publicUser(authentication.user, roleCodes, permissionCodes), csrfToken, accessExpiresInSeconds: this.tokens.accessTokenTtlSeconds, }; } private publicUser( user: User, roles: string[], permissions: string[], ): PublicUser { return { id: user.id, username: user.username, firstName: user.firstName, lastName: user.lastName, email: user.email, mustChangePassword: user.mustChangePassword, roles, permissions, }; } private publicPrincipal(principal: AuthPrincipal): PublicUser { return { id: principal.userId, username: principal.username, firstName: principal.firstName, lastName: principal.lastName, email: principal.email, mustChangePassword: principal.mustChangePassword, roles: principal.roles, permissions: principal.permissions, }; } }