From 41b42eddc2964d45906009ae3542eef262a5d4d8 Mon Sep 17 00:00:00 2001 From: adevopg Date: Tue, 14 Jul 2026 14:27:51 +0000 Subject: [PATCH] =?UTF-8?q?A=C3=B1adir=20/security-history=20(Historial=20?= =?UTF-8?q?de=20seguridad):=20actividad,=20reino,=20web?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tres tablas: actividad (solicitudes de token, home_securitytoken), conexiones al reino (logs_ip_actions + account.last_ip/last_login como conexión actual), y conexiones web (home_loginattempt, estado Exito/Fracaso -> Conexión exitosa/ Contraseña incorrecta). lib/security-history.ts (getSecurityHistory), tolerante si falta la tabla. Django era stub. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../app/[locale]/security-history/page.tsx | 194 ++++++++++++++++++ web-next/lib/security-history.ts | 104 ++++++++++ 2 files changed, 298 insertions(+) create mode 100644 web-next/app/[locale]/security-history/page.tsx create mode 100644 web-next/lib/security-history.ts diff --git a/web-next/app/[locale]/security-history/page.tsx b/web-next/app/[locale]/security-history/page.tsx new file mode 100644 index 0000000..06f32d7 --- /dev/null +++ b/web-next/app/[locale]/security-history/page.tsx @@ -0,0 +1,194 @@ +import type { Metadata } from 'next' +import { setRequestLocale } from 'next-intl/server' +import { redirect } from '@/i18n/navigation' +import { getSession } from '@/lib/session' +import { getSecurityHistory } from '@/lib/security-history' +import { PageShell } from '@/components/PageShell' +import { ServiceBox } from '@/components/ServiceBox' + +export const dynamic = 'force-dynamic' + +export const metadata: Metadata = { title: 'Historial de seguridad' } + +/** Fecha en formato HH:MM:SS DD-MM-YYYY (como el diseño). */ +function fmt(d: Date): string { + const p = (n: number) => String(n).padStart(2, '0') + return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())} ${p(d.getDate())}-${p(d.getMonth() + 1)}-${d.getFullYear()}` +} + +export default async function SecurityHistoryPage({ params }: { params: Promise<{ locale: string }> }) { + const { locale } = await params + setRequestLocale(locale) + + const session = await getSession() + if (!session.bnetId) redirect({ href: '/login', locale }) + if (!session.username) redirect({ href: '/select-account', locale }) + + const displayUser = session.username || session.bnetEmail || '—' + const { activity, realm, web } = await getSecurityHistory(session.accountId!, displayUser, session.bnetEmail || '') + + return ( + + +
+

Aquí verás información de actividad y conexiones de tu cuenta.

+

Cada tabla tiene una explicación detallada de la información provista.

+

+ Si desconoces algún tipo de actividad te recomendamos cambiar la contraseña de tu cuenta y del correo ligado a + la misma. +

+
+

+ Historial de actividad +

+

Muestra actividad de seguridad de la cuenta, como por ejemplo cambios de contraseña.

+
+

+ Historial de conexiones (Reino) +

+

+ Muestra un historial de conexiones al reino sólo cuando la IP ha cambiado. No es un historial de cada + conexión. +

+
+

+ Historial de conexiones (Web) +

+

Muestra un historial de cada conexión al sitio web.

+
+ + {/* Historial de actividad */} +
+
+

Historial de actividad

+
+
+ {activity.length === 0 ? ( + + + + + + +
+ No hay actividad +
+ ) : ( + + + + + + + + + {activity.map((a, i) => ( + + + + + + + ))} + +
UsuarioAcciónIPFecha
+ {a.user} + + {a.action} + + {a.ip} + + {fmt(a.date)} +
+ )} +
+
+ + {/* Historial de conexiones (Reino) */} +
+
+

Historial de conexiones (Reino)

+
+
+ {realm.length === 0 ? ( + + + + + + +
+ No hay conexiones +
+ ) : ( + + + + + + + {realm.map((c, i) => ( + + + + + ))} + +
IPFecha
+ {c.ip} + + {fmt(c.date)} +
+ )} +
+
+ + {/* Historial de conexiones (Web) */} +
+
+

Historial de conexiones (Web)

+
+
+ {web.length === 0 ? ( + + + + + + +
+ No hay conexiones +
+ ) : ( + + + + + + + + + {web.map((c, i) => ( + + + + + + + ))} + +
UsuarioEstadoIPFecha
+ {c.user} + + {c.status} + + {c.ip} + + {fmt(c.date)} +
+ )} +
+
+
+ ) +} diff --git a/web-next/lib/security-history.ts b/web-next/lib/security-history.ts new file mode 100644 index 0000000..3226688 --- /dev/null +++ b/web-next/lib/security-history.ts @@ -0,0 +1,104 @@ +import type { RowDataPacket } from 'mysql2' +import { db, DB } from './db' + +export interface ActivityEntry { + user: string + action: string + ip: string + date: Date +} +export interface RealmConnection { + ip: string + date: Date +} +export interface WebConnection { + user: string + status: string + ip: string + date: Date +} + +/** Traduce el estado guardado del intento de login web a la etiqueta del diseño. */ +function webStatusLabel(raw: string): string { + const s = (raw || '').toLowerCase() + if (s.includes('exito') || s.includes('éxito') || s.includes('success')) return 'Conexión exitosa' + if (s.includes('fracaso') || s.includes('fail') || s.includes('incorrect')) return 'Contraseña incorrecta' + return raw || '—' +} + +/** + * Historial de seguridad de una cuenta, en tres bloques: + * - **Actividad**: acciones de seguridad (solicitudes de token) desde `home_securitytoken`. + * - **Conexiones (Reino)**: `acore_auth.logs_ip_actions` (histórico de IP) + la última + * conexión conocida (`account.last_ip`/`last_login`) si aporta una IP nueva. + * - **Conexiones (Web)**: cada intento de login web desde `home_loginattempt`. + * Cada consulta es tolerante a fallos (devuelve [] si la tabla no existe). + */ +export async function getSecurityHistory( + accountId: number, + displayUser: string, + email: string, + limit = 100, +): Promise<{ activity: ActivityEntry[]; realm: RealmConnection[]; web: WebConnection[] }> { + const activity: ActivityEntry[] = [] + const realm: RealmConnection[] = [] + const web: WebConnection[] = [] + if (!accountId) return { activity, realm, web } + + // 1) Actividad: solicitudes de token de seguridad. + try { + const [rows] = await db(DB.default).query( + 'SELECT created_at, ip_address FROM home_securitytoken WHERE user_id = ? ORDER BY created_at DESC LIMIT ?', + [accountId, limit], + ) + for (const r of rows) { + activity.push({ user: displayUser, action: 'Token de seguridad solicitado', ip: String(r.ip_address || '—'), date: new Date(r.created_at) }) + } + } catch { + /* tabla ausente */ + } + + // 2) Conexiones al reino: histórico de IP + última conexión conocida. + try { + const [rows] = await db(DB.auth).query( + 'SELECT ip, unixtime FROM logs_ip_actions WHERE account_id = ? ORDER BY unixtime DESC LIMIT ?', + [accountId, limit], + ) + for (const r of rows) realm.push({ ip: String(r.ip), date: new Date(Number(r.unixtime) * 1000) }) + } catch { + /* tabla ausente */ + } + try { + const [acc] = await db(DB.auth).query( + 'SELECT last_ip, last_login FROM account WHERE id = ? LIMIT 1', + [accountId], + ) + const a = acc[0] + if (a && a.last_ip && a.last_login) { + // Añade la última conexión si no coincide con la IP más reciente ya listada. + if (realm.length === 0 || realm[0].ip !== String(a.last_ip)) { + realm.unshift({ ip: String(a.last_ip), date: new Date(a.last_login) }) + } + } + } catch { + /* tabla ausente */ + } + realm.sort((x, y) => y.date.getTime() - x.date.getTime()) + + // 3) Conexiones web: intentos de login (por email de la cuenta). + if (email) { + try { + const [rows] = await db(DB.default).query( + 'SELECT username, status, ip_address, timestamp FROM home_loginattempt WHERE username = ? ORDER BY timestamp DESC LIMIT ?', + [email, limit], + ) + for (const r of rows) { + web.push({ user: displayUser, status: webStatusLabel(String(r.status)), ip: String(r.ip_address || '—'), date: new Date(r.timestamp) }) + } + } catch { + /* tabla ausente */ + } + } + + return { activity, realm, web } +}