import { isIP } from 'node:net'; import { ForbiddenException, Injectable, UnauthorizedException, } from '@nestjs/common'; import { DataSource } 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 { LoginDto } from './dto/login.dto'; import { PasswordService } from './services/password.service'; import { IssuedRefreshToken, TokenService, } from './services/token.service'; interface RequestMetadata { ip: string | null; userAgent: string | null; } 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 móvil inválida o vencida', }); } function inspectorRequired(): ForbiddenException { return new ForbiddenException({ code: 'INSPECTION_INSPECTOR_ROLE_REQUIRED', message: 'Sólo un usuario con rol inspector puede ingresar a la aplicación móvil', }); } 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 MobileAuthService { 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 audit: AuditService, ) {} async login(dto: LoginDto, request: RequestWithContext) { 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.ANDROID, ...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.ANDROID, ...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() || 'DH Android', }); 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.ANDROID, ...metadata, metadata: { deviceLabel: session.deviceLabel }, }, manager, ); return { user, accessToken, refreshToken }; }, ); if (!outcome) throw invalidCredentials(); return this.complete(outcome); } async refresh(rawToken: string, request: RequestWithContext) { const parsed = this.tokens.parseRefreshToken(rawToken); if (!parsed) 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.ANDROID, ...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.ANDROID, ...metadata, metadata: { replacedSessionId: session.id }, }, manager, ); return { kind: 'ok', user, accessToken, refreshToken }; }, ); if (outcome.kind !== 'ok') throw invalidSession(); return this.complete(outcome); } async logout( principal: AuthPrincipal, request: RequestWithContext, ) { 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.ANDROID, ...metadata, }, manager, ); }); return { status: 'ok' }; } private async complete(authentication: SuccessfulAuthentication) { const [roleCodes, permissionCodes] = await Promise.all([ this.roles.findRoleCodesForUser(authentication.user.id), this.roles.findPermissionCodesForUser(authentication.user.id), ]); if (!roleCodes.includes('inspector')) { await this.sessions.revokeSession( authentication.refreshToken.sessionId, authentication.user.id, ); throw inspectorRequired(); } return { user: { id: authentication.user.id, username: authentication.user.username, firstName: authentication.user.firstName, lastName: authentication.user.lastName, email: authentication.user.email, mustChangePassword: authentication.user.mustChangePassword, roles: roleCodes, permissions: permissionCodes, }, accessToken: authentication.accessToken, refreshToken: authentication.refreshToken.token, accessExpiresInSeconds: this.tokens.accessTokenTtlSeconds, }; } }