From 1da7349373a93c8566b8f2b08eab94c9bbee02d4 Mon Sep 17 00:00:00 2001 From: adevopg Date: Sun, 12 Jul 2026 23:36:05 +0000 Subject: [PATCH] =?UTF-8?q?Token=20de=20seguridad=20y=20cambio=20de=20cont?= =?UTF-8?q?rase=C3=B1a=20(p=C3=A1ginas=20protegidas)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - lib/security-token.ts: requestSecurityToken (token de 6 chars, cooldown 7 días, guarda en home_securitytoken, email) + checkSecurityToken. - lib/change-password.ts: changePassword (valida token + contraseña actual con authenticate, re-deriva verifier SRP6 v2, actualiza battlenet_accounts). - Routes /api/account/security-token y /change-password (guard; el cambio hace logout de la sesión). Páginas /security-token y /change-password (protegidas) con sus forms cliente e i18n (SecurityToken, ChangePassword). Verificado: páginas redirigen a login sin sesión, APIs 401. Reutiliza home_securitytoken de Django y la crypto validada. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../change-password/ChangePasswordForm.tsx | 76 +++++++++++++++++++ .../app/[locale]/change-password/page.tsx | 22 ++++++ .../security-token/SecurityTokenForm.tsx | 53 +++++++++++++ web-next/app/[locale]/security-token/page.tsx | 35 +++++++++ .../app/api/account/change-password/route.ts | 24 ++++++ .../app/api/account/security-token/route.ts | 9 +++ web-next/lib/change-password.ts | 41 ++++++++++ web-next/lib/security-token.ts | 63 +++++++++++++++ web-next/messages/en.json | 30 ++++++++ web-next/messages/es.json | 30 ++++++++ 10 files changed, 383 insertions(+) create mode 100644 web-next/app/[locale]/change-password/ChangePasswordForm.tsx create mode 100644 web-next/app/[locale]/change-password/page.tsx create mode 100644 web-next/app/[locale]/security-token/SecurityTokenForm.tsx create mode 100644 web-next/app/[locale]/security-token/page.tsx create mode 100644 web-next/app/api/account/change-password/route.ts create mode 100644 web-next/app/api/account/security-token/route.ts create mode 100644 web-next/lib/change-password.ts create mode 100644 web-next/lib/security-token.ts diff --git a/web-next/app/[locale]/change-password/ChangePasswordForm.tsx b/web-next/app/[locale]/change-password/ChangePasswordForm.tsx new file mode 100644 index 0000000..8b38c7e --- /dev/null +++ b/web-next/app/[locale]/change-password/ChangePasswordForm.tsx @@ -0,0 +1,76 @@ +'use client' + +import { useState } from 'react' +import { useTranslations } from 'next-intl' +import { useRouter } from '@/i18n/navigation' + +const ERROR_KEYS = [ + 'missingFields', + 'passwordMismatch', + 'passwordTooLong', + 'invalidToken', + 'wrongCurrentPassword', + 'accountNotFound', +] as const + +export function ChangePasswordForm() { + const t = useTranslations('ChangePassword') + const router = useRouter() + const [form, setForm] = useState({ currentPassword: '', newPassword: '', confPassword: '', token: '' }) + const [busy, setBusy] = useState(false) + const [done, setDone] = useState(false) + const [message, setMessage] = useState<{ ok: boolean; text: string } | null>(null) + + function upd(k: string, v: string) { + setForm((f) => ({ ...f, [k]: v })) + } + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault() + if (busy || done) return + setBusy(true) + setMessage(null) + try { + const res = await fetch('/api/account/change-password', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'same-origin', + body: JSON.stringify(form), + }) + const data: { success?: boolean; error?: string } = await res.json() + if (data.success) { + setDone(true) + setMessage({ ok: true, text: t('success') }) + setTimeout(() => { + router.push('/login') + router.refresh() + }, 2500) + } else { + const key = (ERROR_KEYS as readonly string[]).includes(data.error ?? '') ? data.error! : 'genericError' + setMessage({ ok: false, text: t(key) }) + } + } catch { + setMessage({ ok: false, text: t('genericError') }) + } finally { + if (!done) setBusy(false) + } + } + + const field = 'w-full rounded border border-amber-900/60 bg-[#2c1e14] px-3 py-2' + + return ( +
+

{t('info')}

+
+ upd('currentPassword', e.target.value)} className={field} /> + upd('newPassword', e.target.value)} className={field} /> + upd('confPassword', e.target.value)} className={field} /> + upd('token', e.target.value)} className={field} /> + +
+ {message &&

{message.text}

} +
+ ) +} diff --git a/web-next/app/[locale]/change-password/page.tsx b/web-next/app/[locale]/change-password/page.tsx new file mode 100644 index 0000000..3180f23 --- /dev/null +++ b/web-next/app/[locale]/change-password/page.tsx @@ -0,0 +1,22 @@ +import { getTranslations, setRequestLocale } from 'next-intl/server' +import { redirect } from '@/i18n/navigation' +import { getSession } from '@/lib/session' +import { ChangePasswordForm } from './ChangePasswordForm' + +export const dynamic = 'force-dynamic' + +export default async function ChangePasswordPage({ params }: { params: Promise<{ locale: string }> }) { + const { locale } = await params + setRequestLocale(locale) + const t = await getTranslations('ChangePassword') + const session = await getSession() + if (!session.bnetId) redirect({ href: '/login', locale }) + if (!session.username) redirect({ href: '/select-account', locale }) + + return ( +
+

{t('title')}

+ +
+ ) +} diff --git a/web-next/app/[locale]/security-token/SecurityTokenForm.tsx b/web-next/app/[locale]/security-token/SecurityTokenForm.tsx new file mode 100644 index 0000000..35f78be --- /dev/null +++ b/web-next/app/[locale]/security-token/SecurityTokenForm.tsx @@ -0,0 +1,53 @@ +'use client' + +import { useState } from 'react' +import { useTranslations, useLocale } from 'next-intl' + +const ERROR_KEYS = ['cooldown', 'noEmail'] as const + +export function SecurityTokenForm({ initialDate }: { initialDate: string | null }) { + const t = useTranslations('SecurityToken') + const locale = useLocale() + const [date, setDate] = useState(initialDate) + const [busy, setBusy] = useState(false) + const [message, setMessage] = useState<{ ok: boolean; text: string } | null>(null) + + async function request() { + if (busy) return + setBusy(true) + setMessage(null) + try { + const res = await fetch('/api/account/security-token', { method: 'POST', credentials: 'same-origin' }) + const data: { success?: boolean; error?: string; tokenDate?: string } = await res.json() + if (data.success) { + setMessage({ ok: true, text: t('success') }) + if (data.tokenDate) setDate(data.tokenDate) + } else { + const key = (ERROR_KEYS as readonly string[]).includes(data.error ?? '') ? data.error! : 'genericError' + setMessage({ ok: false, text: t(key) }) + } + } catch { + setMessage({ ok: false, text: t('genericError') }) + } finally { + setBusy(false) + } + } + + return ( +
+

{t('info')}

+

+ {t('lastRequest')}:{' '} + {date ? new Date(date).toLocaleString(locale) : t('never')} +

+ + {message &&

{message.text}

} +
+ ) +} diff --git a/web-next/app/[locale]/security-token/page.tsx b/web-next/app/[locale]/security-token/page.tsx new file mode 100644 index 0000000..eeee11c --- /dev/null +++ b/web-next/app/[locale]/security-token/page.tsx @@ -0,0 +1,35 @@ +import type { RowDataPacket } from 'mysql2' +import { getTranslations, setRequestLocale } from 'next-intl/server' +import { redirect } from '@/i18n/navigation' +import { getSession } from '@/lib/session' +import { db, DB } from '@/lib/db' +import { SecurityTokenForm } from './SecurityTokenForm' + +export const dynamic = 'force-dynamic' + +export default async function SecurityTokenPage({ params }: { params: Promise<{ locale: string }> }) { + const { locale } = await params + setRequestLocale(locale) + const t = await getTranslations('SecurityToken') + const session = await getSession() + if (!session.bnetId) redirect({ href: '/login', locale }) + if (!session.username) redirect({ href: '/select-account', locale }) + + let tokenDate: string | null = null + try { + const [rows] = await db(DB.default).query( + 'SELECT created_at FROM home_securitytoken WHERE user_id = ? ORDER BY created_at DESC LIMIT 1', + [session.accountId ?? 0], + ) + if (rows[0]) tokenDate = new Date(rows[0].created_at).toISOString() + } catch { + /* ignore */ + } + + return ( +
+

{t('title')}

+ +
+ ) +} diff --git a/web-next/app/api/account/change-password/route.ts b/web-next/app/api/account/change-password/route.ts new file mode 100644 index 0000000..c1adc10 --- /dev/null +++ b/web-next/app/api/account/change-password/route.ts @@ -0,0 +1,24 @@ +import { getSession } from '@/lib/session' +import { changePassword } from '@/lib/change-password' + +export async function POST(request: Request) { + const session = await getSession() + if (!session.accountId) return Response.json({ success: false, error: 'notAuthenticated' }, { status: 401 }) + let b: Record = {} + try { + b = await request.json() + } catch { + return Response.json({ success: false, error: 'invalidRequest' }, { status: 400 }) + } + const result = await changePassword( + session, + String(b.currentPassword ?? ''), + String(b.newPassword ?? ''), + String(b.confPassword ?? ''), + String(b.token ?? ''), + ) + if (result.success) { + session.destroy() // logout por seguridad + } + return Response.json(result) +} diff --git a/web-next/app/api/account/security-token/route.ts b/web-next/app/api/account/security-token/route.ts new file mode 100644 index 0000000..c7a256c --- /dev/null +++ b/web-next/app/api/account/security-token/route.ts @@ -0,0 +1,9 @@ +import { getSession } from '@/lib/session' +import { requestSecurityToken } from '@/lib/security-token' + +export async function POST(request: Request) { + const session = await getSession() + if (!session.accountId) return Response.json({ success: false, error: 'notAuthenticated' }, { status: 401 }) + const ip = (request.headers.get('x-forwarded-for') || '').split(',')[0].trim() + return Response.json(await requestSecurityToken(session, ip)) +} diff --git a/web-next/lib/change-password.ts b/web-next/lib/change-password.ts new file mode 100644 index 0000000..02d417d --- /dev/null +++ b/web-next/lib/change-password.ts @@ -0,0 +1,41 @@ +import { db, DB } from './db' +import { authenticate } from './auth' +import { bnetMakeRegistration, normalizeEmail } from './bnet' +import { checkSecurityToken } from './security-token' +import type { SessionData } from './session' + +export interface Result { + success: boolean + error?: string +} + +/** Cambia la contraseña de la cuenta Battle.net (re-deriva el verifier SRP6 v2). */ +export async function changePassword( + session: SessionData, + currentPassword: string, + newPassword: string, + confPassword: string, + token: string, +): Promise { + const email = session.bnetEmail ?? '' + const userId = session.accountId ?? 0 + if (!currentPassword || !newPassword || !confPassword || !token) return { success: false, error: 'missingFields' } + if (newPassword !== confPassword) return { success: false, error: 'passwordMismatch' } + if (newPassword.length > 16) return { success: false, error: 'passwordTooLong' } + + if (!(await checkSecurityToken(userId, token))) return { success: false, error: 'invalidToken' } + if (!(await authenticate(email, currentPassword))) return { success: false, error: 'wrongCurrentPassword' } + + try { + const reg = bnetMakeRegistration(email, newPassword) + const [res] = await db(DB.auth).query( + 'UPDATE battlenet_accounts SET srp_version = ?, salt = ?, verifier = ? WHERE email = ?', + [reg.srpVersion, reg.salt, reg.verifier, normalizeEmail(email)], + ) + // @ts-expect-error affectedRows + if (!res.affectedRows) return { success: false, error: 'accountNotFound' } + } catch { + return { success: false, error: 'genericError' } + } + return { success: true } +} diff --git a/web-next/lib/security-token.ts b/web-next/lib/security-token.ts new file mode 100644 index 0000000..c75b9ca --- /dev/null +++ b/web-next/lib/security-token.ts @@ -0,0 +1,63 @@ +import crypto from 'node:crypto' +import type { RowDataPacket } from 'mysql2' +import { db, DB } from './db' +import { sendMail } from './mail' +import type { SessionData } from './session' + +export interface Result { + success: boolean + error?: string + tokenDate?: string +} + +/** Solicita un token de seguridad (6 caracteres) por email. 1 cada 7 días. */ +export async function requestSecurityToken(session: SessionData, ip: string): Promise { + const userId = session.accountId ?? 0 + const email = session.bnetEmail ?? '' + if (!email) return { success: false, error: 'noEmail' } + + const [existing] = await db(DB.default).query( + 'SELECT id, created_at FROM home_securitytoken WHERE user_id = ? ORDER BY created_at DESC LIMIT 1', + [userId], + ) + if (existing[0]) { + const days = (Date.now() - new Date(existing[0].created_at).getTime()) / 86400_000 + if (days < 7) return { success: false, error: 'cooldown' } + } + + const token = crypto.randomBytes(4).toString('base64url').slice(0, 6) + const expiresAt = new Date(Date.now() + 7 * 86400_000) + + await db(DB.default).query('DELETE FROM home_securitytoken WHERE user_id = ?', [userId]) + await db(DB.default).query( + 'INSERT INTO home_securitytoken (token, created_at, expires_at, ip_address, user_id) VALUES (?, NOW(), ?, ?, ?)', + [token, expiresAt, ip || '0.0.0.0', userId], + ) + + await sendMail( + email, + 'Token de seguridad - Nova WoW', + ` +
+

Nova WoW

Token de seguridad

+

Tu token de seguridad es:

+

${token}

+

Consérvalo en un lugar seguro. Se solicitará para acciones importantes de la cuenta.

+
`, + ) + + const [created] = await db(DB.default).query( + 'SELECT created_at FROM home_securitytoken WHERE user_id = ? ORDER BY created_at DESC LIMIT 1', + [userId], + ) + return { success: true, tokenDate: created[0] ? new Date(created[0].created_at).toISOString() : undefined } +} + +/** Comprueba que el token coincide con el de la cuenta. */ +export async function checkSecurityToken(userId: number, token: string): Promise { + const [rows] = await db(DB.default).query( + 'SELECT token FROM home_securitytoken WHERE user_id = ? ORDER BY created_at DESC LIMIT 1', + [userId], + ) + return Boolean(rows[0] && rows[0].token === token) +} diff --git a/web-next/messages/en.json b/web-next/messages/en.json index a77c710..e143ebe 100644 --- a/web-next/messages/en.json +++ b/web-next/messages/en.json @@ -176,5 +176,35 @@ "destination": "Destination account", "success": "Character {name} has been transferred to the destination account." } + }, + "SecurityToken": { + "title": "Security token", + "info": "The security token is a 6-character (case-sensitive) code required for important account actions. You can request a new one every 7 days.", + "lastRequest": "Request date", + "never": "Not requested", + "request": "Request token", + "requesting": "Requesting…", + "success": "The security token has been sent to your email.", + "cooldown": "You can only request a token every 7 days.", + "noEmail": "Add an email to your account before requesting a token.", + "genericError": "Something went wrong. Please try again later." + }, + "ChangePassword": { + "title": "Change password", + "info": "The new password must be alphanumeric and up to 16 characters. You need a security token.", + "currentPassword": "Current password", + "newPassword": "New password", + "confPassword": "Confirm new password", + "token": "Security token", + "submit": "Change password", + "changing": "Changing…", + "success": "Password changed. You have been logged out for security.", + "missingFields": "Please fill in all fields.", + "passwordMismatch": "Passwords do not match.", + "passwordTooLong": "The password must not exceed 16 characters.", + "invalidToken": "The security token is incorrect.", + "wrongCurrentPassword": "The current password is incorrect.", + "accountNotFound": "Account not found.", + "genericError": "Something went wrong. Please try again later." } } diff --git a/web-next/messages/es.json b/web-next/messages/es.json index 68df9a9..700437f 100644 --- a/web-next/messages/es.json +++ b/web-next/messages/es.json @@ -176,5 +176,35 @@ "destination": "Cuenta de destino", "success": "El personaje {name} ha sido transferido a la cuenta de destino." } + }, + "SecurityToken": { + "title": "Token de seguridad", + "info": "El token de seguridad es un código de 6 caracteres (sensible a mayúsculas) que se solicita para acciones importantes de tu cuenta. Puedes solicitar uno nuevo cada 7 días.", + "lastRequest": "Fecha de solicitud", + "never": "Sin solicitar", + "request": "Solicitar token", + "requesting": "Solicitando…", + "success": "Se ha enviado el token de seguridad a tu correo.", + "cooldown": "Solo puedes solicitar un token cada 7 días.", + "noEmail": "Añade un correo a tu cuenta antes de solicitar un token.", + "genericError": "Algo ha salido mal. Inténtalo más tarde." + }, + "ChangePassword": { + "title": "Cambiar contraseña", + "info": "La nueva contraseña debe ser alfanumérica y de hasta 16 caracteres. Necesitas un token de seguridad.", + "currentPassword": "Contraseña actual", + "newPassword": "Contraseña nueva", + "confPassword": "Confirmar contraseña nueva", + "token": "Token de seguridad", + "submit": "Cambiar contraseña", + "changing": "Cambiando…", + "success": "Contraseña cambiada. Has sido desconectado por seguridad.", + "missingFields": "Por favor, complete todos los campos.", + "passwordMismatch": "Las contraseñas no coinciden.", + "passwordTooLong": "La contraseña no debe exceder los 16 caracteres.", + "invalidToken": "El token de seguridad es incorrecto.", + "wrongCurrentPassword": "La contraseña actual es incorrecta.", + "accountNotFound": "No se ha encontrado la cuenta.", + "genericError": "Algo ha salido mal. Inténtalo más tarde." } }