From 60c2e44afd147d88584b0124364218b237d18139 Mon Sep 17 00:00:00 2001 From: adevopg Date: Sun, 12 Jul 2026 22:42:23 +0000 Subject: [PATCH] =?UTF-8?q?Login=20end-to-end=20en=20Next.js:=20iron-sessi?= =?UTF-8?q?on=20+=20auth=20SRP6=20+=20p=C3=A1gina=20i18n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - lib/session.ts: sesión cifrada httpOnly con iron-session (SESSION_SECRET en .env.local). SessionData {bnetId, bnetEmail, username, accountId}. - lib/auth.ts: authenticate(email,password) verifica contra battlenet_accounts con bnetVerify (SRP6 v2); getGameAccounts(bnetId). Resiliente si acore no está. - app/api/auth/login/route.ts: POST -> autentica, fija sesión, resuelve cuentas de juego (1 -> auto; 0/varias -> needsSelection). Devuelve JSON. - app/[locale]/login: página SSR + LoginForm (cliente, next-intl, router i18n). Textos en messages/*.json (namespace Login). - tsconfig target ES2020 (literales BigInt de bnet.ts). Validado end-to-end: con una cuenta creada por bnet.py (como haría AzerothCore), el login TS la verifica OK y emite la cookie de sesión; password incorrecta -> invalidCredentials; sin campos -> missingFields. Página en ES y EN. Co-Authored-By: Claude Opus 4.8 (1M context) --- web-next/app/[locale]/login/LoginForm.tsx | 94 +++++++++++++++++++++++ web-next/app/[locale]/login/page.tsx | 15 ++++ web-next/app/api/auth/login/route.ts | 42 ++++++++++ web-next/lib/account-session.ts | 8 ++ web-next/lib/auth.ts | 46 +++++++++++ web-next/lib/session.ts | 25 ++++++ web-next/messages/en.json | 15 ++++ web-next/messages/es.json | 15 ++++ web-next/package-lock.json | 36 +++++++++ web-next/package.json | 1 + web-next/tsconfig.json | 16 +++- 11 files changed, 309 insertions(+), 4 deletions(-) create mode 100644 web-next/app/[locale]/login/LoginForm.tsx create mode 100644 web-next/app/[locale]/login/page.tsx create mode 100644 web-next/app/api/auth/login/route.ts create mode 100644 web-next/lib/account-session.ts create mode 100644 web-next/lib/auth.ts create mode 100644 web-next/lib/session.ts diff --git a/web-next/app/[locale]/login/LoginForm.tsx b/web-next/app/[locale]/login/LoginForm.tsx new file mode 100644 index 0000000..d96cc6e --- /dev/null +++ b/web-next/app/[locale]/login/LoginForm.tsx @@ -0,0 +1,94 @@ +'use client' + +import { useState } from 'react' +import { useTranslations } from 'next-intl' +import { useRouter } from '@/i18n/navigation' + +const ERROR_KEYS = ['invalidCredentials', 'missingFields', 'invalidRequest'] as const + +export function LoginForm() { + const t = useTranslations('Login') + const router = useRouter() + const [email, setEmail] = useState('') + const [password, setPassword] = useState('') + const [showPw, setShowPw] = useState(false) + 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 + if (!email.trim() || !password) { + setMessage({ ok: false, text: t('missingFields') }) + return + } + setBusy(true) + setMessage(null) + try { + const res = await fetch('/api/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'same-origin', + body: JSON.stringify({ email: email.trim(), password }), + }) + const data: { success?: boolean; needsSelection?: boolean; error?: string } = await res.json() + if (data.success) { + setMessage({ ok: true, text: t('success') }) + router.push(data.needsSelection ? '/select-account' : '/account') + } else { + const key = (ERROR_KEYS as readonly string[]).includes(data.error ?? '') ? data.error! : 'genericError' + setMessage({ ok: false, text: t(key) }) + setBusy(false) + } + } catch { + setMessage({ ok: false, text: t('genericError') }) + setBusy(false) + } + } + + return ( +
+
+ setEmail(e.target.value)} + placeholder={t('email')} + autoFocus + required + className="w-full rounded border border-amber-900/60 bg-[#2c1e14] px-3 py-2" + /> +
+ setPassword(e.target.value)} + placeholder={t('password')} + maxLength={16} + required + className="w-full rounded border border-amber-900/60 bg-[#2c1e14] px-3 py-2 pr-10" + /> + +
+ +
+ + {message && ( +

{message.text}

+ )} +
+ ) +} diff --git a/web-next/app/[locale]/login/page.tsx b/web-next/app/[locale]/login/page.tsx new file mode 100644 index 0000000..4d2d576 --- /dev/null +++ b/web-next/app/[locale]/login/page.tsx @@ -0,0 +1,15 @@ +import { getTranslations, setRequestLocale } from 'next-intl/server' +import { LoginForm } from './LoginForm' + +export default async function LoginPage({ params }: { params: Promise<{ locale: string }> }) { + const { locale } = await params + setRequestLocale(locale) + const t = await getTranslations('Login') + + return ( +
+

{t('title')}

+ +
+ ) +} diff --git a/web-next/app/api/auth/login/route.ts b/web-next/app/api/auth/login/route.ts new file mode 100644 index 0000000..97b9d52 --- /dev/null +++ b/web-next/app/api/auth/login/route.ts @@ -0,0 +1,42 @@ +import { authenticate, getGameAccounts } from '@/lib/auth' +import { getSession } from '@/lib/session' +import { setGameAccountSession } from '@/lib/account-session' + +export async function POST(request: Request) { + let email = '' + let password = '' + try { + const body = await request.json() + email = String(body.email ?? '').trim() + password = String(body.password ?? '') + } catch { + return Response.json({ success: false, error: 'invalidRequest' }, { status: 400 }) + } + + if (!email || !password) { + return Response.json({ success: false, error: 'missingFields' }) + } + + const account = await authenticate(email, password) + if (!account) { + return Response.json({ success: false, error: 'invalidCredentials' }) + } + + const session = await getSession() + session.bnetId = account.id + session.bnetEmail = account.email + + const games = await getGameAccounts(account.id) + let needsSelection = true + if (games.length === 1) { + setGameAccountSession(session, games[0]) + needsSelection = false + } else { + // 0 o >1 cuentas de juego: dejar la selección pendiente + delete session.username + delete session.accountId + } + await session.save() + + return Response.json({ success: true, needsSelection }) +} diff --git a/web-next/lib/account-session.ts b/web-next/lib/account-session.ts new file mode 100644 index 0000000..7b878f3 --- /dev/null +++ b/web-next/lib/account-session.ts @@ -0,0 +1,8 @@ +import type { SessionData } from './session' +import type { GameAccount } from './auth' + +/** Fija la cuenta de juego elegida en la sesión (equiv. a _set_game_account_session). */ +export function setGameAccountSession(session: SessionData, game: GameAccount): void { + session.username = game.username + session.accountId = game.id +} diff --git a/web-next/lib/auth.ts b/web-next/lib/auth.ts new file mode 100644 index 0000000..2123e90 --- /dev/null +++ b/web-next/lib/auth.ts @@ -0,0 +1,46 @@ +import type { RowDataPacket } from 'mysql2' +import { db, DB } from './db' +import { bnetVerify, normalizeEmail } from './bnet' + +export interface BnetAccount { + id: number + email: string +} + +export interface GameAccount { + id: number + username: string + index: number +} + +/** Verifica email+password contra battlenet_accounts (SRP6 v2). Null si falla. */ +export async function authenticate(email: string, password: string): Promise { + try { + const [rows] = await db(DB.auth).query( + 'SELECT id, email, salt, verifier FROM battlenet_accounts WHERE email = ?', + [normalizeEmail(email)], + ) + const row = rows[0] + if (!row) return null + const salt: Buffer | null = row.salt ?? null + const verifier: Buffer | null = row.verifier ?? null + if (!bnetVerify(email, password, salt, verifier)) return null + return { id: row.id, email: row.email } + } catch { + // La BD de cuentas (AzerothCore) puede no estar disponible/poblada. + return null + } +} + +/** Cuentas de juego enlazadas a una cuenta Battle.net. */ +export async function getGameAccounts(bnetId: number): Promise { + try { + const [rows] = await db(DB.auth).query( + 'SELECT id, username, battlenet_index FROM account WHERE battlenet_account = ? ORDER BY battlenet_index', + [bnetId], + ) + return rows.map((r) => ({ id: r.id, username: r.username, index: r.battlenet_index })) + } catch { + return [] + } +} diff --git a/web-next/lib/session.ts b/web-next/lib/session.ts new file mode 100644 index 0000000..88a0776 --- /dev/null +++ b/web-next/lib/session.ts @@ -0,0 +1,25 @@ +import { getIronSession, type SessionOptions } from 'iron-session' +import { cookies } from 'next/headers' + +// Sesión cifrada en cookie httpOnly (equivalente a la sesión Django de antes). +export interface SessionData { + bnetId?: number + bnetEmail?: string + username?: string // cuenta de juego elegida (#) + accountId?: number +} + +export const sessionOptions: SessionOptions = { + password: process.env.SESSION_SECRET as string, + cookieName: 'novawow_session', + cookieOptions: { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + path: '/', + }, +} + +export async function getSession() { + return getIronSession(await cookies(), sessionOptions) +} diff --git a/web-next/messages/en.json b/web-next/messages/en.json index 8963098..91c2205 100644 --- a/web-next/messages/en.json +++ b/web-next/messages/en.json @@ -15,5 +15,20 @@ "noNews": "There are no news available at the moment.", "link": "Link", "here": "here" + }, + "Login": { + "title": "Sign in to Nova WoW", + "email": "Email", + "password": "Password", + "submit": "Sign in", + "connecting": "Signing in…", + "success": "Signed in successfully. Redirecting…", + "invalidCredentials": "Incorrect email or password", + "missingFields": "Please fill in all fields.", + "invalidRequest": "Invalid request.", + "genericError": "Something went wrong. Please try again later.", + "forgot": "I forgot my password/username", + "newHere": "New to the community? You can create an account", + "createAccount": "here" } } diff --git a/web-next/messages/es.json b/web-next/messages/es.json index 2ae749d..fd054a7 100644 --- a/web-next/messages/es.json +++ b/web-next/messages/es.json @@ -15,5 +15,20 @@ "noNews": "No hay noticias disponibles en este momento.", "link": "Enlace", "here": "aquí" + }, + "Login": { + "title": "Conectar a Nova WoW", + "email": "Correo electrónico", + "password": "Contraseña", + "submit": "Conectar", + "connecting": "Conectando…", + "success": "Conexión exitosa. Redirigiendo…", + "invalidCredentials": "Correo o contraseña incorrectos", + "missingFields": "Por favor rellene todos los campos.", + "invalidRequest": "Solicitud no válida.", + "genericError": "Algo ha salido mal. Inténtalo más tarde.", + "forgot": "He olvidado mi contraseña/usuario", + "newHere": "¿Nuevo en la comunidad? Puedes crear una cuenta", + "createAccount": "aquí" } } diff --git a/web-next/package-lock.json b/web-next/package-lock.json index e7bbcfe..4005608 100644 --- a/web-next/package-lock.json +++ b/web-next/package-lock.json @@ -8,6 +8,7 @@ "name": "web-next", "version": "0.1.0", "dependencies": { + "iron-session": "^8.0.4", "mysql2": "^3.22.6", "next": "16.2.10", "next-intl": "^4.13.2", @@ -3506,6 +3507,14 @@ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "dev": true }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -4867,6 +4876,28 @@ "@formatjs/icu-messageformat-parser": "3.5.14" } }, + "node_modules/iron-session": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/iron-session/-/iron-session-8.0.4.tgz", + "integrity": "sha512-9ivNnaKOd08osD0lJ3i6If23GFS2LsxyMU8Gf/uBUEgm8/8CC1hrrCHFDpMo3IFbpBgwoo/eairRsaD3c5itxA==", + "funding": [ + "https://github.com/sponsors/vvo", + "https://github.com/sponsors/brc-dd" + ], + "dependencies": { + "cookie": "^0.7.2", + "iron-webcrypto": "^1.2.1", + "uncrypto": "^0.1.3" + } + }, + "node_modules/iron-webcrypto": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/iron-webcrypto/-/iron-webcrypto-1.2.1.tgz", + "integrity": "sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==", + "funding": { + "url": "https://github.com/sponsors/brc-dd" + } + }, "node_modules/is-array-buffer": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", @@ -7279,6 +7310,11 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/uncrypto": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/uncrypto/-/uncrypto-0.1.3.tgz", + "integrity": "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==" + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", diff --git a/web-next/package.json b/web-next/package.json index eae5f9f..917641b 100644 --- a/web-next/package.json +++ b/web-next/package.json @@ -9,6 +9,7 @@ "lint": "eslint" }, "dependencies": { + "iron-session": "^8.0.4", "mysql2": "^3.22.6", "next": "16.2.10", "next-intl": "^4.13.2", diff --git a/web-next/tsconfig.json b/web-next/tsconfig.json index 3a13f90..058448b 100644 --- a/web-next/tsconfig.json +++ b/web-next/tsconfig.json @@ -1,7 +1,11 @@ { "compilerOptions": { - "target": "ES2017", - "lib": ["dom", "dom.iterable", "esnext"], + "target": "ES2020", + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], "allowJs": true, "skipLibCheck": true, "strict": true, @@ -19,7 +23,9 @@ } ], "paths": { - "@/*": ["./*"] + "@/*": [ + "./*" + ] } }, "include": [ @@ -30,5 +36,7 @@ ".next/dev/types/**/*.ts", "**/*.mts" ], - "exclude": ["node_modules"] + "exclude": [ + "node_modules" + ] }