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')
|