diff --git a/web/backend/middlewares/auth.js b/web/backend/middlewares/auth.js index d69a410..61f236a 100644 --- a/web/backend/middlewares/auth.js +++ b/web/backend/middlewares/auth.js @@ -77,9 +77,9 @@ export async function authMiddleware(req, res, next) { export const basicAuthMiddleware = authMiddleware; // Función para crear sesión -export async function createSession(username) { +export async function createSession(username, fingerprint = null, deviceInfo = null) { const { createSession: createSessionInDB } = await import('../services/mongodb.js'); - return await createSessionInDB(username); + return await createSessionInDB(username, fingerprint, deviceInfo); } // Función para invalidar sesión diff --git a/web/backend/routes/admin.js b/web/backend/routes/admin.js new file mode 100644 index 0000000..0e31d44 --- /dev/null +++ b/web/backend/routes/admin.js @@ -0,0 +1,81 @@ +import express from 'express'; +import { basicAuthMiddleware } from '../middlewares/auth.js'; +import { adminAuthMiddleware } from '../middlewares/adminAuth.js'; +import { getDB, getAllSessions, deleteSession, getRateLimiterInfo } from '../services/mongodb.js'; + +const router = express.Router(); + +// Obtener información del rate limiter y bloqueos (requiere admin) +router.get('/rate-limiter', basicAuthMiddleware, adminAuthMiddleware, async (req, res) => { + try { + const rateLimiterInfo = await getRateLimiterInfo(); + res.json(rateLimiterInfo); + } catch (error) { + console.error('Error obteniendo información del rate limiter:', error); + res.status(500).json({ error: error.message }); + } +}); + +// Obtener todas las sesiones (requiere admin) +router.get('/sessions', basicAuthMiddleware, adminAuthMiddleware, async (req, res) => { + try { + const db = getDB(); + if (!db) { + return res.status(500).json({ error: 'MongoDB no está disponible' }); + } + + const sessions = await getAllSessions(); + + // Agrupar por usuario para estadísticas + const sessionsByUser = {}; + let activeSessions = 0; + let expiredSessions = 0; + + sessions.forEach(session => { + if (!sessionsByUser[session.username]) { + sessionsByUser[session.username] = 0; + } + sessionsByUser[session.username]++; + + if (session.isExpired) { + expiredSessions++; + } else { + activeSessions++; + } + }); + + res.json({ + sessions, + stats: { + total: sessions.length, + active: activeSessions, + expired: expiredSessions, + byUser: sessionsByUser, + }, + }); + } catch (error) { + console.error('Error obteniendo sesiones:', error); + res.status(500).json({ error: error.message }); + } +}); + +// Eliminar una sesión específica (requiere admin) +router.delete('/sessions/:token', basicAuthMiddleware, adminAuthMiddleware, async (req, res) => { + try { + const { token } = req.params; + + const deleted = await deleteSession(token); + + if (deleted) { + res.json({ success: true, message: 'Sesión eliminada correctamente' }); + } else { + res.status(404).json({ error: 'Sesión no encontrada' }); + } + } catch (error) { + console.error('Error eliminando sesión:', error); + res.status(500).json({ error: error.message }); + } +}); + +export default router; + diff --git a/web/backend/routes/articles.js b/web/backend/routes/articles.js index bb116d3..32f90c3 100644 --- a/web/backend/routes/articles.js +++ b/web/backend/routes/articles.js @@ -1,5 +1,5 @@ import express from 'express'; -import { getNotifiedArticles, getArticleFacets, searchArticles } from '../services/mongodb.js'; +import { getNotifiedArticles, getArticleFacets, searchArticles, getArticle } from '../services/mongodb.js'; import { basicAuthMiddleware } from '../middlewares/auth.js'; const router = express.Router(); @@ -25,6 +25,9 @@ router.get('/', basicAuthMiddleware, async (req, res) => { if (req.query.worker_name) filter.worker_name = req.query.worker_name; if (req.query.platform) filter.platform = req.query.platform; + // Siempre incluir el username del usuario autenticado para incluir su is_favorite + filter.currentUsername = user.username; + const articles = await getNotifiedArticles(filter); const limit = parseInt(req.query.limit) || 100; @@ -87,6 +90,9 @@ router.get('/search', basicAuthMiddleware, async (req, res) => { if (req.query.worker_name) filter.worker_name = req.query.worker_name; if (req.query.platform) filter.platform = req.query.platform; + // Siempre incluir el username del usuario autenticado para incluir su is_favorite + filter.currentUsername = user.username; + // Obtener modo de búsqueda (AND u OR), por defecto AND const searchMode = (req.query.mode || 'AND').toUpperCase(); @@ -104,5 +110,39 @@ router.get('/search', basicAuthMiddleware, async (req, res) => { } }); +// Obtener un artículo específico por plataforma e ID (requiere autenticación obligatoria) +router.get('/:platform/:id', basicAuthMiddleware, async (req, res) => { + try { + const { platform, id } = req.params; + + // Obtener usuario autenticado (requerido) + const user = req.user; + const isAdmin = user.role === 'admin'; + + // Obtener el artículo con is_favorite del usuario autenticado + const article = await getArticle(platform, id, user.username); + + if (!article) { + return res.status(404).json({ error: 'Artículo no encontrado' }); + } + + // Si no es admin, verificar que el artículo pertenezca al usuario + // Verificar en user_info si el artículo tiene alguna relación con el usuario + if (!isAdmin) { + const userInfoList = article.user_info || []; + const userHasAccess = userInfoList.some(ui => ui.username === user.username); + + // También verificar compatibilidad con estructura antigua + if (!userHasAccess && article.username !== user.username) { + return res.status(403).json({ error: 'No tienes permiso para ver este artículo' }); + } + } + + res.json(article); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + export default router; diff --git a/web/backend/routes/users.js b/web/backend/routes/users.js index 8d4ab46..29d82f9 100644 --- a/web/backend/routes/users.js +++ b/web/backend/routes/users.js @@ -3,6 +3,7 @@ import bcrypt from 'bcrypt'; import { getDB, getUser, createUser, deleteUser as deleteUserFromDB, getAllUsers, updateUserPassword } from '../services/mongodb.js'; import { basicAuthMiddleware, createSession, invalidateSession, invalidateUserSessions } from '../middlewares/auth.js'; import { adminAuthMiddleware } from '../middlewares/adminAuth.js'; +import { combineFingerprint } from '../utils/fingerprint.js'; const router = express.Router(); @@ -14,7 +15,7 @@ router.post('/login', async (req, res) => { return res.status(500).json({ error: 'MongoDB no está disponible' }); } - const { username, password } = req.body; + const { username, password, fingerprint: clientFingerprint, deviceInfo: clientDeviceInfo } = req.body; if (!username || !password) { return res.status(400).json({ error: 'username y password son requeridos' }); @@ -41,8 +42,11 @@ router.post('/login', async (req, res) => { return res.status(401).json({ error: 'Invalid credentials', message: 'Usuario o contraseña incorrectos' }); } - // Crear sesión/token - const token = await createSession(username); + // Generar fingerprint del dispositivo + const { fingerprint, deviceInfo } = combineFingerprint(clientFingerprint, clientDeviceInfo, req); + + // Crear sesión/token con fingerprint + const token = await createSession(username, fingerprint, deviceInfo); // Obtener rol del usuario const userRole = user.role || 'user'; diff --git a/web/backend/server.js b/web/backend/server.js index be3c659..d0c05d1 100644 --- a/web/backend/server.js +++ b/web/backend/server.js @@ -17,10 +17,14 @@ import configRouter from './routes/config.js'; import telegramRouter from './routes/telegram.js'; import pushRouter from './routes/push.js'; import usersRouter from './routes/users.js'; +import adminRouter from './routes/admin.js'; const app = express(); const server = createServer(app); +// Configurar Express para confiar en proxies (necesario para obtener IP real) +app.set('trust proxy', true); + // Middlewares globales app.use(cors()); app.use(express.json()); @@ -44,6 +48,7 @@ app.use('/api/config', configRouter); app.use('/api/telegram', telegramRouter); app.use('/api/push', pushRouter); app.use('/api/users', usersRouter); +app.use('/api/admin', adminRouter); // Inicializar servidor async function startServer() { diff --git a/web/backend/services/mongodb.js b/web/backend/services/mongodb.js index c69b142..98cca41 100644 --- a/web/backend/services/mongodb.js +++ b/web/backend/services/mongodb.js @@ -88,6 +88,8 @@ async function createIndexes() { // Índices para sesiones (con TTL) await db.collection('sessions').createIndex({ token: 1 }, { unique: true }); await db.collection('sessions').createIndex({ expiresAt: 1 }, { expireAfterSeconds: 0 }); + await db.collection('sessions').createIndex({ username: 1, fingerprint: 1 }); + await db.collection('sessions').createIndex({ fingerprint: 1 }); // Índices para workers await db.collection('workers').createIndex({ username: 1 }); @@ -259,6 +261,54 @@ export function getRateLimiter() { return rateLimiter; } +// Obtener información del rate limiter y bloqueos (para memoria) +export async function getRateLimiterInfo() { + if (!rateLimiter) { + return { + enabled: false, + type: 'none', + blocks: [], + stats: { + totalBlocks: 0, + activeBlocks: 0, + }, + }; + } + + try { + // RateLimiterMemory no expone directamente las claves bloqueadas + // Necesitamos usar una aproximación diferente + // Por ahora, retornamos información básica + return { + enabled: true, + type: 'memory', + blocks: [], + stats: { + totalBlocks: 0, + activeBlocks: 0, + }, + config: { + points: RATE_LIMIT.POINTS, + duration: RATE_LIMIT.DURATION, + blockDuration: RATE_LIMIT.BLOCK_DURATION, + }, + note: 'Rate limiter en memoria no expone información detallada de bloqueos', + }; + } catch (error) { + console.error('Error obteniendo información del rate limiter:', error.message); + return { + enabled: true, + type: 'memory', + blocks: [], + stats: { + totalBlocks: 0, + activeBlocks: 0, + }, + error: error.message, + }; + } +} + export function getConfig() { return config; } @@ -303,6 +353,9 @@ export async function getNotifiedArticles(filter = {}) { .sort({ createdAt: -1, modified_at: -1 }) .toArray(); + // Obtener el username del usuario actual para incluir su is_favorite + const currentUsername = filter.currentUsername || filter.username; + // Filtrar y transformar artículos según el usuario solicitado return articles.map(article => { // Si hay filtro de username, solo devolver el user_info correspondiente @@ -350,7 +403,15 @@ export async function getNotifiedArticles(filter = {}) { if (firstUserInfo) { result.username = firstUserInfo.username; result.worker_name = firstUserInfo.worker_name; - result.is_favorite = firstUserInfo.is_favorite || false; + // Si hay un usuario actual, buscar su is_favorite específico, sino usar el del primer user_info + if (currentUsername) { + const currentUserInfo = (article.user_info || []).find( + ui => ui.username === currentUsername + ); + result.is_favorite = currentUserInfo ? (currentUserInfo.is_favorite || false) : false; + } else { + result.is_favorite = firstUserInfo.is_favorite || false; + } result.notifiedAt = firstUserInfo.notified_at?.getTime?.() || (typeof firstUserInfo.notified_at === 'number' ? firstUserInfo.notified_at : null) || @@ -579,6 +640,9 @@ export async function searchArticles(searchQuery, filter = {}, searchMode = 'AND .sort({ createdAt: -1, modified_at: -1 }) .toArray(); + // Obtener el username del usuario actual para incluir su is_favorite + const currentUsername = filter.currentUsername || filter.username; + // Transformar artículos según el usuario solicitado (similar a getNotifiedArticles) return articles.map(article => { let relevantUserInfo = null; @@ -619,7 +683,15 @@ export async function searchArticles(searchQuery, filter = {}, searchMode = 'AND if (firstUserInfo) { result.username = firstUserInfo.username; result.worker_name = firstUserInfo.worker_name; - result.is_favorite = firstUserInfo.is_favorite || false; + // Si hay un usuario actual, buscar su is_favorite específico, sino usar el del primer user_info + if (currentUsername) { + const currentUserInfo = (article.user_info || []).find( + ui => ui.username === currentUsername + ); + result.is_favorite = currentUserInfo ? (currentUserInfo.is_favorite || false) : false; + } else { + result.is_favorite = firstUserInfo.is_favorite || false; + } result.notifiedAt = firstUserInfo.notified_at?.getTime?.() || (typeof firstUserInfo.notified_at === 'number' ? firstUserInfo.notified_at : null) || @@ -978,7 +1050,7 @@ export async function setTelegramConfig(username, telegramConfig) { } // Funciones para sesiones -export async function createSession(username) { +export async function createSession(username, fingerprint = null, deviceInfo = null) { if (!db) { throw new Error('MongoDB no está disponible'); } @@ -989,9 +1061,22 @@ export async function createSession(username) { try { const sessionsCollection = db.collection('sessions'); + + // Si hay fingerprint, eliminar sesiones anteriores del mismo dispositivo/usuario + if (fingerprint) { + await sessionsCollection.deleteMany({ + username, + fingerprint, + expiresAt: { $gt: new Date() } // Solo eliminar sesiones activas + }); + } + + // Crear nueva sesión con fingerprint await sessionsCollection.insertOne({ token, username, + fingerprint: fingerprint || null, + deviceInfo: deviceInfo || null, createdAt: new Date(), expiresAt, }); @@ -1046,15 +1131,95 @@ export async function deleteUserSessions(username) { } } +export async function getAllSessions() { + if (!db) { + return []; + } + + try { + const sessionsCollection = db.collection('sessions'); + const sessions = await sessionsCollection.find({}).toArray(); + return sessions.map(session => ({ + token: session.token, + username: session.username, + fingerprint: session.fingerprint || null, + deviceInfo: session.deviceInfo || null, + createdAt: session.createdAt, + expiresAt: session.expiresAt, + isExpired: session.expiresAt ? new Date(session.expiresAt) < new Date() : false, + })); + } catch (error) { + console.error('Error obteniendo todas las sesiones:', error.message); + return []; + } +} + // Funciones para artículos -export async function getArticle(platform, id) { +export async function getArticle(platform, id, currentUsername = null) { if (!db) { return null; } try { const articlesCollection = db.collection('articles'); - return await articlesCollection.findOne({ platform, id }); + const article = await articlesCollection.findOne({ platform, id }); + + if (!article) { + return null; + } + + // Transformar el artículo similar a getNotifiedArticles para incluir is_favorite + const result = { + ...article, + _id: article._id.toString(), + expiresAt: article.expiresAt?.getTime() || null, + }; + + const fallbackTime = + article.createdAt?.getTime?.() || + (typeof article.createdAt === 'number' ? article.createdAt : null) || + article.modified_at?.getTime?.() || + null; + + // Si hay un usuario actual, buscar su is_favorite específico + if (currentUsername) { + const currentUserInfo = (article.user_info || []).find( + ui => ui.username === currentUsername + ); + if (currentUserInfo) { + result.is_favorite = currentUserInfo.is_favorite || false; + result.notifiedAt = + currentUserInfo.notified_at?.getTime?.() || + (typeof currentUserInfo.notified_at === 'number' ? currentUserInfo.notified_at : null) || + fallbackTime; + } else { + // Si no hay user_info para este usuario, usar false + result.is_favorite = false; + } + } else { + // Sin usuario específico, usar el primer user_info o datos generales + const firstUserInfo = (article.user_info || [])[0]; + if (firstUserInfo) { + result.username = firstUserInfo.username; + result.worker_name = firstUserInfo.worker_name; + result.is_favorite = firstUserInfo.is_favorite || false; + result.notifiedAt = + firstUserInfo.notified_at?.getTime?.() || + (typeof firstUserInfo.notified_at === 'number' ? firstUserInfo.notified_at : null) || + fallbackTime; + } else { + // Compatibilidad con estructura antigua + result.username = article.username; + result.worker_name = article.worker_name; + result.is_favorite = article.is_favorite || false; + result.notifiedAt = + article.notifiedAt?.getTime?.() || + (typeof article.notifiedAt === 'number' ? article.notifiedAt : null) || + fallbackTime; + } + } + + return result; } catch (error) { console.error('Error obteniendo artículo:', error.message); return null; diff --git a/web/backend/services/redis.js b/web/backend/services/redis.js deleted file mode 100644 index e84f5c9..0000000 --- a/web/backend/services/redis.js +++ /dev/null @@ -1,322 +0,0 @@ -import redis from 'redis'; -import yaml from 'yaml'; -import { readFileSync, existsSync } from 'fs'; -import bcrypt from 'bcrypt'; -import { RateLimiterRedis } from 'rate-limiter-flexible'; -import { PATHS } from '../config/constants.js'; -import { RATE_LIMIT } from '../config/constants.js'; -import { readJSON } from '../utils/fileUtils.js'; - -let redisClient = null; -let rateLimiter = null; -let config = null; - -// Inicializar Redis si está configurado -export async function initRedis() { - try { - config = yaml.parse(readFileSync(PATHS.CONFIG, 'utf8')); - const cacheConfig = config?.cache; - - if (cacheConfig?.type === 'redis') { - const redisConfig = cacheConfig.redis; - // En Docker, usar el nombre del servicio si no se especifica host - const redisHost = process.env.REDIS_HOST || redisConfig.host || 'localhost'; - redisClient = redis.createClient({ - socket: { - host: redisHost, - port: redisConfig.port || 6379, - }, - password: redisConfig.password || undefined, - database: redisConfig.db || 0, - }); - - redisClient.on('error', (err) => console.error('Redis Client Error', err)); - await redisClient.connect(); - console.log('✅ Conectado a Redis'); - - // Inicializar rate limiter con Redis - try { - // Crear un cliente Redis adicional para el rate limiter - const rateLimiterClient = redis.createClient({ - socket: { - host: redisHost, - port: redisConfig.port || 6379, - }, - password: redisConfig.password || undefined, - database: redisConfig.db || 0, - }); - await rateLimiterClient.connect(); - - rateLimiter = new RateLimiterRedis({ - storeClient: rateLimiterClient, - keyPrefix: 'rl:', - points: RATE_LIMIT.POINTS, - duration: RATE_LIMIT.DURATION, - blockDuration: RATE_LIMIT.BLOCK_DURATION, - }); - console.log('✅ Rate limiter inicializado con Redis'); - } catch (error) { - console.error('Error inicializando rate limiter:', error.message); - } - - // Inicializar usuario admin por defecto si no existe - await initDefaultAdmin(); - - // Migrar workers.json a Redis para admin si no existe - await migrateWorkersFromFile(); - - } else { - console.log('ℹ️ Redis no configurado, usando modo memoria'); - console.log('⚠️ Rate limiting y autenticación requieren Redis'); - } - } catch (error) { - console.error('Error inicializando Redis:', error.message); - } -} - -// Inicializar usuario admin por defecto -async function initDefaultAdmin() { - if (!redisClient) return; - - try { - const adminExists = await redisClient.exists('user:admin'); - if (!adminExists) { - // Crear usuario admin por defecto con contraseña "admin" - // En producción, esto debería cambiarse - const defaultPassword = 'admin'; - const hashedPassword = await bcrypt.hash(defaultPassword, 10); - await redisClient.hSet('user:admin', { - username: 'admin', - passwordHash: hashedPassword, - createdAt: new Date().toISOString(), - }); - console.log('✅ Usuario admin creado por defecto (usuario: admin, contraseña: admin)'); - console.log('⚠️ IMPORTANTE: Cambia la contraseña por defecto en producción'); - } - } catch (error) { - console.error('Error inicializando usuario admin:', error.message); - } -} - -// Getters -export function getRedisClient() { - return redisClient; -} - -export function getRateLimiter() { - return rateLimiter; -} - -export function getConfig() { - return config; -} - -export function reloadConfig() { - try { - config = yaml.parse(readFileSync(PATHS.CONFIG, 'utf8')); - return config; - } catch (error) { - console.error('Error recargando configuración:', error.message); - return config; - } -} - -// Funciones de utilidad para artículos -export async function getNotifiedArticles() { - if (!redisClient) { - return []; - } - - try { - const keys = await redisClient.keys('notified:*'); - const articles = []; - - for (const key of keys) { - const parts = key.split(':'); - if (parts.length >= 3) { - const platform = parts[1]; - const id = parts.slice(2).join(':'); - const ttl = await redisClient.ttl(key); - const value = await redisClient.get(key); - - // Intentar parsear como JSON (nuevo formato con toda la info) - let articleData = {}; - try { - if (value && value !== '1') { - articleData = JSON.parse(value); - } - } catch (e) { - // Si no es JSON válido, usar valor por defecto - } - - articles.push({ - platform: articleData.platform || platform, - id: articleData.id || id, - title: articleData.title || null, - description: articleData.description || null, - price: articleData.price || null, - currency: articleData.currency || null, - location: articleData.location || null, - allows_shipping: articleData.allows_shipping !== undefined ? articleData.allows_shipping : null, - url: articleData.url || null, - images: articleData.images || [], - modified_at: articleData.modified_at || null, - username: articleData.username || null, - worker_name: articleData.worker_name || null, - notifiedAt: Date.now() - (7 * 24 * 60 * 60 - ttl) * 1000, - expiresAt: Date.now() + ttl * 1000, - }); - } - } - - return articles; - } catch (error) { - console.error('Error obteniendo artículos de Redis:', error.message); - return []; - } -} - -export async function getFavorites() { - if (!redisClient) { - return []; - } - - try { - const keys = await redisClient.keys('notified:*'); - const favorites = []; - - for (const key of keys) { - const value = await redisClient.get(key); - if (value) { - try { - const articleData = JSON.parse(value); - if (articleData.is_favorite === true) { - favorites.push(articleData); - } - } catch (e) { - // Si no es JSON válido, ignorar - } - } - } - - return favorites; - } catch (error) { - console.error('Error obteniendo favoritos de Redis:', error.message); - return []; - } -} - -// Inicializar claves conocidas para evitar notificar artículos existentes -export async function initNotifiedArticleKeys() { - if (!redisClient) { - return new Set(); - } - - try { - const initialKeys = await redisClient.keys('notified:*'); - const keysSet = new Set(initialKeys); - console.log(`📋 ${keysSet.size} artículos ya notificados detectados`); - return keysSet; - } catch (error) { - console.error('Error inicializando claves de artículos:', error.message); - return new Set(); - } -} - -// Funciones para manejar workers por usuario -export async function getWorkers(username) { - if (!redisClient) { - throw new Error('Redis no está disponible'); - } - - try { - const workersKey = `workers:${username}`; - const workersData = await redisClient.get(workersKey); - - if (!workersData) { - // Retornar estructura vacía por defecto - return { - general: { - title_exclude: [], - description_exclude: [] - }, - items: [], - disabled: [] - }; - } - - return JSON.parse(workersData); - } catch (error) { - console.error(`Error obteniendo workers para ${username}:`, error.message); - throw error; - } -} - -export async function setWorkers(username, workers) { - if (!redisClient) { - throw new Error('Redis no está disponible'); - } - - try { - const workersKey = `workers:${username}`; - const workersData = JSON.stringify(workers); - await redisClient.set(workersKey, workersData); - return true; - } catch (error) { - console.error(`Error guardando workers para ${username}:`, error.message); - throw error; - } -} - -// Migrar workers.json a Redis para el usuario admin si no existe -async function migrateWorkersFromFile() { - if (!redisClient) { - return; - } - - try { - const adminWorkersKey = 'workers:admin'; - const adminWorkersExists = await redisClient.exists(adminWorkersKey); - - // Si ya existen workers para admin en Redis, no migrar - if (adminWorkersExists) { - console.log('ℹ️ Workers de admin ya existen en Redis, omitiendo migración'); - return; - } - - // Intentar leer workers.json - if (!existsSync(PATHS.WORKERS)) { - console.log('ℹ️ workers.json no existe, creando estructura vacía para admin'); - // Crear estructura vacía por defecto - const defaultWorkers = { - general: { - title_exclude: [], - description_exclude: [] - }, - items: [], - disabled: [] - }; - await setWorkers('admin', defaultWorkers); - return; - } - - // Leer workers.json y migrar a Redis - const workersData = readJSON(PATHS.WORKERS, { - general: { - title_exclude: [], - description_exclude: [] - }, - items: [], - disabled: [] - }); - - // Guardar en Redis para admin - await setWorkers('admin', workersData); - console.log(`✅ Workers migrados desde workers.json al usuario admin (${workersData.items?.length || 0} items)`); - - } catch (error) { - console.error('Error migrando workers.json a Redis:', error.message); - // No lanzar error, solo registrar - } -} - diff --git a/web/backend/utils/fingerprint.js b/web/backend/utils/fingerprint.js new file mode 100644 index 0000000..e9d151b --- /dev/null +++ b/web/backend/utils/fingerprint.js @@ -0,0 +1,165 @@ +import crypto from 'crypto'; + +/** + * Genera un fingerprint del dispositivo basado en headers HTTP + * @param {Object} req - Request object de Express + * @returns {Object} Objeto con fingerprint hash y metadata del dispositivo + */ +export function generateDeviceFingerprint(req) { + // Extraer información del dispositivo desde headers + const userAgent = req.headers['user-agent'] || ''; + const acceptLanguage = req.headers['accept-language'] || ''; + const acceptEncoding = req.headers['accept-encoding'] || ''; + const accept = req.headers['accept'] || ''; + const connection = req.headers['connection'] || ''; + const upgradeInsecureRequests = req.headers['upgrade-insecure-requests'] || ''; + + // IP del cliente (considerando proxies) + const ip = req.ip || + req.headers['x-forwarded-for']?.split(',')[0]?.trim() || + req.headers['x-real-ip'] || + req.connection?.remoteAddress || + 'unknown'; + + // Crear string combinado para el hash + const fingerprintString = [ + userAgent, + acceptLanguage, + acceptEncoding, + accept, + connection, + upgradeInsecureRequests, + ip + ].join('|'); + + // Generar hash SHA-256 + const fingerprintHash = crypto + .createHash('sha256') + .update(fingerprintString) + .digest('hex'); + + // Extraer información legible del User-Agent + const deviceInfo = parseUserAgent(userAgent); + + return { + fingerprint: fingerprintHash, + deviceInfo: { + ...deviceInfo, + ip: ip, + userAgent: userAgent.substring(0, 200), // Limitar longitud + } + }; +} + +/** + * Parsea el User-Agent para extraer información legible + * @param {string} userAgent - User-Agent string + * @returns {Object} Información del dispositivo + */ +function parseUserAgent(userAgent) { + if (!userAgent) { + return { + browser: 'Unknown', + browserVersion: '', + os: 'Unknown', + osVersion: '', + device: 'Unknown', + }; + } + + const ua = userAgent.toLowerCase(); + + // Detectar navegador + let browser = 'Unknown'; + let browserVersion = ''; + + if (ua.includes('chrome') && !ua.includes('edg') && !ua.includes('opr')) { + browser = 'Chrome'; + const match = ua.match(/chrome\/([\d.]+)/); + browserVersion = match ? match[1] : ''; + } else if (ua.includes('firefox')) { + browser = 'Firefox'; + const match = ua.match(/firefox\/([\d.]+)/); + browserVersion = match ? match[1] : ''; + } else if (ua.includes('safari') && !ua.includes('chrome')) { + browser = 'Safari'; + const match = ua.match(/version\/([\d.]+)/); + browserVersion = match ? match[1] : ''; + } else if (ua.includes('edg')) { + browser = 'Edge'; + const match = ua.match(/edg\/([\d.]+)/); + browserVersion = match ? match[1] : ''; + } else if (ua.includes('opr')) { + browser = 'Opera'; + const match = ua.match(/opr\/([\d.]+)/); + browserVersion = match ? match[1] : ''; + } + + // Detectar sistema operativo + let os = 'Unknown'; + let osVersion = ''; + + if (ua.includes('windows')) { + os = 'Windows'; + if (ua.includes('windows nt 10')) osVersion = '10/11'; + else if (ua.includes('windows nt 6.3')) osVersion = '8.1'; + else if (ua.includes('windows nt 6.2')) osVersion = '8'; + else if (ua.includes('windows nt 6.1')) osVersion = '7'; + } else if (ua.includes('mac os x') || ua.includes('macintosh')) { + os = 'macOS'; + const match = ua.match(/mac os x ([\d_]+)/); + osVersion = match ? match[1].replace(/_/g, '.') : ''; + } else if (ua.includes('linux')) { + os = 'Linux'; + } else if (ua.includes('android')) { + os = 'Android'; + const match = ua.match(/android ([\d.]+)/); + osVersion = match ? match[1] : ''; + } else if (ua.includes('ios') || ua.includes('iphone') || ua.includes('ipad')) { + os = 'iOS'; + const match = ua.match(/os ([\d_]+)/); + osVersion = match ? match[1].replace(/_/g, '.') : ''; + } + + // Detectar tipo de dispositivo + let device = 'Desktop'; + if (ua.includes('mobile') || ua.includes('android') || ua.includes('iphone')) { + device = 'Mobile'; + } else if (ua.includes('tablet') || ua.includes('ipad')) { + device = 'Tablet'; + } + + return { + browser, + browserVersion, + os, + osVersion, + device, + }; +} + +/** + * Genera un fingerprint desde el frontend (cuando se envía desde el cliente) + * @param {string} fingerprintHash - Hash del fingerprint generado en el cliente + * @param {Object} deviceInfo - Información del dispositivo del cliente + * @param {Object} req - Request object de Express + * @returns {Object} Objeto con fingerprint y metadata combinada + */ +export function combineFingerprint(fingerprintHash, deviceInfo, req) { + const serverFingerprint = generateDeviceFingerprint(req); + + // Si el cliente envió un fingerprint, combinarlo con el del servidor + // Esto permite usar librerías avanzadas del cliente pero validar con servidor + const combinedFingerprint = fingerprintHash + ? crypto.createHash('sha256').update(fingerprintHash + serverFingerprint.fingerprint).digest('hex') + : serverFingerprint.fingerprint; + + return { + fingerprint: combinedFingerprint, + deviceInfo: { + ...serverFingerprint.deviceInfo, + ...deviceInfo, // El del cliente tiene prioridad si existe + } + }; +} + diff --git a/web/frontend/package-lock.json b/web/frontend/package-lock.json index 28ef621..2daf3e3 100644 --- a/web/frontend/package-lock.json +++ b/web/frontend/package-lock.json @@ -8,6 +8,7 @@ "name": "wallabicher-frontend", "version": "1.0.0", "dependencies": { + "@fingerprintjs/fingerprintjs": "^5.0.1", "@heroicons/vue": "^2.1.1", "axios": "^1.6.0", "chart.js": "^4.4.0", @@ -473,6 +474,12 @@ "node": ">=12" } }, + "node_modules/@fingerprintjs/fingerprintjs": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@fingerprintjs/fingerprintjs/-/fingerprintjs-5.0.1.tgz", + "integrity": "sha512-KbaeE/rk2WL8MfpRP6jTI4lSr42SJPjvkyrjP3QU6uUDkOMWWYC2Ts1sNSYcegHC8avzOoYTHBj+2fTqvZWQBA==", + "license": "MIT" + }, "node_modules/@heroicons/vue": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/@heroicons/vue/-/vue-2.2.0.tgz", diff --git a/web/frontend/package.json b/web/frontend/package.json index 17eca20..d6df72d 100644 --- a/web/frontend/package.json +++ b/web/frontend/package.json @@ -8,19 +8,19 @@ "preview": "vite preview" }, "dependencies": { - "vue": "^3.3.4", - "vue-router": "^4.2.5", - "axios": "^1.6.0", + "@fingerprintjs/fingerprintjs": "^5.0.1", "@heroicons/vue": "^2.1.1", + "axios": "^1.6.0", "chart.js": "^4.4.0", - "vue-chartjs": "^5.2.0" + "vue": "^3.3.4", + "vue-chartjs": "^5.2.0", + "vue-router": "^4.2.5" }, "devDependencies": { "@vitejs/plugin-vue": "^4.4.0", - "vite": "^5.0.0", "autoprefixer": "^10.4.16", "postcss": "^8.4.31", - "tailwindcss": "^3.3.5" + "tailwindcss": "^3.3.5", + "vite": "^5.0.0" } } - diff --git a/web/frontend/src/App.vue b/web/frontend/src/App.vue index fc95a47..dce5ecd 100644 --- a/web/frontend/src/App.vue +++ b/web/frontend/src/App.vue @@ -166,6 +166,8 @@ import { Cog6ToothIcon, UserGroupIcon, DocumentMagnifyingGlassIcon, + ShieldExclamationIcon, + ClockIcon, Bars3Icon, XMarkIcon, SunIcon, @@ -187,6 +189,8 @@ const allNavItems = [ { path: '/workers', name: 'Workers', icon: Cog6ToothIcon, adminOnly: false }, { path: '/users', name: 'Usuarios', icon: UserGroupIcon, adminOnly: false }, { path: '/logs', name: 'Logs', icon: DocumentMagnifyingGlassIcon, adminOnly: true }, + { path: '/rate-limiter', name: 'Rate Limiter', icon: ShieldExclamationIcon, adminOnly: true }, + { path: '/sessions', name: 'Sesiones', icon: ClockIcon, adminOnly: true }, ]; const router = useRouter(); diff --git a/web/frontend/src/components/ArticleCard.vue b/web/frontend/src/components/ArticleCard.vue index 4f3d3f3..6d5f552 100644 --- a/web/frontend/src/components/ArticleCard.vue +++ b/web/frontend/src/components/ArticleCard.vue @@ -9,7 +9,11 @@
Cargando artículo...
+{{ error }}
+ +Localidad
+{{ article.location }}
+Envío
++ {{ article.allows_shipping ? '✅ Acepta envíos' : '❌ No acepta envíos' }} +
+Modificado
+{{ formatDateShort(article.modified_at) }}
+Notificado
+{{ formatDateShort(article.notifiedAt) }}
+ID
+{{ article.id }}
++ {{ article.description }} +
+Acceso Denegado
++ Solo los administradores pueden ver el rate limiter +
+Rate Limiter Deshabilitado
++ {{ rateLimiterInfo.message || 'El rate limiter no está configurado' }} +
+{{ rateLimiterInfo.note }}
+| + Clave/IP + | ++ Estado + | ++ Puntos Restantes + | ++ Total Hits + | ++ Tiempo Restante + | +
|---|---|---|---|---|
| + {{ block.key }} + | ++ + {{ block.isBlocked ? 'Bloqueado' : 'Activo' }} + + | ++ {{ block.remainingPoints }} + | ++ {{ block.totalHits }} + | ++ {{ formatTimeRemaining(block.msBeforeNext) }} + | +
No hay bloqueos registrados
+Cargando información del rate limiter...
+Acceso Denegado
++ Solo los administradores pueden ver las sesiones +
+| + Usuario + | ++ Dispositivo + | ++ Token + | ++ Creada + | ++ Expira + | ++ Estado + | ++ Acciones + | +
|---|---|---|---|---|---|---|
| + {{ session.username }} + | +
+
+
+
+ {{ formatDeviceInfo(session.deviceInfo) }}
+
+
+ {{ session.deviceInfo.os || 'Unknown OS' }}
+ {{ session.deviceInfo.osVersion }}
+
+
+ IP: {{ session.deviceInfo.ip }}
+
+
+ Sin información
+
+ |
+ + + {{ session.token.substring(0, 16) }}... + + | ++ {{ formatDate(session.createdAt) }} + | ++ {{ formatDate(session.expiresAt) }} + | ++ + {{ session.isExpired ? 'Expirada' : 'Activa' }} + + | ++ + | +
No hay sesiones registradas
+Cargando sesiones...
+