Recuperación de cuenta en Next.js (3 flujos + reset), con Turnstile
- 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) <noreply@anthropic.com>
This commit is contained in:
@@ -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 `<!DOCTYPE html><html lang="es"><body style="font-family:Arial,sans-serif;background:#1b120b;color:#e8dccb;padding:24px">
|
||||
<div style="max-width:600px;margin:0 auto;background:#241812;border:1px solid #4a3320;border-radius:8px;padding:28px">
|
||||
<h1 style="color:#d79602;margin:0 0 8px">Nova WoW</h1><h2 style="margin:0 0 16px">${title}</h2>${bodyHtml}
|
||||
</div></body></html>`
|
||||
}
|
||||
|
||||
/** Recuperación por email (anti-enumeración: respuesta genérica siempre). */
|
||||
export async function requestRecovery(type: string, email: string): Promise<Result> {
|
||||
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<RowDataPacket[]>(
|
||||
'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',
|
||||
`<p>Pulsa para elegir una nueva contraseña (válido 1 hora):</p>
|
||||
<p><a href="${link}" style="display:inline-block;padding:12px 22px;background:#d79602;color:#1b120b;text-decoration:none;border-radius:6px;font-weight:bold">Restablecer</a></p>
|
||||
<p style="font-size:13px;word-break:break-all"><a href="${link}" style="color:#2471a9">${link}</a></p>`,
|
||||
),
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
/* acore no disponible: respuesta genérica igualmente */
|
||||
}
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
if (type === 'accountname') {
|
||||
try {
|
||||
const [acc] = await db(DB.auth).query<RowDataPacket[]>(
|
||||
'SELECT id FROM battlenet_accounts WHERE email = ?',
|
||||
[normalizeEmail(email)],
|
||||
)
|
||||
if (acc[0]) {
|
||||
const [games] = await db(DB.auth).query<RowDataPacket[]>(
|
||||
'SELECT username FROM account WHERE battlenet_account = ? ORDER BY battlenet_index',
|
||||
[acc[0].id],
|
||||
)
|
||||
const list = games.map((g) => `<li>${g.username}</li>`).join('') || '<li>—</li>'
|
||||
await sendMail(
|
||||
email,
|
||||
'Tus cuentas de juego - Nova WoW',
|
||||
emailShell('Tus cuentas de juego', `<p>Cuentas asociadas a <strong>${email}</strong>:</p><ul>${list}</ul>`),
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
if (type === 'activation') {
|
||||
try {
|
||||
const [rows] = await db(DB.default).query<RowDataPacket[]>(
|
||||
'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', `<p><a href="${link}" style="color:#d79602">Activar cuenta</a></p><p style="font-size:13px;word-break:break-all">${link}</p>`),
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
return { success: false, error: 'invalidType' }
|
||||
}
|
||||
|
||||
export async function resetPassword(token: string, newPassword: string, confPassword: string): Promise<Result> {
|
||||
const [rows] = await db(DB.default).query<RowDataPacket[]>(
|
||||
'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' }
|
||||
}
|
||||
Reference in New Issue
Block a user