diff --git a/docs/MIGRACION_3.4.3.md b/docs/MIGRACION_3.4.3.md new file mode 100644 index 0000000..35d9ad8 --- /dev/null +++ b/docs/MIGRACION_3.4.3.md @@ -0,0 +1,94 @@ +# Migración del portal 3.3.5a → 3.4.3 (cuentas Battle.net) + +Este documento describe los cambios hechos para adaptar el portal (antes +AzerothCore 3.3.5a) a un servidor **TrinityCore 3.4.3** con **cuentas +Battle.net** (login por email + cuentas de juego + selector). + +## Resumen del cambio de modelo + +| Concepto | 3.3.5a (antes) | 3.4.3 (ahora) | +|---|---|---| +| Login | `username` en `account` | **email** en `battlenet_accounts` | +| Contraseña | SRP6 Grunt (SHA1) | SRP6 **v2** (PBKDF2-HMAC-SHA512) para bnet | +| Cuenta de juego | la propia `account` | 1..N filas en `account` enlazadas por `battlenet_account` + `battlenet_index` | +| Nombre de cuenta de juego | elegido por el usuario | generado: `#` (p.ej. `5#1`) | +| Selector | — | sí, si la cuenta bnet tiene varias cuentas de juego | + +Un registro crea **dos filas**: una en `battlenet_accounts` y otra en `account`. + +## Algoritmos SRP6 (`home/bnet.py`) + +- **`battlenet_accounts` (SRP6 v2)** — replica `BnetSRP6v2` del source: + - `N` de 2048 bits, `g = 2`, salt de 32 bytes. + - usuario SRP = `HEX(SHA256(UPPER(email)))`. + - `x = PBKDF2-HMAC-SHA512(usuario + ":" + password, salt, 15000, 64)`, con + ajuste del bit alto y `x %= (N-1)`. + - `verifier = g^x mod N`, almacenado en little-endian de longitud mínima. + - `srp_version = 2`, `email` en MAYÚSCULAS. +- **`account` (SRP6 Grunt/SHA1)** — el clásico de 3.3.5a, reutilizado para la + cuenta de juego. Username `#` y password (16, mayúsculas). + +> ⚠️ **Verificación pendiente (requiere el servidor real):** el orden de bytes +> del `verifier` de bnet se ha fijado a little-endian según el source. El login +> del PORTAL funciona siempre (registra y verifica igual). Para confirmar que el +> CLIENTE 3.4.3 puede conectar con una cuenta creada por el portal, crea una +> cuenta desde la web y prueba a entrar en el juego. Si fallara, cambia +> `_BNET_VERIFIER_LITTLE_ENDIAN = False` en `home/bnet.py`. Alternativa: compara +> los bytes de `salt`/`verifier` de una cuenta creada con `.bnetaccount create` +> frente a una creada por el portal con el mismo email+password. + +## Cambios en el código + +- `home/bnet.py` (NUEVO): SRP6 v2 + Grunt y helpers bnet↔cuentas de juego. +- `home/views.py`: + - `login_view`: login por **email** contra `battlenet_accounts`; resuelve las + cuentas de juego (autoselecciona si hay una, deja pendiente si hay varias). + - `select_account_view` (NUEVO) + `auth/select_account.html`: selector. + - `my_account`: si hay login bnet sin cuenta de juego elegida → selector. + - `register_view` / `activate_account_view`: crean bnet + cuenta de juego. + - `change_password_view`: cambia la contraseña en `battlenet_accounts`. + - `change_email_view` / `confirm_new_email_view`: cambian el email en + `battlenet_accounts` **recalculando** salt/verifier (dependen del email). + - `logout_view`: limpia `username`, `account_id`, `bnet_id`, `bnet_email`. +- `home/models.py`: `AccountActivation` con `username`/`salt`/`verifier` + opcionales (la identidad es el email). Migración `0012_*`. +- Plantillas: `partials/login.html` (campo email) y `partials/register.html` + (registro por email, sin username). + +### Sesión + +Tras elegir cuenta de juego se guardan en sesión, **igual que antes**: +`request.session['username']` (= `#`) y `account_id`. Por eso las +~60 vistas de tienda/personajes/puntos siguen funcionando sin cambios. Además se +guardan `bnet_id` y `bnet_email` para cambios de contraseña/email. + +## Requisitos de esquema (BD `auth`) + +- `battlenet_accounts`: `id, email, srp_version, salt, verifier, ...` + (columnas base de TrinityCore). Custom del source: `battletag`, + `trial_expiry`, `tournament_expiry`, `game_time_expiry`, `battlePayCredits`, + `voteCredits`. +- `account`: debe tener `battlenet_account` y `battlenet_index` (además de + `username, salt, verifier, email, reg_mail, expansion, recruiter, ...`). + +## Pasos para desplegar + +1. Aplicar la migración del portal: + ```bash + python manage.py migrate home + ``` +2. Apuntar `DB_NAME_AUTH` al `auth` del servidor 3.4.3. +3. Registrar una cuenta desde la web y comprobar el email de activación. +4. Activar y **probar login en el cliente 3.4.3** (ver nota de verificación). + +## Pendiente / opcional + +- **Battlepay/SumUp**: el source trae `battlePayCredits` (en `battlenet_accounts`) + y las tablas `battlepay_orders` / `battlepay_price` (`sql/custom/battlepay_sumup.sql`). + Ojo: `battlepay_orders.account_id` es el **id de cuenta de juego** + (`account.id`), mientras que `battlePayCredits` está en la **cuenta bnet** + (`battlenet_accounts.id`); se unen por `account.battlenet_account`. La + integración de la pasarela con estas tablas se puede abordar como paso + siguiente. +- Mostrar el email (en lugar de `#1`) como nombre visible en + «Mi cuenta» es un retoque cosmético opcional. diff --git a/home/bnet.py b/home/bnet.py new file mode 100644 index 0000000..c5afba6 --- /dev/null +++ b/home/bnet.py @@ -0,0 +1,204 @@ +""" +Utilidades de autenticación para servidores 3.4.3 (TrinityCore) con cuentas +Battle.net. + +Dos algoritmos SRP6 distintos conviven: + + * ``battlenet_accounts`` -> SRP6 **v2**: PBKDF2-HMAC-SHA512, N de 2048 bits, + g = 2. El "usuario" SRP es HEX(SHA256(UPPER(email))). Es el que usa el + cliente 3.4.3 para conectarse (login por email). + + * ``account`` (cuenta de juego) -> SRP6 **Grunt/SHA1**: el clásico de + AzerothCore/3.3.5a. Cada cuenta bnet tiene 1..N cuentas de juego con + username ``#``. + +Referencias del propio source 3.4.3: + src/common/Cryptography/Authentication/SRP6.cpp (CalculateX / CalculateVerifier) + src/server/game/Accounts/BattlenetAccountMgr.cpp (CreateBattlenetAccount) +""" + +import hashlib +import os + +# --------------------------------------------------------------------------- +# SRP6 v2 (battlenet_accounts) — PBKDF2-HMAC-SHA512 +# --------------------------------------------------------------------------- + +# BnetSRP6v2Base::N (2048 bits) y g, tal cual en SRP6.cpp +_BNET_N = int( + "AC6BDB41324A9A9BF166DE5E1389582FAF72B6651987EE07FC3192943DB56050" + "A37329CBB4A099ED8193E0757767A13DD52312AB4B03310DCD7F48A9DA04FD50" + "E8083969EDB767B0CF6095179A163AB3661A05FBD5FAAAE82918A9962F0B93B8" + "55F97993EC975EEAA80D740ADBF4FF747359D041D5C33EA71D281E446B14773B" + "CA97B43A23FB801676BD207A436C6481F1D2B9078717461A5B9D32E688F87748" + "544523B524B0D57D5EA77A2775D2ECFA032CFBDBF52FB3786160279004E57AE6" + "AF874E7303CE53299CCC041C7BC308D82A5698F3A8D0C38271AE35F8E9DBFBB6" + "94B5C803D89F7AE435DE236D525F54759B65E372FCD68EF20FA7111F9E4AFF73", + 16, +) +_BNET_g = 2 +_BNET_SALT_LEN = 32 +_BNET_ITERATIONS = 15000 # GetXIterations() para v2 +_BNET_SRP_VERSION = 2 +_TWO_POW_512 = 1 << 512 + +# TrinityCore almacena el verifier con BigNumber::ToByteVector(), que por +# defecto es little-endian. Si al comparar contra una cuenta creada por el +# propio servidor (`.bnetaccount create`) el login del CLIENTE fallara, prueba a +# poner esto en False (big-endian). El login del PORTAL funciona en cualquier +# caso porque registra y verifica con el mismo criterio. +_BNET_VERIFIER_LITTLE_ENDIAN = True + + +def bnet_srp_username(email): + """SRP username de bnet = HEX(SHA256(UPPER(email))) en mayúsculas.""" + email_up = email.strip().upper() + return hashlib.sha256(email_up.encode("utf-8")).hexdigest().upper() + + +def _bnet_calculate_x(email, password, salt): + """Replica BnetSRP6v2Base::CalculateX (SRP6.cpp).""" + srp_user = bnet_srp_username(email) + tmp = (srp_user + ":" + password).encode("utf-8") + x_bytes = hashlib.pbkdf2_hmac("sha512", tmp, salt, _BNET_ITERATIONS, dklen=64) + x = int.from_bytes(x_bytes, "big") + if x_bytes[0] & 0x80: # si el bit alto está puesto, resta 2^512 + x -= _TWO_POW_512 + return x % (_BNET_N - 1) + + +def _int_to_verifier_bytes(value): + """Emula BigNumber::ToByteVector(): longitud mínima, endianness configurable.""" + length = max(1, (value.bit_length() + 7) // 8) + order = "little" if _BNET_VERIFIER_LITTLE_ENDIAN else "big" + return value.to_bytes(length, order) + + +def _verifier_bytes_to_int(data): + order = "little" if _BNET_VERIFIER_LITTLE_ENDIAN else "big" + return int.from_bytes(data, order) + + +def bnet_make_registration(email, password): + """ + Genera (salt, verifier, srp_version) para una nueva cuenta Battle.net. + + salt: 32 bytes aleatorios (se guarda tal cual). + verifier: g^x mod N en longitud mínima (como TrinityCore::ToByteVector). + """ + salt = os.urandom(_BNET_SALT_LEN) + x = _bnet_calculate_x(email, password, salt) + v = pow(_BNET_g, x, _BNET_N) + return salt, _int_to_verifier_bytes(v), _BNET_SRP_VERSION + + +def bnet_verify(email, password, salt, stored_verifier): + """Comprueba la contraseña de una cuenta Battle.net (login por email).""" + if salt is None or stored_verifier is None: + return False + if isinstance(salt, memoryview): + salt = bytes(salt) + if isinstance(stored_verifier, memoryview): + stored_verifier = bytes(stored_verifier) + x = _bnet_calculate_x(email, password, salt) + v = pow(_BNET_g, x, _BNET_N) + # Comparación numérica (robusta frente a padding). + return v == _verifier_bytes_to_int(stored_verifier) + + +# --------------------------------------------------------------------------- +# SRP6 Grunt/SHA1 (cuenta de juego `account`) — el clásico de 3.3.5a +# --------------------------------------------------------------------------- + +_GRUNT_g = 7 +_GRUNT_N = int("894B645E89E1535BBDAD5B8B290650530801B18EBFBF5E8FAB3C82872A3E9BB7", 16) +_GRUNT_SALT_LEN = 32 + + +def _sha1(data): + return hashlib.sha1(data).digest() + + +def game_calculate_verifier(username, password, salt): + """ + SRP6 Grunt (SHA1) para la tabla `account`. ``username`` y ``password`` se + pasan en mayúsculas (TrinityCore hace Utf8ToUpperOnlyLatin en ambos). + Devuelve el verifier en bytes little-endian (32). + """ + if isinstance(salt, memoryview): + salt = bytes(salt) + h1 = _sha1((username.upper() + ":" + password.upper()).encode("utf-8")) + h2 = _sha1(salt + h1) + h2_int = int.from_bytes(h2, "little") + verifier = pow(_GRUNT_g, h2_int, _GRUNT_N) + return verifier.to_bytes(32, "little") + + +def game_make_registration(username, password): + """Genera (salt, verifier) Grunt para una cuenta de juego nueva.""" + salt = os.urandom(_GRUNT_SALT_LEN) + verifier = game_calculate_verifier(username, password[:16], salt) + return salt, verifier + + +def game_verify(username, password, salt, stored_verifier): + """Comprueba la contraseña de una cuenta de juego (SRP6 Grunt).""" + if salt is None or stored_verifier is None: + return False + if isinstance(stored_verifier, memoryview): + stored_verifier = bytes(stored_verifier) + calc = game_calculate_verifier(username, password[:16], salt) + return int.from_bytes(calc, "little") == int.from_bytes(stored_verifier, "little") + + +# --------------------------------------------------------------------------- +# Helpers de acceso a la BD auth (bnet <-> cuentas de juego) +# --------------------------------------------------------------------------- + +def normalize_email(email): + """El email en battlenet_accounts se almacena en MAYÚSCULAS.""" + return email.strip().upper() + + +def get_bnet_account_by_email(cursor, email): + """Devuelve dict {id, email, srp_version, salt, verifier} o None.""" + cursor.execute( + "SELECT id, email, srp_version, salt, verifier " + "FROM battlenet_accounts WHERE email = %s", + [normalize_email(email)], + ) + row = cursor.fetchone() + if not row: + return None + return { + "id": row[0], + "email": row[1], + "srp_version": row[2], + "salt": row[3], + "verifier": row[4], + } + + +def get_game_accounts_for_bnet(cursor, bnet_id): + """Lista de cuentas de juego de una cuenta bnet: [{id, username, index}].""" + cursor.execute( + "SELECT id, username, battlenet_index " + "FROM account WHERE battlenet_account = %s ORDER BY battlenet_index", + [bnet_id], + ) + return [{"id": r[0], "username": r[1], "index": r[2]} for r in cursor.fetchall()] + + +def get_next_battlenet_index(cursor, bnet_id): + """Siguiente battlenet_index libre para una cuenta bnet (1-based).""" + cursor.execute( + "SELECT MAX(battlenet_index) FROM account WHERE battlenet_account = %s", + [bnet_id], + ) + row = cursor.fetchone() + return (row[0] or 0) + 1 + + +def make_game_account_username(bnet_id, index=1): + """Nombre de la cuenta de juego: ``#`` (como TrinityCore).""" + return f"{bnet_id}#{index}" diff --git a/home/migrations/0012_alter_accountactivation_salt_and_more.py b/home/migrations/0012_alter_accountactivation_salt_and_more.py new file mode 100644 index 0000000..0127f10 --- /dev/null +++ b/home/migrations/0012_alter_accountactivation_salt_and_more.py @@ -0,0 +1,28 @@ +# Generated by Django 6.0.7 on 2026-07-12 16:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('home', '0011_pedido_alter_guildrenamesettings_options_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='accountactivation', + name='salt', + field=models.BinaryField(blank=True, max_length=32, null=True), + ), + migrations.AlterField( + model_name='accountactivation', + name='username', + field=models.CharField(blank=True, max_length=17, null=True), + ), + migrations.AlterField( + model_name='accountactivation', + name='verifier', + field=models.BinaryField(blank=True, max_length=256, null=True), + ), + ] diff --git a/home/models.py b/home/models.py index 732bc5d..9b1218c 100644 --- a/home/models.py +++ b/home/models.py @@ -194,12 +194,17 @@ from django.db import models from django.utils import timezone class AccountActivation(models.Model): - username = models.CharField(max_length=17) + # En 3.4.3 (Battle.net) la identidad es el email; el username de la cuenta + # de juego se genera al activar (#1), por eso es opcional aquí. + username = models.CharField(max_length=17, null=True, blank=True) email = models.EmailField() old_email = models.EmailField(null=True, blank=True) password = models.CharField(max_length=64) - salt = models.BinaryField(max_length=32) - verifier = models.BinaryField(max_length=32) + # salt/verifier ya no se precalculan en el registro (se calculan al activar, + # tanto el de bnet como el de la cuenta de juego). Se conservan opcionales + # por compatibilidad con el flujo de cambio de email. + salt = models.BinaryField(max_length=32, null=True, blank=True) + verifier = models.BinaryField(max_length=256, null=True, blank=True) recruiter_id = models.IntegerField(null=True, blank=True) hash = models.CharField(max_length=32, unique=True) old_email_hash = models.CharField(max_length=32, null=True, blank=True) diff --git a/home/templates/auth/select_account.html b/home/templates/auth/select_account.html new file mode 100644 index 0000000..4f5ac19 --- /dev/null +++ b/home/templates/auth/select_account.html @@ -0,0 +1,53 @@ + + + {% include 'partials/head.html' %} + {% include 'partials/header.html' %} + {% include 'partials/video.html' %} + +
+
+
+
+

Selecciona tu cuenta de juego

+
+
+
+ {% if bnet_email %} +

Conectado como {{ bnet_email }}

+
+ {% endif %} + + {% if game_accounts %} +

Tu cuenta Battle.net tiene varias cuentas de juego. Elige con cuál quieres continuar:

+
+
+ {% csrf_token %} + + {% for account in game_accounts %} + + + + {% endfor %} +
+ +
+
+ {% else %} +

No se han encontrado cuentas de juego asociadas a esta cuenta Battle.net.

+
+

Contacta con el soporte si crees que es un error.

+ {% endif %} + +
+

Cerrar sesión

+
+
+
+
+
+ + {% include 'partials/social.html' %} + {% include 'partials/footer.html' %} + {% include 'partials/final.html' %} diff --git a/home/templates/partials/login.html b/home/templates/partials/login.html index ec8a92a..30d21df 100644 --- a/home/templates/partials/login.html +++ b/home/templates/partials/login.html @@ -27,7 +27,7 @@ {% csrf_token %} - +
diff --git a/home/templates/partials/register.html b/home/templates/partials/register.html index 0616d42..e83bcfe 100644 --- a/home/templates/partials/register.html +++ b/home/templates/partials/register.html @@ -5,13 +5,13 @@

Creación de cuenta

-

El usuario debe ser alfanumérico y con una longitud de hasta 17 caracteres.

+

Tu cuenta es de tipo Battle.net: se identifica con tu correo electrónico.

La contraseña debe ser alfanumérica y con una longitud de hasta 16 caracteres.

-

El correo debe pertenecer a gmail y con acceso al mismo. Un mismo correo puede usarse para registrar hasta 10 cuentas.

+

El correo debe pertenecer a gmail y con acceso al mismo.


Una vez que la cuenta se haya creado exitosamente, se enviará un correo con un enlace de activación.

De no usar el enlace de activación, la cuenta no podrá usarse para conectar al servidor.

-

Al iniciar sesión, ingresa con el NOMBRE de la cuenta.

+

Al iniciar sesión, ingresa con tu correo electrónico.


La cuenta del foro es independiente y se puede crear aquí.

@@ -37,13 +37,13 @@

Creación de cuenta

-

El usuario debe ser alfanumérico y con una longitud de hasta 17 caracteres.

+

Tu cuenta es de tipo Battle.net: se identifica con tu correo electrónico.

La contraseña debe ser alfanumérica y con una longitud de hasta 16 caracteres.

-

El correo debe pertenecer a gmail y con acceso al mismo. Un mismo correo puede usarse para registrar hasta 10 cuentas.

+

El correo debe pertenecer a gmail y con acceso al mismo.


Una vez que la cuenta se haya creado exitosamente, se enviará un correo con un enlace de activación.

De no usar el enlace de activación, la cuenta no podrá usarse para conectar al servidor.

-

Al iniciar sesión, ingresa con el NOMBRE de la cuenta.

+

Al iniciar sesión, ingresa con tu correo electrónico.


La cuenta del foro es independiente y se puede crear aquí.

@@ -58,9 +58,6 @@ - - - diff --git a/home/urls.py b/home/urls.py index 711a426..6c82b25 100644 --- a/home/urls.py +++ b/home/urls.py @@ -8,6 +8,7 @@ urlpatterns = [ path('content-creators/', views.content_creators_view, name='content_creators'), path('log-in/', views.login_view, name='login'), path('log-out/', views.logout_view, name='log-out'), + path('select-account/', views.select_account_view, name='select-account'), 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'), diff --git a/home/views.py b/home/views.py index 7719538..192950b 100644 --- a/home/views.py +++ b/home/views.py @@ -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"""
- La cuenta '{username}' ha sido creada.

+ La cuenta '{email}' ha sido creada.

Se ha enviado un enlace de activación al correo {email}.
""" 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 (#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': 'El token ingresado es incorrecto.'}) - # 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': 'La contraseña actual es incorrecta.'}) - # 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': '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_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': 'Token de seguridad incorrecto.'}) - 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': 'Contraseña incorrecta.'}) # 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': 'No se ha encontrado la cuenta Battle.net.', + }) + 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': 'Por favor, completa todos los campos.'}) - - if not authenticate(username, current_password): - return JsonResponse({'success': False, 'message': 'La contraseña actual es incorrecta.'}) + + if not authenticate(email, current_password): + return JsonResponse({'success': False, 'message': 'La contraseña actual es incorrecta.'}) if not security_token or security_token.token != token: return JsonResponse({'success': False, 'message': 'Token de seguridad incorrecto.'})