diff --git a/web-next/app/[locale]/create-account/RegisterForm.tsx b/web-next/app/[locale]/create-account/RegisterForm.tsx index deb473d..7e40e55 100644 --- a/web-next/app/[locale]/create-account/RegisterForm.tsx +++ b/web-next/app/[locale]/create-account/RegisterForm.tsx @@ -63,7 +63,9 @@ export function RegisterForm() { const [busy, setBusy] = useState(false) const [captcha, setCaptcha] = useState('') const [captchaKey, setCaptchaKey] = useState(0) // remonta el widget para pedir un token nuevo - const [message, setMessage] = useState<{ ok: boolean; text: string } | null>(null) + // `extra` es la segunda línea del aviso de éxito (a qué correo fue el enlace); + // los errores solo usan `text`. + const [message, setMessage] = useState<{ ok: boolean; text: string; extra?: string } | null>(null) const canSubmit = accepted && notUs && !busy @@ -99,7 +101,10 @@ export function RegisterForm() { }) const data: { success?: boolean; error?: string } = await res.json() if (data.success) { - setMessage({ ok: true, text: t('success') }) + // La cuenta ES el correo (Battle.net), así que el enlace de activación va + // justo a la dirección con la que se registró: mismo valor en las 2 líneas. + const email = form.email.trim() + setMessage({ ok: true, text: t('success', { email }), extra: t('successActivation', { email }) }) } else { const key = (ERROR_KEYS as readonly string[]).includes(data.error ?? '') ? data.error! : 'genericError' setMessage({ ok: false, text: t(key) }) @@ -200,8 +205,15 @@ export function RegisterForm() {
-
+
{message && {message.text}} + {message?.extra && ( + <> +
+
+ {message.extra} + + )}
) diff --git a/web-next/lib/random-token.ts b/web-next/lib/random-token.ts new file mode 100644 index 0000000..1f13786 --- /dev/null +++ b/web-next/lib/random-token.ts @@ -0,0 +1,30 @@ +import crypto from 'node:crypto' + +/** Letras y dígitos: el alfabeto por defecto del `get_random_string` de Django. */ +export const ALPHANUMERIC = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789' +/** Solo letras, para el token de seguridad (formato `uRlPBR`). */ +export const LETTERS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz' + +/** + * Código aleatorio de `length` caracteres tomados de `alphabet`. + * + * Estos códigos se portaron del Django original como `randomBytes(n).toString('hex')`, + * que cambiaba el formato sin querer: hex solo usa `0-9a-f`, mientras que el + * `get_random_string` original daba alfanumérico con mayúsculas y minúsculas. + * + * Muestreo por rechazo: 256 no suele ser múltiplo del tamaño del alfabeto, así que + * `byte % alphabet.length` haría más probables sus primeros caracteres. Descartando + * los bytes desde el múltiplo más alto que cabe en uno, el reparto queda uniforme. + */ +export function randomToken(length: number, alphabet: string = ALPHANUMERIC): string { + const limit = 256 - (256 % alphabet.length) + let token = '' + while (token.length < length) { + for (const byte of crypto.randomBytes(length)) { + if (byte >= limit) continue + token += alphabet[byte % alphabet.length] + if (token.length === length) break + } + } + return token +} diff --git a/web-next/lib/register.ts b/web-next/lib/register.ts index a103d54..2b37230 100644 --- a/web-next/lib/register.ts +++ b/web-next/lib/register.ts @@ -1,9 +1,9 @@ -import crypto from 'node:crypto' import type { ResultSetHeader, RowDataPacket } from 'mysql2' import { db, DB } from './db' import { normalizeEmail, bnetMakeRegistration, gameMakeRegistration, makeGameAccountUsername } from './bnet' import { sendMail } from './mail' import { activationEmailHtml } from './emails' +import { randomToken } from './random-token' const GMAIL_RE = /^[a-zA-Z0-9._%+-]+@gmail\.com$/ @@ -66,7 +66,10 @@ export async function registerAccount(input: RegisterInput): Promise { } } - const hash = crypto.randomBytes(16).toString('hex') // 32 hex chars (col hash = CharField(32)) + // 32 alfanuméricos, como el `get_random_string(32)` original (col hash = varchar(32)). + // Se portó como `randomBytes(16).toString('hex')`, que llenaba los 32 caracteres pero + // solo con `0-9a-f`: cambiaba el formato del enlace de activación sin querer. + const hash = randomToken(32) await db(DB.default).query( 'INSERT INTO accountactivation (email, password, recruiter_id, hash, created_at, is_used, is_new_email_used) VALUES (?, ?, ?, ?, NOW(), 0, 0)', [email, password, recruiterId, hash], diff --git a/web-next/lib/security-token.ts b/web-next/lib/security-token.ts index 1756055..ab1f583 100644 --- a/web-next/lib/security-token.ts +++ b/web-next/lib/security-token.ts @@ -1,8 +1,8 @@ -import crypto from 'node:crypto' import type { RowDataPacket } from 'mysql2' import { db, DB } from './db' import { sendMail } from './mail' import { securityTokenEmailHtml } from './emails' +import { randomToken, LETTERS } from './random-token' import type { SessionData } from './session' export interface Result { @@ -11,35 +11,16 @@ export interface Result { tokenDate?: string } -/** Letras A-Z y a-z, sin dígitos: el formato del token es `uRlPBR`. */ -const TOKEN_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz' -const TOKEN_LENGTH = 6 - /** - * Genera el token de seguridad: 6 letras al azar. + * 6 letras, sin dígitos (formato `uRlPBR`). * * Antes era `randomBytes(4).toString('base64url').slice(0, 6)`, portado tal cual * del Django original (`secrets.token_urlsafe(4)[:6]`). Tenía dos taras: metía * `-` y `_` en un código que se lee y se teclea desde un correo, y sobre todo 4 * bytes son 32 bits mientras que 6 caracteres base64 codifican 36 → al último * carácter solo le llegaban 2 bits reales y SIEMPRE salía `A`, `Q`, `g` o `w`. - * - * Muestreo por rechazo: 256 no es múltiplo de 52, así que `byte % 52` haría más - * probables las primeras letras del alfabeto. Descartando los bytes >= 208 - * (52*4, el múltiplo de 52 más alto que cabe en un byte) el reparto es uniforme. */ -function generateSecurityToken(): string { - const limit = 256 - (256 % TOKEN_ALPHABET.length) - let token = '' - while (token.length < TOKEN_LENGTH) { - for (const byte of crypto.randomBytes(TOKEN_LENGTH)) { - if (byte >= limit) continue - token += TOKEN_ALPHABET[byte % TOKEN_ALPHABET.length] - if (token.length === TOKEN_LENGTH) break - } - } - return token -} +const TOKEN_LENGTH = 6 /** Solicita un token de seguridad (6 letras) por email. 1 cada 7 días. */ export async function requestSecurityToken(session: SessionData, ip: string): Promise { @@ -56,7 +37,7 @@ export async function requestSecurityToken(session: SessionData, ip: string): Pr if (days < 7) return { success: false, error: 'cooldown' } } - const token = generateSecurityToken() + const token = randomToken(TOKEN_LENGTH, LETTERS) const expiresAt = new Date(Date.now() + 7 * 86400_000) await db(DB.default).query('DELETE FROM securitytoken WHERE user_id = ?', [userId]) diff --git a/web-next/messages/en.json b/web-next/messages/en.json index a5ffdaf..3b78385 100644 --- a/web-next/messages/en.json +++ b/web-next/messages/en.json @@ -177,7 +177,8 @@ "recruiter": "Recruiter (optional)", "submit": "Create account", "creating": "Creating…", - "success": "The account has been created. Check your email to activate it.", + "success": "The account '{email}' has been created.", + "successActivation": "An activation link has been sent to {email}.", "missingFields": "Please fill in all fields.", "passwordMismatch": "Passwords do not match.", "passwordTooLong": "The password must not exceed 16 characters.", diff --git a/web-next/messages/es.json b/web-next/messages/es.json index 1951901..09505d0 100644 --- a/web-next/messages/es.json +++ b/web-next/messages/es.json @@ -177,7 +177,8 @@ "recruiter": "Reclutante (opcional)", "submit": "Crear cuenta", "creating": "Creando…", - "success": "La cuenta se ha creado. Revisa tu correo para activarla.", + "success": "La cuenta '{email}' ha sido creada.", + "successActivation": "Se ha enviado un enlace de activación al correo {email}.", "missingFields": "Por favor, complete todos los campos.", "passwordMismatch": "Las contraseñas no coinciden.", "passwordTooLong": "La contraseña no debe exceder los 16 caracteres.",