diff --git a/web-next/app/[locale]/security-2falogin/page.tsx b/web-next/app/[locale]/security-2falogin/page.tsx new file mode 100644 index 0000000..8daac6c --- /dev/null +++ b/web-next/app/[locale]/security-2falogin/page.tsx @@ -0,0 +1,71 @@ +import type { Metadata } from 'next' +import { setRequestLocale } from 'next-intl/server' +import { redirect, Link } from '@/i18n/navigation' +import { getSession } from '@/lib/session' +import { is2faEnabled } from '@/lib/two-factor' +import { PageShell } from '@/components/PageShell' +import { TwoFactorLogin } from '@/components/TwoFactorLogin' + +export const dynamic = 'force-dynamic' + +export const metadata: Metadata = { title: 'Verificación en 2 pasos' } + +const IMG = '/nw-themes/nw-ryu/nw-images' + +export default async function Security2faLoginPage({ params }: { params: Promise<{ locale: string }> }) { + const { locale } = await params + setRequestLocale(locale) + + const session = await getSession() + if (!session.bnetId) redirect({ href: '/login', locale }) + if (!session.username) redirect({ href: '/select-account', locale }) + + const enabled = session.accountId ? await is2faEnabled(session.accountId) : false + + return ( + +
+
+

Información

+ Regresar a mi cuenta + Regresar +
+
+
+
+ Vista previa autenticador +
+

2FA son las siglas de "Two Factor Authentication", o verificación en dos pasos.

+
+

Es un método que añade una capa adicional de seguridad al proceso de inicio de sesión.

+

En lugar de simplemente ingresar tu nombre de usuario y contraseña, 2FA requiere un segundo paso de verificación, aumentando así la protección de tu cuenta.

+
+

Una vez activado, luego de ingresar usuario y contraseña, aparecerá una ventana (Imagen derecha) que te pedirá el código de autenticación generado por la aplicación.

+
+

Códigos de respaldo

+

Al activar 2FA, se te proporcionarán códigos de respaldo únicos.

+

Estos códigos son útiles en caso de que pierdas acceso a tu aplicación de autenticación.

+

Cada código de respaldo puede usarse una sola vez.

+

Guárdalos en un lugar seguro, ya que te permitirán acceder a tu cuenta en el sitio web si no puedes usar la aplicación de autenticación.

+
+

Si aún no tienes Token de seguridad, puedes solicitarlo aquí.

+
+
+
+
+ NOTA +
+

Utiliza una aplicación de autenticación para iniciar sesión en el juego una vez que lo tengas activado.

+
+
+ + +
+
+
+ ) +} diff --git a/web-next/app/api/account/2fa/route.ts b/web-next/app/api/account/2fa/route.ts new file mode 100644 index 0000000..828f654 --- /dev/null +++ b/web-next/app/api/account/2fa/route.ts @@ -0,0 +1,109 @@ +import QRCode from 'qrcode' +import { getSession } from '@/lib/session' +import { authenticate } from '@/lib/auth' +import { checkSecurityToken } from '@/lib/security-token' +import { getRealmName } from '@/lib/realm' +import { + base32Encode, + otpauthUrl, + generateSecret, + validateToken, + is2faEnabled, + setAccountTotpSecret, + clearAccountTotpSecret, + getAccountTotpSecret, +} from '@/lib/two-factor' + +// "15#1" -> "WOW1" (índice tras '#'); si no hay índice, usa el username tal cual. +function wowLabel(username: string): string { + const idx = username.split('#')[1] + return idx ? `WOW${idx}` : username +} + +export async function POST(request: Request) { + const session = await getSession() + if (!session.bnetId || !session.accountId || !session.bnetEmail || !session.username) { + return Response.json({ success: false, message: 'Tu sesión ha expirado. Inicia sesión de nuevo.' }, { status: 401 }) + } + const accountId = session.accountId + + let body: Record = {} + try { + body = await request.json() + } catch { + return Response.json({ success: false, message: 'Solicitud no válida.' }, { status: 400 }) + } + const action = String(body.action ?? '') + + /* ------------------------------- Activar 2FA ------------------------------ */ + if (action === 'activate') { + if (await is2faEnabled(accountId)) { + return Response.json({ success: false, message: 'La verificación en 2 pasos ya está activada en esta cuenta.' }) + } + const password = String(body.password ?? '') + const securityToken = String(body.securityToken ?? '') + if (!password || !securityToken) { + return Response.json({ success: false, message: 'Introduce tu contraseña y tu token de seguridad.' }) + } + const bnet = await authenticate(session.bnetEmail, password) + if (!bnet) return Response.json({ success: false, message: 'Contraseña incorrecta.' }) + if (!(await checkSecurityToken(accountId, securityToken))) { + return Response.json({ success: false, message: 'Token de seguridad incorrecto. Puedes solicitar uno nuevo.' }) + } + + const secret = generateSecret() + const secretBase32 = base32Encode(secret) + const realm = await getRealmName() + const url = otpauthUrl(realm, wowLabel(session.username), secretBase32) + const qrCodeUrl = await QRCode.toDataURL(url, { margin: 1, width: 220 }) + + // Guardamos el secreto como PENDIENTE (no se escribe en la cuenta hasta que + // el usuario confirme un código válido; así se evita cualquier bloqueo). + session.pending2fa = secret.toString('hex') + session.pending2faFor = accountId + await session.save() + + return Response.json({ + success: true, + message: 'Escanea el código QR con tu aplicación de autenticación y confirma un código para activar 2FA.', + qrCodeUrl, + secretKey: secretBase32, + }) + } + + /* ------------------------------- Verificar -------------------------------- */ + if (action === 'verify') { + if (!session.pending2fa || session.pending2faFor !== accountId) { + return Response.json({ success: false, message: 'No hay una activación pendiente. Vuelve a empezar.' }) + } + const code = String(body.code ?? '').replace(/\D/g, '') + if (code.length !== 6) return Response.json({ success: false, message: 'Introduce el código de 6 dígitos.' }) + + const secret = Buffer.from(session.pending2fa, 'hex') + if (!validateToken(secret, Number(code))) { + return Response.json({ success: false, message: 'Código incorrecto. Comprueba la hora de tu dispositivo e inténtalo de nuevo.' }) + } + + await setAccountTotpSecret(accountId, secret) + session.pending2fa = undefined + session.pending2faFor = undefined + await session.save() + + return Response.json({ success: true, message: '¡Verificación en 2 pasos activada! A partir de ahora se pedirá el código al iniciar sesión en el juego.' }) + } + + /* ------------------------------- Desactivar ------------------------------- */ + if (action === 'disable') { + const secret = await getAccountTotpSecret(accountId) + if (!secret) return Response.json({ success: false, message: 'La verificación en 2 pasos no está activada.' }) + const code = String(body.code ?? '').replace(/\D/g, '') + if (code.length !== 6) return Response.json({ success: false, message: 'Introduce el código de 6 dígitos de tu aplicación.' }) + if (!validateToken(secret, Number(code))) { + return Response.json({ success: false, message: 'Código incorrecto. No se ha desactivado 2FA.' }) + } + await clearAccountTotpSecret(accountId) + return Response.json({ success: true, message: 'Verificación en 2 pasos desactivada.' }) + } + + return Response.json({ success: false, message: 'Acción no válida.' }, { status: 400 }) +} diff --git a/web-next/app/globals.css b/web-next/app/globals.css index 1aca273..f31c9e0 100644 --- a/web-next/app/globals.css +++ b/web-next/app/globals.css @@ -448,3 +448,27 @@ textarea:focus { vertical-align: middle; margin-right: 6px; } + +/* ==== Verificación en 2 pasos (/security-2falogin) ==== */ +.twofa-login-info { display: block; } +.twofa-login-preview { + float: right; + max-width: 370px; + margin-left: 24px; + margin-bottom: 8px; + height: auto; + border: 2px solid #352e2b; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.35); +} +.google-play-logo { height: 40px; width: 140px; vertical-align: middle; } +.apple-store-logo { height: 40px; width: 120px; vertical-align: middle; } +.toggle-password, .toggle-token { cursor: pointer; margin-left: -28px; color: #b1997f; transition: .3s; } +.toggle-password:hover, .toggle-token:hover { color: #fff; } +.copy-secret-container { display: flex; align-items: center; justify-content: center; gap: 8px; margin-top: 6px; } +#secret-key { width: 240px; text-align: center; } +.copy-btn { cursor: pointer; } +#qr-code { display: block; margin: 0 auto; max-width: 220px; height: auto; background: #fff; padding: 8px; border-radius: 6px; } +@media screen and (max-width: 1024px) { + .twofa-login-info { display: flex; flex-direction: column-reverse; align-items: center; } + .twofa-login-preview { float: none; display: block; clear: both; margin: 10px auto 8px auto; max-width: 90vw; } +} diff --git a/web-next/components/TwoFactorLogin.tsx b/web-next/components/TwoFactorLogin.tsx new file mode 100644 index 0000000..c2fb10e --- /dev/null +++ b/web-next/components/TwoFactorLogin.tsx @@ -0,0 +1,292 @@ +'use client' + +import { useState } from 'react' + +const LOGOS = '/nw-themes/nw-ryu/nw-images/nw-logos' + +type Msg = { success: boolean; text: string } | null + +async function post(action: string, extra: Record): Promise<{ success: boolean; message: string; qrCodeUrl?: string; secretKey?: string }> { + const res = await fetch('/api/account/2fa', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'same-origin', + body: JSON.stringify({ action, ...extra }), + }) + return res.json() +} + +/** Ojo para mostrar/ocultar un campo de contraseña/token. */ +function EyeToggle({ shown, onToggle }: { shown: boolean; onToggle: () => void }) { + return ( + + ) +} + +export function TwoFactorLogin({ enabled }: { enabled: boolean }) { + return ( +
+
+
+ {enabled ? : } +
+ ) +} + +/* ------------------------------ Activar + QR ------------------------------- */ +function ActivateFlow() { + const [password, setPassword] = useState('') + const [showPassword, setShowPassword] = useState(false) + const [token, setToken] = useState('') + const [showToken, setShowToken] = useState(false) + const [busy, setBusy] = useState(false) + const [msg, setMsg] = useState(null) + const [qr, setQr] = useState<{ qrCodeUrl: string; secretKey: string } | null>(null) + const [done, setDone] = useState(false) + + // verificación + const [code, setCode] = useState('') + const [vBusy, setVBusy] = useState(false) + const [vMsg, setVMsg] = useState(null) + const [copied, setCopied] = useState(false) + + async function activate(e: React.FormEvent) { + e.preventDefault() + if (busy) return + setBusy(true) + setMsg(null) + try { + const d = await post('activate', { password, securityToken: token }) + setMsg({ success: !!d.success, text: d.message }) + if (d.success && d.qrCodeUrl && d.secretKey) setQr({ qrCodeUrl: d.qrCodeUrl, secretKey: d.secretKey }) + } catch { + setMsg({ success: false, text: 'Algo ha salido mal. Por favor intente más tarde.' }) + } finally { + setBusy(false) + } + } + + async function verify(e: React.FormEvent) { + e.preventDefault() + if (vBusy) return + setVBusy(true) + setVMsg(null) + try { + const d = await post('verify', { code }) + setVMsg({ success: !!d.success, text: d.message }) + if (d.success) setDone(true) + } catch { + setVMsg({ success: false, text: 'Algo ha salido mal. Por favor intente más tarde.' }) + } finally { + setVBusy(false) + } + } + + function copySecret() { + if (!qr) return + navigator.clipboard?.writeText(qr.secretKey).then(() => { + setCopied(true) + setTimeout(() => setCopied(false), 1500) + }) + } + + return ( + <> +
+ + + + + + + + + {!qr && ( + + + + )} + +
+ setPassword(e.target.value)} + disabled={!!qr} + /> + setShowPassword((s) => !s)} /> +
+ setToken(e.target.value)} + disabled={!!qr} + /> + setShowToken((s) => !s)} /> +
+ +
+
+
+ {msg && ( +
+ {msg.text} +
+ )} + + {qr && ( +
+

Paso 1) Descarga la aplicación de autenticación:

+ + Google Play + + + App Store + +
+
+

Paso 2) Escanea este código QR con tu aplicación de autenticación:

+ QR Code +
+
+

Si no puedes escanear el código QR copia y pega esta clave en tu aplicación de autenticación:

+
+ + +
+
+
+

Paso 3) Ingresa el código generado por tu aplicación de autenticación:

+
+ + + + + + + + + +
+ setCode(e.target.value)} + disabled={done} + /> +
+ +
+
+
+ {vMsg && ( +
+ {vMsg.text} +
+ )} +
+
+ )} + + ) +} + +/* ------------------------------- Desactivar -------------------------------- */ +function DisableForm() { + const [code, setCode] = useState('') + const [busy, setBusy] = useState(false) + const [msg, setMsg] = useState(null) + const [done, setDone] = useState(false) + + async function disable(e: React.FormEvent) { + e.preventDefault() + if (busy) return + setBusy(true) + setMsg(null) + try { + const d = await post('disable', { code }) + setMsg({ success: !!d.success, text: d.message }) + if (d.success) setDone(true) + } catch { + setMsg({ success: false, text: 'Algo ha salido mal. Por favor intente más tarde.' }) + } finally { + setBusy(false) + } + } + + return ( + <> +

La verificación en 2 pasos está activada en esta cuenta.

+

Para desactivarla, introduce un código actual de tu aplicación de autenticación.

+
+
+ + + + + + + + + +
+ setCode(e.target.value)} + disabled={done} + /> +
+ +
+
+
+ {msg && ( +
+ {msg.text} +
+ )} + + ) +} diff --git a/web-next/lib/session.ts b/web-next/lib/session.ts index 88a0776..d594745 100644 --- a/web-next/lib/session.ts +++ b/web-next/lib/session.ts @@ -7,6 +7,10 @@ export interface SessionData { bnetEmail?: string username?: string // cuenta de juego elegida (#) accountId?: number + // Secreto TOTP pendiente de confirmar (hex) durante el alta de 2FA, ligado a + // la cuenta de juego que lo generó. Se guarda sólo hasta verificar el código. + pending2fa?: string + pending2faFor?: number } export const sessionOptions: SessionOptions = { diff --git a/web-next/lib/two-factor.ts b/web-next/lib/two-factor.ts new file mode 100644 index 0000000..00dc574 --- /dev/null +++ b/web-next/lib/two-factor.ts @@ -0,0 +1,159 @@ +import crypto from 'node:crypto' +import type { RowDataPacket } from 'mysql2' +import { db, DB } from './db' + +/** + * TOTP para el login del juego (Battle.net / AzerothCore 3.4.3). + * + * El secreto se guarda en `acore_auth.account.totp_secret` (VARBINARY). El + * servidor lo lee y valida en cada inicio de sesión. Reproduce exactamente la + * implementación del core (src/common/Cryptography/TOTP.cpp): HMAC-SHA1, ventana + * de 30 s, 6 dígitos y una tolerancia de ±1 intervalo. + * + * Almacenamiento (igual que `.account 2fa setup` del core): + * - Sin master key (por defecto, TOTPMasterSecret vacío): se guarda el secreto + * en crudo (20 bytes). + * - Con master key (TOTP_MASTER_SECRET = mismo hex que TOTPMasterSecret del + * bnetserver): se cifra con AES-128-GCM y se guarda `ciphertext‖IV(12)‖tag(12)`. + */ + +const SECRET_LENGTH = 20 +const INTERVAL = 30 + +/* --------------------------------- Base32 ---------------------------------- */ +const B32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567' + +export function base32Encode(buf: Buffer): string { + let bits = 0 + let value = 0 + let out = '' + for (const byte of buf) { + value = (value << 8) | byte + bits += 8 + while (bits >= 5) { + out += B32_ALPHABET[(value >>> (bits - 5)) & 31] + bits -= 5 + } + } + if (bits > 0) out += B32_ALPHABET[(value << (5 - bits)) & 31] + return out // sin relleno '='; los autenticadores lo aceptan +} + +/* ---------------------------------- TOTP ----------------------------------- */ +/** Genera el token de 6 dígitos para un instante dado (mismo algoritmo que el core). */ +export function generateToken(secret: Buffer, timestamp: number): number { + let counter = Math.floor(timestamp / INTERVAL) + const challenge = Buffer.alloc(8) + for (let i = 7; i >= 0; i--) { + challenge[i] = counter & 0xff + counter = Math.floor(counter / 256) + } + const digest = crypto.createHmac('sha1', secret).update(challenge).digest() + const offset = digest[19] & 0xf + const truncated = + (((digest[offset] & 0x7f) << 24) | + (digest[offset + 1] << 16) | + (digest[offset + 2] << 8) | + digest[offset + 3]) >>> + 0 + return truncated % 1000000 +} + +/** Valida un token con tolerancia de ±1 intervalo (now-30, now, now+30). */ +export function validateToken(secret: Buffer, token: number): boolean { + const now = Math.floor(Date.now() / 1000) + return ( + token === generateToken(secret, now - INTERVAL) || + token === generateToken(secret, now) || + token === generateToken(secret, now + INTERVAL) + ) +} + +/* -------------------------------- otpauth ---------------------------------- */ +export function otpauthUrl(issuer: string, label: string, secretBase32: string): string { + const iss = encodeURIComponent(issuer) + const lbl = encodeURIComponent(label) + return `otpauth://totp/${iss}:${lbl}?secret=${secretBase32}&issuer=${iss}&algorithm=SHA1&digits=6&period=30` +} + +/* ------------------------ Cifrado para la base de datos --------------------- */ +// Clave AES de 16 bytes desde el master key hex, en little-endian (igual que +// BigNumber::ToByteArray del core, cuyo valor por defecto es littleEndian=true). +function masterKeyBytes(): Buffer | null { + const hex = (process.env.TOTP_MASTER_SECRET || '').trim() + if (!hex) return null + let n: bigint + try { + n = BigInt('0x' + hex.replace(/^0x/i, '')) + } catch { + return null + } + const buf = Buffer.alloc(16) + let v = n + for (let i = 0; i < 16; i++) { + buf[i] = Number(v & 0xffn) + v >>= 8n + } + return buf +} + +/** Codifica el secreto para guardarlo en `account.totp_secret` (crudo o AES-GCM). */ +export function encodeSecretForDb(raw: Buffer): Buffer { + const key = masterKeyBytes() + if (!key) return raw + const iv = crypto.randomBytes(12) + const cipher = crypto.createCipheriv('aes-128-gcm', key, iv, { authTagLength: 12 }) + const ct = Buffer.concat([cipher.update(raw), cipher.final()]) + const tag = cipher.getAuthTag() // 12 bytes + return Buffer.concat([ct, iv, tag]) // ciphertext ‖ IV(12) ‖ tag(12) +} + +/** Decodifica el secreto almacenado (crudo o AES-GCM). Devuelve null si falla. */ +export function decodeSecretFromDb(stored: Buffer): Buffer | null { + const key = masterKeyBytes() + if (!key) return stored + if (stored.length < 24) return null + const tag = stored.subarray(stored.length - 12) + const iv = stored.subarray(stored.length - 24, stored.length - 12) + const ct = stored.subarray(0, stored.length - 24) + try { + const decipher = crypto.createDecipheriv('aes-128-gcm', key, iv, { authTagLength: 12 }) + decipher.setAuthTag(tag) + return Buffer.concat([decipher.update(ct), decipher.final()]) + } catch { + return null + } +} + +/* --------------------------- Acceso a la cuenta ---------------------------- */ +export async function is2faEnabled(accountId: number): Promise { + const [rows] = await db(DB.auth).query( + 'SELECT totp_secret FROM account WHERE id = ?', + [accountId], + ) + return Boolean(rows[0] && rows[0].totp_secret != null) +} + +/** Devuelve el secreto TOTP en crudo (descifrado) de la cuenta, o null si no tiene 2FA. */ +export async function getAccountTotpSecret(accountId: number): Promise { + const [rows] = await db(DB.auth).query( + 'SELECT totp_secret FROM account WHERE id = ?', + [accountId], + ) + const stored = rows[0]?.totp_secret + if (stored == null) return null + return decodeSecretFromDb(Buffer.from(stored)) +} + +export async function setAccountTotpSecret(accountId: number, raw: Buffer): Promise { + await db(DB.auth).query('UPDATE account SET totp_secret = ? WHERE id = ?', [encodeSecretForDb(raw), accountId]) +} + +export async function clearAccountTotpSecret(accountId: number): Promise { + await db(DB.auth).query('UPDATE account SET totp_secret = NULL WHERE id = ?', [accountId]) +} + +/** Genera un secreto TOTP aleatorio (20 bytes, como RECOMMENDED_SECRET_LENGTH del core). */ +export function generateSecret(): Buffer { + return crypto.randomBytes(SECRET_LENGTH) +} diff --git a/web-next/package-lock.json b/web-next/package-lock.json index 2aae554..21cfea7 100644 --- a/web-next/package-lock.json +++ b/web-next/package-lock.json @@ -8,11 +8,13 @@ "name": "web-next", "version": "0.1.0", "dependencies": { + "@types/qrcode": "^1.5.6", "iron-session": "^8.0.4", "mysql2": "^3.22.6", "next": "16.2.10", "next-intl": "^4.13.2", "nodemailer": "^9.0.3", + "qrcode": "^1.5.4", "react": "19.2.4", "react-dom": "19.2.4", "sanitize-html": "^2.17.6", @@ -2450,6 +2452,14 @@ "@types/node": "*" } }, + "node_modules/@types/qrcode": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz", + "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/react": { "version": "19.2.17", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", @@ -3192,11 +3202,18 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "engines": { + "node": ">=8" + } + }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "dependencies": { "color-convert": "^2.0.1" }, @@ -3558,6 +3575,14 @@ "node": ">=6" } }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "engines": { + "node": ">=6" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001805", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001805.tgz", @@ -3598,11 +3623,20 @@ "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==" }, + "node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "dependencies": { "color-name": "~1.1.4" }, @@ -3613,8 +3647,7 @@ "node_modules/color-name": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" }, "node_modules/concat-map": { "version": "0.0.1", @@ -3735,6 +3768,14 @@ } } }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -3799,6 +3840,11 @@ "node": ">=8" } }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==" + }, "node_modules/doctrine": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", @@ -4771,6 +4817,14 @@ "node": ">=6.9.0" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -5312,6 +5366,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "engines": { + "node": ">=8" + } + }, "node_modules/is-generator-function": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", @@ -6521,6 +6583,14 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "engines": { + "node": ">=6" + } + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -6542,7 +6612,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, "engines": { "node": ">=8" } @@ -6579,6 +6648,14 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/po-parser": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/po-parser/-/po-parser-2.1.1.tgz", @@ -6649,6 +6726,22 @@ "node": ">=6" } }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -6736,6 +6829,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==" + }, "node_modules/resolve": { "version": "2.0.0-next.7", "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz", @@ -6898,6 +7004,11 @@ "semver": "bin/semver.js" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==" + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -7134,6 +7245,24 @@ "node": ">= 0.4" } }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" + }, "node_modules/string.prototype.includes": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", @@ -7242,6 +7371,17 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-bom": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", @@ -7785,6 +7925,11 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==" + }, "node_modules/which-typed-array": { "version": "1.1.22", "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.22.tgz", @@ -7815,12 +7960,111 @@ "node": ">=0.10.0" } }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "dev": true }, + "node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/yargs/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yargs/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/web-next/package.json b/web-next/package.json index 2c25a7a..9ad64c7 100644 --- a/web-next/package.json +++ b/web-next/package.json @@ -9,11 +9,13 @@ "lint": "eslint" }, "dependencies": { + "@types/qrcode": "^1.5.6", "iron-session": "^8.0.4", "mysql2": "^3.22.6", "next": "16.2.10", "next-intl": "^4.13.2", "nodemailer": "^9.0.3", + "qrcode": "^1.5.4", "react": "19.2.4", "react-dom": "19.2.4", "sanitize-html": "^2.17.6", diff --git a/web-next/public/nw-themes/nw-ryu/nw-css/novawow-style.css b/web-next/public/nw-themes/nw-ryu/nw-css/novawow-style.css index 052cc24..0d62ecb 100644 --- a/web-next/public/nw-themes/nw-ryu/nw-css/novawow-style.css +++ b/web-next/public/nw-themes/nw-ryu/nw-css/novawow-style.css @@ -1028,7 +1028,7 @@ textarea:focus, select:focus, input[type=password]:focus, input[type=number]:foc width: 304px; } -.change-password-button, .change-email-button, .transfer-button, .sec-token-button, .dnt-button, .recover-button, .rename-guild-button, .trade-points-sell-button, .trade-points-buy-button, .trade-points-check-button, .show-gift-button { +.change-password-button, .change-email-button, .transfer-button, .sec-token-button, .dnt-button, .recover-button, .rename-guild-button, .trade-points-sell-button, .trade-points-buy-button, .trade-points-check-button, .show-gift-button, .activate-2fa-button, .verify-2fa-button, .deactivate-2fa-button, .confirm-deactivate-2fa-button { display: block; width: 304px; margin: 0 auto; diff --git a/web-next/public/nw-themes/nw-ryu/nw-images/nw-general/nw-authenticator-preview.png b/web-next/public/nw-themes/nw-ryu/nw-images/nw-general/nw-authenticator-preview.png new file mode 100644 index 0000000..b384987 Binary files /dev/null and b/web-next/public/nw-themes/nw-ryu/nw-images/nw-general/nw-authenticator-preview.png differ diff --git a/web-next/public/nw-themes/nw-ryu/nw-images/nw-logos/app-store.png b/web-next/public/nw-themes/nw-ryu/nw-images/nw-logos/app-store.png new file mode 100644 index 0000000..dfb54be Binary files /dev/null and b/web-next/public/nw-themes/nw-ryu/nw-images/nw-logos/app-store.png differ diff --git a/web-next/public/nw-themes/nw-ryu/nw-images/nw-logos/google-play.png b/web-next/public/nw-themes/nw-ryu/nw-images/nw-logos/google-play.png new file mode 100644 index 0000000..db325ce Binary files /dev/null and b/web-next/public/nw-themes/nw-ryu/nw-images/nw-logos/google-play.png differ