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
+94
View File
@@ -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: `<bnetId>#<index>` (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<SHA256>` 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 `<bnetId>#<index>` 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']` (= `<bnetId>#<index>`) 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 `<bnetId>#1`) como nombre visible en
«Mi cuenta» es un retoque cosmético opcional.
+204
View File
@@ -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 ``<bnetId>#<index>``.
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: ``<bnetId>#<index>`` (como TrinityCore)."""
return f"{bnet_id}#{index}"
@@ -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),
),
]
+8 -3
View File
@@ -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 (<bnetId>#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)
+53
View File
@@ -0,0 +1,53 @@
<!DOCTYPE html>
{% include 'partials/head.html' %}
{% include 'partials/header.html' %}
{% include 'partials/video.html' %}
<div class="main-page">
<div class="middle-content">
<div class="body-content">
<div class="title-content">
<h1>Selecciona tu cuenta de juego</h1>
</div>
<div class="box-content">
<div class="body-box-content centered">
{% if bnet_email %}
<p>Conectado como <strong>{{ bnet_email }}</strong></p>
<br>
{% endif %}
{% if game_accounts %}
<p>Tu cuenta Battle.net tiene varias cuentas de juego. Elige con cuál quieres continuar:</p>
<br>
<form action="{% url 'select-account' %}" method="POST" accept-charset="utf-8">
{% csrf_token %}
<table class="middle-center-table">
{% for account in game_accounts %}
<tr>
<td>
<button type="submit" name="account_id" value="{{ account.id }}" class="login-button">
{{ account.username }}
</button>
</td>
</tr>
{% endfor %}
</table>
</form>
{% else %}
<p>No se han encontrado cuentas de juego asociadas a esta cuenta Battle.net.</p>
<br>
<p>Contacta con el soporte si crees que es un error.</p>
{% endif %}
<hr>
<p><a href="log-out">Cerrar sesión</a></p>
</div>
</div>
</div>
</div>
</div>
{% include 'partials/social.html' %}
{% include 'partials/footer.html' %}
{% include 'partials/final.html' %}
+1 -1
View File
@@ -27,7 +27,7 @@
{% csrf_token %}
<table class="middle-center-table">
<tr>
<td><input type="text" maxlength="20" name="username" id="username" placeholder="Nombre de usuario" autofocus required></td>
<td><input type="email" maxlength="320" name="email" id="username" placeholder="Correo electrónico" autofocus required></td>
</tr>
<tr>
<td>
+6 -9
View File
@@ -5,13 +5,13 @@
<div class="title-content"><h1>Creación de cuenta</h1></div>
<div class="box-content" id="focus" tabindex="1">
<div class="body-box-content info-box-light justified">
<p>El usuario debe ser alfanumérico y con una longitud de hasta 17 caracteres.</p>
<p>Tu cuenta es de tipo Battle.net: se identifica con tu <strong>correo electrónico</strong>.</p>
<p>La contraseña debe ser alfanumérica y con una longitud de hasta 16 caracteres.</p>
<p>El correo debe pertenecer a gmail y con acceso al mismo. Un mismo correo puede usarse para registrar hasta 10 cuentas.</p>
<p>El correo debe pertenecer a gmail y con acceso al mismo.</p>
<br>
<p>Una vez que la cuenta se haya creado exitosamente, se enviará un correo con un enlace de activación.</p>
<p>De no usar el enlace de activación, la cuenta no podrá usarse para conectar al servidor.</p>
<p><i class="fas fa-exclamation-triangle"></i> Al iniciar sesión, ingresa con el NOMBRE de la cuenta.</p>
<p><i class="fas fa-exclamation-triangle"></i> Al iniciar sesión, ingresa con tu <strong>correo electrónico</strong>.</p>
<br>
<p>La cuenta del foro es independiente y se puede crear <a href="https://foro.novawow.com/register" target="_blank" rel="noopener noreferrer">aquí</a>.</p>
</div>
@@ -37,13 +37,13 @@
<div class="title-content"><h1>Creación de cuenta</h1></div>
<div class="box-content" id="focus" tabindex="1">
<div class="body-box-content info-box-light justified">
<p>El usuario debe ser alfanumérico y con una longitud de hasta 17 caracteres.</p>
<p>Tu cuenta es de tipo Battle.net: se identifica con tu <strong>correo electrónico</strong>.</p>
<p>La contraseña debe ser alfanumérica y con una longitud de hasta 16 caracteres.</p>
<p>El correo debe pertenecer a gmail y con acceso al mismo. Un mismo correo puede usarse para registrar hasta 10 cuentas.</p>
<p>El correo debe pertenecer a gmail y con acceso al mismo.</p>
<br>
<p>Una vez que la cuenta se haya creado exitosamente, se enviará un correo con un enlace de activación.</p>
<p>De no usar el enlace de activación, la cuenta no podrá usarse para conectar al servidor.</p>
<p><i class="fas fa-exclamation-triangle"></i> Al iniciar sesión, ingresa con el NOMBRE de la cuenta.</p>
<p><i class="fas fa-exclamation-triangle"></i> Al iniciar sesión, ingresa con tu <strong>correo electrónico</strong>.</p>
<br>
<p>La cuenta del foro es independiente y se puede crear <a href="https://foro.novawow.com/register" target="_blank" rel="noopener noreferrer">aquí</a>.</p>
</div>
@@ -58,9 +58,6 @@
<table class="middle-center-table">
<script src="{{ URL_PRINCIPAL }}/static/nw-themes/nw-ryu/nw-js-handlers/register.js"></script>
<tbody><tr>
<td><input type="text" maxlength="17" name="username" id="username" placeholder="Nombre de usuario"></td>
</tr>
<tr>
<td><input type="password" maxlength="16" name="password" id="password" placeholder="Contraseña">
<span class="far fa-eye toggle-password"></span></td>
</tr>
+1
View File
@@ -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'),
+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>'})