↓ Ir al contenido

Recuperación de contraseñas olvidadas en FreeRADIUS con reset seguro por email y validación de tokens de un solo uso

·4 mins
Rogelio Guerra Riverón
Autor
Rogelio Guerra Riverón
Construyendo mi propia infraestructura web desde cero. Aquí documento cada paso: servidores, redes, contenedores y lo que vaya surgiendo.

El problema
#

Hace poco me encontré con una situación incómoda en mi servidor doméstico. Un usuario olvidó su contraseña de FreeRADIUS y no había forma segura de recuperarla. Resetear manualmente en la base de datos no es opción, y permitir que cualquiera cambie contraseñas sin validación es un desastre de seguridad.

Necesitaba un sistema que fuera:

  • Seguro (tokens únicos, con expiración)
  • Automatizado (sin intervención manual)
  • Auditado (registros de intentos)
  • Simple de mantener

La arquitectura que implementé
#

Usé una combinación de FreeRADIUS, una base de datos MySQL, Python para generar tokens, y Postfix para enviar emails. Nada exótico, todo probado.

1. Extensión de la base de datos
#

Primero, agregué dos tablas a mi esquema de FreeRADIUS:

CREATE TABLE password_reset_tokens (
    id INT AUTO_INCREMENT PRIMARY KEY,
    username VARCHAR(255) NOT NULL,
    token VARCHAR(64) NOT NULL UNIQUE,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    expires_at TIMESTAMP NOT NULL,
    used BOOLEAN DEFAULT FALSE,
    used_at TIMESTAMP NULL,
    ip_address VARCHAR(45),
    INDEX idx_token (token),
    INDEX idx_username (username),
    INDEX idx_expires (expires_at)
);

CREATE TABLE password_reset_logs (
    id INT AUTO_INCREMENT PRIMARY KEY,
    username VARCHAR(255) NOT NULL,
    action VARCHAR(50),
    ip_address VARCHAR(45),
    timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    details TEXT
);

2. Script Python para generar tokens
#

Creé un script que genera tokens seguros y envía emails:

#!/usr/bin/env python3
import secrets
import mysql.connector
import smtplib
from email.mime.text import MIMEText
from datetime import datetime, timedelta
import sys

DB_CONFIG = {
    'host': 'localhost',
    'user': 'radius_user',
    'password': 'radius_pass',
    'database': 'radius'
}

SMTP_SERVER = 'localhost'
SMTP_FROM = 'noreply@tudominio.local'
RESET_URL_BASE = 'https://tudominio.local/password-reset'
TOKEN_EXPIRY_MINUTES = 30

def generate_reset_token(username, email):
    token = secrets.token_urlsafe(32)
    expires_at = datetime.now() + timedelta(minutes=TOKEN_EXPIRY_MINUTES)
    
    try:
        conn = mysql.connector.connect(**DB_CONFIG)
        cursor = conn.cursor()
        
        # Guardar token
        query = """
        INSERT INTO password_reset_tokens 
        (username, token, expires_at) 
        VALUES (%s, %s, %s)
        """
        cursor.execute(query, (username, token, expires_at))
        conn.commit()
        
        # Enviar email
        reset_link = f"{RESET_URL_BASE}?token={token}"
        send_reset_email(email, username, reset_link)
        
        # Registrar en logs
        log_action(username, 'TOKEN_GENERATED', 'Token generated and sent')
        
        return True
    except Exception as e:
        log_action(username, 'TOKEN_ERROR', str(e))
        return False
    finally:
        cursor.close()
        conn.close()

def send_reset_email(email, username, reset_link):
    subject = 'Recupera tu contraseña'
    body = f"""Hola {username},

Recibimos una solicitud para resetear tu contraseña.

Usa este enlace (válido por 30 minutos):
{reset_link}

Si no solicitaste esto, ignora este email.
"""
    
    msg = MIMEText(body)
    msg['Subject'] = subject
    msg['From'] = SMTP_FROM
    msg['To'] = email
    
    with smtplib.SMTP(SMTP_SERVER) as server:
        server.sendmail(SMTP_FROM, [email], msg.as_string())

def log_action(username, action, details):
    conn = mysql.connector.connect(**DB_CONFIG)
    cursor = conn.cursor()
    query = "INSERT INTO password_reset_logs (username, action, details) VALUES (%s, %s, %s)"
    cursor.execute(query, (username, action, details))
    conn.commit()
    cursor.close()
    conn.close()

if __name__ == '__main__':
    username = sys.argv[1]
    email = sys.argv[2]
    generate_reset_token(username, email)

3. Validación en la aplicación web
#

Cuando el usuario accede con el token:

def validate_reset_token(token):
    conn = mysql.connector.connect(**DB_CONFIG)
    cursor = conn.cursor(dictionary=True)
    
    query = """
    SELECT * FROM password_reset_tokens 
    WHERE token = %s 
    AND used = FALSE 
    AND expires_at > NOW()
    """
    cursor.execute(query, (token,))
    result = cursor.fetchone()
    
    cursor.close()
    conn.close()
    
    return result

def apply_new_password(token, new_password):
    reset_record = validate_reset_token(token)
    
    if not reset_record:
        return False, "Token inválido o expirado"
    
    username = reset_record['username']
    
    # Marcar token como usado
    conn = mysql.connector.connect(**DB_CONFIG)
    cursor = conn.cursor()
    
    cursor.execute("""
    UPDATE password_reset_tokens 
    SET used = TRUE, used_at = NOW() 
    WHERE token = %s
    """, (token,))
    
    # Actualizar contraseña en FreeRADIUS
    hashed_pass = hashlib.sha1(new_password.encode()).hexdigest()
    cursor.execute("""
    UPDATE radcheck 
    SET value = %s 
    WHERE username = %s AND attribute = 'Password'
    """, (hashed_pass, username))
    
    conn.commit()
    log_action(username, 'PASSWORD_RESET', 'Password changed successfully')
    
    cursor.close()
    conn.close()
    
    return True, "Contraseña actualizada"

Lo que aprendí
#

Los tokens de un solo uso son imprescindibles. Sin el flag used, alguien podría reutilizar un token capturado.

La expiración es crítica. 30 minutos es suficiente para que el usuario actúe, pero no tanto como para que un token comprometido sea útil indefinidamente.

Los logs salvan vidas. Cuando algo falla, los registros me muestran exactamente qué pasó y desde dónde.

Próximos pasos
#

Tengo en la lista agregar rate limiting para evitar fuerza bruta, y integrar 2FA con TOTP como segunda capa de validación.

Por ahora funciona bien. Cero dolores de cabeza.