Implementa la recuperación de cuenta (contraseña / cuentas / activación) + islas

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) <noreply@anthropic.com>
This commit is contained in:
2026-07-12 21:00:42 +00:00
parent 45f48c2799
commit 8920585b24
14 changed files with 511 additions and 52 deletions
+1
View File
@@ -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
+108
View File
@@ -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')