+ )
+}
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."
}
}