English · Español
Lab 01 — Onboarding passwordless (el primer login fija la contraseña)¶
🇪🇸 Una persona nueva entra al portal con su nombre de usuario y nada más. El sistema detecta que aún no hay contraseña y la lleva a una pantalla para crearla. La segunda vez ya tiene que escribirla. La regla: el "primer beso" es sin fricción; lo demás, con Argon2id y pepper.
Objetivo¶
Implementar el flujo sin contraseña por defecto definido en docs/phase-41-learner-portal/theory/03-passwordless-onboarding.md. En el primer login el usuario teclea solo un username; si su registro tiene argon2_hash IS NULL AND must_set_on_next_login=TRUE, se le redirige a /set-password, donde se calcula un hash Argon2id del lado del servidor (con pepper global), se escribe al vault de secretos, y se voltea la bandera. Cada transición se audita. Logins posteriores piden la contraseña normalmente.
Por qué existe este lab¶
La audiencia para este portal son personas que Borja conoce en persona (familia, estudiantes). Forzarles a inventar una contraseña antes de haber visto siquiera el dashboard es la mayor fuente individual de drop-off "lo haré luego" en herramientas educativas de esta forma. La teoría de la Fase 41 sostiene que un único apretón de manos verbal en persona ("aquí está tu username, entra una vez y fija una contraseña") es una señal de confianza más fuerte que un flujo de enlace aleatorio por email — y más barato de operar.
La implementación es pequeña pero la superficie de seguridad es afilada. Los parámetros de Argon2id, el split pepper-vs-salt, el logging de auditoría de la transición de bandera, y la decisión de redirect-vs-render deben ser todos deliberados. Este lab los hace deliberados.
Prerrequisitos¶
- Lab 00 hecho (
/healthzverde). - El módulo sanitizer de la Fase 37 existe (
src/sanitizer/) — usado aquí para input de formulario. - BLUEPRINT para
src/miniportal/auth/escrito y aprobado (cubre los parámetros de Argon2id, la ubicación de almacenamiento del pepper, el schema del audit log). - La tabla
studentsexiste parcialmente del schema del plan Fase 41 §4; la lista de columnas disponible aunque las migraciones vivan en el lab 02.
Entregables¶
src/miniportal/auth/__init__.py— marcador de paquete.src/miniportal/auth/passwords.py— hasher Argon2id (params + pepper, sin plaintext loggeado).src/miniportal/auth/sessions.py— sesión por cookie firmada conitsdangerous.src/miniportal/auth/vault.py— lectura/escritura del pepper + material por secreto.src/miniportal/routes/login.py—GET /login,POST /login,GET /set-password,POST /set-password.src/miniportal/templates/login.html.jinja,templates/set-password.html.jinja.src/miniportal/audit.py— escritor de audit log append-only (un archivo JSONL por día).- Migración
alembiccreandostudents.argon2_hash,students.must_set_on_next_login, tablaaudit_log. tests/portal/test_passwordless_first_login.py.tests/portal/test_subsequent_login_requires_password.py.tests/portal/test_audit_log_first_password_set.py.
Paso 1 — El hasher Argon2id¶
# src/miniportal/auth/passwords.py
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
from miniportal.auth.vault import get_pepper
# Phase 41 BLUEPRINT chooses: time_cost=3, memory_cost=64*1024, parallelism=4, hash_len=32.
# Justification: Borja's i5-8250U benchmark — see experiments/41-onboarding/argon2-bench.md.
_HASHER = PasswordHasher(time_cost=3, memory_cost=64 * 1024, parallelism=4, hash_len=32)
def hash_password(plaintext: str) -> str:
"""Return the Argon2id encoded hash of (plaintext + pepper). Never log plaintext."""
raise NotImplementedError("Lab 01 step 1 — concatenate plaintext with pepper, pass to hasher.")
def verify_password(plaintext: str, stored: str) -> bool:
"""Constant-time verify. Returns False on mismatch (never raises to caller)."""
raise NotImplementedError("Lab 01 step 1 — call verify, catch VerifyMismatchError, return False.")
Reglas del pepper:
- Cargado de la variable de entorno
PORTAL_PEPPER(leída una vez al arrancar la app víaauth.vault.get_pepper). - Longitud ≥ 32 bytes (lanzar excepción con valores más cortos).
- Nunca escrito a un archivo que la app pueda leer en tiempo de ejecución; vive en el vault de secretos del despliegue (el lab de deploy de la Fase 41 finaliza esto).
Paso 2 — Session cookie¶
# src/miniportal/auth/sessions.py
from itsdangerous import URLSafeTimedSerializer
from miniportal.settings import PortalSettings
def make_serializer(settings: PortalSettings) -> URLSafeTimedSerializer:
raise NotImplementedError("Lab 01 step 2 — return URLSafeTimedSerializer(settings.session_secret, salt='miniportal-session').")
def set_session(response, student_id: int, settings: PortalSettings) -> None:
raise NotImplementedError("Lab 01 step 2 — sign student_id, set HttpOnly Secure SameSite=Lax cookie with max_age=8h.")
def read_session(request, settings: PortalSettings) -> int | None:
raise NotImplementedError("Lab 01 step 2 — read cookie, verify signature + age; return student_id or None.")
Flags de la cookie: HttpOnly no es negociable; Secure está activo siempre que settings.env != "dev"; SameSite=Lax es el valor por defecto elegido (la teoría §03 §csrf de la Fase 41 discute por qué no Strict).
Paso 3 — Las rutas de login¶
# src/miniportal/routes/login.py
from fastapi import APIRouter, Form, Request
from fastapi.responses import RedirectResponse
router = APIRouter()
@router.get("/login")
async def login_form(request: Request):
raise NotImplementedError("Lab 01 step 3 — render login.html.jinja, single username field.")
@router.post("/login")
async def login_submit(request: Request, username: str = Form(...)):
"""Decision tree:
1. Username does not exist -> render login with neutral error (no user enumeration).
2. argon2_hash IS NULL -> redirect to /set-password?u=<id-token>.
3. argon2_hash IS NOT NULL, password absent -> render login.html with password field.
4. password provided + verifies -> set session cookie, redirect to /.
"""
raise NotImplementedError("Lab 01 step 3 — implement the four-branch decision tree.")
@router.get("/set-password")
async def set_password_form(request: Request, u: str):
"""`u` is a single-use signed token (max_age=10min) referencing the student.
Never include the bare student_id in a URL."""
raise NotImplementedError("Lab 01 step 3 — verify token, render set-password.html.")
@router.post("/set-password")
async def set_password_submit(request: Request, u: str = Form(...), password: str = Form(...), confirm: str = Form(...)):
"""Validate strength (Phase 37 sanitizer + zxcvbn or equivalent), hash, write to vault row,
flip must_set_on_next_login=FALSE, audit, redirect to /."""
raise NotImplementedError("Lab 01 step 3 — full set-password flow.")
Seguridad anti-enumeración de usuarios: en la rama 1 la página de respuesta DEBE ser byte-a-byte idéntica a la respuesta de error de la rama 3. La equivalencia de tiempos es un best-effort pero no la defensa primaria; la defensa primaria es "mismo body, mismo status, mismas cabeceras".
Paso 4 — Audit log¶
# src/miniportal/audit.py
from pathlib import Path
import json, time
def audit(event: str, *, student_id: int | None = None, request_id: str | None = None, **fields) -> None:
"""Append a JSONL row to var/audit/YYYY-MM-DD.log. Never raises."""
raise NotImplementedError("Lab 01 step 4 — open file in append mode, write JSON line, fsync.")
Eventos emitidos por este lab:
auth.login.attempt(success/fail por rama — nunca contraseña en plaintext en los campos).auth.first_password_set(student_id, request_id, argon2_params_version).auth.session.start.
Paso 5 — La migración¶
# alembic/versions/<rev>_passwordless_onboarding.py
def upgrade() -> None:
raise NotImplementedError("Lab 01 step 5 — add columns argon2_hash NULLABLE, must_set_on_next_login BOOL DEFAULT TRUE; create audit_log if not exists.")
def downgrade() -> None:
raise NotImplementedError("Lab 01 step 5 — drop the two columns; keep audit_log (one-way).")
downgrade deliberadamente no borra el audit log — perder el historial de auditoría durante un rollback es peor que dejar una tabla vacía detrás.
Paso 6 — Los tres tests¶
# tests/portal/test_passwordless_first_login.py
def test_first_login_redirects_to_set_password():
raise NotImplementedError(
"Seed a student with argon2_hash=NULL, must_set=TRUE. "
"POST /login with username only. Assert 302 -> /set-password?u=...; "
"assert no Set-Cookie session yet."
)
def test_set_password_writes_hash_and_flips_flag():
raise NotImplementedError(
"POST /set-password with valid token + matching password+confirm. "
"Assert: argon2_hash is not NULL; must_set_on_next_login is FALSE; "
"audit_log has auth.first_password_set row."
)
# tests/portal/test_subsequent_login_requires_password.py
def test_login_after_first_set_demands_password():
raise NotImplementedError(
"Seed a student already through onboarding. POST /login with username only -> "
"response is the login page with password field visible (status 200, not 302)."
)
def test_wrong_password_returns_neutral_error():
raise NotImplementedError(
"POST /login with correct username + wrong password. Response status, body, and headers "
"must equal those of POST /login with a nonexistent username."
)
# tests/portal/test_audit_log_first_password_set.py
def test_audit_event_no_plaintext():
raise NotImplementedError(
"Run the first-password-set flow with password='hunter2'. Read today's audit JSONL. "
"Assert: exactly one row with event='auth.first_password_set'; the row does NOT contain 'hunter2' "
"anywhere in its serialized form."
)
Cómo es "done"¶
-
alembic upgrade headse aplica limpiamente en una DB fresca. - Flujo de primer login: POST con solo username redirige a
/set-password. - Flujo set-password: escribe hash Argon2id, voltea bandera, audita.
- Logins posteriores piden contraseña.
- El test anti-enumeración pasa (equivalencia de respuesta para usuario desconocido vs contraseña incorrecta).
- El audit log contiene el evento y nunca el plaintext.
-
bandit -r src/miniportal/auth/reporta cero hallazgos altos; el pepper no está hardcodeado. -
mypy --strict src/miniportal/auth/está verde. - Notas del benchmark de Argon2 registradas en
experiments/41-onboarding/argon2-bench.md(justifica la elección de parámetros para el hardware de Borja).
Trampas comunes¶
- Devolver códigos de estado diferentes para usuario desconocido vs conocido. Enumeración de usuarios clásica. Mismo body, mismo status, mismas cabeceras — ambas ramas.
- Loggear el payload del formulario. Un log de request que captura el body POST filtra la contraseña en plaintext a disco. Configura el logger de request para redactar campos de formulario llamados
password/confirm. - Olvidar el pepper. Argon2 sin pepper está bien — hasta que la DB se exfiltra y los salts están ahí mismo en el mismo archivo. El pepper vive fuera de la DB.
- Poner el student_id en la URL de set-password. Usa un token firmado de TTL corto. El ID en bruto hace URLs enumerables.
- Saltarse la comprobación de fuerza. El instinto de Borja es "estamos entre amigos, cualquier contraseña vale". La teoría §03 disiente: la contraseña del amigo se reutiliza en su banco. La comprobación de fuerza es el favor más barato que les hacemos.
- Un archivo de auditoría por fila. El sistema de archivos se quejará a escala. Un archivo por día, append-only, JSONL.
Siguiente: lab/02-multi-student-profiles.md — la tabla students y el enum de rol.