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.