Adapta el portal a 3.4.3 (cuentas Battle.net + selector de cuenta de juego)

- home/bnet.py: SRP6 v2 (PBKDF2-HMAC-SHA512) para battlenet_accounts y SRP6
  Grunt (SHA1) para las cuentas de juego, con helpers bnet<->game account
- login por email contra battlenet_accounts; selector de cuenta de juego
  cuando la cuenta bnet tiene varias (my_account redirige al selector)
- registro/activación crean battlenet_accounts + account enlazada (<bnetId>#1,
  battlenet_account/battlenet_index)
- cambio de contraseña sobre battlenet_accounts; cambio de email recalcula
  salt/verifier (el usuario SRP depende del email)
- AccountActivation: identidad por email (username/salt/verifier opcionales) + migración
- plantillas login/register por email + nueva plantilla del selector
- docs/MIGRACION_3.4.3.md con el detalle y los pasos de verificación

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-12 16:43:11 +00:00
parent b43b888f53
commit 215061776b
9 changed files with 598 additions and 135 deletions
+203 -122
View File
@@ -15,6 +15,7 @@ from django.views.decorators.csrf import csrf_exempt
from datetime import datetime, timedelta
from .zone_definitions import get_zone_name
from .ac_soap import execute_soap_command
from . import bnet
from django.utils import timezone
from .library_correo import enviar_correo
from django.utils.crypto import get_random_string
@@ -131,9 +132,9 @@ def formatear_fecha(fecha):
return f"{dia_semana} {fecha.day:02d} de {mes} del {fecha.year}"
# Clase de formulario de inicio de sesión
# Clase de formulario de inicio de sesión (Battle.net: login por email)
class LoginForm(forms.Form):
username = forms.CharField(max_length=32, required=True)
email = forms.EmailField(max_length=320, required=True)
password = forms.CharField(widget=forms.PasswordInput, required=True)
def sha1(data):
@@ -164,60 +165,96 @@ def calculate_srp6_verifier(username, password, salt):
# Convertir el resultado a hexadecimal en mayúsculas
return binascii.hexlify(verifier_bytes).decode('utf-8').upper()
def authenticate(username, password):
def authenticate(email, password):
"""Autentica una cuenta Battle.net por email (SRP6 v2)."""
try:
# Obtener el salt y el verifier de la base de datos
with connections['acore_auth'].cursor() as cursor:
cursor.execute("SELECT salt, verifier FROM account WHERE username = %s", [username])
result = cursor.fetchone()
if not result:
return False
salt, stored_verifier = result
# Convertir `salt` y `verifier` a formato hexadecimal si son bytes
if isinstance(salt, bytes):
salt = salt.hex()
if isinstance(stored_verifier, bytes):
stored_verifier = stored_verifier.hex()
# Calcular el verifier con el username y password proporcionados
calculated_verifier = calculate_srp6_verifier(username, password, salt)
# Comparar el verifier calculado con el almacenado en la base de datos
return calculated_verifier.strip().upper() == stored_verifier.strip().upper()
account = bnet.get_bnet_account_by_email(cursor, email)
if not account:
return None
if bnet.bnet_verify(account['email'], password, account['salt'], account['verifier']):
return account
return None
except Exception:
return False
return None
def _set_game_account_session(request, game_account):
"""Fija en sesión la cuenta de juego seleccionada (para las demás vistas)."""
request.session['username'] = game_account['username'].upper()
request.session['account_id'] = game_account['id']
@csrf_exempt
def login_view(request):
if request.method == 'POST':
form = LoginForm(request.POST)
if form.is_valid():
username = form.cleaned_data['username']
email = form.cleaned_data['email']
password = form.cleaned_data['password']
ip_address = request.META.get('REMOTE_ADDR') # Obtener la IP del cliente
ip_address = request.META.get('REMOTE_ADDR')
# Llamar a la función de autenticación
if authenticate(username, password):
# Guardar el intento exitoso
log_login_attempt(username, "Exito", ip_address)
account = authenticate(email, password)
if account:
log_login_attempt(email, "Exito", ip_address)
# Guardar la identidad Battle.net en la sesión
request.session['bnet_id'] = account['id']
request.session['bnet_email'] = account['email']
# Resolver las cuentas de juego enlazadas
with connections['acore_auth'].cursor() as cursor:
game_accounts = bnet.get_game_accounts_for_bnet(cursor, account['id'])
if len(game_accounts) == 1:
# Una sola cuenta de juego: seleccionar automáticamente
_set_game_account_session(request, game_accounts[0])
elif len(game_accounts) > 1:
# Varias: dejar la selección pendiente (my-account redirige al selector)
request.session.pop('username', None)
request.session.pop('account_id', None)
# 0 cuentas: se queda sin username; el selector mostrará el aviso
# Guardar el nombre de usuario en mayúsculas en la sesión
request.session['username'] = username.upper()
return JsonResponse({'success': True})
else:
# Guardar el intento fallido
log_login_attempt(username, "Fracaso", ip_address)
return JsonResponse({'success': False, 'error': "Usuario o contraseña incorrectos"})
log_login_attempt(email, "Fracaso", ip_address)
return JsonResponse({'success': False, 'error': "Correo o contraseña incorrectos"})
else:
return JsonResponse({'success': False, 'error': "Formulario no valido"})
form = LoginForm()
return render(request, 'auth/login.html', {'form': form})
def select_account_view(request):
"""Selector de cuenta de juego para cuentas Battle.net con varias."""
bnet_id = request.session.get('bnet_id')
if not bnet_id:
return redirect('login')
with connections['acore_auth'].cursor() as cursor:
game_accounts = bnet.get_game_accounts_for_bnet(cursor, bnet_id)
if request.method == 'POST':
try:
selected_id = int(request.POST.get('account_id', ''))
except (TypeError, ValueError):
selected_id = None
chosen = next((g for g in game_accounts if g['id'] == selected_id), None)
if chosen:
_set_game_account_session(request, chosen)
return redirect('my-account')
messages.error(request, 'Cuenta de juego no válida.')
# Autoseleccionar si solo hay una
if len(game_accounts) == 1:
_set_game_account_session(request, game_accounts[0])
return redirect('my-account')
return render(request, 'auth/select_account.html', {
'game_accounts': game_accounts,
'bnet_email': request.session.get('bnet_email'),
})
# Función para contar los personajes en línea
def get_online_characters_count():
with connections['acore_characters'].cursor() as cursor:
@@ -292,42 +329,46 @@ def server_status_view(request):
# Vista para cerrar sesión
def logout_view(request):
if 'username' in request.session:
del request.session['username']
for key in ('username', 'account_id', 'bnet_id', 'bnet_email'):
request.session.pop(key, None)
return redirect('index')
# Vistas adicionales
@csrf_exempt
def register_view(request):
if request.method == 'POST':
username = request.POST.get('username').strip()
password = request.POST.get('password').strip()
conf_password = request.POST.get('conf-password').strip()
email = request.POST.get('email').strip()
conf_email = request.POST.get('conf-email').strip()
recruiter = request.POST.get('recruiter').strip()
password = request.POST.get('password', '').strip()
conf_password = request.POST.get('conf-password', '').strip()
email = request.POST.get('email', '').strip()
conf_email = request.POST.get('conf-email', '').strip()
recruiter = request.POST.get('recruiter', '').strip()
# Validar entradas
if not username or not password or not email:
# En 3.4.3 la identidad es el email (cuenta Battle.net); no hay username.
if not password or not email:
return JsonResponse({'success': False, 'message': 'Por favor, complete todos los campos.'})
if len(username) > 17 or not username.isalnum():
return JsonResponse({'success': False, 'message': 'Nombre de usuario no válido.'})
if password != conf_password:
return JsonResponse({'success': False, 'message': 'Las contraseñas no coinciden.'})
if len(password) > 16:
return JsonResponse({'success': False, 'message': 'La contraseña no debe exceder los 16 caracteres.'})
if email != conf_email or not re.match(r'^[a-zA-Z0-9._%+-]+@gmail\.com$', email):
return JsonResponse({'success': False, 'message': 'El correo electrónico no es válido.'})
# Verificar si el usuario ya existe
# Verificar si ya existe una cuenta Battle.net con ese email
email_norm = bnet.normalize_email(email)
with connections['acore_auth'].cursor() as cursor:
cursor.execute("SELECT COUNT(*) FROM account WHERE username = %s", [username])
cursor.execute("SELECT COUNT(*) FROM battlenet_accounts WHERE email = %s", [email_norm])
if cursor.fetchone()[0] > 0:
return JsonResponse({'success': False, 'message': 'El nombre de usuario ya está en uso.'})
return JsonResponse({'success': False, 'message': 'Ya existe una cuenta con ese correo electrónico.'})
# Validar y obtener recruiter_id
recruiter_id = 0 # Valor por defecto si no se proporciona un reclutador
# Comprobar que no haya un registro de activación pendiente para ese email
if AccountActivation.objects.filter(email=email, old_email__isnull=True).exists():
AccountActivation.objects.filter(email=email, old_email__isnull=True).delete()
# Validar y obtener recruiter_id (id de cuenta de juego del reclutador)
recruiter_id = 0
if recruiter:
with connections['acore_auth'].cursor() as cursor:
cursor.execute("SELECT id FROM account WHERE username = %s", [recruiter])
@@ -343,24 +384,13 @@ def register_view(request):
else:
return JsonResponse({'success': False, 'message': 'El reclutador ingresado no existe.'})
# Generar salt y verifier
salt = binascii.hexlify(os.urandom(32)).decode('utf-8').upper()
verifier = calculate_srp6_verifier(username, password, salt)
# Convertir `salt` y `verifier` a bytes antes de guardarlos en la base de datos
salt_bytes = binascii.unhexlify(salt)
verifier_bytes = binascii.unhexlify(verifier)
# Generar un hash único para activación
# Generar un hash único para activación. Los salt/verifier se calculan al
# activar (tanto el de bnet como el de la cuenta de juego).
activation_hash = get_random_string(32)
# Guardar la información en la tabla `AccountActivation`
AccountActivation.objects.create(
username=username,
email=email,
password=password,
salt=salt_bytes,
verifier=verifier_bytes,
recruiter_id=recruiter_id,
hash=activation_hash
)
@@ -368,57 +398,82 @@ def register_view(request):
# Enviar correo de activación
activation_link = f"{settings.URL_PRINCIPAL}/es/activate-account?act={activation_hash}"
context = {
'username': username,
'username': email,
'password': password,
'activation_link': activation_link,
'NOMBRE_SERVIDOR': settings.NOMBRE_SERVIDOR
}
enviar_correo(
subject=f'Activación de la cuenta {username} - {settings.NOMBRE_SERVIDOR}',
subject=f'Activación de la cuenta {email} - {settings.NOMBRE_SERVIDOR}',
to_email=email,
template='emails/activation.html',
context=context
)
# Preparar el mensaje de respuesta
message = f"""
<div class="alert-message" id="create-response" style="display: block;">
<span class="ok-form-response">La cuenta '{username}' ha sido creada.</span><br><br>
<span class="ok-form-response">La cuenta '{email}' ha sido creada.</span><br><br>
<span>Se ha enviado un enlace de activación al correo {email}.</span>
</div>
"""
return JsonResponse({'success': True, 'message': message})
return render(request, 'auth/register.html')
def activate_account_view(request):
activation_hash = request.GET.get('act')
try:
activation = AccountActivation.objects.get(hash=activation_hash)
if activation.is_expired():
return render(request, 'auth/activation_invalid.html')
# Insertar la cuenta en `acore_auth`
with connections['acore_auth'].cursor() as cursor:
cursor.execute("""
INSERT INTO account (username, salt, verifier, email, reg_mail, recruiter, joindate, last_ip)
VALUES (%s, %s, %s, %s, %s, %s, NOW(), %s)
""", [
activation.username, activation.salt, activation.verifier,
activation.email, activation.email, activation.recruiter_id,
request.META.get('REMOTE_ADDR')
])
# Eliminar el registro de activación
activation.delete()
email_norm = bnet.normalize_email(activation.email)
password = activation.password
last_ip = request.META.get('REMOTE_ADDR')
with connections['acore_auth'].cursor() as cursor:
# Evitar duplicados si el enlace se usa dos veces
cursor.execute("SELECT id FROM battlenet_accounts WHERE email = %s", [email_norm])
existing = cursor.fetchone()
if existing:
activation.delete()
return render(request, 'auth/activation_success.html')
# 1) Crear la cuenta Battle.net (SRP6 v2)
bnet_salt, bnet_verifier, srp_version = bnet.bnet_make_registration(email_norm, password)
cursor.execute(
"INSERT INTO battlenet_accounts (email, srp_version, salt, verifier) "
"VALUES (%s, %s, %s, %s)",
[email_norm, srp_version, bnet_salt, bnet_verifier]
)
# 2) Recuperar el id de la cuenta bnet recién creada
cursor.execute("SELECT id FROM battlenet_accounts WHERE email = %s", [email_norm])
bnet_id = cursor.fetchone()[0]
# 3) Crear la cuenta de juego enlazada (<bnetId>#1, SRP6 Grunt)
game_username = bnet.make_game_account_username(bnet_id, 1)
game_salt, game_verifier = bnet.game_make_registration(game_username, password)
cursor.execute(
"INSERT INTO account "
"(username, salt, verifier, email, reg_mail, recruiter, joindate, "
" last_ip, expansion, battlenet_account, battlenet_index) "
"VALUES (%s, %s, %s, %s, %s, %s, NOW(), %s, %s, %s, %s)",
[
game_username, game_salt, game_verifier,
email_norm, email_norm, activation.recruiter_id or 0,
last_ip, 2, bnet_id, 1
]
)
activation.delete()
return render(request, 'auth/activation_success.html')
except AccountActivation.DoesNotExist:
return render(request, 'auth/activation_invalid.html')
return render(request, 'auth/activation_invalid.html')
def recover_account_view(request):
return render(request, 'auth/recover_account.html')
@@ -610,11 +665,14 @@ def get_recruited_friends_at_level_80(account_id, required_count):
def my_account(request):
# Verificar si la sesión contiene el usuario
# Verificar si la sesión contiene la cuenta de juego seleccionada
username = request.session.get('username')
# Si la sesión no tiene el usuario, redirigir al inicio de sesión
# Si aún no se ha elegido cuenta de juego pero hay login Battle.net,
# enviar al selector; si no, al inicio de sesión.
if not username:
if request.session.get('bnet_id'):
return redirect('select-account')
return redirect('login')
is_logged_in = True # Si el usuario está en la sesión, asumimos que está conectado
@@ -868,27 +926,29 @@ def change_password_view(request):
if existing_token.token != security_token:
return JsonResponse({'success': False, 'message': '<span class="red-form-response">El token ingresado es incorrecto.</span>'})
# Verificar la contraseña actual usando la función `authenticate`
if not authenticate(username, current_password):
# Verificar la contraseña actual contra la cuenta Battle.net (por email)
if not authenticate(email, current_password):
return JsonResponse({'success': False, 'message': '<span class="red-form-response">La contraseña actual es incorrecta.</span>'})
# Generar salt y verifier para la nueva contraseña
new_salt = binascii.hexlify(os.urandom(32)).decode('utf-8').upper()
new_verifier = calculate_srp6_verifier(username, new_password, new_salt)
# Generar salt y verifier SRP6 v2 para la nueva contraseña (bnet)
new_salt_bytes, new_verifier_bytes, srp_version = bnet.bnet_make_registration(email, new_password)
# Convertir `salt` y `verifier` a bytes antes de guardarlos en la base de datos
new_salt_bytes = binascii.unhexlify(new_salt)
new_verifier_bytes = binascii.unhexlify(new_verifier)
# Actualizar la contraseña en la base de datos
# Localizar la cuenta bnet (por sesión o por email) y actualizarla
bnet_id = request.session.get('bnet_id')
with connections['acore_auth'].cursor() as cursor:
cursor.execute("""
UPDATE account SET salt = %s, verifier = %s WHERE username = %s
""", [new_salt_bytes, new_verifier_bytes, username])
if not bnet_id:
account = bnet.get_bnet_account_by_email(cursor, email)
bnet_id = account['id'] if account else None
if not bnet_id:
return JsonResponse({'success': False, 'message': '<span class="red-form-response">No se ha encontrado la cuenta.</span>'})
cursor.execute(
"UPDATE battlenet_accounts SET srp_version = %s, salt = %s, verifier = %s WHERE id = %s",
[srp_version, new_salt_bytes, new_verifier_bytes, bnet_id]
)
# Cerrar la sesión por seguridad
if 'username' in request.session:
del request.session['username']
for key in ('username', 'account_id', 'bnet_id', 'bnet_email'):
request.session.pop(key, None)
# Redirigir al usuario a la página de inicio de sesión
return JsonResponse({
@@ -1051,21 +1111,22 @@ def change_email_view(request):
if not security_token or security_token.token != token:
return JsonResponse({'success': False, 'message': '<span class="red-form-response">Token de seguridad incorrecto.</span>'})
if not authenticate(username, current_password):
# Verificar la contraseña contra la cuenta Battle.net (por email actual)
if not authenticate(current_email, current_password):
return JsonResponse({'success': False, 'message': '<span class="red-form-response">Contraseña incorrecta.</span>'})
# Generar hashes
old_email_hash = secrets.token_urlsafe(16)
new_email_hash = secrets.token_urlsafe(16)
# Guardar en `AccountActivation`
# Guardar en `AccountActivation`. Se conserva la contraseña porque al
# cambiar el email hay que recalcular el verifier de bnet (el usuario SRP
# depende del email).
AccountActivation.objects.create(
username=username,
email=new_email,
old_email=current_email,
password='',
salt=b'',
verifier=b'',
password=current_password,
recruiter_id=user_id,
hash=new_email_hash,
old_email_hash=old_email_hash
@@ -1136,13 +1197,33 @@ def confirm_new_email_view(request):
activation.is_new_email_used = True
activation.save()
# Actualizar el correo en la base de datos
# Cambiar el email en la cuenta Battle.net implica recalcular salt/verifier,
# porque el usuario SRP = SHA256(UPPER(email)).
new_email_norm = bnet.normalize_email(activation.email)
old_email_norm = bnet.normalize_email(activation.old_email or '')
new_salt, new_verifier, srp_version = bnet.bnet_make_registration(new_email_norm, activation.password)
with connections['acore_auth'].cursor() as cursor:
cursor.execute("""
UPDATE account
SET email = %s, reg_mail = %s
WHERE username = %s
""", [activation.email, activation.email, activation.username])
# Localizar la cuenta bnet por su email actual (el antiguo)
cursor.execute("SELECT id FROM battlenet_accounts WHERE email = %s", [old_email_norm])
row = cursor.fetchone()
if not row:
return JsonResponse({
'success': False,
'message': '<span class="red-form-response">No se ha encontrado la cuenta Battle.net.</span>',
})
bnet_id = row[0]
# Actualizar email + verifier en battlenet_accounts
cursor.execute(
"UPDATE battlenet_accounts SET email = %s, srp_version = %s, salt = %s, verifier = %s WHERE id = %s",
[new_email_norm, srp_version, new_salt, new_verifier, bnet_id]
)
# Actualizar el email de las cuentas de juego enlazadas (cosmético)
cursor.execute(
"UPDATE account SET email = %s, reg_mail = %s WHERE battlenet_account = %s",
[new_email_norm, new_email_norm, bnet_id]
)
# Enviar notificación al correo anterior
context_old_email = {
@@ -2390,9 +2471,9 @@ def rename_guild_view(request):
if not old_guild_name or not new_guild_name or not conf_new_guild_name or not current_password or not token:
return JsonResponse({'success': False, 'message': '<span class="red-form-response">Por favor, completa todos los campos.</span>'})
if not authenticate(username, current_password):
return JsonResponse({'success': False, 'message': '<span class="red-form-response">La contraseña actual es incorrecta.</span>'})
if not authenticate(email, current_password):
return JsonResponse({'success': False, 'message': '<span class="red-form-response">La contraseña actual es incorrecta.</span>'})
if not security_token or security_token.token != token:
return JsonResponse({'success': False, 'message': '<span class="red-form-response">Token de seguridad incorrecto.</span>'})