import { Injectable, UnauthorizedException, ConflictException, BadRequestException, } from "@nestjs/common"; import { JwtService } from "@nestjs/jwt"; import { ConfigService } from "@nestjs/config"; import { PrismaService } from "../prisma/prisma.service"; import { WarehousesService } from "../warehouses/warehouses.service"; import { generateSuiteCode } from "../common/utils/suite-code.util"; import { RegisterDto, LoginDto } from "./dto/auth.dto"; import * as bcrypt from "bcrypt"; import * as crypto from "crypto"; import { TOTP, generateSecret, generateURI, verify as totpVerify } from "otplib"; const TENANT_SLUG = "moraworld"; const BCRYPT_ROUNDS = 10; @Injectable() export class AuthService { constructor( private prisma: PrismaService, private jwt: JwtService, private config: ConfigService, private warehouses: WarehousesService, ) {} /** Builds the suite address from the default warehouse in DB, falls back to env vars */ private async buildSuiteAddress(suiteCode: string, tenantId: string): Promise { const wh = await this.warehouses.findDefault(tenantId); if (wh) { return `${wh.street}, Suite ${suiteCode}, ${wh.city}, ${wh.state} ${wh.zip}, EE.UU.`; } // Fallback to env vars (backwards compat during migration) const street = this.config.get("WAREHOUSE_ADDRESS_STREET", "150 N Day St"); const city = this.config.get("WAREHOUSE_ADDRESS_CITY", "City of Orange"); const state = this.config.get("WAREHOUSE_ADDRESS_STATE", "NJ"); const zip = this.config.get("WAREHOUSE_ADDRESS_ZIP", "07050"); return `${street}, Suite ${suiteCode}, ${city}, ${state} ${zip}, EE.UU.`; } // ─── Register ──────────────────────────────────────────────── async register(dto: RegisterDto): Promise { const tenant = await this.prisma.client.tenant.findUnique({ where: { slug: TENANT_SLUG } }); if (!tenant) throw new BadRequestException("Tenant no encontrado."); const existing = await this.prisma.client.user.findUnique({ where: { tenantId_email: { tenantId: tenant.id, email: dto.email.toLowerCase() } }, }); if (existing) throw new ConflictException("Ya existe una cuenta con ese email."); const passwordHash = await bcrypt.hash(dto.password, BCRYPT_ROUNDS); const user = await this.prisma.client.user.create({ data: { tenantId: tenant.id, email: dto.email.toLowerCase(), passwordHash, firstName: dto.firstName, lastName: dto.lastName, phone: dto.phone ?? null, role: "CLIENTE", }, }); // Asignar Suite automáticamente (doc §09) const suiteCount = await this.prisma.client.suite.count({ where: { tenantId: tenant.id } }); const suiteCode = generateSuiteCode(suiteCount + 1); await this.prisma.client.suite.create({ data: { tenantId: tenant.id, userId: user.id, code: suiteCode }, }); await this.audit(tenant.id, user.id, "USER_REGISTER", "User", user.id); const tokens = await this.generateTokens(user.id, user.email, user.role, tenant.id); return { user: this.sanitizeUser(user), suiteCode, suiteAddress: await this.buildSuiteAddress(suiteCode, tenant.id), ...tokens, }; } // ─── Login ─────────────────────────────────────────────────── async login(dto: LoginDto, ip?: string): Promise { const tenant = await this.prisma.client.tenant.findUnique({ where: { slug: TENANT_SLUG } }); if (!tenant) throw new UnauthorizedException(); const user = await this.prisma.client.user.findUnique({ where: { tenantId_email: { tenantId: tenant.id, email: dto.email.toLowerCase() } }, }); if (!user || !user.isActive) { await this.audit(tenant.id, null, "LOGIN_FAILED", "User", dto.email); throw new UnauthorizedException("Credenciales inválidas."); } const valid = await bcrypt.compare(dto.password, user.passwordHash); if (!valid) { await this.audit(tenant.id, user.id, "LOGIN_FAILED", "User", user.id); throw new UnauthorizedException("Credenciales inválidas."); } // MFA if (user.mfaEnabled) { if (!dto.totpCode) return { requiresMfa: true, userId: user.id }; const ok = totpVerify({ token: dto.totpCode, secret: user.mfaSecret! }); if (!ok) { await this.audit(tenant.id, user.id, "MFA_FAILED", "User", user.id); throw new UnauthorizedException("Código MFA inválido."); } } await this.prisma.client.user.update({ where: { id: user.id }, data: { lastLoginAt: new Date() }, }); await this.audit(tenant.id, user.id, "LOGIN_SUCCESS", "User", user.id); const suite = await this.prisma.client.suite.findUnique({ where: { userId: user.id } }); const tokens = await this.generateTokens(user.id, user.email, user.role, tenant.id); return { user: this.sanitizeUser(user), suite: suite ? { code: suite.code, address: await this.buildSuiteAddress(suite.code, tenant.id), } : null, ...tokens, }; } // ─── Refresh ───────────────────────────────────────────────── async refresh(refreshToken: string): Promise { const stored = await this.prisma.client.refreshToken.findUnique({ where: { token: refreshToken } }); if (!stored || stored.revokedAt || stored.expiresAt < new Date()) { throw new UnauthorizedException("Refresh token inválido o expirado."); } const user = await this.prisma.client.user.findUnique({ where: { id: stored.userId } }); if (!user || !user.isActive) throw new UnauthorizedException(); await this.prisma.client.refreshToken.update({ where: { id: stored.id }, data: { revokedAt: new Date() }, }); return this.generateTokens(user.id, user.email, user.role, user.tenantId); } // ─── Logout ────────────────────────────────────────────────── async logout(refreshToken: string, userId: string): Promise { await this.prisma.client.refreshToken.updateMany({ where: { token: refreshToken, userId }, data: { revokedAt: new Date() }, }); await this.audit(null, userId, "LOGOUT", "User", userId); } // ─── MFA Setup ─────────────────────────────────────────────── async setupMfa(userId: string): Promise { const user = await this.prisma.client.user.findUnique({ where: { id: userId } }); if (!user) throw new UnauthorizedException(); const secret = generateSecret(); const otpAuthUrl = generateURI({ issuer: "Moraworld Imports", label: user.email, secret, }); await this.prisma.client.user.update({ where: { id: userId }, data: { mfaSecret: secret, mfaEnabled: false }, }); return { secret, otpAuthUrl }; } async verifyMfa(userId: string, totpCode: string): Promise { const user = await this.prisma.client.user.findUnique({ where: { id: userId } }); if (!user?.mfaSecret) throw new BadRequestException("Primero genera el secreto MFA."); const ok = totpVerify({ token: totpCode, secret: user.mfaSecret }); if (!ok) throw new BadRequestException("Código TOTP inválido."); await this.prisma.client.user.update({ where: { id: userId }, data: { mfaEnabled: true } }); await this.audit(user.tenantId, userId, "MFA_ENABLED", "User", userId); return { mfaEnabled: true }; } // ─── Change Password ───────────────────────────────────────── async changePassword(userId: string, oldPassword: string, newPassword: string): Promise { const user = await this.prisma.client.user.findUnique({ where: { id: userId } }); if (!user) throw new UnauthorizedException(); const valid = await bcrypt.compare(oldPassword, user.passwordHash); if (!valid) throw new BadRequestException("La contraseña actual es incorrecta."); if (oldPassword === newPassword) throw new BadRequestException("La nueva contraseña debe ser diferente."); const passwordHash = await bcrypt.hash(newPassword, BCRYPT_ROUNDS); await this.prisma.client.user.update({ where: { id: userId }, data: { passwordHash } }); await this.audit(user.tenantId, userId, "PASSWORD_CHANGED", "User", userId); } // ─── Update Profile ─────────────────────────────────────────── async updateProfile(userId: string, data: { firstName?: string; lastName?: string; phone?: string }): Promise { const user = await this.prisma.client.user.update({ where: { id: userId }, data: { ...(data.firstName ? { firstName: data.firstName } : {}), ...(data.lastName ? { lastName: data.lastName } : {}), ...(data.phone !== undefined ? { phone: data.phone || null } : {}), }, }); return this.sanitizeUser(user); } // ─── Disable MFA ───────────────────────────────────────────── async disableMfa(userId: string, totpCode: string): Promise { const user = await this.prisma.client.user.findUnique({ where: { id: userId } }); if (!user) throw new UnauthorizedException(); if (!user.mfaEnabled) throw new BadRequestException("MFA no está activada."); const ok = totpVerify({ token: totpCode, secret: user.mfaSecret! }); if (!ok) throw new BadRequestException("Código TOTP inválido."); await this.prisma.client.user.update({ where: { id: userId }, data: { mfaEnabled: false, mfaSecret: null }, }); await this.audit(user.tenantId, userId, "MFA_DISABLED", "User", userId); return { mfaEnabled: false }; } // ─── Profile ───────────────────────────────────────────────── async getProfile(userId: string): Promise { const user = await this.prisma.client.user.findUnique({ where: { id: userId } }); if (!user) throw new UnauthorizedException(); const suite = await this.prisma.client.suite.findUnique({ where: { userId } }); return { ...this.sanitizeUser(user), suite: suite ? { code: suite.code, address: await this.buildSuiteAddress(suite.code, user.tenantId), } : null, }; } // ─── Helpers ───────────────────────────────────────────────── private async generateTokens(userId: string, email: string, role: string, tenantId: string): Promise { const payload = { sub: userId, email, role, tenantId }; const accessToken = this.jwt.sign(payload, { expiresIn: this.config.get("JWT_EXPIRES_IN", "15m") }); const refreshToken = crypto.randomBytes(64).toString("hex"); const refreshExpires = new Date(); refreshExpires.setDate(refreshExpires.getDate() + 7); await this.prisma.client.refreshToken.create({ data: { userId, token: refreshToken, expiresAt: refreshExpires }, }); return { accessToken, refreshToken }; } private sanitizeUser(user: any): any { const { passwordHash, mfaSecret, ...safe } = user; return safe; } private async audit(tenantId: string | null, userId: string | null, action: string, resource?: string, resourceId?: string): Promise { await this.prisma.client.auditLog.create({ data: { tenantId, userId, action, resource, resourceId }, }); } }