Files
moraworld/apps/api/src/auth/auth.service.ts
T
Lizandro Guarnizo 98ab5a309c feat: Stripe Checkout, Zeptomail, pre-alert linking, perfil 3-tab, carga-pesada landing
- payments: real Stripe Checkout Session (redirect flow), confirmBySession, webhook handler
- notifications: Zeptomail REST email, wa.me WhatsApp links, IntegrationsService injection
- auth: changePassword, updateProfile, disableMfa (TOTP-verified) endpoints
- packages: tryLinkPreAlert() auto-links on create (non-blocking)
- integrations: catalog updated (zeptomail, stripe_webhook_secret; removed sendgrid/whatsapp-api)
- web/portal/perfil: 3-tab layout (datos / contraseña / MFA)
- web/portal/pago: Stripe redirect + ?success=1&session_id= callback, cancelled banner
- web/admin/b2b: fix enum values (EN_COTIZACION, ACEPTADO)
- web/carga-pesada: rich marketing landing (FCL/LCL, 7-step process, sectors, INEN callout)
- tests: fix payments.service.spec (IntegrationsService+ConfigService mocks)
- tests: fix notifications.service.spec (IntegrationsService mock, phone in mockUser)
- all 104 tests passing, API + web builds clean
2026-06-01 20:24:35 -05:00

271 lines
12 KiB
TypeScript

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<string> {
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<any> {
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<any> {
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<any> {
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<void> {
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<any> {
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<any> {
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<void> {
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<any> {
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<any> {
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<any> {
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<any> {
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<void> {
await this.prisma.client.auditLog.create({
data: { tenantId, userId, action, resource, resourceId },
});
}
}