399 lines
14 KiB
TypeScript
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();
|