From 8920585b2424ee8bb8cf448751dbcd329b0d2408 Mon Sep 17 00:00:00 2001 From: adevopg Date: Sun, 12 Jul 2026 21:00:42 +0000 Subject: [PATCH] =?UTF-8?q?Implementa=20la=20recuperaci=C3=B3n=20de=20cuen?= =?UTF-8?q?ta=20(contrase=C3=B1a=20/=20cuentas=20/=20activaci=C3=B3n)=20+?= =?UTF-8?q?=20islas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit recover_account_view era un stub que solo renderizaba (los formularios no tenían backend y el HTML arrastraba un reCAPTCHA de otro dominio). Ahora es funcional: Backend: - Modelo PasswordReset (token+email+expiración 1h) + migración 0014. - recover_account_view maneja POST JSON con 3 tipos, todo por email (bnet), con respuestas genéricas anti-enumeración: * password: crea token y envía enlace de reset (emails/password_reset.html). * accountname: envía las cuentas de juego ligadas (emails/account_names.html). * activation: reenvía el enlace de activación pendiente (emails/activation.html). - reset_password_view: GET valida el token (isla), POST re-deriva el verifier SRP6 v2 de la cuenta bnet (bnet.bnet_make_registration) y lo actualiza; marca el token usado. Ruta 'reset-password'. - Resiliente si la BD de cuentas (AzerothCore) no está disponible: mensaje limpio, nunca 500. Frontend (islas Vite): RecoverForm.tsx (selector de tipo + email) y ResetPassword.tsx (nueva contraseña con token). Se elimina el reCAPTCHA roto; se puede añadir uno propio si se aportan claves. Verificado en producción: los 3 flujos devuelven éxito genérico; reset valida token y contraseñas; token válido no provoca 500. Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend/src/components/RecoverForm.tsx | 97 ++++++++++++++ frontend/src/components/ResetPassword.tsx | 131 +++++++++++++++++++ frontend/src/entries/recover.tsx | 12 ++ frontend/src/entries/reset_password.tsx | 18 +++ frontend/vite.config.ts | 2 + home/migrations/0014_passwordreset.py | 24 ++++ home/models/accounts.py | 14 ++ home/templates/auth/reset_password.html | 29 ++++ home/templates/emails/account_names.html | 34 +++++ home/templates/emails/password_reset.html | 29 ++++ home/templates/partials/recover_account.html | 63 ++------- home/urls.py | 1 + home/views/_base.py | 1 + home/views/auth.py | 108 +++++++++++++++ 14 files changed, 511 insertions(+), 52 deletions(-) create mode 100644 frontend/src/components/RecoverForm.tsx create mode 100644 frontend/src/components/ResetPassword.tsx create mode 100644 frontend/src/entries/recover.tsx create mode 100644 frontend/src/entries/reset_password.tsx create mode 100644 home/migrations/0014_passwordreset.py create mode 100644 home/templates/auth/reset_password.html create mode 100644 home/templates/emails/account_names.html create mode 100644 home/templates/emails/password_reset.html diff --git a/frontend/src/components/RecoverForm.tsx b/frontend/src/components/RecoverForm.tsx new file mode 100644 index 0000000..9200680 --- /dev/null +++ b/frontend/src/components/RecoverForm.tsx @@ -0,0 +1,97 @@ +import { useState } from 'react' + +interface Props { + recoverUrl: string + csrfToken: string +} + +const OPTIONS = [ + { value: 'password', label: 'Contraseña' }, + { value: 'accountname', label: 'Nombre de cuenta' }, + { value: 'activation', label: 'Enlace de activación' }, +] as const + +export function RecoverForm({ recoverUrl, csrfToken }: Props) { + const [type, setType] = useState('password') + const [email, setEmail] = useState('') + const [busy, setBusy] = useState(false) + const [message, setMessage] = useState<{ ok: boolean; text: string } | null>(null) + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault() + if (busy) return + setBusy(true) + setMessage(null) + + const body = new URLSearchParams() + body.set('type', type) + body.set('email', email.trim()) + body.set('csrfmiddlewaretoken', csrfToken) + + try { + const resp = await fetch(recoverUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'X-CSRFToken': csrfToken, + Accept: 'application/json', + }, + credentials: 'same-origin', + body: body.toString(), + }) + if (!resp.ok) throw new Error(`HTTP ${resp.status}`) + const data: { success?: boolean; message?: string } = await resp.json() + setMessage({ ok: !!data.success, text: data.message || '' }) + } catch { + setMessage({ ok: false, text: 'Error en el servidor. Inténtalo de nuevo más tarde.' }) + } finally { + setBusy(false) + } + } + + return ( + <> +

Escoge la opción acorde a la información que quieres recuperar

+
+
+ + + + + + + + + + +
+ setEmail(e.target.value)} + /> +
+ +
+
+
+
+ {message && ( + {message.text} + )} +
+ + ) +} diff --git a/frontend/src/components/ResetPassword.tsx b/frontend/src/components/ResetPassword.tsx new file mode 100644 index 0000000..add5dd4 --- /dev/null +++ b/frontend/src/components/ResetPassword.tsx @@ -0,0 +1,131 @@ +import { useState } from 'react' + +interface Props { + valid: boolean + token: string + resetUrl: string + recoverUrl: string + csrfToken: string +} + +export function ResetPassword({ valid, token, resetUrl, recoverUrl, csrfToken }: Props) { + const [password, setPassword] = useState('') + const [confPassword, setConfPassword] = useState('') + const [showPw, setShowPw] = useState(false) + const [busy, setBusy] = useState(false) + const [message, setMessage] = useState<{ ok: boolean; text: string } | null>(null) + const [done, setDone] = useState(false) + + if (!valid) { + return ( +
+

El enlace no es válido o ha caducado.

+
+

+ Puedes solicitar uno nuevo en recuperar cuenta. +

+
+ ) + } + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault() + if (busy || done) return + if (password.trim() === '' || password.trim() !== confPassword.trim()) { + setMessage({ ok: false, text: 'Las contraseñas no coinciden.' }) + return + } + setBusy(true) + setMessage(null) + + const body = new URLSearchParams() + body.set('token', token) + body.set('new-password', password.trim()) + body.set('conf-password', confPassword.trim()) + body.set('csrfmiddlewaretoken', csrfToken) + + try { + const resp = await fetch(resetUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'X-CSRFToken': csrfToken, + Accept: 'application/json', + }, + credentials: 'same-origin', + body: body.toString(), + }) + if (!resp.ok) throw new Error(`HTTP ${resp.status}`) + const data: { success?: boolean; message?: string; redirect?: string } = await resp.json() + setMessage({ ok: !!data.success, text: data.message || '' }) + if (data.success) { + setDone(true) + if (data.redirect) { + setTimeout(() => { + window.location.href = data.redirect as string + }, 2500) + } + } else { + setBusy(false) + } + } catch { + setMessage({ ok: false, text: 'Error en el servidor. Inténtalo de nuevo más tarde.' }) + setBusy(false) + } + } + + return ( +
+
+ + + + + + + + + + + + +
+ setPassword(e.target.value)} + /> + setShowPw((v) => !v)} + /> +
+ setConfPassword(e.target.value)} + /> +
+ +
+
+
+
+ {message && ( + {message.text} + )} +
+
+ ) +} diff --git a/frontend/src/entries/recover.tsx b/frontend/src/entries/recover.tsx new file mode 100644 index 0000000..d6d7726 --- /dev/null +++ b/frontend/src/entries/recover.tsx @@ -0,0 +1,12 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import { RecoverForm } from '../components/RecoverForm' + +const el = document.getElementById('recover-app') +if (el) { + createRoot(el).render( + + + , + ) +} diff --git a/frontend/src/entries/reset_password.tsx b/frontend/src/entries/reset_password.tsx new file mode 100644 index 0000000..5b99c09 --- /dev/null +++ b/frontend/src/entries/reset_password.tsx @@ -0,0 +1,18 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import { ResetPassword } from '../components/ResetPassword' + +const el = document.getElementById('reset-password-app') +if (el) { + createRoot(el).render( + + + , + ) +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 7526e6c..273e7e5 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -18,6 +18,8 @@ export default defineConfig({ login: resolve(__dirname, 'src/entries/login.tsx'), register: resolve(__dirname, 'src/entries/register.tsx'), select_account: resolve(__dirname, 'src/entries/select_account.tsx'), + recover: resolve(__dirname, 'src/entries/recover.tsx'), + reset_password: resolve(__dirname, 'src/entries/reset_password.tsx'), }, }, }, diff --git a/home/migrations/0014_passwordreset.py b/home/migrations/0014_passwordreset.py new file mode 100644 index 0000000..9f98f29 --- /dev/null +++ b/home/migrations/0014_passwordreset.py @@ -0,0 +1,24 @@ +# Generated by Django 6.0.7 on 2026-07-12 20:53 + +import django.utils.timezone +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('home', '0013_stripelog_fulfilled'), + ] + + operations = [ + migrations.CreateModel( + name='PasswordReset', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('email', models.EmailField(max_length=254)), + ('token', models.CharField(max_length=64, unique=True)), + ('created_at', models.DateTimeField(default=django.utils.timezone.now)), + ('used', models.BooleanField(default=False)), + ], + ), + ] diff --git a/home/models/accounts.py b/home/models/accounts.py index 6c1fb81..8148d72 100644 --- a/home/models/accounts.py +++ b/home/models/accounts.py @@ -28,6 +28,20 @@ class AccountActivation(models.Model): def __str__(self): return f"Activation for {self.username}" +class PasswordReset(models.Model): + """Token de restablecimiento de contraseña (cuenta Battle.net, por email).""" + email = models.EmailField() + token = models.CharField(max_length=64, unique=True) + created_at = models.DateTimeField(default=timezone.now) + used = models.BooleanField(default=False) + + def is_expired(self): + return timezone.now() > self.created_at + timezone.timedelta(hours=1) + + def __str__(self): + return f"PasswordReset for {self.email}" + + class SecurityToken(models.Model): user = models.ForeignKey(User, on_delete=models.CASCADE, null=True, blank=True) token = models.CharField(max_length=6) diff --git a/home/templates/auth/reset_password.html b/home/templates/auth/reset_password.html new file mode 100644 index 0000000..b0fcd47 --- /dev/null +++ b/home/templates/auth/reset_password.html @@ -0,0 +1,29 @@ + + + {% include 'partials/head.html' %} + {% include 'partials/header.html' %} + {% include 'partials/video.html' %} + {% load django_vite %} +
+
+
+
+

Restablecer contraseña

+
+
+ +
+ {% vite_asset 'src/entries/reset_password.tsx' %} +
+
+
+
+ {% include 'partials/social.html' %} + {% include 'partials/footer.html' %} + {% include 'partials/final.html' %} diff --git a/home/templates/emails/account_names.html b/home/templates/emails/account_names.html new file mode 100644 index 0000000..d734682 --- /dev/null +++ b/home/templates/emails/account_names.html @@ -0,0 +1,34 @@ + + + + + + + + +
+ + + + +
+

{{ NOMBRE_SERVIDOR }}

+

Tus cuentas de juego

+

Hola,

+

Estas son las cuentas de juego asociadas a tu cuenta Battle.net ({{ email }}). Recuerda: para iniciar sesión en la web usas tu correo electrónico.

+ + {% for a in accounts %} + + + + {% empty %} + + + + {% endfor %} +
{{ a.username }}
No hay cuentas de juego asociadas todavía.
+

Si no has solicitado esta información, ignora este correo.

+
+
+ + diff --git a/home/templates/emails/password_reset.html b/home/templates/emails/password_reset.html new file mode 100644 index 0000000..f81519f --- /dev/null +++ b/home/templates/emails/password_reset.html @@ -0,0 +1,29 @@ + + + + + + + + +
+ + + + +
+

{{ NOMBRE_SERVIDOR }}

+

Restablecer contraseña

+

Hola,

+

Hemos recibido una solicitud para restablecer la contraseña de la cuenta Battle.net asociada a {{ email }}.

+

Pulsa el botón para elegir una nueva contraseña. El enlace caduca en 1 hora.

+

+ Restablecer contraseña +

+

Si el botón no funciona, copia esta dirección en tu navegador:

+

{{ reset_link }}

+

Si no has solicitado este cambio, ignora este correo: tu contraseña no se modificará.

+
+
+ + diff --git a/home/templates/partials/recover_account.html b/home/templates/partials/recover_account.html index 867e53b..adb799f 100644 --- a/home/templates/partials/recover_account.html +++ b/home/templates/partials/recover_account.html @@ -1,3 +1,4 @@ +{% load django_vite %}
@@ -7,15 +8,15 @@
Si has olvidado la contraseña de la cuenta:

- Selecciona la opción 'Contraseña'.

-

- Escribe Nombre de usuario.

+

- Escribe el correo electrónico de la cuenta.

- Un correo será enviado con un enlace para generar una contraseña nueva.


- Si has olvidado el nombre de la cuenta: + Si has olvidado con qué cuentas de juego cuentas:

- Selecciona la opción 'Nombre de cuenta'.

- Escribe el correo electrónico.

- Un correo será enviado con todas las cuentas ligadas al correo.


- Si no has recibido en enlace de activación: + Si no has recibido el enlace de activación:

- Selecciona la opción 'Enlace de activación'.

- Escribe el correo electrónico.

- Un correo será enviado con el enlace de activación.

@@ -25,57 +26,15 @@

Solicitud de información

-

Escoge la opción acorde a la información que quieres recuperar

-
- -

- - - - - - - - - - -
-
- - -
-
+ +
+ {% vite_asset 'src/entries/recover.tsx' %}
-
\ No newline at end of file +
diff --git a/home/urls.py b/home/urls.py index 6338271..a588541 100644 --- a/home/urls.py +++ b/home/urls.py @@ -12,6 +12,7 @@ urlpatterns = [ path('create-account/', views.register_view, name='register'), path('novawow-realm/', views.novawow_realm_view, name='novawow_realm'), path('recover/', views.recover_account_view, name='recover'), + path('reset-password', views.reset_password_view, name='reset_password'), path('contact-us/', views.contact_us_view, name='contact_us'), path('legal-notice/', views.legal_notice_view, name='legal_notice'), path('terms-and-conditions/', views.terms_and_conditions_view, name='terms_and_conditions'), diff --git a/home/views/_base.py b/home/views/_base.py index c77094e..9e7dbec 100644 --- a/home/views/_base.py +++ b/home/views/_base.py @@ -35,6 +35,7 @@ from ..models import ( GuildRenameSettings, VoteSite, VoteLog, HomeApiPoints, UnstuckHistory, ReviveHistory, RenamePrice, CustomizePrice, ChangeRacePrice, ChangeFactionPrice, LevelUpPrice, GoldPrice, TransferPrice, Category, Item, StripeLog, LoginAttempt, Pedido, + PasswordReset, ) # Configuración del logger diff --git a/home/views/auth.py b/home/views/auth.py index d1d0c54..ca3019c 100644 --- a/home/views/auth.py +++ b/home/views/auth.py @@ -246,7 +246,115 @@ def activate_account_view(request): except AccountActivation.DoesNotExist: return render(request, 'auth/activation_invalid.html') def recover_account_view(request): + """Recuperación de cuenta (Battle.net, por email). 3 tipos: + password (enlace de reset), accountname (lista de cuentas), activation (reenvío). + Respuestas genéricas para no revelar si el correo existe (anti-enumeración). + """ + if request.method == 'POST': + 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): + return JsonResponse({'success': False, 'message': 'Introduce un correo de Gmail válido.'}) + + if rtype == 'password': + try: + with connections['acore_auth'].cursor() as cursor: + account = bnet.get_bnet_account_by_email(cursor, email) + except Exception as e: + logger.warning("recover(password): BD de cuentas no disponible: %s", e) + account = None + if account: + token = get_random_string(64) + PasswordReset.objects.create(email=email, token=token) + reset_link = f"{settings.URL_PRINCIPAL}/es/reset-password?token={token}" + enviar_correo( + subject=f'Restablecer contraseña - {settings.NOMBRE_SERVIDOR}', + to_email=email, + template='emails/password_reset.html', + context={'reset_link': reset_link, 'email': email, + 'NOMBRE_SERVIDOR': settings.NOMBRE_SERVIDOR, + 'URL_PRINCIPAL': settings.URL_PRINCIPAL}, + ) + return JsonResponse({'success': True, 'message': 'Si existe una cuenta con ese correo, te hemos enviado un enlace para restablecer la contraseña.'}) + + if rtype == 'accountname': + try: + with connections['acore_auth'].cursor() as cursor: + account = bnet.get_bnet_account_by_email(cursor, email) + game_accounts = bnet.get_game_accounts_for_bnet(cursor, account['id']) if account else [] + except Exception as e: + logger.warning("recover(accountname): BD de cuentas no disponible: %s", e) + account, game_accounts = None, [] + if account: + enviar_correo( + subject=f'Tus cuentas de juego - {settings.NOMBRE_SERVIDOR}', + to_email=email, + template='emails/account_names.html', + context={'email': email, 'accounts': game_accounts, + 'NOMBRE_SERVIDOR': settings.NOMBRE_SERVIDOR, + 'URL_PRINCIPAL': settings.URL_PRINCIPAL}, + ) + return JsonResponse({'success': True, 'message': 'Si existe una cuenta con ese correo, te hemos enviado la lista de cuentas asociadas.'}) + + if rtype == 'activation': + activation = AccountActivation.objects.filter( + email=email, old_email__isnull=True).order_by('-created_at').first() + if activation: + activation_link = f"{settings.URL_PRINCIPAL}/es/activate-account?act={activation.hash}" + enviar_correo( + subject=f'Activación de la cuenta {email} - {settings.NOMBRE_SERVIDOR}', + to_email=email, + template='emails/activation.html', + context={'username': email, 'password': activation.password, + 'activation_link': activation_link, + 'NOMBRE_SERVIDOR': settings.NOMBRE_SERVIDOR}, + ) + return JsonResponse({'success': True, 'message': 'Si hay una activación pendiente para ese correo, te hemos reenviado el enlace.'}) + + return JsonResponse({'success': False, 'message': 'Opción no válida.'}) + return render(request, 'auth/recover_account.html') + + +def reset_password_view(request): + """Fija una nueva contraseña a partir de un token de PasswordReset (por email). + Re-deriva el verifier SRP6 v2 de la cuenta Battle.net (igual que change_password).""" + if request.method == 'POST': + token = request.POST.get('token', '').strip() + new_password = request.POST.get('new-password', '').strip() + conf_password = request.POST.get('conf-password', '').strip() + + pr = PasswordReset.objects.filter(token=token, used=False).first() + if not pr or pr.is_expired(): + return JsonResponse({'success': False, 'message': 'El enlace no es válido o ha caducado.'}) + if not new_password or new_password != conf_password: + return JsonResponse({'success': False, 'message': 'Las contraseñas no coinciden.'}) + if len(new_password) > 16: + return JsonResponse({'success': False, 'message': 'La contraseña no debe exceder los 16 caracteres.'}) + + new_salt, new_verifier, srp_version = bnet.bnet_make_registration(pr.email, new_password) + try: + with connections['acore_auth'].cursor() as cursor: + account = bnet.get_bnet_account_by_email(cursor, pr.email) + if not account: + return JsonResponse({'success': False, 'message': 'No se ha encontrado la cuenta.'}) + cursor.execute( + "UPDATE battlenet_accounts SET srp_version = %s, salt = %s, verifier = %s WHERE id = %s", + [srp_version, new_salt, new_verifier, account['id']] + ) + except Exception as e: + logger.error("reset_password: error al actualizar la cuenta: %s", e) + return JsonResponse({'success': False, 'message': 'No se ha podido actualizar la contraseña. Inténtalo más tarde.'}) + pr.used = True + pr.save(update_fields=['used']) + return JsonResponse({'success': True, + 'message': 'Contraseña restablecida. Ya puedes iniciar sesión.', + 'redirect': reverse('login')}) + + token = request.GET.get('token', '').strip() + pr = PasswordReset.objects.filter(token=token, used=False).first() + valid = bool(pr and not pr.is_expired()) + return render(request, 'auth/reset_password.html', {'token': token, 'valid': valid}) def change_password_view(request): # Verificar si el usuario está autenticado mediante la sesión username = request.session.get('username')