feat: añadir soporte para /t web con tokens de login y util crypto

Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
webui
brobert 2 weeks ago
parent 1e188c2e96
commit c8c4fdd927

@ -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,

@ -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<string> {
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('');
}

@ -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);
});
});
Loading…
Cancel
Save