Files
NightSpire/web-next/components/TwoFactorLogin.tsx
T
Inna 4e089b5ebb Añadir ruta /security-2falogin (verificación en 2 pasos / TOTP)
Activación de 2FA (TOTP) para el login del juego, guardando el secreto en
acore_auth.account.totp_secret tal como lo lee el bnetserver 3.4.3.

- lib/two-factor.ts: TOTP (HMAC-SHA1, 30s, 6 dígitos, ±1) idéntico al core
  (verificado contra los vectores RFC 6238), Base32, otpauth, y codificado
  del secreto: crudo por defecto o AES-128-GCM (ciphertext‖IV12‖tag12) si se
  define TOTP_MASTER_SECRET (mismo hex que TOTPMasterSecret del servidor).
- Flujo seguro sin bloqueos: el secreto se genera y queda PENDIENTE en la
  sesión; sólo se escribe en la cuenta tras verificar un código válido.
- /api/account/2fa: activar (password + token de seguridad), verificar y
  desactivar (con código actual). QR generado en el servidor (el secreto no
  sale a terceros).
- Página + componente fieles al markup (preview, pasos, copiar clave, ojos).

Verificado E2E: activar -> QR/secreto -> verificar -> totp_secret = 20 bytes
(igual al secreto escaneado) -> desactivar -> NULL.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 21:37:13 +00:00

293 lines
9.8 KiB
TypeScript

'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<string, string>): 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 (
<span
className={`far ${shown ? 'fa-eye-slash' : 'fa-eye'} toggle-password`}
onClick={onToggle}
role="button"
tabIndex={0}
aria-label={shown ? 'Ocultar' : 'Mostrar'}
></span>
)
}
export function TwoFactorLogin({ enabled }: { enabled: boolean }) {
return (
<div className="centered">
<br />
<br />
{enabled ? <DisableForm /> : <ActivateFlow />}
</div>
)
}
/* ------------------------------ 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<Msg>(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<Msg>(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 (
<>
<form onSubmit={activate} id="uw-2fa-form">
<table className="middle-center-table">
<tbody>
<tr>
<td>
<input
type={showPassword ? 'text' : 'password'}
maxLength={16}
name="password"
id="password"
placeholder="Contraseña"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={!!qr}
/>
<EyeToggle shown={showPassword} onToggle={() => setShowPassword((s) => !s)} />
</td>
</tr>
<tr>
<td>
<input
type={showToken ? 'text' : 'password'}
maxLength={6}
name="security-token"
id="security-token"
placeholder="Token de seguridad"
value={token}
onChange={(e) => setToken(e.target.value)}
disabled={!!qr}
/>
<EyeToggle shown={showToken} onToggle={() => setShowToken((s) => !s)} />
</td>
</tr>
{!qr && (
<tr>
<td>
<button type="submit" className="activate-2fa-button" disabled={busy}>
{busy ? 'Activando 2FA' : 'Activar 2FA'}
</button>
</td>
</tr>
)}
</tbody>
</table>
</form>
<hr />
{msg && (
<div className={`alert-message ${msg.success ? 'green-info' : 'red-info'}`} style={{ display: 'block' }}>
{msg.text}
</div>
)}
{qr && (
<div id="qr-code-container">
<p>Paso 1) Descarga la aplicación de autenticación:</p>
<a href="https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2" target="_blank" rel="noopener noreferrer" title="Google Authenticator (Android)">
<img src={`${LOGOS}/google-play.png`} alt="Google Play" className="google-play-logo" />
</a>
<a href="https://apps.apple.com/app/google-authenticator/id388497605" target="_blank" rel="noopener noreferrer" title="Google Authenticator (iOS)">
<img src={`${LOGOS}/app-store.png`} alt="App Store" className="apple-store-logo" />
</a>
<br />
<br />
<p>Paso 2) Escanea este código QR con tu aplicación de autenticación:</p>
<img id="qr-code" src={qr.qrCodeUrl} alt="QR Code" />
<br />
<br />
<p>Si no puedes escanear el código QR copia y pega esta clave en tu aplicación de autenticación:</p>
<div className="copy-secret-container">
<input type="text" id="secret-key" className="centered" value={qr.secretKey} readOnly />
<span
className={`fa ${copied ? 'fa-check' : 'fa-copy'} copy-btn second-brown`}
title={copied ? '¡Copiado!' : 'Copiar clave'}
onClick={copySecret}
role="button"
tabIndex={0}
></span>
</div>
<br />
<br />
<p>Paso 3) Ingresa el código generado por tu aplicación de autenticación:</p>
<form onSubmit={verify} id="uw-2fa-verify-form" autoComplete="off">
<table className="middle-center-table">
<tbody>
<tr>
<td>
<input
type="text"
maxLength={6}
name="authenticator-code"
id="authenticator-code"
placeholder="Código de 6 dígitos"
required
className="centered"
inputMode="numeric"
pattern="\d{6}"
value={code}
onChange={(e) => setCode(e.target.value)}
disabled={done}
/>
</td>
</tr>
<tr>
<td>
<button type="submit" className="verify-2fa-button" disabled={vBusy || done}>
{done ? 'Verificado ✔️' : vBusy ? 'Verificando...' : 'Verificar código'}
</button>
</td>
</tr>
</tbody>
</table>
</form>
<hr />
{vMsg && (
<div className={`alert-message ${vMsg.success ? 'green-info' : 'red-info'}`} style={{ display: 'block' }}>
{vMsg.text}
</div>
)}
<br />
</div>
)}
</>
)
}
/* ------------------------------- Desactivar -------------------------------- */
function DisableForm() {
const [code, setCode] = useState('')
const [busy, setBusy] = useState(false)
const [msg, setMsg] = useState<Msg>(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 (
<>
<p className="green-info"><i className="fas fa-shield-alt"></i> La verificación en 2 pasos está activada en esta cuenta.</p>
<p>Para desactivarla, introduce un código actual de tu aplicación de autenticación.</p>
<br />
<form onSubmit={disable} id="uw-2fa-disable-form">
<table className="middle-center-table">
<tbody>
<tr>
<td>
<input
type="text"
maxLength={6}
name="authenticator-code"
placeholder="Código de 6 dígitos"
required
className="centered"
inputMode="numeric"
pattern="\d{6}"
value={code}
onChange={(e) => setCode(e.target.value)}
disabled={done}
/>
</td>
</tr>
<tr>
<td>
<button type="submit" className="deactivate-2fa-button" disabled={busy || done}>
{done ? '2FA desactivado ✔️' : busy ? 'Desactivando...' : 'Desactivar 2FA'}
</button>
</td>
</tr>
</tbody>
</table>
</form>
<hr />
{msg && (
<div className={`alert-message ${msg.success ? 'green-info' : 'red-info'}`} style={{ display: 'block' }}>
{msg.text}
</div>
)}
</>
)
}