From 3573b7eb401cadaa8802cf639a41e6e73e427490 Mon Sep 17 00:00:00 2001 From: adevopg Date: Sun, 12 Jul 2026 23:07:16 +0000 Subject: [PATCH] =?UTF-8?q?Recuperaci=C3=B3n=20de=20cuenta=20en=20Next.js?= =?UTF-8?q?=20(3=20flujos=20+=20reset),=20con=20Turnstile?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - lib/recover.ts: requestRecovery(type,email) -> password (crea token en home_passwordreset + email), accountname (email con las cuentas), activation (reenvía enlace); respuesta genérica anti-enumeración. resetPassword(token,pass) re-deriva el verifier SRP6 v2 (bnetMakeRegistration) y actualiza battlenet_accounts, marca el token usado. - Routes /api/auth/recover (Turnstile) y /api/auth/reset-password. - Páginas app/[locale]/recover (RecoverForm: selector de tipo + email + Turnstile) y reset-password (ResetForm con token de la URL). Catálogos Recover, Reset. Verificado: recover sin captcha -> captchaFailed; reset token inválido -> invalidLink; páginas 200. Reutiliza home_passwordreset de Django. Crypto de reset ya validada. Co-Authored-By: Claude Opus 4.8 (1M context) --- web-next/app/[locale]/recover/RecoverForm.tsx | 62 +++++++++ web-next/app/[locale]/recover/page.tsx | 14 ++ .../app/[locale]/reset-password/ResetForm.tsx | 66 +++++++++ web-next/app/[locale]/reset-password/page.tsx | 22 +++ web-next/app/api/auth/recover/route.ts | 22 +++ web-next/app/api/auth/reset-password/route.ts | 18 +++ web-next/lib/recover.ts | 129 ++++++++++++++++++ web-next/messages/en.json | 29 ++++ web-next/messages/es.json | 29 ++++ 9 files changed, 391 insertions(+) create mode 100644 web-next/app/[locale]/recover/RecoverForm.tsx create mode 100644 web-next/app/[locale]/recover/page.tsx create mode 100644 web-next/app/[locale]/reset-password/ResetForm.tsx create mode 100644 web-next/app/[locale]/reset-password/page.tsx create mode 100644 web-next/app/api/auth/recover/route.ts create mode 100644 web-next/app/api/auth/reset-password/route.ts create mode 100644 web-next/lib/recover.ts diff --git a/web-next/app/[locale]/recover/RecoverForm.tsx b/web-next/app/[locale]/recover/RecoverForm.tsx new file mode 100644 index 0000000..d0ea59b --- /dev/null +++ b/web-next/app/[locale]/recover/RecoverForm.tsx @@ -0,0 +1,62 @@ +'use client' + +import { useState } from 'react' +import { useTranslations } from 'next-intl' +import { Turnstile } from '@/components/Turnstile' + +const ERROR_KEYS = ['invalidEmail', 'captchaFailed'] as const +const SITE_KEY = process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY + +export function RecoverForm() { + const t = useTranslations('Recover') + const [type, setType] = useState('password') + const [email, setEmail] = useState('') + const [captcha, setCaptcha] = useState('') + const [busy, setBusy] = useState(false) + const [message, setMessage] = useState<{ ok: boolean; text: string } | null>(null) + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault() + if (busy) return + setBusy(true) + setMessage(null) + try { + const res = await fetch('/api/auth/recover', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ type, email: email.trim(), turnstileToken: captcha }), + }) + const data: { success?: boolean; error?: string } = await res.json() + if (data.success) setMessage({ ok: true, text: t('success') }) + 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) + } + } + + const field = 'w-full rounded border border-amber-900/60 bg-[#2c1e14] px-3 py-2' + + return ( +
+
+ + + setEmail(e.target.value)} required className={field} /> + + + + {message &&

{message.text}

} +
+ ) +} diff --git a/web-next/app/[locale]/recover/page.tsx b/web-next/app/[locale]/recover/page.tsx new file mode 100644 index 0000000..033c180 --- /dev/null +++ b/web-next/app/[locale]/recover/page.tsx @@ -0,0 +1,14 @@ +import { getTranslations, setRequestLocale } from 'next-intl/server' +import { RecoverForm } from './RecoverForm' + +export default async function RecoverPage({ params }: { params: Promise<{ locale: string }> }) { + const { locale } = await params + setRequestLocale(locale) + const t = await getTranslations('Recover') + return ( +
+

{t('title')}

+ +
+ ) +} diff --git a/web-next/app/[locale]/reset-password/ResetForm.tsx b/web-next/app/[locale]/reset-password/ResetForm.tsx new file mode 100644 index 0000000..ac9e196 --- /dev/null +++ b/web-next/app/[locale]/reset-password/ResetForm.tsx @@ -0,0 +1,66 @@ +'use client' + +import { useState } from 'react' +import { useTranslations } from 'next-intl' +import { useRouter, Link } from '@/i18n/navigation' + +const ERROR_KEYS = ['invalidLink', 'expiredLink', 'passwordMismatch', 'passwordTooLong', 'accountNotFound'] as const + +export function ResetForm({ token }: { token: string }) { + const t = useTranslations('Reset') + const router = useRouter() + const [password, setPassword] = useState('') + const [confPassword, setConfPassword] = useState('') + const [busy, setBusy] = useState(false) + const [done, setDone] = useState(false) + const [message, setMessage] = useState<{ ok: boolean; text: string } | null>(null) + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault() + if (busy || done) return + setBusy(true) + setMessage(null) + try { + const res = await fetch('/api/auth/reset-password', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token, password, confPassword }), + }) + const data: { success?: boolean; error?: string } = await res.json() + if (data.success) { + setDone(true) + setMessage({ ok: true, text: t('success') }) + setTimeout(() => router.push('/login'), 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 { + setBusy(false) + } + } + + const field = 'w-full rounded border border-amber-900/60 bg-[#2c1e14] px-3 py-2' + + return ( +
+
+ setPassword(e.target.value)} required className={field} /> + setConfPassword(e.target.value)} required className={field} /> + +
+ {message &&

{message.text}

} + {done && ( +

+ + {t('goLogin')} + +

+ )} +
+ ) +} diff --git a/web-next/app/[locale]/reset-password/page.tsx b/web-next/app/[locale]/reset-password/page.tsx new file mode 100644 index 0000000..f45077f --- /dev/null +++ b/web-next/app/[locale]/reset-password/page.tsx @@ -0,0 +1,22 @@ +import { getTranslations, setRequestLocale } from 'next-intl/server' +import { ResetForm } from './ResetForm' + +export default async function ResetPasswordPage({ + params, + searchParams, +}: { + params: Promise<{ locale: string }> + searchParams: Promise<{ token?: string }> +}) { + const { locale } = await params + setRequestLocale(locale) + const { token } = await searchParams + const t = await getTranslations('Reset') + + return ( +
+

{t('title')}

+ +
+ ) +} diff --git a/web-next/app/api/auth/recover/route.ts b/web-next/app/api/auth/recover/route.ts new file mode 100644 index 0000000..44ef7b9 --- /dev/null +++ b/web-next/app/api/auth/recover/route.ts @@ -0,0 +1,22 @@ +import { requestRecovery } from '@/lib/recover' +import { verifyTurnstile } from '@/lib/turnstile' + +export async function POST(request: Request) { + let type = '' + let email = '' + let turnstileToken = '' + try { + const body = await request.json() + type = String(body.type ?? '') + email = String(body.email ?? '') + turnstileToken = String(body.turnstileToken ?? '') + } catch { + return Response.json({ success: false, error: 'invalidRequest' }, { status: 400 }) + } + const ip = (request.headers.get('x-forwarded-for') || '').split(',')[0].trim() + if (!(await verifyTurnstile(turnstileToken, ip))) { + return Response.json({ success: false, error: 'captchaFailed' }) + } + const result = await requestRecovery(type, email) + return Response.json(result) +} diff --git a/web-next/app/api/auth/reset-password/route.ts b/web-next/app/api/auth/reset-password/route.ts new file mode 100644 index 0000000..ac23217 --- /dev/null +++ b/web-next/app/api/auth/reset-password/route.ts @@ -0,0 +1,18 @@ +import { resetPassword } from '@/lib/recover' + +export async function POST(request: Request) { + let token = '' + let password = '' + let confPassword = '' + try { + const body = await request.json() + token = String(body.token ?? '') + password = String(body.password ?? '').trim() + confPassword = String(body.confPassword ?? '').trim() + } catch { + return Response.json({ success: false, error: 'invalidRequest' }, { status: 400 }) + } + if (!token) return Response.json({ success: false, error: 'invalidLink' }) + const result = await resetPassword(token, password, confPassword) + return Response.json(result) +} diff --git a/web-next/lib/recover.ts b/web-next/lib/recover.ts new file mode 100644 index 0000000..03af418 --- /dev/null +++ b/web-next/lib/recover.ts @@ -0,0 +1,129 @@ +import crypto from 'node:crypto' +import type { RowDataPacket } from 'mysql2' +import { db, DB } from './db' +import { normalizeEmail, bnetMakeRegistration } from './bnet' +import { sendMail } from './mail' + +const GMAIL_RE = /^[a-zA-Z0-9._%+-]+@gmail\.com$/ + +export interface Result { + success: boolean + error?: string + redirect?: string +} + +function emailShell(title: string, bodyHtml: string): string { + return ` +
+

Nova WoW

${title}

${bodyHtml} +
` +} + +/** Recuperación por email (anti-enumeración: respuesta genérica siempre). */ +export async function requestRecovery(type: string, email: string): Promise { + email = (email || '').trim() + if (!email || !GMAIL_RE.test(email)) return { success: false, error: 'invalidEmail' } + const site = process.env.SITE_URL || '' + + if (type === 'password') { + try { + const [rows] = await db(DB.auth).query( + 'SELECT id FROM battlenet_accounts WHERE email = ?', + [normalizeEmail(email)], + ) + if (rows[0]) { + const token = crypto.randomBytes(32).toString('hex') + await db(DB.default).query( + 'INSERT INTO home_passwordreset (email, token, created_at, used) VALUES (?, ?, NOW(), 0)', + [email, token], + ) + const link = `${site}/reset-password?token=${token}` + await sendMail( + email, + 'Restablecer contraseña - Nova WoW', + emailShell( + 'Restablecer contraseña', + `

Pulsa para elegir una nueva contraseña (válido 1 hora):

+

Restablecer

+

${link}

`, + ), + ) + } + } catch { + /* acore no disponible: respuesta genérica igualmente */ + } + return { success: true } + } + + if (type === 'accountname') { + try { + const [acc] = await db(DB.auth).query( + 'SELECT id FROM battlenet_accounts WHERE email = ?', + [normalizeEmail(email)], + ) + if (acc[0]) { + const [games] = await db(DB.auth).query( + 'SELECT username FROM account WHERE battlenet_account = ? ORDER BY battlenet_index', + [acc[0].id], + ) + const list = games.map((g) => `
  • ${g.username}
  • `).join('') || '
  • ' + await sendMail( + email, + 'Tus cuentas de juego - Nova WoW', + emailShell('Tus cuentas de juego', `

    Cuentas asociadas a ${email}:

      ${list}
    `), + ) + } + } catch { + /* ignore */ + } + return { success: true } + } + + if (type === 'activation') { + try { + const [rows] = await db(DB.default).query( + 'SELECT hash, password FROM home_accountactivation WHERE email = ? AND old_email IS NULL ORDER BY created_at DESC LIMIT 1', + [email], + ) + if (rows[0]) { + const link = `${site}/activate-account?act=${rows[0].hash}` + await sendMail( + email, + `Activación de la cuenta ${email} - Nova WoW`, + emailShell('Activa tu cuenta', `

    Activar cuenta

    ${link}

    `), + ) + } + } catch { + /* ignore */ + } + return { success: true } + } + + return { success: false, error: 'invalidType' } +} + +export async function resetPassword(token: string, newPassword: string, confPassword: string): Promise { + const [rows] = await db(DB.default).query( + 'SELECT id, email, created_at FROM home_passwordreset WHERE token = ? AND used = 0', + [token], + ) + const pr = rows[0] + if (!pr) return { success: false, error: 'invalidLink' } + if (Date.now() - new Date(pr.created_at).getTime() > 3600_000) return { success: false, error: 'expiredLink' } + if (!newPassword || newPassword !== confPassword) return { success: false, error: 'passwordMismatch' } + if (newPassword.length > 16) return { success: false, error: 'passwordTooLong' } + + try { + const reg = bnetMakeRegistration(pr.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(pr.email)], + ) + // @ts-expect-error affectedRows + if (!res.affectedRows) return { success: false, error: 'accountNotFound' } + } catch { + return { success: false, error: 'genericError' } + } + await db(DB.default).query('UPDATE home_passwordreset SET used = 1 WHERE id = ?', [pr.id]) + return { success: true, redirect: '/login' } +} diff --git a/web-next/messages/en.json b/web-next/messages/en.json index a21463a..9c21eda 100644 --- a/web-next/messages/en.json +++ b/web-next/messages/en.json @@ -81,5 +81,34 @@ "invalidLink": "The link is invalid or has already been used.", "expiredLink": "The link has expired. Please request a new one.", "goLogin": "Go to sign in" + }, + "Recover": { + "title": "Recover account", + "type": "What do you want to recover?", + "typePassword": "Password", + "typeAccountName": "Account name", + "typeActivation": "Activation link", + "email": "Gmail email address", + "submit": "Request", + "sending": "Sending…", + "success": "If an account exists with that email, we have sent you the information.", + "invalidEmail": "Enter a valid Gmail address.", + "genericError": "Something went wrong. Please try again later.", + "captchaFailed": "Anti-bot verification failed. Please retry." + }, + "Reset": { + "title": "Reset password", + "password": "New password", + "confPassword": "Confirm password", + "submit": "Reset", + "saving": "Saving…", + "success": "Password reset. You can now sign in.", + "goLogin": "Go to sign in", + "invalidLink": "The link is invalid or has already been used.", + "expiredLink": "The link has expired. Please request a new one.", + "passwordMismatch": "Passwords do not match.", + "passwordTooLong": "The password must not exceed 16 characters.", + "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 247b757..ebefa1a 100644 --- a/web-next/messages/es.json +++ b/web-next/messages/es.json @@ -81,5 +81,34 @@ "invalidLink": "El enlace no es válido o ya se ha usado.", "expiredLink": "El enlace ha caducado. Solicita uno nuevo.", "goLogin": "Ir a iniciar sesión" + }, + "Recover": { + "title": "Recuperar cuenta", + "type": "¿Qué quieres recuperar?", + "typePassword": "Contraseña", + "typeAccountName": "Nombre de cuenta", + "typeActivation": "Enlace de activación", + "email": "Correo electrónico de Gmail", + "submit": "Solicitar", + "sending": "Enviando…", + "success": "Si existe una cuenta con ese correo, te hemos enviado la información.", + "invalidEmail": "Introduce un correo de Gmail válido.", + "genericError": "Algo ha salido mal. Inténtalo más tarde.", + "captchaFailed": "Verificación anti-bots fallida. Reintenta." + }, + "Reset": { + "title": "Restablecer contraseña", + "password": "Nueva contraseña", + "confPassword": "Confirmar contraseña", + "submit": "Restablecer", + "saving": "Guardando…", + "success": "Contraseña restablecida. Ya puedes iniciar sesión.", + "goLogin": "Ir a iniciar sesión", + "invalidLink": "El enlace no es válido o ya se ha usado.", + "expiredLink": "El enlace ha caducado. Solicita uno nuevo.", + "passwordMismatch": "Las contraseñas no coinciden.", + "passwordTooLong": "La contraseña no debe exceder los 16 caracteres.", + "accountNotFound": "No se ha encontrado la cuenta.", + "genericError": "Algo ha salido mal. Inténtalo más tarde." } }