import { createHash, randomBytes } from 'node:crypto'; import { Injectable, UnauthorizedException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { JwtService } from '@nestjs/jwt'; import * as argon2 from 'argon2'; import ms from 'ms'; import { PrismaService } from '../prisma/prisma.service'; import { AuditService } from '../audit/audit.service'; import { LoginThrottleService } from './login-throttle.service'; import { MailerService } from './mailer.service'; export interface RequestContext { ip: string; userAgent?: string; } export interface TokenPair { accessToken: string; accessTokenTtlMs: number; refreshToken: string; refreshTokenTtlMs: number; } const GENERIC_LOGIN_ERROR = 'Credenciais inválidas.'; function hashToken(token: string): string { return createHash('sha256').update(token).digest('hex'); } @Injectable() export class AuthService { private readonly accessTtl: string; private readonly refreshTtl: string; constructor( private readonly prisma: PrismaService, private readonly jwtService: JwtService, private readonly config: ConfigService, private readonly audit: AuditService, private readonly loginThrottle: LoginThrottleService, private readonly mailer: MailerService, ) { this.accessTtl = this.config.get('JWT_ACCESS_TTL', '15m'); this.refreshTtl = this.config.get('JWT_REFRESH_TTL', '7d'); } async getUserPermissions(userId: string): Promise { const roles = await this.prisma.userRole.findMany({ where: { userId }, include: { role: { include: { permissions: { include: { permission: true } } } }, }, }); const permissions = new Set(); for (const userRole of roles) { for (const rolePermission of userRole.role.permissions) { permissions.add(rolePermission.permission.key); } } return [...permissions]; } async login( email: string, password: string, ctx: RequestContext, ): Promise { await this.loginThrottle.assertNotBlocked(ctx.ip); const user = await this.prisma.user.findUnique({ where: { email } }); const passwordValid = user ? await argon2.verify(user.passwordHash, password).catch(() => false) : false; if (!user || !user.isActive || !passwordValid) { await this.loginThrottle.recordFailure(ctx.ip); await this.audit.log({ userId: user?.id ?? null, action: 'login_failed', entityType: 'user', entityId: user?.id, ipAddress: ctx.ip, userAgent: ctx.userAgent, }); throw new UnauthorizedException(GENERIC_LOGIN_ERROR); } await this.loginThrottle.recordSuccess(ctx.ip); await this.prisma.user.update({ where: { id: user.id }, data: { lastLoginAt: new Date() }, }); await this.audit.log({ userId: user.id, action: 'login', entityType: 'user', entityId: user.id, ipAddress: ctx.ip, userAgent: ctx.userAgent, }); const permissions = await this.getUserPermissions(user.id); const tokens = await this.issueTokenPair( user.id, user.email, permissions, ctx, ); return { ...tokens, mustChangePassword: user.mustChangePassword }; } private async issueTokenPair( userId: string, email: string, permissions: string[], ctx: RequestContext, ): Promise { const accessToken = await this.jwtService.signAsync( { sub: userId, email, permissions }, { expiresIn: this.accessTtl }, ); const refreshTokenPlain = randomBytes(48).toString('base64url'); const refreshTokenTtlMs = ms(this.refreshTtl); await this.prisma.session.create({ data: { userId, refreshTokenHash: hashToken(refreshTokenPlain), userAgent: ctx.userAgent, ipAddress: ctx.ip, expiresAt: new Date(Date.now() + refreshTokenTtlMs), }, }); return { accessToken, accessTokenTtlMs: ms(this.accessTtl), refreshToken: refreshTokenPlain, refreshTokenTtlMs, }; } // Rotação de refresh token: cada uso invalida o token anterior e emite um // novo par (agente.md seção 9: "rotação de refresh token"). async refresh( refreshTokenPlain: string, ctx: RequestContext, ): Promise { const tokenHash = hashToken(refreshTokenPlain); const session = await this.prisma.session.findFirst({ where: { refreshTokenHash: tokenHash }, include: { user: true }, }); if (!session || session.revokedAt || session.expiresAt < new Date()) { throw new UnauthorizedException('Sessão inválida ou expirada.'); } await this.prisma.session.update({ where: { id: session.id }, data: { revokedAt: new Date() }, }); if (!session.user.isActive) { throw new UnauthorizedException('Usuário inativo.'); } const permissions = await this.getUserPermissions(session.userId); return this.issueTokenPair( session.userId, session.user.email, permissions, ctx, ); } async logout( refreshTokenPlain: string | undefined, userId: string | undefined, ctx: RequestContext, ): Promise { if (refreshTokenPlain) { const tokenHash = hashToken(refreshTokenPlain); await this.prisma.session.updateMany({ where: { refreshTokenHash: tokenHash, revokedAt: null }, data: { revokedAt: new Date() }, }); } await this.audit.log({ userId, action: 'logout', entityType: 'user', entityId: userId, ipAddress: ctx.ip, userAgent: ctx.userAgent, }); } async changePassword( userId: string, currentPassword: string, newPassword: string, ctx: RequestContext, ): Promise { const user = await this.prisma.user.findUniqueOrThrow({ where: { id: userId }, }); const valid = await argon2 .verify(user.passwordHash, currentPassword) .catch(() => false); if (!valid) throw new UnauthorizedException('Senha atual incorreta.'); const passwordHash = await argon2.hash(newPassword, { type: argon2.argon2id, }); await this.prisma.user.update({ where: { id: userId }, data: { passwordHash, mustChangePassword: false }, }); // Revoga todas as sessões existentes ao trocar senha (boa prática de // segurança: um refresh token vazado antes da troca deixa de funcionar). await this.prisma.session.updateMany({ where: { userId, revokedAt: null }, data: { revokedAt: new Date() }, }); await this.audit.log({ userId, action: 'password_changed', entityType: 'user', entityId: userId, ipAddress: ctx.ip, userAgent: ctx.userAgent, }); } // Resposta sempre genérica independente de o e-mail existir, para não // permitir enumeração de usuários (agente.md seção 9). async forgotPassword(email: string): Promise { const user = await this.prisma.user.findUnique({ where: { email } }); if (!user) return; const tokenPlain = randomBytes(32).toString('base64url'); const expiresAt = new Date(Date.now() + ms('1h')); await this.prisma.passwordResetToken.create({ data: { userId: user.id, tokenHash: hashToken(tokenPlain), expiresAt }, }); this.mailer.sendPasswordReset(user.email, tokenPlain); } async resetPassword( tokenPlain: string, newPassword: string, ctx: RequestContext, ): Promise { const tokenHash = hashToken(tokenPlain); const resetToken = await this.prisma.passwordResetToken.findUnique({ where: { tokenHash }, }); if (!resetToken || resetToken.usedAt || resetToken.expiresAt < new Date()) { throw new UnauthorizedException( 'Token de recuperação inválido ou expirado.', ); } const passwordHash = await argon2.hash(newPassword, { type: argon2.argon2id, }); await this.prisma.$transaction([ this.prisma.user.update({ where: { id: resetToken.userId }, data: { passwordHash, mustChangePassword: false }, }), this.prisma.passwordResetToken.update({ where: { id: resetToken.id }, data: { usedAt: new Date() }, }), this.prisma.session.updateMany({ where: { userId: resetToken.userId, revokedAt: null }, data: { revokedAt: new Date() }, }), ]); await this.audit.log({ userId: resetToken.userId, action: 'password_reset', entityType: 'user', entityId: resetToken.userId, ipAddress: ctx.ip, userAgent: ctx.userAgent, }); } }