Autenticación de Usuarios en FastHTML: OAuth, Login y Controles de Administrador
Aprende a añadir autenticación de usuarios en FastHTML a tu app Python con GitHub OAuth, login por email, sesiones, protección CSRF y controles de administrador basados en roles.
Actualizado Publicado 53 min de lectura

FastHTML Tutorial Series
Part 6 of 6
En este tutorial de autenticación de usuarios en FastHTML, añadiremos login con GitHub OAuth, registro con email/contraseña y controles de administrador basados en roles al Generador de Títulos con IA de nuestra serie. Esta es la Parte 6. Si has seguido la serie desde FastHTML para Principiantes, pasando por la estructura de proyecto multipágina y el Generador de Títulos con IA que construimos antes, ya tienes una app funcional que genera títulos con un LLM y los guarda en SQLite. Hoy la protegemos.
Al finalizar tendrás:
- Login con GitHub OAuth mediante el
GitHubAppClientintegrado (sin código manual conrequests) - Registro y login con email/contraseña y hash seguro de contraseñas
- Protección CSRF en todos los formularios que modifican estado
- Endurecimiento de sesiones (
secret_key,max_age,same_site, solo HTTPS) - Controles de administrador basados en roles por petición usando Beforeware
- Paneles de historial por usuario y globales para administradores
Serie: FastHTML Tutorial Series
Esta es la Parte 6. Las partes anteriores cubren la construcción de una UI, la estructura multipágina, SQLite con Fastlite, herramientas de IA complejas y la integración con PydanticAI. FastHTML es un framework web de Python de AnswerDotAI que usa HTMX y Starlette internamente.
Requisitos previos
Antes de empezar, asegúrate de tener:
- Python >= 3.10
python-fasthtml>=0.14,<0.15fijada en turequirements.txt- Una cuenta de GitHub con una OAuth App registrada (Settings > Developer settings > OAuth Apps)
- El Generador de Títulos de la Parte 5 funcionando en local
- Un archivo
.envconSECRET_KEY,GITHUB_CLIENT_ID,GITHUB_CLIENT_SECRET,ADMIN_EMAILyADMIN_PASSWORD
Fijación de Versión
Este tutorial está dirigido a python-fasthtml 0.14.x. La versión 0.14.1 eliminó varios imports de fasthtml.common (incluidos uvicorn, Database, fastlite y pico), y la 0.12.0 cambió fastlite a apsw. Si actualizas desde una versión anterior, fija python-fasthtml>=0.14,<0.15 en tu requirements.txt y actualiza los imports en consecuencia.
Visión general
Este sistema de autenticación de usuarios en FastHTML tiene varias capas que cooperan entre sí. Así encajan las piezas:
| Componente | Archivos | Descripción | Características clave |
|---|---|---|---|
| Capa de base de datos | db/database.py, db/user_dao.py, db/history_dao.py |
Persistencia de datos y seguridad de contraseñas | SQLite con tablas users + title_history, PBKDF2 con 600k iteraciones, claves foráneas, creación automática de admin |
| Servicios de autenticación | auth/auth_manager.py, auth/email_auth.py |
Login, gestión de sesiones, validación de contraseñas | Auth por email/contraseña, helpers de sesión, comprobaciones de rol |
| GitHub OAuth | main.py (subclase OAuth inline) |
Flujo OAuth de GitHub integrado en FastHTML | GitHubAppClient + subclase OAuth, manejo automático de redirecciones |
| Beforeware | main.py |
Comprobaciones de auth y admin por petición | Sustituye a require_auth() por ruta, inyecta el parámetro auth, consulta is_admin en vivo |
| CSRF | main.py, plantillas de formularios |
Protección contra falsificación de peticiones entre sitios | Token guardado en sesión, campo Hidden, validación con hmac.compare_digest |
| Componentes de UI | components/header.py, components/page_layout.py |
Navegación y layout conscientes del estado de autenticación | Nav dinámica según el estado de auth, enlaces de admin, tokens CSRF en formularios |
| Páginas públicas | pages/home.py, pages/login.py, pages/register.py |
Accesibles sin login | Landing page, formulario de login con GitHub + email, registro con validación |
| Páginas protegidas | pages/title_generator.py, pages/history.py |
Requieren autenticación | Formulario de generación de títulos, panel de historial por usuario |
| Páginas de administración | pages/admin.py |
Requieren rol de administrador | Gestión de usuarios, asignación de roles, vista global del historial |
Explicación del flujo de autenticación
El flujo de GitHub OAuth de FastHTML usa el GitHubAppClient integrado y la subclase OAuth en lugar de llamadas manuales con requests. Estos son los cuatro flujos:
1. Flujo de registro:
- El usuario visita
/registery envía el formulario - El handler
email_register()valida la entrada (formato, longitud de contraseña, email único) UserDAO.create_user()hashea la contraseña con PBKDF2 (600k iteraciones) y la guarda- Si todo va bien, redirige a
/logincon un mensaje de éxito
2. Flujo de login por email:
- El usuario envía email + contraseña en
/login - El handler
email_login()llama aEmailAuth.authenticate() - La contraseña se compara con
hmac.compare_digest()(tiempo constante) AuthManager.login_user()escribeuser_idy un token CSRF en la sesión- Redirige a
/(Beforeware ya reconoce la sesión)
3. Flujo de login con GitHub OAuth:
- El usuario hace clic en “Sign in with GitHub”, que enlaza a
oauth.login_link(req) - GitHub redirige de vuelta a
/auth/github/callbackcon un código de autorización - La subclase
OAuthintercambia el código por la información del usuario víaclient.retr_info() get_auth()encuentra o crea el usuario en SQLite y establece la sesión- Redirige automáticamente a
/
4. Comprobación de autorización (Beforeware):
- Cada petición pasa por
Beforeware(before, skip=[...]) before()compruebasession.get('user_id'); si falta, redirige a/login- En las rutas de admin, el handler hace una consulta
is_admina la BD por petición (sin caché en sesión), para que las degradaciones surtan efecto de inmediato - Las rutas públicas (
/,/login,/register, archivos estáticos) se omiten
# Simplified Beforeware flow
login_redir = RedirectResponse('/login', status_code=303)
def before(req, sess):
auth = req.scope['auth'] = sess.get('user_id', None)
if not auth:
return login_redir
bware = Beforeware(before, skip=[r'/favicon\.ico', r'/static/.*', r'.*\.css',
'/', '/login', '/register', '/auth/github/callback'])Visión general del esquema de la base de datos
La capa de base de datos usa SQLite con dos tablas. Si has seguido la serie, ya añadiste una base de datos SQLite con Fastlite. Aquí extendemos ese esquema.
Tabla users
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE,
email TEXT UNIQUE,
password_hash TEXT,
salt TEXT,
github_id TEXT UNIQUE,
is_admin BOOLEAN DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_login TIMESTAMP
)El flag is_admin controla el acceso basado en roles. En lugar de cachearlo en la sesión, lo consultamos en la base de datos en cada petición mediante Beforeware. Esto significa que si degradas a un admin en la base de datos, pierde el acceso inmediatamente (sin necesidad de volver a iniciar sesión).
Las columnas password_hash y salt almacenan las credenciales del hash de contraseñas de FastHTML usando PBKDF2-HMAC-SHA256 con 600.000 iteraciones y un salt aleatorio por usuario (secrets.token_hex(16)). La comparación de contraseñas usa hmac.compare_digest() para una comparación en tiempo constante que evita ataques de temporización.
Hash de Contraseñas en FastHTML
OWASP recomienda PBKDF2-HMAC-SHA256 con al menos 600.000 iteraciones (frente a las 100k que usan muchos tutoriales antiguos). Argon2id es la recomendación principal para proyectos nuevos. Consulta la OWASP Password Storage Cheat Sheet. Si quieres Argon2id, usa argon2-cffi o passlib en lugar del enfoque con hashlib de la stdlib mostrado aquí.
Tabla title_history
CREATE TABLE IF NOT EXISTS title_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER,
topic TEXT NOT NULL,
platform TEXT NOT NULL,
style TEXT NOT NULL,
number_of_titles INTEGER NOT NULL,
titles TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id)
)La clave foránea user_id asocia cada generación con un usuario. Los usuarios normales solo ven su propio historial; los administradores lo ven todo. Es el mismo patrón de aislamiento de datos por usuario que recomiendan los docs de FastHTML (todos.xtra(name=auth)).
Cambios en la estructura del proyecto
Estamos limpiando el árbol de archivos. El antiguo auth/github_auth.py (OAuth manual con requests) desaparece, reemplazado por el GitHubAppClient integrado en main.py. Si sigues la estructura de proyecto multipágina de la Parte 3, la disposición te resultará familiar:
your-app/
├── auth/
│ ├── auth_manager.py # Session helpers, login_user(), is_admin()
│ └── email_auth.py # Email/password validation and authentication
├── components/
│ ├── header.py # Auth-aware navigation
│ ├── footer.py # Footer component
│ └── page_layout.py # Page wrapper with header/footer
├── db/
│ ├── database.py # Database class, schema init, password hashing
│ ├── user_dao.py # User CRUD operations
│ └── history_dao.py # History CRUD operations
├── pages/
│ ├── home.py # Landing page
│ ├── login.py # Login form (email + GitHub OAuth)
│ ├── register.py # Registration form
│ ├── title_generator.py # Protected: title generation form
│ ├── history.py # Protected: user history dashboard
│ └── admin.py # Admin-only: user mgmt, global history
├── tools/
│ └── title_generator.py # LLM title generation logic
├── main.py # Routes, OAuth subclass, Beforeware, app init
├── config.py # Environment config
├── .env # Secrets (not committed)
└── requirements.txt # python-fasthtml>=0.14,<0.15, python-dotenv, etc.Implementación paso a paso
Paso 1: actualizar la estructura de la base de datos
El esquema de la base de datos es el mismo de antes, pero subimos el número de iteraciones de PBKDF2 de 100.000 a 600.000 y cambiamos a hmac.compare_digest() para la verificación de contraseñas.
Archivo: db/database.py
import os
import hashlib
import hmac
import secrets
from contextlib import contextmanager
import sqlite3
import config
class Database:
def __init__(self, db_path=None):
self.db_path = db_path or config.DB_PATH
if not self.db_path:
self.db_path = "tools.db"
self._initialize_db()
def _initialize_db(self):
with self.get_connection() as conn:
cursor = conn.cursor()
cursor.execute('''
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE,
email TEXT UNIQUE,
password_hash TEXT,
salt TEXT,
github_id TEXT UNIQUE,
is_admin BOOLEAN DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_login TIMESTAMP
)
''')
cursor.execute('''
CREATE TABLE IF NOT EXISTS title_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER,
topic TEXT NOT NULL,
platform TEXT NOT NULL,
style TEXT NOT NULL,
number_of_titles INTEGER NOT NULL,
titles TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id)
)
''')
# Auto-create admin if configured
cursor.execute("SELECT COUNT(*) FROM users WHERE is_admin = 1")
if cursor.fetchone()[0] == 0 and config.ADMIN_EMAIL and config.ADMIN_PASSWORD:
salt = secrets.token_hex(16)
password_hash = self._hash_password(config.ADMIN_PASSWORD, salt)
cursor.execute('''
INSERT INTO users (username, email, password_hash, salt, is_admin)
VALUES (?, ?, ?, ?, 1)
''', ('admin', config.ADMIN_EMAIL, password_hash, salt))
conn.commit()
@staticmethod
def _hash_password(password, salt):
"""PBKDF2-HMAC-SHA256 with 600,000 iterations (OWASP minimum)."""
return hashlib.pbkdf2_hmac(
'sha256',
password.encode('utf-8'),
salt.encode('utf-8'),
600_000 # was 100_000 in the original article
).hex()
@contextmanager
def get_connection(self):
db_dir = os.path.dirname(self.db_path)
if db_dir:
os.makedirs(db_dir, exist_ok=True)
conn = sqlite3.connect(self.db_path)
conn.row_factory = sqlite3.Row
try:
yield conn
finally:
conn.close()
db = Database()Archivo: db/user_dao.py (los cambios clave son la comparación de contraseñas en tiempo constante):
from typing import Optional, Dict, Any, List
import hmac
import secrets
from datetime import datetime
from .database import db
class UserDAO:
@staticmethod
def create_user(username: str, email: str, password: Optional[str] = None,
github_id: Optional[str] = None) -> int:
try:
with db.get_connection() as conn:
cursor = conn.cursor()
cursor.execute(
"SELECT id FROM users WHERE email = ? OR username = ? "
"OR (github_id = ? AND github_id IS NOT NULL)",
(email, username, github_id)
)
if cursor.fetchone():
return 0
password_hash = None
salt = None
if password:
salt = secrets.token_hex(16)
password_hash = db._hash_password(password, salt)
cursor.execute('''
INSERT INTO users (username, email, password_hash, salt, github_id, is_admin)
VALUES (?, ?, ?, ?, ?, 0)
''', (username, email, password_hash, salt, github_id))
conn.commit()
return cursor.lastrowid
except Exception as e:
print(f"Error creating user: {e}")
return 0
@staticmethod
def authenticate_email(email: str, password: str) -> Optional[Dict[str, Any]]:
with db.get_connection() as conn:
cursor = conn.cursor()
cursor.execute(
"SELECT * FROM users WHERE email = ?", (email,)
)
user = cursor.fetchone()
if not user:
return None
if not user['password_hash'] or not user['salt']:
return None
# Constant-time comparison (OWASP recommendation)
password_hash = db._hash_password(password, user['salt'])
if not hmac.compare_digest(password_hash, user['password_hash']):
return None
# Update last_login
cursor.execute(
"UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = ?",
(user['id'],)
)
conn.commit()
return dict(user)
@staticmethod
def find_or_create_github_user(github_id: str, username: str,
email: str) -> Optional[Dict[str, Any]]:
try:
with db.get_connection() as conn:
cursor = conn.cursor()
# Try to find by github_id first
cursor.execute("SELECT * FROM users WHERE github_id = ?", (github_id,))
user = cursor.fetchone()
if user:
cursor.execute(
"UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = ?",
(user['id'],)
)
conn.commit()
return dict(user)
# Create new GitHub user
cursor.execute('''
INSERT INTO users (username, email, github_id, is_admin)
VALUES (?, ?, ?, 0)
''', (username, email, github_id))
conn.commit()
cursor.execute("SELECT * FROM users WHERE id = ?", (cursor.lastrowid,))
return dict(cursor.fetchone())
except Exception as e:
print(f"Error with GitHub user: {e}")
return None
@staticmethod
def get_user_by_id(user_id: int) -> Optional[Dict[str, Any]]:
with db.get_connection() as conn:
cursor = conn.cursor()
cursor.execute("SELECT * FROM users WHERE id = ?", (user_id,))
user = cursor.fetchone()
return dict(user) if user else None
@staticmethod
def is_user_admin(user_id: int) -> bool:
"""Check admin status from DB (not session) for live demotion."""
with db.get_connection() as conn:
cursor = conn.cursor()
cursor.execute("SELECT is_admin FROM users WHERE id = ?", (user_id,))
user = cursor.fetchone()
return bool(user and user['is_admin'])
@staticmethod
def get_all_users() -> List[Dict[str, Any]]:
with db.get_connection() as conn:
cursor = conn.cursor()
cursor.execute("SELECT id, username, email, github_id, is_admin, "
"created_at, last_login FROM users ORDER BY created_at DESC")
return [dict(row) for row in cursor.fetchall()]
@staticmethod
def set_admin(user_id: int, is_admin: bool) -> bool:
with db.get_connection() as conn:
cursor = conn.cursor()
cursor.execute("UPDATE users SET is_admin = ? WHERE id = ?",
(int(is_admin), user_id))
conn.commit()
return cursor.rowcount > 0
@staticmethod
def delete_user(user_id: int) -> bool:
try:
with db.get_connection() as conn:
cursor = conn.cursor()
cursor.execute("DELETE FROM title_history WHERE user_id = ?", (user_id,))
cursor.execute("DELETE FROM users WHERE id = ?", (user_id,))
conn.commit()
return cursor.rowcount > 0
except Exception as e:
print(f"Error deleting user: {e}")
return FalseEl HistoryDAO se mantiene prácticamente igual que el original. Filtra por user_id para usuarios normales y devuelve todos los registros a los administradores.
Cambio Disruptivo: fastlite 0.12.0+
Si actualizas desde una versión antigua, fastlite ahora usa apsw en lugar del sqlite3 de la stdlib. Si tu base de datos se creó con el driver antiguo, puede que tengas que recrearla. La clase Database mostrada arriba usa sqlite3 de la stdlib directamente, que sigue funcionando bien.
Verificación: Ejecuta python -c "from db.database import Database; Database()" para confirmar que el esquema se inicializa sin errores. Debería crearse un archivo tools.db.
Paso 2: configuración y seguridad de la sesión
Archivo: config.py
import os
from dotenv import load_dotenv
load_dotenv()
# Database
DB_PATH = os.getenv("DB_PATH", "tools.db")
# Security -- no placeholder default! Fail fast if unset.
SECRET_KEY = os.getenv("SECRET_KEY")
if not SECRET_KEY:
raise ValueError("SECRET_KEY environment variable is required. "
"Generate one with: python -c 'import secrets; print(secrets.token_hex(32))'")
# Session
SESSION_EXPIRY = int(os.getenv("SESSION_EXPIRY", 604800)) # 7 days in seconds
# GitHub OAuth
GITHUB_CLIENT_ID = os.getenv("GITHUB_CLIENT_ID", "")
GITHUB_CLIENT_SECRET = os.getenv("GITHUB_CLIENT_SECRET", "")
# Admin bootstrap
ADMIN_EMAIL = os.getenv("ADMIN_EMAIL", "")
ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD", "")
# LLM (inherited from Part 5)
DEFAULT_MODEL = os.getenv("DEFAULT_MODEL", "openai/gpt-4o-mini")
# App
DEBUG = os.getenv("DEBUG", "false").lower() == "true"No Uses Secretos de Relleno
El código antiguo tenía "your-secret-key-change-in-production" como valor por defecto de SECRET_KEY. Eso es una trampa. Cualquiera que olvide definir la variable de entorno ejecuta la app con una clave conocida. La configuración anterior lanza ValueError inmediatamente si SECRET_KEY no está definida. Genera una clave con: python -c "import secrets; print(secrets.token_hex(32))"
Inicialización de la app con Beforeware
Archivo: main.py (inicialización de la app y Beforeware, que sustituye al antiguo patrón require_auth() por ruta):
from fasthtml.common import *
from fasthtml.oauth import GitHubAppClient, OAuth, redir_url
import secrets
import hmac
import config
from auth.auth_manager import AuthManager
from auth.email_auth import EmailAuth
from db.user_dao import UserDAO
from db.history_dao import HistoryDAO
login_redir = RedirectResponse('/login', status_code=303)
def before(req, sess):
"""Beforeware: check session auth, redirect to login if missing."""
auth = req.scope['auth'] = sess.get('user_id', None)
if not auth:
return login_redir
bware = Beforeware(before, skip=[
r'/favicon\.ico', r'/static/.*', r'.*\.css',
'/', '/login', '/register',
'/auth/github/callback',
'/error',
])
app = FastHTML(
secret_key=config.SECRET_KEY,
before=bware,
session_cookie="session",
max_age=config.SESSION_EXPIRY,
sess_path="/",
same_site="lax",
sess_https_only=not config.DEBUG,
)
# Route helper: get user_id from req.scope (set by Beforeware)
def get_auth(req): return req.scope['auth']Comportamiento de ./sesskey
Si no pasas secret_key a FastHTML(), genera automáticamente una clave de firma y la guarda en ./sesskey. Esto significa que las sesiones sobreviven a los reinicios, pero deberías añadir sesskey a .gitignore si dependes de esto. En producción, define siempre SECRET_KEY explícitamente.
El patrón Beforeware sustituye a las antiguas llamadas require_auth(session) por ruta. Los handlers que necesitan el ID de usuario declaran un parámetro auth. FastHTML lo inyecta desde req.scope['auth']. La lista skip define las rutas públicas que no necesitan autenticación.
Paso 3: GitHub OAuth con GitHubAppClient
Este es el mayor cambio de código. El antiguo auth/github_auth.py (unas 100 líneas de OAuth manual basado en requests con query strings sin codificar) se reemplaza por ~30 líneas usando el OAuth integrado de FastHTML.
Configuración de la OAuth App de GitHub
- Ve a GitHub > Settings > Developer settings > OAuth Apps > New OAuth App
- Define la Authorization callback URL con tu URL completa:
http://localhost:5001/auth/github/callback - Copia el Client ID y el Client Secret a tu archivo
.env
La URL de callback debe ser absoluta (no una ruta relativa como /auth/github/callback). redir_url() de FastHTML la construye automáticamente a partir del host de la petición.
La subclase OAuth
# In main.py, after app initialization
client = GitHubAppClient(
config.GITHUB_CLIENT_ID,
config.GITHUB_CLIENT_SECRET,
scope="user:email"
)
class Auth(OAuth):
def get_auth(self, info, ident, session, state):
"""
Called after GitHub redirects back.
Args:
info: dict with 'login', 'email' (may be None), etc.
ident: the GitHub user ID (from client.id_key == 'id')
session: the Starlette session
state: the CSRF state parameter
"""
# GitHub may return email: null for non-public emails
email = info.get('email') or f"{info['login']}-{ident}@github.user"
user = UserDAO.find_or_create_github_user(
str(ident), info['login'], email
)
if not user:
return None # redirects back to /login
AuthManager.login_user(session, user)
# Generate CSRF token on login
session['csrf'] = secrets.token_hex(32)
return RedirectResponse('/', status_code=303)
oauth = Auth(app, client, redir_path='/auth/github/callback', login_path='/login')Luego, en la plantilla de tu página de login:
# pages/login.py
from fasthtml.common import *
def login_page(error=None, success=None, session=None):
return Div(
# Error/success messages...
H2("Sign In", cls="text-2xl font-bold mb-6"),
# GitHub OAuth button
A("Sign in with GitHub",
href="/login", # OAuth's login_path handles the redirect
cls="block w-full text-center bg-gray-800 text-white py-2 px-4 rounded mb-4 hover:bg-gray-700"),
Div("or", cls="text-center text-gray-500 my-4"),
# Email/password form
Form(
# CSRF token in every state-changing form
Input(type="hidden", id="csrf", value=session.get('csrf', '')),
Input(type="email", name="email", placeholder="Email", required=True,
cls="w-full p-2 border rounded mb-3"),
Input(type="password", name="password", placeholder="Password",
required=True, cls="w-full p-2 border rounded mb-3"),
Button("Sign In with Email", type="submit",
cls="w-full bg-blue-600 text-white py-2 px-4 rounded hover:bg-blue-700"),
method="post", action="/auth/email/login"
),
# Register link
P(A("Create an account", href="/register", cls="text-blue-600"),
cls="mt-4 text-center"),
cls="max-w-md mx-auto mt-10 p-6 bg-white rounded shadow"
)Caso Límite con el Email de GitHub
Con scope="user:email", el endpoint /user de GitHub devuelve email: null para usuarios que no han configurado un email público. El fallback ({username}-{id}@github.user) crea un email de relleno. En producción, llamarías a https://api.github.com/user/emails con el token de acceso para obtener el email principal. El GitHubAppClient se encarga del intercambio del token por ti.
Verificación: Visita /login en tu navegador. El enlace “Sign in with GitHub” debería redirigir a https://github.com/login/oauth/authorize?.... Tras autorizar, deberías ser redirigido de vuelta y quedar logueado.
Paso 4: autenticación con email y contraseña
El servicio EmailAuth gestiona la validación del registro y el login. Con Beforeware en marcha, los handlers simplemente escriben en session['user_id'] (no hacen falta comprobaciones manuales de auth).
Archivo: auth/email_auth.py
import re
from typing import Optional, Dict, Any, Tuple
class EmailAuth:
@staticmethod
def validate_registration(username: str, email: str,
password: str) -> Tuple[bool, str]:
"""Validate registration input. Returns (is_valid, error_message)."""
if not username or len(username) < 3:
return False, "Username must be at least 3 characters"
if not email or not re.match(r'^[\w\.-]+@[\w\.-]+\.\w+$', email):
return False, "Invalid email address"
if not password or len(password) < 8:
return False, "Password must be at least 8 characters"
if not re.search(r'[A-Z]', password) or not re.search(r'[0-9]', password):
return False, "Password must contain at least one uppercase letter and one number"
return True, ""
@staticmethod
def authenticate(email: str, password: str) -> Optional[Dict[str, Any]]:
"""Authenticate user with email/password."""
from db.user_dao import UserDAO
return UserDAO.authenticate_email(email, password)Handlers de rutas en main.py:
@app.post('/auth/email/register')
def email_register(req, username: str, email: str, password: str,
confirm_password: str, csrf: str = ""):
session = req.session
# CSRF validation
if not hmac.compare_digest(session.get('csrf', ''), csrf):
return RedirectResponse('/register', status_code=303)
# Validate input
valid, error = EmailAuth.validate_registration(username, email, password)
if not valid:
return register_page(error=error, session=session)
if password != confirm_password:
return register_page(error="Passwords do not match", session=session)
# Create user
from db.user_dao import UserDAO
user_id = UserDAO.create_user(username=username, email=email, password=password)
if not user_id:
return register_page(error="Email or username already exists", session=session)
return RedirectResponse('/login?registered=1', status_code=303)
@app.post('/auth/email/login')
def email_login(req, email: str, password: str, csrf: str = ""):
session = req.session
# CSRF validation
if not hmac.compare_digest(session.get('csrf', ''), csrf):
return login_page(error="Invalid request", session=session)
user = EmailAuth.authenticate(email, password)
if not user:
return login_page(error="Invalid email or password", session=session)
AuthManager.login_user(session, user)
session['csrf'] = secrets.token_hex(32)
return RedirectResponse('/', status_code=303)Verificación: Registra un usuario nuevo en /register y luego inicia sesión en /login. Comprueba que se te redirige a / y que la navegación muestra tu nombre de usuario.
Paso 5: protección CSRF
FastHTML no tiene protección CSRF integrada. Añadimos un patrón de token aleatorio guardado en sesión que protege todos los formularios que modifican estado.
El enfoque:
- Al iniciar sesión (email o GitHub), genera
session['csrf'] = secrets.token_hex(32) - Incluye
Input(type="hidden", id="csrf", value=session.get('csrf', ''))en todos los formularios que hacen POST - En cada handler POST, valida con
hmac.compare_digest(session.get('csrf', ''), csrf) - Si el token no coincide, rechaza la petición
# Helper to inject CSRF hidden field into forms
def csrf_field(session):
return Input(type="hidden", id="csrf", name="csrf",
value=session.get('csrf', ''))
# In any POST form:
Form(
csrf_field(session),
Input(type="text", name="topic", ...),
Button("Generate Titles", type="submit"),
method="post", action="/generate"
)
# In any POST handler:
@app.post('/generate')
def generate_titles(req, topic: str, csrf: str = "", ...):
session = req.session
if not hmac.compare_digest(session.get('csrf', ''), csrf):
return Div("Invalid request", cls="text-red-600")
# ... rest of handlerFastHTML No Tiene Protección CSRF Integrada
Este es un patrón DIY basado en la guía de Marius Vach. Todos los formularios que modifican estado (register, login, make-admin, delete-user, delete-history) necesitan el campo oculto csrf. No te lo saltes. Las acciones de administrador son objetivos de alto valor.
Verificación: Abre las herramientas de desarrollo de tu navegador en cualquier formulario, inspecciona el HTML y confirma que el campo oculto csrf está presente con un valor hexadecimal aleatorio. Envía un formulario sin él (o con un valor manipulado) y confirma que es rechazado.
Paso 6: control de acceso basado en roles y páginas de administración
Los controles de administración usan el flag is_admin de la base de datos. La decisión de diseño clave: is_admin se consulta en la base de datos en cada petición (vía UserDAO.is_user_admin()), no se cachea en la sesión. Esto significa que degradar a un admin en la base de datos surte efecto inmediatamente.
# In main.py -- admin route helper
def require_admin(req):
"""Check admin status from DB. Returns user_id or raises."""
user_id = get_auth(req)
if not UserDAO.is_user_admin(user_id):
raise HTTPException(403, "Admin access required")
return user_id
# Admin dashboard
@app.get('/admin')
def admin_dashboard(req):
user_id = require_admin(req)
users = UserDAO.get_all_users()
return page_layout("Admin Dashboard",
admin_dashboard_content(users),
current_page="/admin", session=req.session)
@app.post('/admin/make-admin')
def make_admin(req, user_id: int, csrf: str = ""):
session = req.session
if not hmac.compare_digest(session.get('csrf', ''), csrf):
raise HTTPException(401, "Invalid CSRF token")
admin_id = require_admin(req)
UserDAO.set_admin(user_id, True)
return RedirectResponse('/admin', status_code=303)
@app.post('/admin/delete-user')
def delete_user(req, user_id: int, csrf: str = ""):
session = req.session
if not hmac.compare_digest(session.get('csrf', ''), csrf):
raise HTTPException(401, "Invalid CSRF token")
admin_id = require_admin(req)
if user_id == admin_id:
return RedirectResponse('/admin?error=cannot_delete_self', status_code=303)
UserDAO.delete_user(user_id)
return RedirectResponse('/admin', status_code=303)Las páginas de administración incluyen:
- Panel de administración (
/admin): vista general con número de usuarios, registros recientes, estadísticas de generación - Usuarios admin (
/admin/users): lista de todos los usuarios con acciones de hacer admin/eliminar - Historial admin (
/admin/history): ver el historial de generación de todos los usuarios
Paso 7: actualizar los componentes de UI
Los componentes de cabecera y layout ahora usan el parámetro auth inyectado por Beforeware.
Archivo: components/header.py
from fasthtml.common import *
from db.user_dao import UserDAO
def header(current_page="/", auth=None):
"""Auth-aware navigation header."""
nav_items = [A("Home", href="/", cls="hover:text-blue-600")]
if auth:
nav_items.extend([
A("Title Generator", href="/title-generator",
cls="hover:text-blue-600"),
A("My History", href="/history",
cls="hover:text-blue-600"),
])
# Admin link only for admins (DB check, not session)
if UserDAO.is_user_admin(auth):
nav_items.append(
A("Admin", href="/admin", cls="hover:text-red-600 font-semibold")
)
nav_items.append(
A("Logout", href="/logout", cls="hover:text-red-600")
)
else:
nav_items.extend([
A("Login", href="/login", cls="hover:text-blue-600"),
A("Register", href="/register", cls="hover:text-blue-600"),
])
return Nav(
Div(
A("AI Title Generator", href="/", cls="text-xl font-bold"),
Div(*nav_items, cls="flex gap-4"),
cls="container mx-auto px-4 py-3 flex justify-between items-center"
),
cls="bg-white shadow"
)Archivo: components/page_layout.py (fíjate en el parámetro auth):
from fasthtml.common import *
from .header import header
from .footer import footer
def page_layout(title, content, current_page="/", auth=None):
return Html(
Head(
Title(title),
Meta(charset="UTF-8"),
Meta(name="viewport", content="width=device-width, initial-scale=1.0"),
Script(src="https://cdn.tailwindcss.com"),
),
Body(
Div(
header(current_page, auth),
Main(
Div(content, cls="container mx-auto px-4 py-8"),
cls="flex-grow"
),
footer(),
cls="flex flex-col min-h-screen"
)
)
)Paso 8: rutas protegidas – generador de títulos e historial de usuario
Con Beforeware gestionando la autenticación, estas rutas quedan protegidas automáticamente. No hace falta ninguna llamada manual a require_auth().
# Title generator -- protected by Beforeware
@app.get('/title-generator')
def title_generator_page(req):
auth = get_auth(req)
return page_layout("Title Generator",
title_generator_form(),
current_page="/title-generator", auth=auth)
@app.post('/generate')
def generate_titles(req, topic: str, platform: str, style: str,
number: int, csrf: str = ""):
session = req.session
auth = get_auth(req)
# CSRF check
if not hmac.compare_digest(session.get('csrf', ''), csrf):
return title_generator_form(error="Invalid request")
# Generate titles (LLM call from Part 5)
from tools.title_generator import generate
titles = generate(topic, platform, style, number)
# Save to history with user_id
HistoryDAO.save_generation(auth, topic, platform, style, number, titles)
return title_generator_form(titles=titles, session=session, auth=auth)
# User history -- shows only the logged-in user's records
@app.get('/history')
def history_page(req):
auth = get_auth(req)
records = HistoryDAO.get_user_history(auth)
return page_layout("My History",
user_history_content(records),
current_page="/history", auth=auth)
# Admin history -- shows all users (admin check via DB)
@app.get('/admin/history')
def admin_history(req):
auth = require_admin(req)
records = HistoryDAO.get_all_history()
return page_layout("All History",
admin_history_content(records),
current_page="/admin/history", auth=auth)Paso 9: variables de entorno y ejecución de la app
Crea tu archivo .env:
# .env
SECRET_KEY=your-random-64-char-hex-string
GITHUB_CLIENT_ID=your_github_client_id
GITHUB_CLIENT_SECRET=your_github_client_secret
ADMIN_EMAIL=[email protected]
ADMIN_PASSWORD=YourStr0ngPassword
DB_PATH=tools.db
DEBUG=true
SESSION_EXPIRY=604800
DEFAULT_MODEL=openai/gpt-4o-miniInstala las dependencias y ejecuta:
pip install python-fasthtml>=0.14,<0.15 python-dotenv
python main.pyAbre http://localhost:5001 en tu navegador. Deberías ver:
- La landing page con opciones de login/registro
- Tras iniciar sesión (email o GitHub), acceso al generador de títulos y a tu historial
- El panel de administración si iniciaste sesión con las credenciales de admin
Scope de GitHub
El cliente OAuth usa scope="user:email", que es suficiente para recuperar el perfil y el email del usuario. La antigua combinación de scopes read:user user:email ya no es necesaria.
Verificación y resolución de problemas
Problemas de versión e imports
python-fasthtml 0.14.1 eliminó varios re-exports de fasthtml.common:
| Qué se eliminó | Dónde importarlo ahora |
|---|---|
uvicorn |
import uvicorn directamente |
Database (apswutils) |
from fastlite import Database |
componentes de fastlite |
from fastlite import * |
componentes de pico |
from fasthtml.pico import picolink |
Si obtienes un ImportError tras actualizar, revisa estos imports primero. Es lo primero que se rompe al pasar de 0.6.x a 0.14.x.
Probar los flujos de autenticación
FastHTML 0.14.1 añadió FastHTMLTestClient con decodificación de sesiones:
from fasthtml.test import FastHTMLTestClient
client = FastHTMLTestClient(app)
# Test registration
resp = client.post('/auth/email/register', data={
'username': 'testuser',
'email': '[email protected]',
'password': 'TestPass123',
'confirm_password': 'TestPass123',
'csrf': '' # bypass CSRF in tests or set session first
})
assert resp.status_code == 303
# Test login
resp = client.post('/auth/email/login', data={
'email': '[email protected]',
'password': 'TestPass123',
'csrf': ''
})
assert resp.status_code == 303
assert 'session' in resp.cookiesModos de fallo comunes
¿Cómo arreglo ./sesskey commiteado a git?
Si ./sesskey está commiteado a git, cualquiera que clone el repositorio obtiene tu clave de firma de sesiones. Añade sesskey a .gitignore inmediatamente y rota la clave borrando el archivo y reiniciando la app (o define SECRET_KEY explícitamente).
Error en el callback de GitHub OAuth
La URL de callback debe ser absoluta: http://localhost:5001/auth/github/callback (no /auth/github/callback). redir_url() de FastHTML la construye a partir del host de la petición, así que funciona en desarrollo y en producción. Asegúrate de que la “Authorization callback URL” de tu OAuth App de GitHub coincide.
Colisión de nombre de usuario de GitHub al registrarse
Si el nombre de usuario de GitHub colisiona con el username de un usuario existente de email/contraseña (que tiene una restricción UNIQUE), el insert de find_or_create_github_user() fallará. El except amplio lo captura y devuelve None, devolviendo al usuario a /login. En producción, gestiona esto con un fallback de nombre de usuario generado.
Un admin degradado sigue teniendo acceso
Si cacheas is_admin en la sesión, las degradaciones no surten efecto hasta el próximo login. El código de este tutorial hace una consulta UserDAO.is_user_admin() a la base de datos en cada petición, así que las degradaciones son inmediatas. Si ves acceso de admin obsoleto, comprueba que tus rutas de administración llaman a require_admin() (consulta a BD) en lugar de leer de la sesión.
La app no arranca: SECRET_KEY no definida
La configuración lanza ValueError si falta SECRET_KEY. Genera una: python -c "import secrets; print(secrets.token_hex(32))" y añádela a tu archivo .env.
Ataque de temporización en la comparación de contraseñas
El código antiguo usaba != para comparar contraseñas, lo cual no es de tiempo constante. El código actualizado usa hmac.compare_digest(), que tarda lo mismo independientemente de dónde difieran las cadenas. Esto evita la adivinación de contraseñas basada en temporización.
Protección contra fuerza bruta
No hay limitación de tasa en /auth/email/login ni en el callback de OAuth. En producción, añade slowapi con @limiter.limit("30/day") en los endpoints de login, o pon fail2ban delante de tu proxy inverso. El paquete fasthtml-admin (AndreasThinks, GitHub) usa este patrón de serie.
Notas de producción
SQLite en producción
Un VPS de instancia única es suficiente para SQLite. Activa el modo WAL para mejor concurrencia:
conn.execute("PRAGMA journal_mode=WAL")La sesión de cookie firmada significa que varios workers de uvicorn pueden compartir el estado de autenticación – no hay un almacén de sesiones en el servidor que sincronizar. Si escalas más allá de una máquina, considera PostgreSQL vía fastlite.
Para el despliegue, puedes ejecutar tu app FastHTML en Docker o desplegar la app con Dokploy. Un VPS de Hetzner por ~5 EUR/mes maneja esta carga sin problemas. Hostinger VPS es otra opción económica con virtualización KVM.
Los precios de los VPS subieron en general en 2026 — si estás replanteándote un servidor alquilado, mira qué cambió y cuándo gana un mini PC.
Paquetes de Autenticación de Terceros
Si necesitas verificación de email, restablecimiento de contraseña o un sistema de autenticación más completo, echa un vistazo a fasthtml-admin (AndreasThinks, 17 estrellas en GitHub, licencia MIT), que implementa registro con confirmación por email, autenticación basada en Beforeware, OAuth y backup/restauración de BD. fasthtml-auth en PyPI anuncia RBAC, gestión de sesiones y proveedores OAuth. Ambos son paquetes jóvenes – pruébalos antes de confiar en ellos en producción. Para endurecer tu servidor más allá de la autenticación a nivel de aplicación, consulta la checklist de seguridad del BSI.
Conclusión
Hemos construido un sistema completo de autenticación de usuarios en FastHTML para el Generador de Títulos con IA: GitHub OAuth mediante el GitHubAppClient integrado, registro con email/contraseña con hash seguro de contraseñas (PBKDF2 con 600k iteraciones), protección CSRF en todos los formularios, endurecimiento de sesiones con una gestión correcta de secret_key y controles de administrador basados en roles con consultas a la base de datos en cada petición. El patrón Beforeware mantiene la lógica de autenticación en un solo lugar en lugar de dispersa por cada handler de ruta.
Desde aquí, los siguientes pasos naturales son la verificación de email, el restablecimiento de contraseña y el acceso por equipos – pero eso es otro tutorial. El paquete fasthtml-admin cubre parte de ese terreno si lo necesitas ya.


