diff --git a/src/services/command.ts b/src/services/command.ts index 370ed15..d00fe4a 100644 --- a/src/services/command.ts +++ b/src/services/command.ts @@ -9,6 +9,7 @@ import { padTaskId, codeId, formatDDMM, bold, italic } from '../utils/formatting import { IdentityService } from './identity'; import { AllowedGroups } from './allowed-groups'; import { Metrics } from './metrics'; +import { randomTokenBase64Url, sha256Hex } from '../utils/crypto'; type CommandContext = { sender: string; // normalized user id (digits only), but accept raw too @@ -955,6 +956,61 @@ export class CommandService { }]; } + // Enlace de acceso a la web (/t web) + if (action === 'web') { + // Solo por DM + if (isGroupId(context.groupId)) { + return [{ + recipient: context.sender, + message: 'ℹ️ Este comando se usa por privado. Envíame `/t web` por DM.' + }]; + } + + const base = (process.env.WEB_BASE_URL || '').trim(); + if (!base) { + return [{ + recipient: context.sender, + message: '⚠️ La web no está configurada todavía. Contacta con el administrador (falta WEB_BASE_URL).' + }]; + } + + const ensured = ensureUserExists(context.sender, this.dbInstance); + if (!ensured) { + throw new Error('No se pudo asegurar el usuario'); + } + + const toIso = (d: Date) => d.toISOString().replace('T', ' ').replace('Z', ''); + const now = new Date(); + const nowIso = toIso(now); + const expiresIso = toIso(new Date(now.getTime() + 10 * 60 * 1000)); // 10 minutos + + // Invalidar tokens vigentes (uso único) + this.dbInstance.prepare(` + UPDATE web_tokens + SET used_at = ? + WHERE user_id = ? + AND used_at IS NULL + AND expires_at > ? + `).run(nowIso, ensured, nowIso); + + // Generar nuevo token y guardar solo el hash + const token = randomTokenBase64Url(32); + const tokenHash = await sha256Hex(token); + + this.dbInstance.prepare(` + INSERT INTO web_tokens (user_id, token_hash, expires_at, metadata) + VALUES (?, ?, ?, NULL) + `).run(ensured, tokenHash, expiresIso); + + try { Metrics.inc('web_tokens_issued_total'); } catch {} + + const url = new URL(`/login?token=${encodeURIComponent(token)}`, base).toString(); + return [{ + recipient: context.sender, + message: `Acceso web: ${url}\nVálido durante 10 minutos. Si caduca, vuelve a enviar "/t web".` + }]; + } + if (action !== 'nueva') { return [{ recipient: context.sender, diff --git a/src/utils/crypto.ts b/src/utils/crypto.ts new file mode 100644 index 0000000..c7b355c --- /dev/null +++ b/src/utils/crypto.ts @@ -0,0 +1,20 @@ +/** + * Utilidades criptográficas sin dependencias externas. + * - randomTokenBase64Url: genera un token aleatorio (base64url, sin relleno). + * - sha256Hex: calcula SHA-256 y devuelve en hex. + */ + +export function randomTokenBase64Url(bytes: number = 32): string { + const arr = new Uint8Array(bytes); + crypto.getRandomValues(arr); + const b64 = Buffer.from(arr).toString('base64'); + // base64url (RFC 4648) sin padding + return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, ''); +} + +export async function sha256Hex(input: string): Promise { + const data = new TextEncoder().encode(input); + const hashBuf = await crypto.subtle.digest('SHA-256', data); + const bytes = new Uint8Array(hashBuf); + return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join(''); +} diff --git a/tests/unit/services/command.web-login.test.ts b/tests/unit/services/command.web-login.test.ts new file mode 100644 index 0000000..c296bcc --- /dev/null +++ b/tests/unit/services/command.web-login.test.ts @@ -0,0 +1,131 @@ +import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; +import { Database } from 'bun:sqlite'; +import { initializeDatabase } from '../../../src/db'; +import { CommandService } from '../../../src/services/command'; +import { sha256Hex } from '../../../src/utils/crypto'; +import { Metrics } from '../../../src/services/metrics'; + +const envBackup = { ...process.env }; +let memdb: Database; + +describe('CommandService - /t web (emisión de token de login)', () => { + beforeEach(() => { + process.env = { + ...envBackup, + NODE_ENV: 'test', + TZ: 'Europe/Madrid', + WEB_BASE_URL: 'https://app.example.test' + }; + Metrics.reset?.(); + memdb = new Database(':memory:'); + initializeDatabase(memdb); + (CommandService as any).dbInstance = memdb; + }); + + afterEach(() => { + process.env = envBackup; + try { memdb.close(); } catch {} + }); + + test('DM feliz: devuelve URL con token y persiste hash en web_tokens', async () => { + const res = await CommandService.handle({ + sender: '34600123456', + groupId: '34600123456@s.whatsapp.net', // DM (no @g.us) + message: '/t web', + mentions: [] + }); + expect(Array.isArray(res)).toBe(true); + expect(res.length).toBe(1); + expect(res[0].recipient).toBe('34600123456'); + expect(res[0].message).toContain('https://app.example.test'); + + const m = res[0].message.match(/https?:\/\/\S+/); + expect(m).toBeTruthy(); + const url = new URL(m![0]); + expect(url.pathname).toBe('/login'); + const token = url.searchParams.get('token') || ''; + expect(token.length).toBeGreaterThan(0); + + const hash = await sha256Hex(token); + const row = memdb.prepare(` + SELECT user_id, token_hash, used_at, expires_at + FROM web_tokens + WHERE user_id = ? AND token_hash = ? + `).get('34600123456', hash) as any; + + expect(row).toBeTruthy(); + expect(row.user_id).toBe('34600123456'); + expect(row.token_hash).toBe(hash); + expect(row.used_at).toBeNull(); + // expires_at debe ser en el futuro + const now = new Date(); + const exp = new Date(String(row.expires_at).replace(' ', 'T') + 'Z'); + expect(exp.getTime()).toBeGreaterThan(now.getTime() + 9 * 60 * 1000 - 10 * 1000); // ~>= 9min50s + expect(Metrics.get('web_tokens_issued_total')).toBe(1); + }); + + test('En grupo: responde que debe usarse por privado y no inserta token', async () => { + const res = await CommandService.handle({ + sender: '34600123456', + groupId: '123@g.us', + message: '/t web', + mentions: [] + }); + expect(res.length).toBe(1); + expect(res[0].recipient).toBe('34600123456'); + expect(res[0].message.toLowerCase()).toContain('privado'); + + const cnt = memdb.prepare(`SELECT COUNT(*) AS c FROM web_tokens WHERE user_id = ?`).get('34600123456') as any; + expect(Number(cnt.c)).toBe(0); + }); + + test('Sin WEB_BASE_URL: error claro y no inserta token', async () => { + delete process.env.WEB_BASE_URL; + + const res = await CommandService.handle({ + sender: '34600123456', + groupId: '34600123456@s.whatsapp.net', + message: '/t web', + mentions: [] + }); + expect(res.length).toBe(1); + expect(res[0].message).toContain('no está configurada'); + const cnt = memdb.prepare(`SELECT COUNT(*) AS c FROM web_tokens WHERE user_id = ?`).get('34600123456') as any; + expect(Number(cnt.c)).toBe(0); + }); + + test('Token vigente: se invalida y se emite uno nuevo (queda solo 1 activo)', async () => { + // Primera emisión + { + const r1 = await CommandService.handle({ + sender: '34600123456', + groupId: '34600123456@s.whatsapp.net', + message: '/t web', + mentions: [] + }); + expect(r1.length).toBe(1); + } + + // Segunda emisión (debe invalidar el anterior) + { + const r2 = await CommandService.handle({ + sender: '34600123456', + groupId: '34600123456@s.whatsapp.net', + message: '/t web', + mentions: [] + }); + expect(r2.length).toBe(1); + } + + const counts = memdb.prepare(` + SELECT + SUM(CASE WHEN used_at IS NULL THEN 1 ELSE 0 END) AS active, + COUNT(*) AS total + FROM web_tokens + WHERE user_id = ? + `).get('34600123456') as any; + + expect(Number(counts.total)).toBeGreaterThanOrEqual(2); + expect(Number(counts.active)).toBe(1); + }); +});