From cac0d28aa95133c38cbbb800e4ae8b9cf6916602 Mon Sep 17 00:00:00 2001 From: adevopg Date: Sun, 12 Jul 2026 21:15:27 +0000 Subject: [PATCH] =?UTF-8?q?A=C3=B1ade=20Cloudflare=20Turnstile=20(captcha)?= =?UTF-8?q?=20a=20login,=20registro=20y=20recuperaci=C3=B3n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Backend: helper verify_turnstile en _base (verifica el token contra challenges.cloudflare.com/siteverify; fail-closed ante error de red; si no hay secreto configurado, no bloquea). Se exige en el POST de login_view, register_view y recover_account_view -> rechazo con mensaje si falla. - settings: TURNSTILE_SITE_KEY / TURNSTILE_SECRET_KEY desde .env (el secreto NO se commitea). La clave de sitio se expone a las plantillas por el context processor (TURNSTILE_SITE_KEY). - Frontend: hook useTurnstile (carga el script de Cloudflare una vez y renderiza el widget, devuelve el token). LoginForm/RegisterForm/RecoverForm incluyen el widget, mandan cf-turnstile-response y deshabilitan el submit hasta tener token. El sitekey llega por data-sitekey en el div de montaje. Verificado: sin token los 3 POST se rechazan; siteverify acepta la clave secreta (error invalid-input-response con token falso, no invalid-input-secret). Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend/src/components/LoginForm.tsx | 13 +++- frontend/src/components/RecoverForm.tsx | 17 ++++- frontend/src/components/RegisterForm.tsx | 13 +++- frontend/src/entries/login.tsx | 1 + frontend/src/entries/recover.tsx | 6 +- frontend/src/entries/register.tsx | 1 + frontend/src/turnstile.ts | 77 ++++++++++++++++++++ home/context_processors.py | 1 + home/templates/partials/login.html | 3 +- home/templates/partials/recover_account.html | 3 +- home/templates/partials/register.html | 3 +- home/views/_base.py | 32 ++++++++ home/views/auth.py | 6 ++ novawow/settings.py | 4 + 14 files changed, 170 insertions(+), 10 deletions(-) create mode 100644 frontend/src/turnstile.ts diff --git a/frontend/src/components/LoginForm.tsx b/frontend/src/components/LoginForm.tsx index cf4e5de..70a9d27 100644 --- a/frontend/src/components/LoginForm.tsx +++ b/frontend/src/components/LoginForm.tsx @@ -1,9 +1,11 @@ import { useState } from 'react' +import { useTurnstile } from '../turnstile' interface Props { loginUrl: string successUrl: string csrfToken: string + siteKey: string } type LoginResponse = { @@ -13,13 +15,14 @@ type LoginResponse = { error?: string } -export function LoginForm({ loginUrl, successUrl, csrfToken }: Props) { +export function LoginForm({ loginUrl, successUrl, csrfToken, siteKey }: Props) { const [email, setEmail] = useState('') const [password, setPassword] = useState('') const [showPassword, setShowPassword] = useState(false) const [buttonText, setButtonText] = useState('Conectar') const [busy, setBusy] = useState(false) const [message, setMessage] = useState<{ kind: 'ok' | 'err'; text: string } | null>(null) + const { ref: turnstileRef, token: captchaToken } = useTurnstile(siteKey) function transientError(text: string) { setMessage({ kind: 'err', text }) @@ -45,6 +48,7 @@ export function LoginForm({ loginUrl, successUrl, csrfToken }: Props) { body.set('email', email.trim()) body.set('password', password.trim()) body.set('csrfmiddlewaretoken', csrfToken) + body.set('cf-turnstile-response', captchaToken) try { const resp = await fetch(loginUrl, { @@ -121,13 +125,18 @@ export function LoginForm({ loginUrl, successUrl, csrfToken }: Props) { /> + + +
+ + diff --git a/frontend/src/components/RecoverForm.tsx b/frontend/src/components/RecoverForm.tsx index 9200680..b8a58c7 100644 --- a/frontend/src/components/RecoverForm.tsx +++ b/frontend/src/components/RecoverForm.tsx @@ -1,8 +1,10 @@ import { useState } from 'react' +import { useTurnstile } from '../turnstile' interface Props { recoverUrl: string csrfToken: string + siteKey: string } const OPTIONS = [ @@ -11,7 +13,8 @@ const OPTIONS = [ { value: 'activation', label: 'Enlace de activación' }, ] as const -export function RecoverForm({ recoverUrl, csrfToken }: Props) { +export function RecoverForm({ recoverUrl, csrfToken, siteKey }: Props) { + const { ref: turnstileRef, token: captchaToken } = useTurnstile(siteKey) const [type, setType] = useState('password') const [email, setEmail] = useState('') const [busy, setBusy] = useState(false) @@ -27,6 +30,7 @@ export function RecoverForm({ recoverUrl, csrfToken }: Props) { body.set('type', type) body.set('email', email.trim()) body.set('csrfmiddlewaretoken', csrfToken) + body.set('cf-turnstile-response', captchaToken) try { const resp = await fetch(recoverUrl, { @@ -78,7 +82,16 @@ export function RecoverForm({ recoverUrl, csrfToken }: Props) { - diff --git a/frontend/src/components/RegisterForm.tsx b/frontend/src/components/RegisterForm.tsx index 12943d2..37c8c9c 100644 --- a/frontend/src/components/RegisterForm.tsx +++ b/frontend/src/components/RegisterForm.tsx @@ -1,8 +1,10 @@ import { useState } from 'react' +import { useTurnstile } from '../turnstile' interface Props { registerUrl: string csrfToken: string + siteKey: string } type RegisterResponse = { @@ -10,7 +12,8 @@ type RegisterResponse = { message?: string } -export function RegisterForm({ registerUrl, csrfToken }: Props) { +export function RegisterForm({ registerUrl, csrfToken, siteKey }: Props) { + const { ref: turnstileRef, token: captchaToken } = useTurnstile(siteKey) const [password, setPassword] = useState('') const [confPassword, setConfPassword] = useState('') const [email, setEmail] = useState('') @@ -36,6 +39,7 @@ export function RegisterForm({ registerUrl, csrfToken }: Props) { body.set('conf-email', confEmail.trim()) body.set('recruiter', recruiter.trim()) body.set('csrfmiddlewaretoken', csrfToken) + body.set('cf-turnstile-response', captchaToken) try { const resp = await fetch(registerUrl, { @@ -181,6 +185,11 @@ export function RegisterForm({ registerUrl, csrfToken }: Props) { + + +
+ + diff --git a/frontend/src/entries/login.tsx b/frontend/src/entries/login.tsx index 6ed65ae..c8e54bf 100644 --- a/frontend/src/entries/login.tsx +++ b/frontend/src/entries/login.tsx @@ -10,6 +10,7 @@ if (el) { loginUrl={el.dataset.loginUrl || ''} successUrl={el.dataset.successUrl || 'my-account'} csrfToken={el.dataset.csrf || ''} + siteKey={el.dataset.sitekey || ''} /> , ) diff --git a/frontend/src/entries/recover.tsx b/frontend/src/entries/recover.tsx index d6d7726..a21ec79 100644 --- a/frontend/src/entries/recover.tsx +++ b/frontend/src/entries/recover.tsx @@ -6,7 +6,11 @@ const el = document.getElementById('recover-app') if (el) { createRoot(el).render( - + , ) } diff --git a/frontend/src/entries/register.tsx b/frontend/src/entries/register.tsx index 72125a3..f1f3225 100644 --- a/frontend/src/entries/register.tsx +++ b/frontend/src/entries/register.tsx @@ -9,6 +9,7 @@ if (el) { , ) diff --git a/frontend/src/turnstile.ts b/frontend/src/turnstile.ts new file mode 100644 index 0000000..ca34c50 --- /dev/null +++ b/frontend/src/turnstile.ts @@ -0,0 +1,77 @@ +import { useEffect, useRef, useState } from 'react' + +interface TurnstileOptions { + sitekey: string + callback?: (token: string) => void + 'error-callback'?: () => void + 'expired-callback'?: () => void + theme?: 'auto' | 'light' | 'dark' +} + +declare global { + interface Window { + turnstile?: { + render: (el: HTMLElement, opts: TurnstileOptions) => string + reset: (id?: string) => void + remove: (id: string) => void + } + } +} + +const SCRIPT_ID = 'cf-turnstile-script' + +/** + * Carga el script de Cloudflare Turnstile (una vez) y renderiza el widget en el + * div referenciado. Devuelve el ref para el contenedor y el token resuelto. + * Si sitekey es vacío, no hace nada (captcha desactivado). + */ +export function useTurnstile(sitekey: string) { + const ref = useRef(null) + const [token, setToken] = useState('') + const rendered = useRef(false) + + useEffect(() => { + if (!sitekey) return + let cancelled = false + + function tryRender() { + if (cancelled || rendered.current || !ref.current || !window.turnstile) return + rendered.current = true + window.turnstile.render(ref.current, { + sitekey, + theme: 'dark', + callback: (t) => setToken(t), + 'error-callback': () => setToken(''), + 'expired-callback': () => setToken(''), + }) + } + + if (window.turnstile) { + tryRender() + 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) + tryRender() + } + }, 200) + return () => { + cancelled = true + window.clearInterval(iv) + } + }, [sitekey]) + + return { ref, token } +} diff --git a/home/context_processors.py b/home/context_processors.py index 1d7f7b5..aed81b9 100644 --- a/home/context_processors.py +++ b/home/context_processors.py @@ -13,6 +13,7 @@ def configuracion_global(request): return { 'NOMBRE_SERVIDOR': settings.NOMBRE_SERVIDOR, 'ANIO_ACTUAL': settings.ANIO_ACTUAL, + 'TURNSTILE_SITE_KEY': settings.TURNSTILE_SITE_KEY, } def get_server_info(request): diff --git a/home/templates/partials/login.html b/home/templates/partials/login.html index 91ad321..708f5a0 100644 --- a/home/templates/partials/login.html +++ b/home/templates/partials/login.html @@ -28,7 +28,8 @@
+ data-success-url="my-account" + data-sitekey="{{ TURNSTILE_SITE_KEY }}">
{% vite_asset 'src/entries/login.tsx' %}

He olvidado mi contraseña/usuario

diff --git a/home/templates/partials/recover_account.html b/home/templates/partials/recover_account.html index adb799f..bdc2f6e 100644 --- a/home/templates/partials/recover_account.html +++ b/home/templates/partials/recover_account.html @@ -30,7 +30,8 @@ cuenta / reenvío de activación). POST a {% url 'recover' %} (JSON). -->
+ data-recover-url="{% url 'recover' %}" + data-sitekey="{{ TURNSTILE_SITE_KEY }}">
{% vite_asset 'src/entries/recover.tsx' %}
diff --git a/home/templates/partials/register.html b/home/templates/partials/register.html index 47cdd9b..c59fddb 100644 --- a/home/templates/partials/register.html +++ b/home/templates/partials/register.html @@ -59,7 +59,8 @@ {% url 'register' %} (que ya devuelve JSON) con CSRF. -->
+ data-register-url="{% url 'register' %}" + data-sitekey="{{ TURNSTILE_SITE_KEY }}"> {% vite_asset 'src/entries/register.tsx' %}
diff --git a/home/views/_base.py b/home/views/_base.py index 9e7dbec..27565f5 100644 --- a/home/views/_base.py +++ b/home/views/_base.py @@ -42,6 +42,38 @@ from ..models import ( logger = logging.getLogger(__name__) +def verify_turnstile(request): + """Verifica el token de Cloudflare Turnstile enviado por el formulario. + + Devuelve True si no hay secreto configurado (captcha desactivado) o si Cloudflare + confirma el token. Ante fallo de red devuelve False (fail-closed). + """ + import json + import urllib.parse + import urllib.request + + secret = settings.TURNSTILE_SECRET_KEY + if not secret: + return True + token = request.POST.get('cf-turnstile-response', '').strip() + if not token: + return False + payload = urllib.parse.urlencode({ + 'secret': secret, + 'response': token, + 'remoteip': request.META.get('REMOTE_ADDR', ''), + }).encode() + try: + req = urllib.request.Request( + 'https://challenges.cloudflare.com/turnstile/v0/siteverify', data=payload) + with urllib.request.urlopen(req, timeout=5) as resp: + result = json.loads(resp.read().decode()) + return bool(result.get('success')) + except Exception as e: + logger.warning("Turnstile: fallo al verificar: %s", e) + return False + + def require_paid_stripe(redirect_to='index'): """ Protege las vistas de "éxito" de pago: exige un `session_id` de Stripe diff --git a/home/views/auth.py b/home/views/auth.py index ca3019c..e18a863 100644 --- a/home/views/auth.py +++ b/home/views/auth.py @@ -48,6 +48,8 @@ def _set_game_account_session(request, game_account): request.session['account_id'] = game_account['id'] def login_view(request): if request.method == 'POST': + if not verify_turnstile(request): + return JsonResponse({'success': False, 'error': 'Verificación anti-bots fallida. Reintenta.'}) form = LoginForm(request.POST) if form.is_valid(): email = form.cleaned_data['email'] @@ -110,6 +112,8 @@ def logout_view(request): return redirect('index') def register_view(request): if request.method == 'POST': + if not verify_turnstile(request): + return JsonResponse({'success': False, 'message': 'Verificación anti-bots fallida. Reintenta.'}) password = request.POST.get('password', '').strip() conf_password = request.POST.get('conf-password', '').strip() email = request.POST.get('email', '').strip() @@ -251,6 +255,8 @@ def recover_account_view(request): Respuestas genéricas para no revelar si el correo existe (anti-enumeración). """ if request.method == 'POST': + if not verify_turnstile(request): + return JsonResponse({'success': False, 'message': 'Verificación anti-bots fallida. Reintenta.'}) rtype = request.POST.get('type', '').strip() email = request.POST.get('email', '').strip() if not email or not re.match(r'^[a-zA-Z0-9._%+-]+@gmail\.com$', email): diff --git a/novawow/settings.py b/novawow/settings.py index 746a2c1..d6c48df 100644 --- a/novawow/settings.py +++ b/novawow/settings.py @@ -79,6 +79,10 @@ INSTALLED_APPS = [ 'django_vite', ] +# Cloudflare Turnstile (captcha). La clave de sitio es pública; la secreta va en .env. +TURNSTILE_SITE_KEY = os.getenv('TURNSTILE_SITE_KEY', '') +TURNSTILE_SECRET_KEY = os.getenv('TURNSTILE_SECRET_KEY', '') + # Configuración para AC SOAP AC_SOAP_URL = os.getenv('AC_SOAP_URL', 'http://127.0.0.1:2079') AC_SOAP_USER = os.getenv('AC_SOAP_USER', 'AC_SOAP')