Ir al contenido

Provisioning TOTP con URLs de un solo uso: entrega segura de QR codes por email

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.

Configurar TOTP (Time-based One-Time Password) en un entorno con varios usuarios tiene un problema de distribución: el secret TOTP tiene que llegar al dispositivo del usuario de forma segura. La solución habitual —que el administrador escanee el QR en el teléfono del usuario— no escala, no es cómoda y rompe el modelo de privacidad. Este artículo documenta un microservicio Python que resuelve el problema generando URLs de un solo uso con TTL que el usuario consume desde su propio dispositivo.

El problema de distribución
#

RFC 6238 define TOTP como un algoritmo que comparte un secret entre el servidor y el cliente. El servidor lo almacena en una base de datos o en /etc/users.totp. El cliente lo introduce en Google Authenticator, Aegis o cualquier app compatible. El vector de ataque más común no está en el algoritmo, sino en el momento de la entrega del secret.

Las alternativas habituales tienen problemas obvios:

  • Mostrar el secret en texto plano durante el registro: el secret queda en logs del navegador, historial, pantallas observadas.
  • Enviar el QR como imagen adjunta en el email: la imagen puede quedar en servidores de correo intermedios, en caché de clientes de correo, en previews de notificaciones.
  • Que el administrador configure el dispositivo: no escala, rompe la cadena de custodia, y en entornos con rotación de personal es un problema de auditoría.

La solución: generar un enlace de un solo uso con TTL de 48 horas que apunte a una página HTML servida localmente. El usuario hace clic, ve el QR, escanea, y el enlace queda invalidado. Si el enlace caduca o ya fue consumido, muestra un error.

Diseño del sistema
#

El flujo tiene tres componentes:

  1. Generador de tokens: crea un token aleatorio de 32 bytes codificado como URL-safe base64, lo persiste en un JSON con metadatos y TTL.
  2. Servidor HTTP mínimo (Flask): recibe el token en la URL, valida TTL y estado used, genera el QR en memoria, sirve la página HTML con el QR embebido como data URI.
  3. Notificador SMTP: envía el enlace al email del usuario con autenticación manual para manejar contraseñas con caracteres no-ASCII.

Los tokens se almacenan en un directorio tokens/ como ficheros JSON individuales. Sin base de datos, sin dependencias externas más allá de Flask y qrencode.

Estructura del token
#

import secrets, json, time
from pathlib import Path

def generate_token(username):
    token = secrets.token_urlsafe(32)
    data = {
        "user": username,
        "created": time.time(),
        "ttl": 172800,  # 48h en segundos
        "used": False
    }
    Path(f"tokens/{token}.json").write_text(json.dumps(data))
    return token

secrets.token_urlsafe(32) genera 32 bytes de entropía criptográfica, codificados en base64 URL-safe: 43 caracteres sin padding, sin / ni +, seguros para usarlos directamente en URLs sin encoding adicional. La entropía efectiva es 256 bits —suficiente para que la probabilidad de colisión sea irrelevante en cualquier escenario real.

Validación del token en el servidor
#

from flask import Flask, abort, render_template_string
import json, time
from pathlib import Path

app = Flask(__name__)

@app.route("/setup/<token>")
def setup(token):
    token_file = Path(f"tokens/{token}.json")
    if not token_file.exists():
        abort(404)

    data = json.loads(token_file.read_text())

    if data["used"]:
        abort(410)  # Gone — ya fue consumido

    if time.time() - data["created"] > data["ttl"]:
        abort(410)  # Gone — expirado

    # Marcar como usado ANTES de servir la respuesta
    data["used"] = True
    token_file.write_text(json.dumps(data))

    username = data["user"]
    secret = get_totp_secret(username)  # Recuperar de almacén seguro
    return render_totp_page(username, secret)

El orden importa: se marca used: true antes de servir la respuesta, no después. Si el servidor falla entre el marcado y el envío, el usuario tendrá que solicitar un nuevo enlace, pero no hay ventana donde un atacante pueda consumir el enlace en paralelo.

Generación del QR en servidor
#

El QR no se genera en el cliente. Se genera en el servidor con qrencode, se convierte a base64 y se embebe directamente en el HTML como data URI. Esto evita que la imagen salga del servidor hacia un servicio externo de generación de QR.

URI otpauth
#

El formato estándar que entienden todas las apps TOTP es:

otpauth://totp/ISSUER:usuario?secret=XXX&issuer=ISSUER&algorithm=SHA1&digits=6&period=30
  • ISSUER aparece dos veces: como prefijo del nombre de cuenta y como parámetro issuer. Algunas apps solo leen uno de los dos; incluir ambos garantiza compatibilidad.
  • secret en base32 (RFC 4648), sin padding.
  • algorithm=SHA1 es el único que soportan la mayoría de apps TOTP en la práctica; SHA256 y SHA512 están en el RFC pero el soporte es inconsistente.
  • digits=6 y period=30 son los valores por defecto de Google Authenticator.

Generación del PNG
#

import subprocess, base64

def generate_qr_base64(otpauth_uri: str) -> str:
    result = subprocess.run(
        ["qrencode", "-o", "-", "-t", "PNG", "-s", "6", otpauth_uri],
        capture_output=True,
        check=True
    )
    return base64.b64encode(result.stdout).decode()

def render_totp_page(username: str, secret: str) -> str:
    issuer = "MiServicio"
    uri = f"otpauth://totp/{issuer}:{username}?secret={secret}&issuer={issuer}&algorithm=SHA1&digits=6&period=30"
    qr_b64 = generate_qr_base64(uri)
    # qr_b64 se embebe en el HTML como data:image/png;base64,...
    return HTML_TEMPLATE.format(username=username, secret=secret, qr_b64=qr_b64)

El tamaño -s 6 genera celdas de 6 píxeles: un QR legible en pantalla sin ser exageradamente grande. qrencode está disponible en los repositorios de cualquier distribución Debian/Ubuntu/RHEL.

Envío del enlace por SMTP: el problema con contraseñas no-ASCII
#

smtplib es la librería estándar de Python para SMTP. Su método login() falla con UnicodeEncodeError si la contraseña contiene caracteres fuera de ASCII —como la letra Ñ, tildes, o cualquier carácter con código > 127. Esto ocurre porque smtplib intenta codificar la contraseña en latin-1 para ciertos servidores, y si el carácter no existe en ese charset, lanza una excepción que no documenta claramente el motivo.

La solución es autenticar manualmente con AUTH PLAIN construyendo el payload en base64 desde bytes UTF-8:

import smtplib, base64, ssl

def send_setup_email(to_email: str, username: str, setup_url: str):
    smtp_host = "smtp.ejemplo.com"
    smtp_port = 465
    smtp_user = "notificaciones@ejemplo.com"
    smtp_pass = "contraseña_con_Ñ"  # Leída desde variable de entorno

    subject = f"Configura tu autenticador para {username}"
    body = f"""Hola {username},

Accede al siguiente enlace desde tu dispositivo móvil para configurar 
Google Authenticator o Aegis. El enlace es válido durante 48 horas 
y solo puede usarse una vez:

{setup_url}

Si no solicitaste este enlace, ignora este email.
"""

    msg = f"From: {smtp_user}\r\nTo: {to_email}\r\nSubject: {subject}\r\n\r\n{body}"

    context = ssl.create_default_context()
    with smtplib.SMTP_SSL(smtp_host, smtp_port, context=context) as smtp:
        smtp.ehlo()
        # AUTH PLAIN manual: evita UnicodeEncodeError con contraseñas no-ASCII
        auth_payload = base64.b64encode(
            b"\x00" + smtp_user.encode("utf-8") + b"\x00" + smtp_pass.encode("utf-8")
        ).decode("ascii")
        smtp.docmd("AUTH PLAIN", auth_payload)
        smtp.sendmail(smtp_user, [to_email], msg.encode("utf-8"))

El protocolo AUTH PLAIN define el payload como \x00authzid\x00authcid\x00password o en su forma más común sin authzid: \x00usuario\x00contraseña. Los tres campos se concatenan en bytes UTF-8, se codifican en base64 y se envían como argumento del comando AUTH PLAIN. El servidor lo decodifica en UTF-8 en su extremo. smtplib.login() hace exactamente esto internamente, pero con una codificación que falla antes de llegar al servidor cuando hay caracteres no-ASCII.

Interfaz HTML embebida
#

La página que ve el usuario no tiene dependencias externas. Sin CDN, sin JavaScript de terceros, sin tracking. Todo en una sola respuesta HTTP:

<!DOCTYPE html>
<html lang="es">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Configurar autenticador — {username}</title>
  <style>
    body {{ font-family: system-ui, sans-serif; max-width: 480px; margin: 2rem auto; padding: 1rem; }}
    .qr {{ text-align: center; margin: 2rem 0; }}
    .secret {{ font-family: monospace; background: #f4f4f4; padding: .5rem 1rem;
               border-radius: 4px; word-break: break-all; letter-spacing: .1em; }}
    .warning {{ background: #fff3cd; border-left: 4px solid #ffc107; padding: .75rem 1rem; }}
  </style>
</head>
<body>
  <h1>Configurar Google Authenticator</h1>
  <p>Escanea el código QR con tu app de autenticación (Google Authenticator, Aegis, etc.).</p>
  <div class="qr">
    <img src="data:image/png;base64,{qr_b64}" alt="QR TOTP" width="250" height="250">
  </div>
  <p>Si no puedes escanear el QR, introduce el secret manualmente:</p>
  <p class="secret">{secret}</p>
  <div class="warning">
    <strong>Este enlace ya no es válido.</strong> Si necesitas reconfigurarlo,
    contacta con el administrador para obtener un nuevo enlace.
  </div>
  <h2>Códigos de emergencia</h2>
  <p>Guarda estos códigos en un lugar seguro. Cada uno es de un solo uso:</p>
  <ul>
    {emergency_codes}
  </ul>
</body>
</html>

Los códigos de emergencia se generan en el mismo paso (secrets.token_hex(4) repetido 8 veces), se almacenan hasheados junto al registro TOTP del usuario y se muestran una única vez en esta página. Después no son recuperables.

Servicio systemd y comandos de gestión
#

# /etc/systemd/system/totp-provisioning.service
[Unit]
Description=TOTP Provisioning Service
After=network.target

[Service]
Type=simple
User=totp-svc
WorkingDirectory=${BASE_DIR}/totp-provisioning
EnvironmentFile=${BASE_DIR}/totp-provisioning/.env
ExecStart=/usr/bin/python3 server.py
Restart=on-failure
RestartSec=5

[Install]
WantedBy=multi-user.target

Los comandos de administración son dos subcomandos del mismo script:

# Crear token y enviar email al usuario
python3 manage.py send usuario@dominio.com usuario

# Generar token sin enviar email (para depuración o entrega alternativa)
python3 manage.py token usuario

send llama a generate_token(), construye la URL completa y llama a send_setup_email(). token solo imprime la URL en stdout para que el administrador la entregue por otro canal si lo prefiere.

Conclusión
#

Este patrón resuelve el problema de distribución de secrets TOTP sin comprometer la seguridad ni requerir intervención manual en el dispositivo del usuario. Los puntos clave del diseño:

  • Entropía suficiente: 256 bits hacen que la enumeración de tokens sea computacionalmente inviable.
  • Un solo uso: el token queda invalidado tras el primer acceso, independientemente de si el usuario completa la configuración.
  • TTL corto: 48 horas reduce la ventana de exposición si el email es interceptado.
  • Sin dependencias externas en el cliente: la página HTML es autocontenida; el QR se genera y embebe en servidor.
  • Autenticación SMTP robusta: el workaround con AUTH PLAIN manual elimina una clase de errores silenciosos que aparecen solo en producción cuando las contraseñas contienen caracteres no-ASCII.

El servicio es deliberadamente mínimo —menos de 300 líneas incluyendo la plantilla HTML— porque en seguridad, el código que no existe no tiene vulnerabilidades.