Files
NightSpire/home/views/auth.py
T
Inna cac0d28aa9 Añade Cloudflare Turnstile (captcha) a login, registro y recuperación
- Backend: helper verify_turnstile en _base (verifica el token contra
  challenges.cloudflare.com/siteverify; fail-closed ante error de red; si no hay
  secreto configurado, no bloquea). Se exige en el POST de login_view,
  register_view y recover_account_view -> rechazo con mensaje si falla.
- settings: TURNSTILE_SITE_KEY / TURNSTILE_SECRET_KEY desde .env (el secreto NO
  se commitea). La clave de sitio se expone a las plantillas por el context
  processor (TURNSTILE_SITE_KEY).
- Frontend: hook useTurnstile (carga el script de Cloudflare una vez y renderiza
  el widget, devuelve el token). LoginForm/RegisterForm/RecoverForm incluyen el
  widget, mandan cf-turnstile-response y deshabilitan el submit hasta tener token.
  El sitekey llega por data-sitekey en el div de montaje.

Verificado: sin token los 3 POST se rechazan; siteverify acepta la clave secreta
(error invalid-input-response con token falso, no invalid-input-secret).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 21:15:27 +00:00

720 lines
33 KiB
Python

from ._base import *
class LoginForm(forms.Form):
email = forms.EmailField(max_length=320, required=True)
password = forms.CharField(widget=forms.PasswordInput, required=True)
def sha1(data):
"""Función auxiliar para calcular SHA-1."""
return hashlib.sha1(data).digest()
def calculate_srp6_verifier(username, password, salt):
"""Calcula el verifier usando SRP6 basado en la implementación PHP."""
g = gmpy2.mpz(7)
N = gmpy2.mpz('894B645E89E1535BBDAD5B8B290650530801B18EBFBF5E8FAB3C82872A3E9BB7', 16)
# Paso 1: Calcular H(username:password)
h1 = sha1((username.upper() + ':' + password.upper()).encode('utf-8'))
# Usar el salt proporcionado (sin invertir)
salt_bytes = bytes.fromhex(salt)
# Paso 2: Calcular H(salt + H(username:password))
h2 = sha1(salt_bytes + h1)
# Convertir el hash a un entero (little-endian)
h2_int = gmpy2.mpz.from_bytes(h2, 'little')
# Calcular el verifier usando g^h2 mod N
verifier = gmpy2.powmod(g, h2_int, N)
verifier_bytes = verifier.to_bytes(32, 'little')
# Convertir el resultado a hexadecimal en mayúsculas
return binascii.hexlify(verifier_bytes).decode('utf-8').upper()
def authenticate(email, password):
"""Autentica una cuenta Battle.net por email (SRP6 v2)."""
try:
with connections['acore_auth'].cursor() as cursor:
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 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']
def login_view(request):
if request.method == 'POST':
if not verify_turnstile(request):
return JsonResponse({'success': False, 'error': 'Verificación anti-bots fallida. Reintenta.'})
form = LoginForm(request.POST)
if form.is_valid():
email = form.cleaned_data['email']
password = form.cleaned_data['password']
ip_address = request.META.get('REMOTE_ADDR')
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
return JsonResponse({'success': True})
else:
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)
# La selección la hace la isla React vía GET (/api/account/select/), sin POST.
# 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'),
})
def logout_view(request):
for key in ('username', 'account_id', 'bnet_id', 'bnet_email'):
request.session.pop(key, None)
return redirect('index')
def register_view(request):
if request.method == 'POST':
if not verify_turnstile(request):
return JsonResponse({'success': False, 'message': 'Verificación anti-bots fallida. Reintenta.'})
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()
# 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 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 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 battlenet_accounts WHERE email = %s", [email_norm])
if cursor.fetchone()[0] > 0:
return JsonResponse({'success': False, 'message': 'Ya existe una cuenta con ese correo electrónico.'})
# 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])
recruiter_data = cursor.fetchone()
if recruiter_data:
recruiter_id = recruiter_data[0]
else:
with connections['acore_characters'].cursor() as char_cursor:
char_cursor.execute("SELECT account FROM characters WHERE name = %s", [recruiter])
character_data = char_cursor.fetchone()
if character_data:
recruiter_id = character_data[0]
else:
return JsonResponse({'success': False, 'message': 'El reclutador ingresado no existe.'})
# 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)
AccountActivation.objects.create(
email=email,
password=password,
recruiter_id=recruiter_id,
hash=activation_hash
)
# Enviar correo de activación
activation_link = f"{settings.URL_PRINCIPAL}/es/activate-account?act={activation_hash}"
context = {
'username': email,
'password': password,
'activation_link': activation_link,
'NOMBRE_SERVIDOR': settings.NOMBRE_SERVIDOR
}
enviar_correo(
subject=f'Activación de la cuenta {email} - {settings.NOMBRE_SERVIDOR}',
to_email=email,
template='emails/activation.html',
context=context
)
message = f"""
<div class="alert-message" id="create-response" style="display: block;">
<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')
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')
def recover_account_view(request):
"""Recuperación de cuenta (Battle.net, por email). 3 tipos:
password (enlace de reset), accountname (lista de cuentas), activation (reenvío).
Respuestas genéricas para no revelar si el correo existe (anti-enumeración).
"""
if request.method == 'POST':
if not verify_turnstile(request):
return JsonResponse({'success': False, 'message': 'Verificación anti-bots fallida. Reintenta.'})
rtype = request.POST.get('type', '').strip()
email = request.POST.get('email', '').strip()
if not email or not re.match(r'^[a-zA-Z0-9._%+-]+@gmail\.com$', email):
return JsonResponse({'success': False, 'message': 'Introduce un correo de Gmail válido.'})
if rtype == 'password':
try:
with connections['acore_auth'].cursor() as cursor:
account = bnet.get_bnet_account_by_email(cursor, email)
except Exception as e:
logger.warning("recover(password): BD de cuentas no disponible: %s", e)
account = None
if account:
token = get_random_string(64)
PasswordReset.objects.create(email=email, token=token)
reset_link = f"{settings.URL_PRINCIPAL}/es/reset-password?token={token}"
enviar_correo(
subject=f'Restablecer contraseña - {settings.NOMBRE_SERVIDOR}',
to_email=email,
template='emails/password_reset.html',
context={'reset_link': reset_link, 'email': email,
'NOMBRE_SERVIDOR': settings.NOMBRE_SERVIDOR,
'URL_PRINCIPAL': settings.URL_PRINCIPAL},
)
return JsonResponse({'success': True, 'message': 'Si existe una cuenta con ese correo, te hemos enviado un enlace para restablecer la contraseña.'})
if rtype == 'accountname':
try:
with connections['acore_auth'].cursor() as cursor:
account = bnet.get_bnet_account_by_email(cursor, email)
game_accounts = bnet.get_game_accounts_for_bnet(cursor, account['id']) if account else []
except Exception as e:
logger.warning("recover(accountname): BD de cuentas no disponible: %s", e)
account, game_accounts = None, []
if account:
enviar_correo(
subject=f'Tus cuentas de juego - {settings.NOMBRE_SERVIDOR}',
to_email=email,
template='emails/account_names.html',
context={'email': email, 'accounts': game_accounts,
'NOMBRE_SERVIDOR': settings.NOMBRE_SERVIDOR,
'URL_PRINCIPAL': settings.URL_PRINCIPAL},
)
return JsonResponse({'success': True, 'message': 'Si existe una cuenta con ese correo, te hemos enviado la lista de cuentas asociadas.'})
if rtype == 'activation':
activation = AccountActivation.objects.filter(
email=email, old_email__isnull=True).order_by('-created_at').first()
if activation:
activation_link = f"{settings.URL_PRINCIPAL}/es/activate-account?act={activation.hash}"
enviar_correo(
subject=f'Activación de la cuenta {email} - {settings.NOMBRE_SERVIDOR}',
to_email=email,
template='emails/activation.html',
context={'username': email, 'password': activation.password,
'activation_link': activation_link,
'NOMBRE_SERVIDOR': settings.NOMBRE_SERVIDOR},
)
return JsonResponse({'success': True, 'message': 'Si hay una activación pendiente para ese correo, te hemos reenviado el enlace.'})
return JsonResponse({'success': False, 'message': 'Opción no válida.'})
return render(request, 'auth/recover_account.html')
def reset_password_view(request):
"""Fija una nueva contraseña a partir de un token de PasswordReset (por email).
Re-deriva el verifier SRP6 v2 de la cuenta Battle.net (igual que change_password)."""
if request.method == 'POST':
token = request.POST.get('token', '').strip()
new_password = request.POST.get('new-password', '').strip()
conf_password = request.POST.get('conf-password', '').strip()
pr = PasswordReset.objects.filter(token=token, used=False).first()
if not pr or pr.is_expired():
return JsonResponse({'success': False, 'message': 'El enlace no es válido o ha caducado.'})
if not new_password or new_password != conf_password:
return JsonResponse({'success': False, 'message': 'Las contraseñas no coinciden.'})
if len(new_password) > 16:
return JsonResponse({'success': False, 'message': 'La contraseña no debe exceder los 16 caracteres.'})
new_salt, new_verifier, srp_version = bnet.bnet_make_registration(pr.email, new_password)
try:
with connections['acore_auth'].cursor() as cursor:
account = bnet.get_bnet_account_by_email(cursor, pr.email)
if not account:
return JsonResponse({'success': False, 'message': 'No se ha encontrado la cuenta.'})
cursor.execute(
"UPDATE battlenet_accounts SET srp_version = %s, salt = %s, verifier = %s WHERE id = %s",
[srp_version, new_salt, new_verifier, account['id']]
)
except Exception as e:
logger.error("reset_password: error al actualizar la cuenta: %s", e)
return JsonResponse({'success': False, 'message': 'No se ha podido actualizar la contraseña. Inténtalo más tarde.'})
pr.used = True
pr.save(update_fields=['used'])
return JsonResponse({'success': True,
'message': 'Contraseña restablecida. Ya puedes iniciar sesión.',
'redirect': reverse('login')})
token = request.GET.get('token', '').strip()
pr = PasswordReset.objects.filter(token=token, used=False).first()
valid = bool(pr and not pr.is_expired())
return render(request, 'auth/reset_password.html', {'token': token, 'valid': valid})
def change_password_view(request):
# Verificar si el usuario está autenticado mediante la sesión
username = request.session.get('username')
if not username:
return redirect('login')
# Obtener el usuario desde la base de datos de `acore_auth`
user_data = get_user_from_acore(username)
if not user_data:
return redirect('login')
user_id = user_data['id']
email = user_data['email']
if request.method == 'POST':
current_password = request.POST.get('cur-password', '').strip()
new_password = request.POST.get('new-password', '').strip()
conf_new_password = request.POST.get('conf-new-password', '').strip()
security_token = request.POST.get('security-token', '').strip()
# Validar campos vacíos
if not current_password or not new_password or not conf_new_password or not security_token:
return JsonResponse({'success': False, 'message': '<span class="red-form-response">Por favor, complete todos los campos.</span>'})
# Validar que la nueva contraseña coincida con su confirmación
if new_password != conf_new_password:
return JsonResponse({'success': False, 'message': '<span class="red-form-response">Las contraseñas no coinciden.</span>'})
# Validar longitud de la nueva contraseña
if len(new_password) > 16:
return JsonResponse({'success': False, 'message': '<span class="red-form-response">La contraseña nueva no debe exceder los 16 caracteres.</span>'})
# Verificar si el token de seguridad ha sido generado
existing_token = SecurityToken.objects.filter(user_id=user_id).first()
if not existing_token:
return JsonResponse({'success': False, 'message': '<span class="red-form-response">No tienes un token de seguridad generado. Por favor, genera uno antes de cambiar tu contraseña.</span>'})
# Verificar si el token proporcionado es correcto
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 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 SRP6 v2 para la nueva contraseña (bnet)
new_salt_bytes, new_verifier_bytes, srp_version = bnet.bnet_make_registration(email, new_password)
# 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:
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
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({
'success': True,
'message': '<span class="ok-form-response">Contraseña cambiada exitosamente. Has sido desconectado por seguridad.</span>',
'redirect': True
})
return render(request, 'account/change_password.html')
def get_user_from_acore(username):
"""
Obtiene la información del usuario desde la base de datos `acore_auth`.
"""
with connections['acore_auth'].cursor() as cursor:
cursor.execute("SELECT id, email FROM account WHERE username = %s", [username])
result = cursor.fetchone()
if result:
return {'id': result[0], 'email': result[1]}
return None
def security_token_view(request):
# Verificar si el usuario está autenticado mediante la sesión
username = request.session.get('username')
if not username:
return redirect('index')
# Obtener el usuario desde la base de datos de `acore_auth`
user_data = get_user_from_acore(username)
if not user_data:
return redirect('index')
user_id = user_data['id']
email = user_data['email']
ip_address = request.META.get('REMOTE_ADDR')
# Verificar si existe un token activo para mostrar la fecha en la plantilla
existing_token = SecurityToken.objects.filter(user_id=user_id).first()
# Obtener la fecha del token o "Sin solicitar" si no hay token
token_date = "Sin solicitar"
if existing_token:
token_date = existing_token.created_at.strftime('%H:%M:%S %d-%m-%Y')
if request.method == 'POST':
# Verificar si el correo del usuario está vacío
if not email:
# Si no tiene correo, devolver un mensaje de error en formato JSON
return JsonResponse({
'success': False,
'message': f'<span class="red-form-response">Debes añadir un correo electrónico a tu cuenta antes de solicitar un Token de seguridad.</span>'
})
# Verificar si ya existe un token y si se puede generar uno nuevo después de 7 días
if existing_token:
# Comprobar si han pasado al menos 7 días desde la creación del token
days_since_creation = (timezone.now() - existing_token.created_at).days
if days_since_creation < 7:
remaining_days = 7 - days_since_creation
return JsonResponse({
'success': False,
'message': f'<span class="red-form-response">Sólo puedes solicitar un nuevo Token de seguridad cada 7 días, faltan {remaining_days} días para generar un nuevo Token.</span>'
})
# Generar un nuevo token
token = secrets.token_urlsafe(4)[:6]
expires_at = timezone.now() + timedelta(days=7)
# Eliminar el token anterior y crear uno nuevo
if existing_token:
existing_token.delete()
# Crear y guardar el nuevo token
new_token = SecurityToken.objects.create(
user_id=user_id,
token=token,
ip_address=ip_address,
expires_at=expires_at # Esta fecha solo se usa para el control de los 7 días
)
# Enviar el correo con el token al usuario
send_security_token_email(email, username, token, ip_address)
# Obtener la nueva fecha del token
new_token_date = new_token.created_at.strftime('%H:%M:%S %d-%m-%Y')
# Responder con un mensaje exitoso y la nueva fecha
return JsonResponse({
'success': True,
'message': f'<span class="ok-form-response">Se ha enviado el Token de seguridad al correo {email}.</span>',
'token_date': new_token_date
})
# Pasar `token_date` al contexto de la plantilla
return render(request, 'account/security_token.html', {
'token_date': token_date
})
def send_security_token_email(email, username, token, ip_address):
"""
Envía un correo electrónico con el token de seguridad.
"""
context = {
'username': username,
'token': token,
'ip_address': ip_address,
'NOMBRE_SERVIDOR': settings.NOMBRE_SERVIDOR
}
# Llamada a tu función personalizada `enviar_correo`
enviar_correo(
subject=f'Token de seguridad de la cuenta {username} - {settings.NOMBRE_SERVIDOR}',
to_email=email,
template='emails/security_token.html', # Asegúrate de que este template exista
context=context
)
def change_email_view(request):
username = request.session.get('username')
if not username:
return redirect('login')
# Obtener el usuario desde la base de datos de `acore_auth`
with connections['acore_auth'].cursor() as cursor:
cursor.execute("""
SELECT id, email, reg_mail
FROM account
WHERE username = %s
""", [username])
account_data = cursor.fetchone()
if not account_data:
return redirect('login')
user_id, current_email, reg_email = account_data
security_token = SecurityToken.objects.filter(user_id=user_id).first()
if request.method == 'POST':
print("Datos recibidos:", request.POST)
current_password = request.POST.get('cur-password', '').strip()
current_email_input = request.POST.get('cur-email', '').strip()
new_email = request.POST.get('new-email', '').strip()
conf_new_email = request.POST.get('conf-new-email', '').strip()
token = request.POST.get('security-token', '').strip()
# Validar los campos
if not all([current_password, current_email_input, new_email, conf_new_email, token]):
return JsonResponse({'success': False, 'message': '<span class="red-form-response">Por favor, complete todos los campos.</span>'})
if current_email_input != current_email:
return JsonResponse({'success': False, 'message': '<span class="red-form-response">El correo actual no es correcto.</span>'})
if new_email != conf_new_email:
return JsonResponse({'success': False, 'message': '<span class="red-form-response">Los correos no coinciden.</span>'})
if not re.match(r'^[a-zA-Z0-9._%+-]+@gmail\.com$', new_email):
return JsonResponse({'success': False, 'message': '<span class="red-form-response">El nuevo correo no es válido.</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>'})
# 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`. 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=current_password,
recruiter_id=user_id,
hash=new_email_hash,
old_email_hash=old_email_hash
)
# Enviar confirmación al correo actual
confirm_old_email_link = f"{settings.URL_PRINCIPAL}/es/confirm-old-email?hash={old_email_hash}"
context_old_email = {
'username': username,
'confirm_link': confirm_old_email_link,
'NOMBRE_SERVIDOR': settings.NOMBRE_SERVIDOR
}
enviar_correo(
subject=f'Confirme el cambio de correo para {username}',
to_email=current_email,
template='emails/confirm_old_email.html',
context=context_old_email
)
return JsonResponse({'success': True, 'message': '<span class="ok-form-response">Se ha enviado un correo de confirmación al correo actual.</span>'})
return render(request, 'account/change_email.html')
def confirm_old_email_view(request):
hash = request.GET.get('hash')
activation = AccountActivation.objects.filter(old_email_hash=hash, is_used=False).first()
if not activation or activation.is_expired():
return JsonResponse({
'success': False,
'message': '<span class="red-form-response">Enlace no válido o expirado.</span>',
'redirect_url': reverse('expired-link')
})
# Marcar solo el old_email_hash como usado
activation.is_used = True
activation.save()
# Enviar correo al nuevo correo para confirmar el cambio
activation_link = f"{settings.URL_PRINCIPAL}/es/confirm-new-email?hash={activation.hash}"
context_new_email = {
'username': activation.username,
'activation_link': activation_link,
'NOMBRE_SERVIDOR': settings.NOMBRE_SERVIDOR
}
enviar_correo(
subject=f'Confirme su nuevo correo para {activation.username}',
to_email=activation.email,
template='emails/confirm_new_email.html',
context=context_new_email
)
return JsonResponse({'success': True, 'message': '<span class="ok-form-response">Correo confirmado. Se ha enviado un enlace al nuevo correo.</span>'})
def confirm_new_email_view(request):
hash = request.GET.get('hash')
activation = AccountActivation.objects.filter(hash=hash, is_new_email_used=False).first()
if not activation or activation.is_expired():
return JsonResponse({
'success': False,
'message': '<span class="red-form-response">Enlace no válido o expirado.</span>',
'redirect_url': reverse('expired-link')
})
# Marcar solo el new_email_hash como usado
activation.is_new_email_used = True
activation.save()
# 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:
# 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 = {
'username': activation.username,
'new_email': activation.email,
'NOMBRE_SERVIDOR': settings.NOMBRE_SERVIDOR
}
enviar_correo(
subject=f'Su correo ha sido cambiado en {settings.NOMBRE_SERVIDOR}',
to_email=activation.old_email,
template='emails/old_email_notification.html',
context=context_old_email
)
return JsonResponse({'success': True, 'message': '<span class="ok-form-response">El cambio de correo ha sido confirmado y completado con éxito.</span>'})
def expired_link_view(request):
return render(request, 'account/expired_link.html')
def log_login_attempt(username, status, ip_address):
# Registrar intento de inicio de sesión en el historial
LoginAttempt.objects.create(
username=username,
status=status,
ip_address=ip_address,
timestamp=timezone.now()
)