Saltar al contenido
🐍 Curso de Python
/

Módulo 5 · Lección 27 de 30

Autenticación en FastAPI

Todo a mano y en cuarenta líneas, los 86 milisegundos que paran el servidor, y el argumento sin el cual PyJWT se niega a funcionar.

⏱ 17 min de lectura

Aquí está la otra mitad de lo que FastAPI no trae. La lección 17 activaba la autenticación de DRF con tres líneas y una migración; aquí no hay usuarios, ni contraseñas, ni tokens, ni permisos.

Y hacerlo a mano no es tan malo como suena —son unas cuarenta líneas— porque el sistema de dependencias de la lección anterior encaja exactamente con esto. Lo que hay que saber es qué decisiones estás tomando tú que Django tomaba por ti, porque dos de ellas se pueden equivocar en silencio.

Las contraseñas#

La primera decisión. La biblioteca que se recomienda hoy:

bash
pip install "pwdlib[argon2]"
python
from pwdlib import PasswordHash

hasher = PasswordHash.recommended()


def cifrar(contrasena: str) -> str:
        return hasher.hash(contrasena)


def comprobar(contrasena: str, cifrada: str) -> bool:
        return hasher.verify(contrasena, cifrada)

Lo que produce:

Salida en pantalla
$argon2id$v=19$m=65536,t=3,p=4$XmW/s8CHntK7sQmdOth4hg$G9lfJu...
algoritmo: argon2id

Argon2id, que es lo que se recomienda hoy y es más resistente que el PBKDF2 que usa Django por defecto. Nunca escribas esto tú: un resumen de contraseña hecho a mano es un fallo de seguridad garantizado, y la lección 32 del módulo 4 explicaba por qué.

Casi todos los tutoriales que encontrarás usan passlib con bcrypt. passlib lleva años sin mantenimiento y da problemas con las versiones recientes de bcrypt; si ves ese código, es de antes.

Los tokens#

python
import jwt
from datetime import datetime, timedelta, timezone

CLAVE = os.environ['CLAVE_JWT']
ALGORITMO = 'HS256'


def crear_token(sujeto: str, minutos: int = 15) -> str:
        ahora = datetime.now(timezone.utc)
    carga = {'sub': sujeto, 'iat': ahora, 'exp': ahora + timedelta(minutes=minutos)}
    return jwt.encode(carga, CLAVE, algorithm=ALGORITMO)


def leer_token(token: str) -> dict:
        return jwt.decode(token, CLAVE, algorithms=[ALGORITMO])

Y hace lo que debe:

Salida en pantalla
carga: {'sub': 'ana', 'iat': 1790107325, 'exp': 1790108225}

token caducado:          ExpiredSignatureError: Signature has expired
token con la firma rota: InvalidSignatureError: Signature verification failed

PyJWT comprueba bastantes cosas de fábrica, cosa que se agradece:

Salida en pantalla
{'verify_signature': True, 'verify_exp': True, 'verify_nbf': True,
 'verify_iat': True, 'verify_aud': True, 'verify_iss': True, ...}

El flujo completo#

Aquí es donde las dependencias de la lección anterior se lucen. Tres, encadenadas:

python
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm

esquema = OAuth2PasswordBearer(tokenUrl='token')


async def usuario_actual(token: Annotated[str, Depends(esquema)]) -> Usuario:
        credenciales = HTTPException(
        status.HTTP_401_UNAUTHORIZED, 'No se pudo validar el token.',
        headers={'WWW-Authenticate': 'Bearer'})
    try:
                carga = leer_token(token)
    except jwt.PyJWTError:
                raise credenciales
    fila = await buscar_usuario(carga.get('sub'))
    if fila is None:
                raise credenciales
    return fila


async def usuario_activo(u: Annotated[Usuario, Depends(usuario_actual)]) -> Usuario:
        if not u.activo:
                raise HTTPException(status.HTTP_403_FORBIDDEN, 'Cuenta desactivada.')
    return u


UsuarioActivo = Annotated[Usuario, Depends(usuario_activo)]

Y usarlo es un argumento:

python
@app.get('/yo')
async def yo(u: UsuarioActivo) -> Usuario:
        return u

Funcionando:

Salida en pantalla
GET /publico sin token  -> 200
GET /yo sin token       -> 401  {"detail": "Not authenticated"}
                                 WWW-Authenticate: Bearer
POST /token clave mala  -> 401  {"detail": "Usuario o contraseña incorrectos."}
POST /token correcto    -> 200  ['access_token', 'token_type']
GET /yo con token       -> 200  {"usuario": "ana", "activo": true, "rol": "editor"}
cuenta desactivada      -> 403  {"detail": "Cuenta desactivada."}

Cuatro detalles de ese código:

La misma excepción para los tres fallos. Token ilegible, caducado o de un usuario que ya no existe dan el mismo 401 con el mismo texto. Decir cuál de los tres fue es información gratis para quien esté probando.

WWW-Authenticate: Bearer es la cabecera que dice cómo identificarse, y es la que la lección 7 buscaba al otro lado.

401 sin token y 403 con uno válido pero sin permiso. Es la distinción de la lección 18, y aquí hay que implementarla a conciencia.

Y OAuth2PasswordBearer hace algo más que validar: rellena el botón de autorizar de la documentación navegable, así que se puede probar la API entera identificado desde el navegador. Eso, en DRF, era el navegador de la API de la lección 12.

Permisos por papel#

Una fábrica de dependencias, que es el patrón que hay que conocer:

python
def exigir_rol(*roles: str):
        async def comprobador(u: Annotated[Usuario, Depends(usuario_activo)]) -> Usuario:
                if u.rol not in roles:
                        raise HTTPException(
                status.HTTP_403_FORBIDDEN,
                f'Hace falta uno de estos papeles: {", ".join(roles)}.')
        return u
    return comprobador
python
@app.delete('/libros/{libro_id}')
async def borrar(libro_id: int,
                 u: Annotated[Usuario, Depends(exigir_rol('editor', 'admin'))]):
        return {'borrado': libro_id, 'por': u.usuario}
Salida en pantalla
DELETE /libros/7 con rol editor -> 200  {"borrado": 7, "por": "ana"}

Y se aplica a un enrutador entero, que es lo de la lección 24:

python
router = APIRouter(
    prefix='/api/admin',
    dependencies=[Depends(exigir_rol('admin'))],
)

Esa forma, con la lista de dependencias en el enrutador, es la que conviene por defecto: protege todo lo que cuelgue de él, incluidas las rutas que añadas mañana. Es la idea de "cerrado por defecto" de la lección 12, aquí por grupos de rutas.