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(); 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(); 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 { 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();