Anexo al reto · FastAPI + PostgreSQL · todo en Docker

Anexo con Docker: el módulo de identidad de tu reto

Tu app del reto necesita un servidor que sepa quién es quién y qué puede hacer cada quien. Este anexo te da uno de referencia con el mismo contrato que usaste en la Práctica 6, te dice por qué construirlo en vez de instalar uno, y cómo adaptarlo a tu proyecto.

Para el reto, no para una sesión FastAPI + PostgreSQL: requisito de la materia Mismo contrato que la Práctica 6 ~300 líneas de Python, todas leídas Docker: base, servidor y túnel Código en github.com/rpversontec/reto-auth-fastapi-docker

Prerrequisitos. La Práctica 6 completa. Este anexo da por sabido qué es un JWT, por qué hay dos tokens, qué diferencia un 401 de un 403, y cómo tu app guarda y renueva la sesión. Del lado del servidor, Python y lo básico de FastAPI.

Lo que trae. Un módulo de autenticación y autorización que ya corre: FastAPI, SQLAlchemy y PostgreSQL, con el mismo contrato que startdroid.com/api. Tu app de la Práctica 6 funciona contra él cambiando una línea — se probó, y más abajo se ve cómo. El código está en un repositorio público:

git clone https://github.com/rpversontec/reto-auth-fastapi-docker.git
cd reto-auth-fastapi-docker

Es un punto de partida, no una dependencia: cópialo a tu proyecto del reto y hazlo tuyo.

Esta es la versión con Docker del anexo de identidad. El módulo y su código son los mismos; cambia cómo se corre: la base, el servidor y el túnel viven en contenedores, y en tu máquina no instalas Python ni PostgreSQL. Si ya empezaste con la otra versión, no hace falta cambiarte.

Relación con el reto. El requisito de la materia es FastAPI + PostgreSQL para el backend. Este es el pedazo del backend que no puedes improvisar, porque si sale mal no se nota hasta que alguien entra como otra persona.

Lo que el módulo tiene que hacer

Es el contrato de la Práctica 6, tal cual. Tu app ya lo consume; lo que cambia es quién lo sirve.

Ruta Sin token Qué hace
POST /api/auth/register ✓ Crea la cuenta y devuelve el par de tokens
POST /api/auth/login ✓ Verifica la contraseña y devuelve el par
POST /api/auth/refresh ✓ Cambia un refresh válido por un par nuevo (y retira el viejo)
POST /api/auth/logout ✓ Revoca ese refresh
GET /api/auth/me Bearer Quién eres, según el token
DELETE /api/auth/sesiones Bearer Revoca todos tus refresh
GET /api/avisos?desde=N Bearer El recurso protegido: cualquiera con sesión. desde filtra lo que el cliente ya tiene
GET /api/avisos/stream Bearer SSE (Práctica 8): empuja cada aviso nuevo hasta que el token vence; acepta Last-Event-ID
POST /api/avisos · DELETE /api/avisos/:id Bearer + rol El recurso protegido por rol

Y las reglas que no se ven en la tabla: la contraseña se guarda como hash Argon2; el access token es un JWT firmado que dura minutos; el refresh es aleatorio, se guarda hasheado, dura días y rota en cada uso; los errores tienen la forma { error, code, hint }; el rol lo decide el servidor.

En tu reto, avisos se llama como se llame tu recurso. Lo demás no cambia.


Tres caminos, y cuál tomar

Se propusieron tres opciones. Aquí está lo que cuesta cada una, medido en lo que importa para el reto: cumplir el requisito, entenderlo en la defensa, y que la app que ya tienes funcione.

Construirlo en FastAPI Keycloak PocketBase
Qué es ~300 líneas de Python: este anexo Servidor de identidad completo (OIDC, SSO, admin) Backend en un binario: base, auth y archivos
FastAPI + PostgreSQL Sí, es tu backend Tu FastAPI además de Keycloak (Java, Docker, ~1 GB RAM) No: trae su propia base (SQLite) y su propio servidor
Tu app de la P6 Funciona cambiando BASE_URL No: el login es por navegador (OAuth + PKCE, librería AppAuth); hay que reescribir el Bloque A y el D Casi: el contrato es otro (/api/collections/users/auth-with-password), hay que reescribir DTO y rutas
Roles Un campo, una dependencia Realms, clients, roles, mappers: potente y largo de configurar Reglas por colección, en su lenguaje
Lo puedes explicar en 2 min Sí, porque lo leíste Parcialmente: la mitad es configuración Parcialmente
Cuándo sí El reto de esta materia Si el reto exige “entrar con Google / con la cuenta del Tec” o SSO entre varias apps Prototipos rápidos fuera de este curso
Construirlo no es inventarlo

“Desde cero” suena a escribir criptografía. No: es ensamblar cuatro piezas que ya existen y están probadas —Argon2 para contraseñas, PyJWT para tokens, secrets para el refresh, PostgreSQL para guardar— en trescientas líneas que puedes leer completas. Lo que no debes hacer nunca es escribir tu propio hash, tu propio formato de token o tu propio generador de aleatorios.

Keycloak y PocketBase resuelven problemas que este reto no tiene (federación, SSO, un backend sin código). Pagarías su complejidad sin cobrar su beneficio, y uno de los dos ni siquiera cumple el requisito de PostgreSQL.

La recomendación es construirlo. Este anexo es el módulo de referencia; lo que sigue es cómo levantarlo, leerlo y adaptarlo.


Levanta el de referencia

Lo que necesitas

  • Docker Desktop. La base, el servidor y el túnel corren en contenedores: no instalas Python, PostgreSQL ni nada más.
  • Git Bash (viene con Git para Windows), para smoke.sh.
  • uv, solo el día que agregues una dependencia (uv add … actualiza uv.lock). Para correr el módulo no hace falta.
Si Docker Desktop se queda en «Starting the Docker Engine…»

En Windows casi siempre es un WSL viejo, y Docker no da ningún error: solo espera. En PowerShell, wsl --update, luego wsl --shutdown, y vuelve a abrir Docker Desktop. Al preparar este anexo, con WSL 2.1.5 se quedó quince minutos; después de actualizar, arrancó en menos de uno.

Y si docker compose responde docker-credential-desktop: executable file not found, abre una terminal nueva: la que tenías abierta antes de instalar Docker no ve sus herramientas.

La imagen del servidor instala exactamente lo que dice pyproject.toml:

pyproject.toml
[project]
name = "auth-fastapi"
version = "0.1.0"
description = "Modulo de autenticacion y autorizacion de referencia para el reto de TC2007B: FastAPI + PostgreSQL, mismo contrato que la API de la Practica 6."
requires-python = ">=3.11"
dependencies = [
    "fastapi==0.141.1",
    "uvicorn[standard]==0.53.0",
    "sqlalchemy==2.0.54",
    "psycopg[binary]==3.3.6",
    "pyjwt==2.14.0",
    "pwdlib[argon2]==0.3.1",
    "pydantic-settings==2.15.0",
]

Las versiones están fijadas y verificadas en septiembre de 2026. Dos advertencias sobre lo que vas a encontrar en tutoriales:

  • passlib está abandonada y rompe con las versiones actuales de bcrypt. La documentación de FastAPI la reemplazó por pwdlib; úsala.
  • python-jose también. Para JWT, PyJWT (import jwt).

Los contenedores y los secretos

El servidor se empaca en una imagen. Las dependencias van en una capa aparte, antes que el código: si solo cambias el código, reconstruir tarda segundos.

Dockerfile
# La imagen del servidor: Python 3.12 y las dependencias exactas de uv.lock.
FROM python:3.12-slim-bookworm
COPY --from=ghcr.io/astral-sh/uv:0.12.9 /uv /bin/uv

WORKDIR /code
ENV UV_LINK_MODE=copy \
    UV_COMPILE_BYTECODE=1 \
    PYTHONUNBUFFERED=1

# Primero las dependencias: si solo cambia tu código, Docker reutiliza esta capa
# y reconstruir tarda segundos, no minutos.
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-install-project --no-dev

COPY app ./app

EXPOSE 8000
CMD ["uv", "run", "--no-sync", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

Y los tres servicios, en la misma red de Docker:

docker-compose.yml
# Todo el backend en la red de Docker:
#   docker compose up -d                   la base y el servidor (http://localhost:8000)
#   docker compose --profile tunel up -d   además, el túnel HTTPS
#   docker compose logs tunel              ahí aparece la dirección https://…trycloudflare.com
services:
  db:
    image: postgres:16
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: postgres
      POSTGRES_DB: avisos
    volumes:
      - pgdata:/var/lib/postgresql/data
    # Sin `ports`: la base solo se ve dentro de la red de Docker. Así no choca con
    # un PostgreSQL que ya tengas en el 5432. Para entrar: docker compose exec db psql -U postgres avisos
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres -d avisos"]
      interval: 2s
      retries: 30

  api:
    build: .
    env_file: .env
    environment:
      # Dentro de la red de Docker, la base se llama `db`, no `localhost`.
      DATABASE_URL: postgresql+psycopg://postgres:postgres@db:5432/avisos
    # --reload + el volumen: editas app/ en tu máquina y el servidor se reinicia solo.
    command: ["uv", "run", "--no-sync", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
    volumes:
      - ./app:/code/app
    # Solo en 127.0.0.1: tu máquina y el emulador (10.0.2.2) llegan; el resto del wifi, no.
    ports:
      - "127.0.0.1:8000:8000"
    depends_on:
      db:
        condition: service_healthy

  tunel:
    image: cloudflare/cloudflared:2026.9.3
    # Llega a la API por su nombre en la red de Docker. http2 porque muchas redes bloquean QUIC (UDP).
    command: ["tunnel", "--no-autoupdate", "--protocol", "http2", "--url", "http://api:8000"]
    depends_on:
      - api
    profiles: ["tunel"]

volumes:
  pgdata:

Tres decisiones que conviene entender:

La base no publica su puerto. Solo la ven los otros contenedores, que la encuentran por su nombre, db. Así no choca con un PostgreSQL que ya tengas en el 5432, y nadie fuera de tu máquina la alcanza.

El código se monta, no se copia. El volumen ./app:/code/app y --reload hacen que, al guardar un archivo de app/, el servidor se reinicie solo. Se probó editando desde Windows: tarda un segundo.

El túnel va en un perfil. No arranca con docker compose up; solo cuando lo pides (lo usas en Conecta tu app).

Los secretos van en .env, que nunca se sube al repositorio:

cp .env.example .env
# Copia este archivo a .env y cambia los valores. .env NUNCA se sube al repositorio.
# DATABASE_URL no va aquí: la pone docker-compose.yml, porque dentro de Docker la base se llama `db`.
JWT_SECRET=cambia-esto-por-48-bytes-al-azar
CODIGO_PROFESOR=cambia-este-codigo
ACCESS_TTL_S=300
REFRESH_TTL_S=604800

CODIGO_PROFESOR es el código que dará rol de profesor en tu servidor: invéntalo tú. No es el de startdroid.com, y no tiene por qué parecerse. smoke.sh lo lee de este mismo .env.

Genera el secreto de verdad en Git Bash, con openssl rand -base64 48, y pégalo en JWT_SECRET. Si el JWT_SECRET de tu servidor se filtra, cualquiera puede firmar tokens con el rol que quiera; es el dato más sensible de todo el backend.

Arranca y prueba

docker compose up -d
docker compose logs -f api

La primera vez descarga las imágenes y construye la del servidor: alrededor de un minuto. logs -f te deja viendo el log del servidor; Ctrl+C deja de verlo, pero el servidor sigue corriendo.

Tres formas de comprobar que vive, de menos a más completa:

  1. http://localhost:8000/api/health — la lista de endpoints. Y http://localhost:8000/docs — FastAPI te regala la documentación interactiva. Para las rutas con candado, toca Authorize y pega tu accessToken.
  2. La consola del curso apuntando a tu servidor: https://startdroid.com/consola?api=http://localhost:8000/api. Regístrate, entra, mira el token, publica, altera el rol. Todo lo de la Parte 0 de la Práctica 6, contra tu máquina.
  3. El recorrido completo: bash smoke.sh corre los veinte casos del contrato —incluidos los de error— e imprime el código HTTP esperado entre corchetes junto al real. Es la prueba que hay que pasar antes de decir que el módulo está listo.
== 5 login mal (401)                           [401]
== 8 avisos con token (200)                    [200]
== 10 alumno publica (403)                     [403]
== 15 reusar refresh viejo (401)               [401]
== 17 token alterado (401)                     [401]
Lo que el smoke encontró la primera vez, para que no te pase

El módulo de referencia se escribió y se corrió antes de escribir este anexo, y el smoke.sh atrapó tres cosas en la primera vuelta:

  1. Un 422 donde iba un 401. El validador de “contraseña de 8 o más” se aplicaba también al login, así que una contraseña corta respondía 422 con el mensaje de la regla — un regalo para quien esté adivinando. En el login, toda credencial mala es el mismo 401.
  2. Un 500 al listar. Los objetos de SQLAlchemy no se serializan solos: al esquema de salida le faltaba from_attributes=True.
  3. createdAt no existía. La columna se llama created_at (estilo Python) y el contrato dice createdAt (estilo JSON). Se resuelve con un alias, no renombrando la columna.

Ninguna se ve en /docs. Se ven corriendo el contrato completo.

Lo que vas a necesitar mientras trabajas:

Si… Corre
Cambiaste código de app/ Nada: el servidor se reinicia solo
Cambiaste .env docker compose up -d (recrea solo el servidor; la base sigue)
Agregaste una dependencia (uv add …) docker compose up -d --build
Quieres entrar a la base docker compose exec db psql -U postgres avisos
Terminaste por hoy docker compose down (los datos se quedan)
Quieres empezar de cero docker compose down -v (borra la base)

Léelo por partes

Son diez archivos y ninguno pasa de cien líneas. El orden en que conviene leerlos es el orden de una petición.

La configuración

app/config.py
class Settings(BaseSettings):
    """Todo lo que cambia entre tu máquina y el servidor vive en el entorno, no en el código.

    Se lee de variables de entorno o de un archivo `.env` (que nunca se sube al repo).
    Si falta `JWT_SECRET` o `CODIGO_PROFESOR`, la app no arranca — a propósito.
    """

    model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")

    database_url: str = "postgresql+psycopg://postgres:postgres@localhost:5432/avisos"
    jwt_secret: str
    codigo_profesor: str

    # Cinco minutos de acceso y siete días de refresco: los mismos números de la Práctica 6.
    access_ttl_s: int = 5 * 60
    refresh_ttl_s: int = 7 * 24 * 60 * 60

jwt_secret y codigo_profesor no tienen valor por omisión. Un secreto con valor por omisión es un secreto que alguien va a dejar así en producción.

Las tablas

app/models.py
class Usuario(Base):
    """Quién es quién. La contraseña NO está: está su hash Argon2 (que ya trae su sal adentro)."""

    __tablename__ = "usuarios"

    usuario: Mapped[str] = mapped_column(String(32), primary_key=True)
    password_hash: Mapped[str] = mapped_column(Text)
    rol: Mapped[str] = mapped_column(String(16))  # "alumno" | "profesor"
    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())


class Sesion(Base):
    """Una fila por login. Guarda el HASH del refresh token, nunca el token."""

    __tablename__ = "sesiones"

    id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
    usuario: Mapped[str] = mapped_column(ForeignKey("usuarios.usuario"), index=True)
    refresh_hash: Mapped[str] = mapped_column(String(64), unique=True)
    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
    expires_at: Mapped[int] = mapped_column(BigInteger)  # epoch en segundos
    revoked_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)

Compáralas con api/auth-schema.sql del Worker: son las mismas tres tablas. Una diferencia: aquí no hay columna salt, porque Argon2 la guarda dentro del propio hash.

La criptografía

app/security.py
# `recommended()` es Argon2id con parámetros razonables. No los ajustes sin leer por qué.
_hasher = PasswordHash.recommended()

ALGORITMO = "HS256"


def hash_password(password: str) -> str:
    return _hasher.hash(password)


def verify_password(password: str, password_hash: str) -> bool:
    return _hasher.verify(password, password_hash)


def sign_jwt(usuario: str, rol: str, ttl_s: int, secret: str) -> str:
    """Un JWT es JSON firmado, no cifrado: cualquiera lo lee, nadie lo altera."""
    ahora = int(time.time())
    payload = {"sub": usuario, "rol": rol, "iat": ahora, "exp": ahora + ttl_s}
    return jwt.encode(payload, secret, algorithm=ALGORITMO)


def verify_jwt(token: str, secret: str) -> dict[str, Any]:
    """PyJWT verifica la firma Y la fecha. El orden importa: un token alterado ni se lee."""
    try:
        return jwt.decode(token, secret, algorithms=[ALGORITMO])
    except jwt.ExpiredSignatureError as e:
        raise TokenInvalido("expirado") from e
    except jwt.InvalidTokenError as e:
        raise TokenInvalido("invalido") from e


def random_token() -> str:
    """32 bytes al azar en base64url. No dice nada de nadie: es una llave, no una credencial."""
    return secrets.token_urlsafe(32)

Son las mismas cuatro piezas de _auth.js, y aquí cada una es una llamada a una librería. Fíjate en algorithms=[ALGORITMO] en verify_jwt: siempre se dice qué algoritmo se acepta. Un verificador que deja que el token elija el algoritmo acepta tokens sin firma (alg: none); fue una vulnerabilidad real en varias librerías.

Argon2 aquí, PBKDF2 allá: ¿por qué distintos?

El Worker de Cloudflare usa PBKDF2 porque WebCrypto no ofrece otra cosa. En Python no hay esa limitación, y Argon2id es lo que hoy recomienda cualquier guía seria (OWASP incluida): resiste mejor a hardware especializado.

La lección no es “Argon2 es mejor”: es que el hash de contraseñas es una decisión que se toma con la mejor opción disponible en tu plataforma, y que se puede cambiar después — el hash guardado dice qué algoritmo lo produjo.

Los errores, con la forma del contrato

app/errors.py
class ApiError(Exception):
    def __init__(self, status: int, error: str, code: str | None = None, hint: str | None = None) -> None:
        super().__init__(error)
        self.status = status
        self.error = error
        self.code = code
        self.hint = hint


def instalar(app: FastAPI) -> None:
    @app.exception_handler(ApiError)
    async def api_error(_: Request, e: ApiError) -> JSONResponse:
        cuerpo: dict[str, str] = {"error": e.error}
        if e.code:
            cuerpo["code"] = e.code
        if e.hint:
            cuerpo["hint"] = e.hint
        headers = {}
        if e.status == 401:
            # El estándar: un 401 dice CÓMO autenticarse. Los clientes serios lo leen.
            headers["WWW-Authenticate"] = 'Bearer realm="reto"'
        return JSONResponse(cuerpo, status_code=e.status, headers=headers)

FastAPI responde {"detail": ...} por omisión. Tu mensajeDe() de Android lee error. Si no unificas la forma, la app va a mostrar “El servidor respondió 403” en vez de “Solo un profesor puede publicar”. Es un detalle chico que se nota mucho.

Quién eres y qué puedes

app/deps.py
# Declarar el esquema Bearer es lo que hace aparecer «Authorize» y los candados en /docs.
# auto_error=False: si falta el token, el 401 lo da usuario_actual con la forma del
# contrato ({error, code, hint}), no FastAPI con {"detail": ...}, que la app no lee.
_bearer = HTTPBearer(auto_error=False)


def usuario_actual(credenciales: HTTPAuthorizationCredentials | None = Depends(_bearer)) -> Identidad:
    """Lee `Authorization: Bearer <jwt>`, verifica la firma y la fecha."""
    if credenciales is None:
        raise ApiError(401, "Falta el token de acceso", "falta_token", "Manda `Authorization: Bearer <accessToken>`.")
    token = credenciales.credentials
    try:
        payload = verify_jwt(token, settings.jwt_secret)
    except TokenInvalido as e:
        if e.reason == "expirado":
            raise ApiError(401, "El token de acceso expiró", "token_expirado", "Pide otro con POST /api/auth/refresh.")
        raise ApiError(401, "El token no es válido", "token_invalido", "La firma no coincide o el token está mal formado.")
    return Identidad(usuario=payload["sub"], rol=payload["rol"], exp=payload["exp"])


def requiere_profesor(quien: Identidad = Depends(usuario_actual)) -> Identidad:
    """La app puede esconder el botón; el servidor no confía en que lo haya hecho."""
    if quien.rol != "profesor":
        raise ApiError(
            403,
            "Solo un profesor puede publicar o borrar avisos",
            hint=f'Tu sesión es de "{quien.usuario}" con rol "{quien.rol}".',
        )
    return quien

HTTPBearer(auto_error=False) hace dos cosas. Le dice a FastAPI que estas rutas esperan un token Bearer, y por eso /docs muestra el botón Authorize y un candado en cada ruta protegida. Y con auto_error=False, cuando falta el token no responde FastAPI con su {"detail": ...}: responde usuario_actual, con la forma del contrato.

Dos dependencias, dos preguntas, dos códigos. Una ruta que solo necesita sesión pide usuario_actual; una que necesita rol pide requiere_profesor, que por dentro pide la primera. Así se ve en las rutas:

app/routers/avisos.py
@router.get("", response_model=list[AvisoOut])
def listar(desde: int = 0, _: Identidad = Depends(usuario_actual), db: Session = Depends(get_db)) -> list[Aviso]:
    """Todo el tablón exige sesión, incluso leerlo. `?desde=N`: solo los avisos con id mayor a N."""
    return list(db.scalars(select(Aviso).where(Aviso.id > desde).order_by(Aviso.id.desc()).limit(100)))


@router.post("", response_model=AvisoOut, status_code=201)
def crear(body: NuevoAviso, quien: Identidad = Depends(requiere_profesor), db: Session = Depends(get_db)) -> Aviso:
    aviso = Aviso(titulo=body.titulo, cuerpo=body.cuerpo, autor=quien.usuario)
    db.add(aviso)
    db.commit()
    db.refresh(aviso)
    return aviso

Proteger una ruta es una dependencia en la firma. Si se te olvida, la ruta queda pública. Revísalas todas antes de entregar.

El stream de la Práctica 8 está en el mismo archivo: un StreamingResponse con un generador que consulta la tabla cada tres segundos y hace yield de cada aviso nuevo en formato SSE, hasta que time.time() pasa el exp del token. Fíjate en que abre una sesión de base de datos corta por vuelta (with SessionLocal() as db:) en vez de la de la petición: una conexión que vive cinco minutos no debe tener una sesión de SQLAlchemy abierta cinco minutos.

Entrar, refrescar, salir

app/routers/auth.py
def emitir_tokens(db: Session, usuario: str, rol: str) -> Tokens:
    """Emite el par y registra la sesión. Se usa en register, login y refresh."""
    access = sign_jwt(usuario, rol, settings.access_ttl_s, settings.jwt_secret)
    refresh = random_token()
    db.add(Sesion(usuario=usuario, refresh_hash=sha256(refresh), expires_at=ahora() + settings.refresh_ttl_s))
    db.commit()
    return Tokens(accessToken=access, refreshToken=refresh, expiresIn=settings.access_ttl_s, usuario=usuario, rol=rol)


@router.post("/login", response_model=Tokens)
def login(body: Credenciales, db: Session = Depends(get_db)) -> Tokens:
    fila = db.get(Usuario, body.usuario)
    # Un solo mensaje para "no existe" y "contraseña mal": no se le dice a un
    # atacante cuál de las dos cosas acertó.
    if fila is None or not verify_password(body.password, fila.password_hash):
        raise ApiError(401, "Usuario o contraseña incorrectos", "credenciales", "Revisa los dos; el mensaje es el mismo a propósito.")
    return emitir_tokens(db, fila.usuario, fila.rol)


@router.post("/refresh", response_model=Tokens)
def refresh(body: RefreshBody, db: Session = Depends(get_db)) -> Tokens:
    sesion = db.scalar(select(Sesion).where(Sesion.refresh_hash == sha256(body.refreshToken)))
    if sesion is None or sesion.revoked_at is not None or sesion.expires_at <= ahora():
        raise ApiError(401, "El refresh token no sirve", "refresh_invalido", "Fue revocado, ya expiró, o nunca existió. Hay que volver a entrar.")

    # Rotación: el token usado se retira y sale uno nuevo. Si alguien lo copió,
    # solo uno de los dos —el ladrón o el dueño— logra usarlo.
    sesion.revoked_at = datetime.now(timezone.utc)
    usuario = db.get(Usuario, sesion.usuario)
    assert usuario is not None
    return emitir_tokens(db, usuario.usuario, usuario.rol)

Y el registro, donde se decide el rol:

app/routers/auth.py — en register
    # El rol lo decide el servidor, nunca el cliente. "profesor" solo con el código
    # que se da en clase; cualquier otra cosa —incluido mandar rol: "profesor"— es alumno.
    rol = "profesor" if body.codigoProfesor and body.codigoProfesor == settings.codigo_profesor else "alumno"

En tu reto probablemente no haya “código de profesor”: el primer usuario es administrador, o los roles los asigna un administrador desde un panel, o vienen de una lista. Lo que no cambia es quién decide: el servidor, con un dato que el cliente no controla.


Conecta tu app

Esto se probó con la app terminada de la Práctica 6, sin tocar una línea de Kotlin más que la URL. El registro, el tablón, publicar, y el refresh tras expirar — el log del servidor (docker compose logs api) lo cuenta:

"POST /api/auth/login HTTP/1.1" 200 OK
"GET /api/avisos HTTP/1.1" 200 OK
"GET /api/avisos HTTP/1.1" 401 Unauthorized
"POST /api/auth/refresh HTTP/1.1" 200 OK
"GET /api/avisos HTTP/1.1" 200 OK

Tres cambios en el proyecto de Android, y los tres son de desarrollo:

data/remote/Network.kt
    // Tu servidor, visto desde el emulador. En un teléfono físico sería la IP de tu máquina en la red local.
    private const val BASE_URL = "http://10.0.2.2:8000/api/"

10.0.2.2 es tu computadora vista desde el emulador. Pero es http://, sin cifrar, y Android se niega a hablar así desde Android 9. La salida correcta no es usesCleartextTraffic="true" en el manifiesto principal — eso lo permitiría en la app publicada—, sino una configuración que solo existe en los builds de debug:

app/src/debug/res/xml/network_security_config.xml
<network-security-config>
    <domain-config cleartextTrafficPermitted="true">
        <domain includeSubdomains="false">10.0.2.2</domain>
    </domain-config>
</network-security-config>
app/src/debug/AndroidManifest.xml
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
    <application android:networkSecurityConfig="@xml/network_security_config" />
</manifest>

El manifiesto de src/debug/ se fusiona con el principal solo al compilar en debug. El APK de release no lleva nada de esto y sigue exigiendo HTTPS — que es lo que tu servidor desplegado va a tener.

Para probar el refresh sin esperar cinco minutos

Pon ACCESS_TTL_S=20 en .env y aplica con docker compose up -d. Entra desde la app, espera veinte segundos mirando el banner, toca Recargar. En docker compose logs api aparecen el 401, el refresh y el reintento. Regresa el valor cuando termines.

Con HTTPS desde el principio: un túnel

Con el emulador, http://10.0.2.2 no expone nada: el tráfico nunca sale de tu computadora. Con un teléfono físico es otra historia. Si apuntas a la IP de tu laptop por wifi, tu contraseña y tus tokens cruzan la red en claro, y en una red compartida —la del campus, la de una cafetería— cualquiera que escuche los lee. No lo hagas.

La salida es un túnel: una dirección pública con HTTPS que llega hasta tu servidor. Cloudflare da una gratis y sin cuenta, y ya viene en tu docker-compose.yml como el servicio tunel:

docker compose --profile tunel up -d
docker compose logs tunel

En el log aparece un recuadro con una dirección como https://palabras-al-azar.trycloudflare.com. Esa va en tu app:

private const val BASE_URL = "https://palabras-al-azar.trycloudflare.com/api/"

Y ya: es HTTPS de verdad, así que la app no necesita la configuración de src/debug/ de arriba —puedes borrarla—, funciona igual en el emulador y en un teléfono físico, y tu servidor sigue sin escuchar en la red: el túnel lo alcanza por dentro de Docker, como api:8000. Se probó con la app de la Práctica 6: registro con rol de profesor, tablón, publicar, y el refresh tras expirar, todo por el túnel.

Lo que hay que saber del túnel rápido

Por eso el compose dice --protocol http2. Por omisión, cloudflared usa QUIC, que va por UDP, y hay redes que lo bloquean. Sin esa opción, todas las peticiones responden 530.

La dirección puede tardar uno o dos minutos en responder la primera vez, mientras se propaga. Y cambia cada vez que el contenedor del túnel se recrea: vuelve a mirar docker compose logs tunel y actualiza BASE_URL.

Es para probar, no para entregar. No tiene garantía de disponibilidad, y no soporta Server-Sent Events: si más adelante agregas el stream de avisos, pruébalo en el emulador con 10.0.2.2 o en tu servidor desplegado.


Adáptalo a tu reto

Lo que cambia, en orden de lo más seguro a lo más delicado:

1. El recurso. avisos se vuelve lo tuyo: pedidos, reportes, citas. Es cambiar models.py, schemas.py y el router. Las dependencias de deps.py se quedan igual.

2. Los roles. Probablemente no son alumno y profesor. Cámbialos en un solo lugar y haz la dependencia genérica:

def requiere_rol(*roles: str):
    def dependencia(quien: Identidad = Depends(usuario_actual)) -> Identidad:
        if quien.rol not in roles:
            raise ApiError(403, "No tienes permiso para esto")
        return quien
    return dependencia

@router.delete("/{id}")
def borrar(id: int, quien: Identidad = Depends(requiere_rol("admin", "supervisor"))): ...

3. Propiedad, no solo rol. En la Práctica 4 cada quien editaba sus reseñas. Eso no es un rol: es comparar quien.usuario con el autor de la fila, dentro de la ruta. Muchas reglas del reto van a ser de este tipo, y un 403 con mensaje claro sigue siendo la respuesta.

4. Cómo se obtiene el rol. Quita el código de profesor y decide: ¿el primer usuario registrado es administrador? ¿Un administrador asigna roles desde un endpoint protegido? ¿Vienen de una tabla que carga el profesor? Cualquiera sirve; lo que no sirve es que lo mande el cliente.

5. Migraciones. create_all crea tablas que no existen; no modifica las que existen. El día que agregues una columna con datos reales, necesitas Alembic (uv add alembic y docker compose up -d --build); ojo, sus archivos de migración tienen que vivir en una carpeta montada, o se pierden al recrear el contenedor. Es el Migration(1, 2) de Room, del lado del servidor.

6. Despliegue. Render, Railway y Fly.io levantan FastAPI + PostgreSQL con TLS incluido, en su plan gratuito o cerca, y los tres construyen tu Dockerfile tal cual: en producción corre su CMD, sin --reload. Ahí van DATABASE_URL, JWT_SECRET y los demás como variables de entorno del servicio — nunca en el repo. Y allow_origins=["*"] del CORS se cambia por los orígenes que de verdad te llaman.


Lo que no hace, y qué haría falta para producción

Este módulo es correcto en lo que hace, y hace lo mínimo. Para una app con usuarios de verdad faltaría, en orden de importancia:

Falta Por qué importa Cuánto cuesta
Límite de intentos en /login Sin él, adivinar contraseñas es cuestión de tiempo slowapi, o un contador por usuario en la base
Detección de reuso del refresh Si un refresh ya rotado se vuelve a usar, alguien lo robó: hay que revocar toda la familia Una columna familia en sesiones y una consulta
Recuperar contraseña Sin correo no hay recuperación; con correo, hay un token de un solo uso Un servicio de correo y una tabla más
Verificar el correo Para que una cuenta sea de quien dice ser Lo mismo
Bitácora de accesos Saber desde dónde y cuándo entró cada quien Una tabla y un INSERT en login
Pruebas automáticas smoke.sh corre a mano; pytest + TestClient corren en cada commit Una tarde
Contraseñas filtradas Rechazar contraseñas que ya aparecen en filtraciones conocidas La API k-anonymity de Have I Been Pwned

Ninguna de estas es requisito del reto. Todas son la diferencia entre “funciona” y “se puede publicar”, y conviene que sepas que existen antes de que alguien te pregunte por ellas en una entrevista.


Lo que se revisa del módulo

Cuando entregues el reto, esto es lo que se busca en el backend de identidad:

  1. Ninguna contraseña en claro, en la base ni en los logs. SELECT password_hash FROM usuarios tiene que empezar con $argon2id$.
  2. JWT_SECRET fuera del repositorio. Un git log -p | grep JWT_SECRET que encuentre algo es una entrega reprobada en este punto.
  3. Cada ruta protegida tiene su dependencia. Se prueba con curl sin token: todo lo que no sea login/register/refresh responde 401.
  4. El rol lo decide el servidor. Mandar "rol": "admin" al registrarse no hace nada.
  5. El refresh rota y se puede revocar. Usar dos veces el mismo refresh responde 401 la segunda.
  6. Tu app maneja el 401 y el 403 como en la Práctica 6: refresca sola, y avisa sin tronar.
  7. smoke.sh —adaptado a tu recurso— pasa completo. Entrégalo junto con el código.

Defensa oral — las preguntas que van a caer

  1. Enséñame dónde se verifica la firma del token. ¿Qué pasa si quito algorithms=[...]?
  2. ¿Por qué el login responde lo mismo si el usuario no existe y si la contraseña está mal?
  3. Un usuario reporta que le robaron el teléfono. ¿Qué haces en el servidor, y qué le pasa al ladrón?
  4. ¿Dónde vive JWT_SECRET en tu despliegue? ¿Qué pasa si lo cambias?
  5. Muéstrame una ruta protegida por rol y una protegida por propiedad. ¿En qué se diferencian?
  6. ¿Por qué tu excepción de HTTP vive en src/debug y solo para 10.0.2.2? ¿Por qué no apuntas el teléfono a la IP de tu laptop por wifi?