diff --git a/web-next/app/[locale]/login/LoginForm.tsx b/web-next/app/[locale]/login/LoginForm.tsx index d96cc6e..9bb5f80 100644 --- a/web-next/app/[locale]/login/LoginForm.tsx +++ b/web-next/app/[locale]/login/LoginForm.tsx @@ -3,8 +3,10 @@ import { useState } from 'react' import { useTranslations } from 'next-intl' import { useRouter } from '@/i18n/navigation' +import { Turnstile } from '@/components/Turnstile' -const ERROR_KEYS = ['invalidCredentials', 'missingFields', 'invalidRequest'] as const +const ERROR_KEYS = ['invalidCredentials', 'missingFields', 'invalidRequest', 'captchaFailed'] as const +const SITE_KEY = process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY export function LoginForm() { const t = useTranslations('Login') @@ -13,6 +15,7 @@ export function LoginForm() { const [password, setPassword] = useState('') const [showPw, setShowPw] = useState(false) const [busy, setBusy] = useState(false) + const [captcha, setCaptcha] = useState('') const [message, setMessage] = useState<{ ok: boolean; text: string } | null>(null) async function handleSubmit(e: React.FormEvent) { @@ -29,7 +32,7 @@ export function LoginForm() { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'same-origin', - body: JSON.stringify({ email: email.trim(), password }), + body: JSON.stringify({ email: email.trim(), password, turnstileToken: captcha }), }) const data: { success?: boolean; needsSelection?: boolean; error?: string } = await res.json() if (data.success) { @@ -77,9 +80,10 @@ export function LoginForm() { {showPw ? '🙈' : '👁'} + diff --git a/web-next/app/api/auth/login/route.ts b/web-next/app/api/auth/login/route.ts index 97b9d52..fdc92bb 100644 --- a/web-next/app/api/auth/login/route.ts +++ b/web-next/app/api/auth/login/route.ts @@ -1,18 +1,26 @@ import { authenticate, getGameAccounts } from '@/lib/auth' import { getSession } from '@/lib/session' import { setGameAccountSession } from '@/lib/account-session' +import { verifyTurnstile } from '@/lib/turnstile' export async function POST(request: Request) { let email = '' let password = '' + let turnstileToken = '' try { const body = await request.json() email = String(body.email ?? '').trim() password = String(body.password ?? '') + turnstileToken = String(body.turnstileToken ?? '') } catch { return Response.json({ success: false, error: 'invalidRequest' }, { status: 400 }) } + const ip = (request.headers.get('x-forwarded-for') || '').split(',')[0].trim() + if (!(await verifyTurnstile(turnstileToken, ip))) { + return Response.json({ success: false, error: 'captchaFailed' }) + } + if (!email || !password) { return Response.json({ success: false, error: 'missingFields' }) } diff --git a/web-next/app/api/auth/register/route.ts b/web-next/app/api/auth/register/route.ts index 5d8ea44..84466be 100644 --- a/web-next/app/api/auth/register/route.ts +++ b/web-next/app/api/auth/register/route.ts @@ -1,4 +1,5 @@ import { registerAccount } from '@/lib/register' +import { verifyTurnstile } from '@/lib/turnstile' export async function POST(request: Request) { let body: Record @@ -7,6 +8,12 @@ export async function POST(request: Request) { } catch { return Response.json({ success: false, error: 'invalidRequest' }, { status: 400 }) } + + const ip = (request.headers.get('x-forwarded-for') || '').split(',')[0].trim() + if (!(await verifyTurnstile(body.turnstileToken ?? '', ip))) { + return Response.json({ success: false, error: 'captchaFailed' }) + } + const result = await registerAccount({ password: body.password ?? '', confPassword: body.confPassword ?? '', diff --git a/web-next/components/Turnstile.tsx b/web-next/components/Turnstile.tsx new file mode 100644 index 0000000..9e99345 --- /dev/null +++ b/web-next/components/Turnstile.tsx @@ -0,0 +1,78 @@ +'use client' + +import { useEffect, useRef } from 'react' + +interface TurnstileApi { + render: ( + el: HTMLElement, + opts: { + sitekey: string + theme?: 'auto' | 'light' | 'dark' + callback?: (token: string) => void + 'error-callback'?: () => void + 'expired-callback'?: () => void + }, + ) => string +} + +declare global { + interface Window { + turnstile?: TurnstileApi + } +} + +const SCRIPT_ID = 'cf-turnstile-script' + +/** Widget de Cloudflare Turnstile. Llama onVerify(token) cuando se resuelve. */ +export function Turnstile({ onVerify }: { onVerify: (token: string) => void }) { + const ref = useRef(null) + const rendered = useRef(false) + const cb = useRef(onVerify) + cb.current = onVerify + const siteKey = process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY + + useEffect(() => { + if (!siteKey) return + let cancelled = false + + function render() { + if (cancelled || rendered.current || !ref.current || !window.turnstile) return + rendered.current = true + window.turnstile.render(ref.current, { + sitekey: siteKey!, + theme: 'dark', + callback: (t) => cb.current(t), + 'error-callback': () => cb.current(''), + 'expired-callback': () => cb.current(''), + }) + } + + if (window.turnstile) { + render() + return () => { + cancelled = true + } + } + if (!document.getElementById(SCRIPT_ID)) { + const s = document.createElement('script') + s.id = SCRIPT_ID + s.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js' + s.async = true + s.defer = true + document.head.appendChild(s) + } + const iv = window.setInterval(() => { + if (window.turnstile) { + window.clearInterval(iv) + render() + } + }, 200) + return () => { + cancelled = true + window.clearInterval(iv) + } + }, [siteKey]) + + if (!siteKey) return null + return
+} diff --git a/web-next/lib/turnstile.ts b/web-next/lib/turnstile.ts new file mode 100644 index 0000000..c16ad31 --- /dev/null +++ b/web-next/lib/turnstile.ts @@ -0,0 +1,19 @@ +// Verificación server-side del token de Cloudflare Turnstile. +export async function verifyTurnstile(token: string, ip?: string): Promise { + const secret = process.env.TURNSTILE_SECRET_KEY + if (!secret) return true // captcha desactivado si no hay secreto + if (!token) return false + try { + const body = new URLSearchParams({ secret, response: token }) + if (ip) body.set('remoteip', ip) + const res = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: body.toString(), + }) + const data: { success?: boolean } = await res.json() + return Boolean(data.success) + } catch { + return false // fail-closed + } +} diff --git a/web-next/messages/en.json b/web-next/messages/en.json index bfc0c3a..a21463a 100644 --- a/web-next/messages/en.json +++ b/web-next/messages/en.json @@ -37,7 +37,8 @@ "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" + "createAccount": "here", + "captchaFailed": "Anti-bot verification failed. Please retry." }, "Account": { "title": "My account", @@ -71,7 +72,8 @@ "invalidEmail": "The email address is not valid.", "emailExists": "An account with that email already exists.", "recruiterNotFound": "The recruiter entered does not exist.", - "genericError": "Something went wrong. Please try again later." + "genericError": "Something went wrong. Please try again later.", + "captchaFailed": "Anti-bot verification failed. Please retry." }, "Activate": { "activating": "Activating your account…", diff --git a/web-next/messages/es.json b/web-next/messages/es.json index f33ec78..247b757 100644 --- a/web-next/messages/es.json +++ b/web-next/messages/es.json @@ -37,7 +37,8 @@ "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í" + "createAccount": "aquí", + "captchaFailed": "Verificación anti-bots fallida. Reintenta." }, "Account": { "title": "Mi cuenta", @@ -71,7 +72,8 @@ "invalidEmail": "El correo electrónico no es válido.", "emailExists": "Ya existe una cuenta con ese correo electrónico.", "recruiterNotFound": "El reclutador ingresado no existe.", - "genericError": "Algo ha salido mal. Inténtalo más tarde." + "genericError": "Algo ha salido mal. Inténtalo más tarde.", + "captchaFailed": "Verificación anti-bots fallida. Reintenta." }, "Activate": { "activating": "Activando tu cuenta…",