almeira-web/transporte-cali-buenaventura/backend/src/services/auth.service.ts

399 lines
14 KiB
TypeScript

import crypto from 'crypto';
import { User, IUser, UserRole } from '../models/User';
import { whatsAppNotificationService } from './whatsapp-notification.service';
import { isPlatformSuperAdmin, SUPER_ADMIN_PHONE, SUPER_ADMIN_EMAIL } from './user-admin.service';
import { isDatabaseConnected } from '../config/database';
import { localUserStore } from './local-user-store';
interface OtpRecord {
code: string;
identifier: string;
phone: string;
email?: string;
expiresAt: number;
attempts: number;
}
const OTP_CACHE = new Map<string, OtpRecord>();
const OTP_EXPIRY_MS = 5 * 60 * 1000; // 5 minutos de validez
const SESSION_DURATION_MS = 24 * 60 * 60 * 1000; // 24 horas exactas de validez
const JWT_SECRET = process.env.JWT_SECRET || 'pt_pacifictrans_go_secret_2026_banrep';
// Memoria de tokens para la plataforma web y API con soporte de fallback
const SESSION_TOKENS = new Map<string, { userId: string; rol: UserRole; expiresAt: number; cachedUser?: any }>();
export class AuthService {
private static instance: AuthService;
private constructor() {}
public static getInstance(): AuthService {
if (!AuthService.instance) {
AuthService.instance = new AuthService();
}
return AuthService.instance;
}
/**
* Genera un código OTP de 6 dígitos seguro
*/
private generateCode(): string {
return Math.floor(100000 + Math.random() * 900000).toString();
}
/**
* Paso 1: Enviar código OTP a través de WhatsApp (y correo si está presente)
*/
public async sendOtp(identifier: string, email?: string): Promise<{
success: boolean;
maskedPhone: string;
message: string;
codePreview?: string;
}> {
const cleanId = identifier.trim().toLowerCase();
const cleanPhone = identifier.replace(/\D/g, '');
const targetPhone = cleanPhone.startsWith('57') ? cleanPhone : `57${cleanPhone}`;
const code = this.generateCode();
const key = cleanId;
OTP_CACHE.set(key, {
code,
identifier: cleanId,
phone: targetPhone,
email: email ? email.trim().toLowerCase() : undefined,
expiresAt: Date.now() + OTP_EXPIRY_MS,
attempts: 0,
});
const masked = targetPhone.length >= 10
? `+${targetPhone.slice(0, 4)}••••${targetPhone.slice(-3)}`
: `+${targetPhone}`;
// Disparar mensaje oficial por WhatsApp vía Evolution API
const waText =
`🔐 *PACIFIC TRANS GO: CÓDIGO DE SEGURIDAD*\n\n` +
`Tu código de verificación de identidad es:\n` +
`👉 *${code}*\n\n` +
`⏱️ _Válido por 5 minutos._\n` +
`Por seguridad de conductores y pasajeros, nunca compartas este código con terceros.`;
const sent = await whatsAppNotificationService.sendTextMessage(targetPhone, waText);
console.log(`🔑 [OTP Generado]: ${code} para ${targetPhone} (Envío WA: ${sent ? 'OK' : 'OFFLINE_PREVIEW'})`);
return {
success: true,
maskedPhone: masked,
message: `Código enviado a ${masked} vía WhatsApp`,
codePreview: sent ? undefined : code,
};
}
/**
* Paso 2: Verificar OTP y emitir sesión autenticada
*/
public async verifyOtp(params: {
identifier: string;
code: string;
nombre?: string;
email?: string;
direccionTexto?: string;
documentoIdentidad?: string;
ciudad?: string;
}): Promise<{
success: boolean;
token?: string;
expiresAt?: number;
expiresIn?: number;
user?: any;
error?: string;
}> {
const cleanId = params.identifier.trim().toLowerCase();
const record = OTP_CACHE.get(cleanId);
if (!record) {
return { success: false, error: 'Código expirado o no solicitado. Solicita uno nuevo.' };
}
if (Date.now() > record.expiresAt) {
OTP_CACHE.delete(cleanId);
return { success: false, error: 'El código ha expirado. Solicita un nuevo código.' };
}
record.attempts++;
if (record.attempts > 4) {
OTP_CACHE.delete(cleanId);
return { success: false, error: 'Demasiados intentos fallidos. Solicita un nuevo código.' };
}
if (record.code !== params.code.trim()) {
return { success: false, error: 'Código incorrecto. Verifica los 6 dígitos recibidos.' };
}
// OTP Correcto -> Limpiar
OTP_CACHE.delete(cleanId);
const cleanPhone = record.phone.replace(/\D/g, '');
const userEmail = params.email || record.email;
const isSuper = isPlatformSuperAdmin(cleanPhone) || (userEmail && isPlatformSuperAdmin(userEmail));
const assignedRole: UserRole = isSuper ? 'ADMIN' : 'PASAJERO';
let user: any = null;
// 1. Intentar consultar o crear en MongoDB solo si está conectado, con timeout rápido
if (isDatabaseConnected()) {
try {
const timeoutPromise = new Promise((_, reject) =>
setTimeout(() => reject(new Error('DB_TIMEOUT')), 2500)
);
const dbQuery = User.findOne({
$or: [
{ telefono: cleanPhone },
...(userEmail ? [{ email: userEmail.toLowerCase().trim() }] : []),
],
});
user = (await Promise.race([dbQuery, timeoutPromise])) as any;
if (!user) {
const createPromise = User.create({
appwriteId: isSuper ? 'usr-superadmin-platform' : `pt-${Date.now()}-${Math.random().toString(36).substr(2, 6)}`,
nombre: isSuper ? (params.nombre || 'Superadministrador Principal') : (params.nombre || `Usuario ${cleanPhone.slice(-4)}`),
telefono: cleanPhone,
email: userEmail || (isSuper ? SUPER_ADMIN_EMAIL : undefined),
documentoIdentidad: params.documentoIdentidad || (isSuper ? '1000000001' : undefined),
direccionTexto: params.direccionTexto || 'Cali, Valle del Cauca',
ciudad: params.ciudad || 'Cali',
rol: assignedRole,
esSuperAdmin: isSuper,
saldoPenalidad: 0,
coordenadasDomicilio: {
type: 'Point',
coordinates: [-76.532, 3.4516],
},
isActive: true,
phoneVerified: true,
twoFactorEnabled: true,
twoFactorMethod: 'whatsapp_otp',
lastLogin: new Date(),
});
user = (await Promise.race([createPromise, timeoutPromise])) as any;
console.log(`👤 [Nuevo Usuario Verificado]: ${user.nombre} (+${user.telefono}) - Rol: ${user.rol} ${isSuper ? '(SUPERADMIN)' : ''}`);
} else {
user.phoneVerified = true;
user.isActive = true;
user.lastLogin = new Date();
if (isSuper) {
user.rol = 'ADMIN';
user.esSuperAdmin = true;
if (!user.nombre || user.nombre.startsWith('Usuario ')) {
user.nombre = 'Superadministrador Principal';
}
}
if (params.documentoIdentidad && !user.documentoIdentidad) {
user.documentoIdentidad = params.documentoIdentidad;
}
if (params.nombre && user.nombre.startsWith('Usuario ')) {
user.nombre = params.nombre;
}
if (userEmail && !user.email) {
user.email = userEmail;
}
await Promise.race([user.save(), timeoutPromise]);
}
} catch (errDb) {
console.warn('⚠️ [Auth DB Notice]: MongoDB no disponible o tiempo agotado. Continuando con almacenamiento autónomo:', errDb);
user = null;
}
}
// 2. Almacenamiento local autónomo siempre sincronizado (Garantiza 100% de disponibilidad sin depender de DB externa)
const localUser = localUserStore.upsert({
id: user?._id?.toString(),
nombre: params.nombre || user?.nombre,
telefono: cleanPhone,
email: userEmail || user?.email,
documentoIdentidad: params.documentoIdentidad || user?.documentoIdentidad,
direccionTexto: params.direccionTexto || user?.direccionTexto,
ciudad: params.ciudad || user?.ciudad,
rol: user?.rol || assignedRole,
esSuperAdmin: isSuper || !!user?.esSuperAdmin,
isActive: true,
lastLogin: new Date().toISOString(),
});
const finalUserId = (user?._id || localUser.id).toString();
const finalRole: UserRole = user?.rol || localUser.rol;
// Generar Token de Sesión seguro firmado con expiración exacta de 24 horas
const expiresAt = Date.now() + SESSION_DURATION_MS;
const tokenPayload = {
uid: finalUserId,
phone: cleanPhone,
rol: finalRole,
exp: expiresAt,
};
const b64 = Buffer.from(JSON.stringify(tokenPayload)).toString('base64url');
const sig = crypto.createHmac('sha256', JWT_SECRET).update(b64).digest('base64url');
const token = `pt_${b64}.${sig}`;
const userPayload = {
id: finalUserId,
_id: finalUserId,
nombre: user?.nombre || localUser.nombre,
telefono: cleanPhone,
email: user?.email || localUser.email,
documentoIdentidad: user?.documentoIdentidad || localUser.documentoIdentidad,
direccionTexto: user?.direccionTexto || localUser.direccionTexto,
ciudad: user?.ciudad || localUser.ciudad,
rol: finalRole,
esSuperAdmin: isSuper || !!user?.esSuperAdmin || !!localUser.esSuperAdmin,
phoneVerified: true,
saldoPenalidad: user?.saldoPenalidad || localUser.saldoPenalidad || 0,
tokenExpiresAt: expiresAt,
};
SESSION_TOKENS.set(token, {
userId: finalUserId,
rol: finalRole,
expiresAt,
cachedUser: userPayload,
});
return {
success: true,
token,
expiresAt,
expiresIn: 86400, // 24 horas en segundos
user: userPayload,
};
}
/**
* Obtener perfil del usuario autenticado actual desde el token
*/
public async getUserFromToken(token: string): Promise<any | null> {
if (!token) return null;
let userId: string | null = null;
let expiresAt: number = 0;
// 1. Validar token firmado criptográficamente con vigencia estricta de 24 horas
if (token.startsWith('pt_') && token.includes('.')) {
try {
const [b64, sig] = token.slice(3).split('.');
const expectedSig = crypto.createHmac('sha256', JWT_SECRET).update(b64).digest('base64url');
if (sig === expectedSig) {
const payload = JSON.parse(Buffer.from(b64, 'base64url').toString('utf8'));
if (Date.now() > payload.exp) {
// El token superó el lapso de 24 horas
console.log(`⏰ [Token Expirado]: El token de usuario ${payload.uid} ha superado las 24 horas.`);
return null;
}
userId = payload.uid;
expiresAt = payload.exp;
}
} catch (err) {
// Formato no válido
}
}
// 2. Fallback a SESSION_TOKENS en memoria para tokens anteriores
if (!userId) {
const session = SESSION_TOKENS.get(token);
if (!session || Date.now() > session.expiresAt) {
if (session) SESSION_TOKENS.delete(token);
return null;
}
userId = session.userId;
expiresAt = session.expiresAt;
}
// 3. Revisar usuario en caché de sesión en memoria
const cachedSession = SESSION_TOKENS.get(token);
if (cachedSession?.cachedUser) {
return cachedSession.cachedUser;
}
// 4. Consultar en MongoDB si la base de datos está disponible
if (isDatabaseConnected()) {
try {
const timeoutPromise = new Promise((_, reject) =>
setTimeout(() => reject(new Error('DB_TIMEOUT')), 2000)
);
const user = (await Promise.race([User.findById(userId), timeoutPromise])) as any;
if (user) {
const isSuper = isPlatformSuperAdmin(user.telefono) || isPlatformSuperAdmin(user.email) || !!user.esSuperAdmin;
return {
id: user._id.toString(),
_id: user._id.toString(),
nombre: user.nombre,
telefono: user.telefono,
email: user.email,
documentoIdentidad: user.documentoIdentidad,
direccionTexto: user.direccionTexto,
ciudad: user.ciudad,
rol: isSuper ? 'ADMIN' : user.rol,
esSuperAdmin: isSuper,
phoneVerified: user.phoneVerified,
saldoPenalidad: user.saldoPenalidad,
isActive: user.isActive,
tokenExpiresAt: expiresAt,
};
}
} catch (e) {
// Fallback al almacén local
}
}
// 5. Fallback al almacén local autónomo
const local = localUserStore.findById(userId) || (userId.length === 10 || userId.length === 12 ? localUserStore.findByPhone(userId) : null);
if (local) {
return {
id: local.id,
_id: local.id,
nombre: local.nombre,
telefono: local.telefono,
email: local.email,
documentoIdentidad: local.documentoIdentidad,
direccionTexto: local.direccionTexto,
ciudad: local.ciudad,
rol: local.rol,
esSuperAdmin: local.esSuperAdmin,
phoneVerified: local.phoneVerified,
saldoPenalidad: local.saldoPenalidad,
isActive: local.isActive,
tokenExpiresAt: expiresAt,
};
}
return null;
}
/**
* Valida si un token tiene un rol permitido
*/
public async verifyRole(token: string, allowedRoles: UserRole[]): Promise<{ allowed: boolean; user?: any; error?: string }> {
if (!token) {
return { allowed: false, error: 'Acceso no autorizado: Token de autenticación requerido.' };
}
const user = await this.getUserFromToken(token);
if (!user) {
return { allowed: false, error: 'Sesión expirada o no válida. Por favor inicia sesión nuevamente.' };
}
if (!user.isActive) {
return { allowed: false, error: 'Tu cuenta se encuentra inactiva. Contacta al Administrador.' };
}
if (!allowedRoles.includes(user.rol)) {
return {
allowed: false,
error: `Acceso denegado: Esta ruta requiere rol [${allowedRoles.join(', ')}]. Tu rol actual es [${user.rol}].`,
user,
};
}
return { allowed: true, user };
}
}
export const authService = AuthService.getInstance();