Original article: How to Build Secure APIs with Flask and Auth0

Las APIs son el corazón del desarrollo moderno. Soportan todo tipo de sistemas, desde móviles, web, y aplicaciones de escritorio, hasta dispositivos IoT y autos autónomos. Son un puente entre tus clientes y la lógica y almacenamiento de tu aplicación.

Este punto central de acceso a la información de tu aplicación plantea la pregunta: ¿Cómo puedes proveer acceso a la información a quienes lo necesitan mientras deniegas el acceso a peticiones no autorizadas?

La industria ha provisto de diversos protocolos y buenas prácticas para asegurar APIs. Hoy nos enfocaremos en OAuth2 (artículo en inglés), una de las opciones más populares para autorizar clientes en nuestras APIs.

Pero ¿cómo implementamos OAuth2? hay dos formas de hacerlo:

  1. La forma DIY (hacerlo tu mismo)
  2. Trabajar con un tercero seguro como Auth0

En este artículo, te guiaré a través de una implementación de OAuth2 para Python y Flask (documentación en inglés) usando Autho como nuestro proveedor de identidad. Pero primero, debemos discutir la forma DIY.

¿Por qué no construir tu propia autenticación y autorización?

Por algunos años ahora, he querido devolver a la comunidad que tanto me ha ayudado al enseñarme programación y ayudarme a progresar en mi búsqueda de conocimiento. Siempre he pensado que una  gran forma de contribuir era al tener mi propio blog, algo que he intentado más de algunas veces y fallado.

Pero ¿dónde fallé? En vez de enfocarme en escribir, intenté construir mi propio motor de blog, ya que está en mi naturaleza. Es lo que los desarrolladores hacen. Les encanta construir.

Pero ¿por qué menciono esto aquí? Ya que muchos caen en la misma trampa al momento de construir APIs. Permíteme explicarlo con un ejemplo.

Bob es un gran desarrollador, y él tiene esta gran idea de hacer una app de ToDo (lista de tareas pendientes) que podría ser la siguiente gran app. Bob está en conocimiento de que para una implementación exitosa, los usuarios pueden acceder sólo su propia información.

Esta es la línea de tiempo de la app de Bob:

  • Sprint 0: Investigar ideas y crear prototipos.
  • Sprint 1: Construir la tabla de usuarios y vista de inicio de sesión con una API.
  • Sprint 2: Añadir vistas de reset de contraseñas y construir las plantillas de email
  • Sprint 3: Construir, crear y listar las vistas de ToDos
  • Sprint 4: MVP se publica
  • Retroalimentación de usuarios:
  • Algunos usuarios no pueden ingresar debido a un bug.
  • Algunos usuarios se sienten inseguros sin autenticación de 2 factores.
  • Algunos usuarios no quieren otra contraseña más. Prefieren ingresar usando Google o Facebook.

Hablemos sobre lo que ha pasado. Bob pasó los primeros sprints no construyendo su app pero construyendo los bloques básicos, como las funcionalidades de login, notificaciones de email y así. Este valioso tiempo lo podría haber usado de forma distinta, pero lo que sucede a continuación es más preocupante.

El backlog de Bob se comienza a llenar. Ahora él necesita improvisar un método de autenticación de 2do factor, añadir un ingreso rápido (google) y algunas funciones no relacionadas con el producto que podrían potencialmente retrasar su producto.

Y aún hay una gran pregunta para ser respondida: ¿Implementó Bob todos los mecanismos de seguridad correctamente? Un error crítico podría exponer toda la información de los usuarios a terceros.

Lo que Bob hizo es lo que yo hice en mi blog muchas ocasiones. Algunas veces, es de gran ayuda descansar en terceros si queremos hacer las cosas bien.

Hoy en día, los hackers y ataques se han hecho tan sofisticados que la seguridad ya no es un factor trivial. Es un sistema complicado en sí, y es usualmente mejor dejar esa implementación a expertos - no solo para que se haga bien, sino para que también nos podamos enfocar en lo que importa: construir nuestra aplicación y sus APIs.

Cómo Configurar una Cuenta Gratuita de Administración de Identidades en Auth0

Auth0 es un proveedor líder en autorización y autenticación, pero veamos cómo puede ayudar a Bob (o a tí) a construir una app mejor.

  1. Ahorra tiempo
  2. Es segura
  3. Tiene un plan gratis

Es tiempo de ponerse prácticos. Primero, asegúrate de tener una cuenta Auth0. Sino, puedes crear una aquí gratuitamente.

Crear una nueva API Auth0

Aún hay una cosa que debemos hacer antes de comenzar a programar. Ve a la sección de APIs de tu dashboard en Auth0 y haz click en el botón "Crear API" (Create API). Después de ello,  llena el formulario con tus detalles. Sin embargo, asegúrate que selecciones  RS256 como Signing Algorithm.

Tu formulario debiera verse así:

XccGez21ClEDsCECuKwiF_1AF5gj2OXXaJKEXVUOBFmxQ7Ci11a1g1O3cu_io185YbdnSJkAlu3dmP0pt6Ww-N6cPqQLTIeweSi2hNv4ototIkuSZhfiprjqcMrFhcMLaGkKfedkm8D0PR2IcjdLPGUChKS27wsiPMvqCsysQRJyGANVYc5Q5EbFdaFo
Creating the API – image showing fields to fill out

Después de crear un API exitosamente, se abre la página de detalles. Mantén esa pestaña abierta, ya que contiene información que necesitaremos para configurar nuestra aplicación. Si la cierras, no te preocupes, siempre puedes acceder a ella nuevamente.

Cómo impulsar nuestra aplicación

Ya que nos enfocaremos sólo en aspectos de seguridad, tomaremos algunos atajos al construir nuestra demo de API. Sin embargo, cuando desarrolles APIs reales (artículo en inglés), por favor sigue las mejores prácticas para APIs Flask (artículo en inglés).

Instalar las dependencias

Primero, instala las siguientes dependencias para establecer Flask y autenticar usuarios.

pipenv install flask python-dotenv python-jose flask-cors six

Construir los endpoints

Nuestra API será directa. Consistirá sólo de tres endpoints, las cuales todas serán, por ahora, accesibles públicamente. Sin embargo, arreglaremos eso pronto. Acá están nuestros endpoints:

  • / (endpoint público)
  • /user (requiere un usuario autenticado)
  • /admin (sólo los usuarios de rol admin)

Vamos a ello:

from flask import Flask

app = Flask(__name__)

@app.route("/")
def index_view():
    """
    Endpoint por defecto, es publico y puede ser accedido por cualquiera
    """
    return jsonify(msg="Hello world!")

@app.route("/user")
def user_view():
    """
    Endpoint Usuario, solo puede ser accedido por un usuario autorizado
    """
    return jsonify(msg="Hello user!")

@app.route("/admin")
def admin_view():
    """
    Endpoint Admin, solo puede ser accedido por un admin
    """
    return jsonify(msg="Hello admin!")

Bastante simple, no? Vamos a ejecutarlo:

~ pipenv run flask run
* Environment: production
   WARNING: This is a development server. Do not use it in a production deployment.
   Use a production WSGI server instead.
 * Debug mode: off
 * Running on http://127.0.0.1:5000/ (Press CTRL+C to quit)

Y si accedemos nuestro endpoint:

~ curl -i http://localhost:5000
HTTP/1.0 200 OK
Content-Type: application/json
Content-Length: 23
Server: Werkzeug/2.0.1 Python/3.9.1
Date: Tue, 24 Jan 2023 21:24:57 GMT

{"msg":"Hello world!"}

~ curl -i http://localhost:5000/user
HTTP/1.0 200 OK
Content-Type: application/json
Content-Length: 22
Server: Werkzeug/2.0.1 Python/3.9.1
Date: Tue, 24 Jan 2023 21:25:42 GMT

{"msg":"Hello user!"}

~ curl -i http://localhost:5000/admin
HTTP/1.0 200 OK
Content-Type: application/json
Content-Length: 23
Server: Werkzeug/2.0.1 Python/3.9.1
Date: Tue, 24 Jan 2023 21:26:18 GMT

{"msg":"Hello admin!"}

Cómo asegurar los endpoints

Ya que estamos usando OAuth, vamos a autenticar las peticiones al validar un token de acceso en formato JWT. Lo enviaremos a la API en cada petición como parte de las cabeceras HTTP.

Variables de Configuración Auth0

Como mencionado en la sección previa, nuestra API necesita ser consciente y requerirá información de nuestro dashboard Auth0. Así que ve devuelta a tu página detallada de API y agarra dos valores distintos.

Primero, el identificador de API:                                          

Este es el valor requerido cuando la API es creada. También puedes obtenerla desde tu página detallada de API:

UffKcasZXNZmXldeB8nhDEjzmOPVao3PR6EUVPbtWzXStuDzcCw2kr5ztEnr0VlWCkBLbhleAM-D11Cv5Cv8fcII8m24D6TfEe4XfxWe8HXN1aNrF-dHeN05zeVeoNfQISWh-VPf0__x8uVfJPL3GGHYIC87utfrr6734Z9Wdk-9eJUApslcdUKOyoSh
How to find the API identifier on the API details page

Siguiente, dominio Auth0:

A menos que estés utilizando un dominio customizado, este valor será [NOMBRE_TENANT].auth0.com, y puedes agarrarlo desde la pestaña Test (asegúrate no incluir https://  y la última barra diagonal /).

cA63NdLr4AWOz2O3jTWBXTTqc7DrGOr1aPOIpNDRYl97-o84I_lX8KtotCm6hRWF06ai0RjiJzgTjS_zRlySKFAB-XO1w737N05i7-bC2-GZioOpcWuS5gaRoEnDL63gXnm5CyP6JOEQusRLQMF1sY_1vjfXtdMVIr5uCW1PMIpokH76lpMq2VFZSIyf
Getting the Auth0 domain

Siguiente, traspasa esos valores a variables para que así puedan ser utilizadas en las funciones de validación.

AUTH0_DOMAIN = 'TU-DOMINIO-AUTH0'
API_IDENTIFIER = 'IDENTIFICADOR-API'
ALGORITHMS = ["RS256"]

Métodos de error

Durante esta implementación, necesitaremos una manera de lanzar errores cuando la autenticación falla. Así que usaremos los siguientes ayudantes para esos menesteres:

class AuthError(Exception):
    def __init__(self, error, status_code):
        self.error = error
        self.status_code = status_code

@app.errorhandler(AuthError)
def handle_auth_error(ex):
    response = jsonify(ex.error)
    response.status_code = ex.status_code
    return response

Cómo capturar el token JWT

El primer paso para validar un usuario es obtener el token JWT desde las cabeceras HTTP. Esto es bastante simple, pero hay algunas cosas a tener en mente. Aquí hay un ejemplo de ello:

def get_token_auth_header():
    """
    Obtiene el Token de Acceso desde la cabecera Authorization
    """
    auth = request.headers.get("Authorization", None)
    if not auth:
        raise AuthError({"code": "authorization_header_missing",
                        "description":
                            "Se esperaba cabecera de autenticacion"}, 401)

    parts = auth.split()

    if parts[0].lower() != "bearer":
        raise AuthError({"code": "invalid_header",
                        "description":
                            "La cabecera de autenticacion debe comenzar con"
                            " Bearer"}, 401)
    elif len(parts) == 1:
        raise AuthError({"code": "invalid_header",
                        "description": "Token no encontrado"}, 401)
    elif len(parts) > 2:
        raise AuthError({"code": "invalid_header",
                        "description":
                            "La cabecera debe ser"
                            " Bearer token"}, 401)

    token = parts[1]
    return token

Cómo validar el token

Tener un token traspasado a nuestra API es una buena señal, pero no significa que es un cliente válido. Necesitamos revisar la firma del token.

Ya que la lógica para requerir autenticación puede ser utilizada para más de un endpoint, sería importante abstraerla y hacerla fácilmente accesible para los desarrolladores su implementación. La mejor forma de hacer esto es usando decoradores (artículos en inglés).

def requires_auth(f):
    """
	Determina si el Token de Acceso es valido
    """
    @wraps(f)
    def decorated(*args, **kwargs):
        token = get_token_auth_header()
        jsonurl = urlopen("https://"+AUTH0_DOMAIN+"/.well-known/jwks.json")
        jwks = json.loads(jsonurl.read())
        unverified_header = jwt.get_unverified_header(token)
        rsa_key = {}
        for key in jwks["keys"]:
            if key["kid"] == unverified_header["kid"]:
                rsa_key = {
                    "kty": key["kty"],
                    "kid": key["kid"],
                    "use": key["use"],
                    "n": key["n"],
                    "e": key["e"]
                }
        if rsa_key:
            try:
                payload = jwt.decode(
                    token,
                    rsa_key,
                    algorithms=ALGORITHMS,
                    audience=API_IDENTIFIER,
                    issuer="https://"+AUTH0_DOMAIN+"/"
                )
            except jwt.ExpiredSignatureError:
                raise AuthError({"code": "token_expired",
                                "description": "token is expired"}, 401)
            except jwt.JWTClaimsError:
                raise AuthError({"code": "invalid_claims",
                                "description":
                                    "incorrect claims,"
                                    "please check the audience and issuer"}, 401)
            except Exception:
                raise AuthError({"code": "invalid_header",
                                "description":
                                    "Unable to parse authentication"
                                    " token."}, 401)

            _request_ctx_stack.top.current_user = payload
            return f(*args, **kwargs)
        raise AuthError({"code": "invalid_header",
                        "description": "Unable to find appropriate key"}, 401)
    return decorated

El nuevo decorador creado requires_auth, cuando aplicado a un endpoint, automáticamente rechazará la petición si no hay un usuario válido que pueda ser autenticado.

Cómo requerir una petición autenticada para un endpoint

Ya estamos listos para asegurar nuestros endpoints, actualicemos los user y admin para utilizar nuestro decorador.

@app.route("/user")
@requires_auth
def user_view():
    """
    Endpoint Usuario, solo puede ser accedido por un usuario autorizado
    """
    return jsonify(msg="Hello user!")

@app.route("/admin")
@requires_auth
def admin_view():
    """
    Endpoint Admin, solo puede ser accedido por un admin
    """
    return jsonify(msg="Hello admin!")

Nuestro único cambio fue añadir @required_auth al comienzo de la declaración de cada función de endpoint, y con ello podemos probar una vez más:

~ curl -i http://localhost:5000/user
HTTP/1.0 401 UNAUTHORIZED
Content-Type: application/json
Content-Length: 89
Server: Werkzeug/2.0.1 Python/3.9.1
Date: Tue, 24 Jan 2023 21:42:26 GMT

{"code":"authorization_header_missing","description":"Se esperaba cabecera de autenticacion"}

~ curl -i http://localhost:5000/admin
HTTP/1.0 401 UNAUTHORIZED
Content-Type: application/json
Content-Length: 89
Server: Werkzeug/2.0.1 Python/3.9.1
Date: Tue, 24 Jan 2023 21:42:42 GMT

{"code":"authorization_header_missing","description":"Se esperaba cabecera de autenticacion"}

Como se esperaba, no podemos acceder a nuestros endpoints ya que la cabecera de autorización no se encuentra. Pero antes de añadir una, veamos si nuestro endpoint público aún funciona:

Como esperado

~ curl -i http://localhost:5000
HTTP/1.0 200 OK
Content-Type: application/json
Content-Length: 23
Server: Werkzeug/2.0.1 Python/3.9.1
Date: Tue, 24 Jan 2023 21:43:55 GMT

{"msg":"Hello world!"}

Increíble, funciona como se esperaba.

Cómo probarlo

Para probar nuestros nuevos endpoints asegurados, necesitamos obtener un token de acceso válido que podamos pasar a la petición. Podemos hacer eso directamente en la pestaña Test de la página detallada de la API, y es tan simple como copiar un valor desde la pantalla:

XCAWL5taQUs3_5qcAdukl9FP_aTVLya-jyS_4IivFW6JCAfX5d2hbPPCIV4PB8QgcuceQrzC__YYpWMQB1y8HT9AnKO01XH5rCiofvQJAmiAPnGF42FcJFxaVHTLLQcL9UpzFjYgan0Qasna69DlZ8AIkoATbqAtqtqibWUszhvakHZiytPNduTU7_Hb
Copying the token for testing

Once we have the token we can change our curl request accordingly:

Una vez que tengamos el token podemos cambiar nuestra petición de curl acorde a:

~ curl -i -H "Authorization: bearer [TOKEN_DE_ACCESO]"  http://localhost:5000/user
HTTP/1.0 200 OK
Content-Type: application/json
Content-Length: 22
Server: Werkzeug/2.0.1 Python/3.9.1
Date: Tue, 24 Jan 2023 22:17:06 GMT

{"msg":"Hello user!"}

Por favor recuerda reemplazar TOKEN_DE_ACCESO con el valor que copiaste desde tu dashboard.

¡Funciona! Pero aún tenemos algo de trabajo que hacer. Incluso cuando nuestro endpoint /admin está asegurado, puede ser accedido por cualquier usuario:

~ curl -i -H "Authorization: bearer [TOKEN_DE_ACCESO]"  http://localhost:5000/admin
HTTP/1.0 200 OK
Content-Type: application/json
Content-Length: 23
Server: Werkzeug/2.0.1 Python/3.9.1
Date: Tue, 24 Jan 2023 22:21:09 GMT

{"msg":"Hello admin!"}

Control de acceso basado en roles

Para el control de acceso basado en roles hay algunas cosas que debemos hacer:

  1. Crear permisos para la API
  2. Activar el añadir permisos a el token JWT para la API
  3. Actualizar el código
  4. Probar con usuarios

Los primeros 2 puntos están bastante bien explicados en los documentos Auth0 (artículo en inglés), así que sólo asegúrate de añadir los permisos correspondientes en tu API.

Siguiente, necesitamos actualizar el código. Necesitamos una función para revisar si dado permiso existe en el token de acceso y retornar True si lo hace y Falsesi no:

def requires_scope(required_scope):
    """
	Determina si el objetivo requerido esta presente en el Token de Acceso
    Args:
    	required_scope (str): El objetivo requerido para acceder al recurso
    """
    token = get_token_auth_header()
    unverified_claims = jwt.get_unverified_claims(token)
    if unverified_claims.get("scope"):
            token_scopes = unverified_claims["scope"].split()
            for token_scope in token_scopes:
                if token_scope == required_scope:
                    return True
    return False

Y últimamente, puede ser como a continuación:

@app.route("/admin")
@requires_auth
def admin_view():
    """
	Endpoint Admin, solo puede ser accedido por un admin
    """
    if requires_scope("read:admin"):
        return jsonify(msg="Hello admin!")

    raise AuthError({
        "code": "Unauthorized",
        "description": "No tienes acceso a este recurso"
    }, 403)

Ahora, sólo usuarios con el permiso read:admin pueden acceder a nuestro endpoint admin.

Con el fin de probar tu implementación final, puedes seguir los pasos detallados en obtener un token de acceso (artículo en inglés) para un usuario determinado.

También puedes usar el Dashboard de Auth0 para probar los permisos, pero eso va fuera del enfoque de este artículo. Si te gustaría aprender más de ello, lee aquí.

Conclusión

Hoy hemos aprendido cómo asegurar una API Flask. Hemos explorado la forma hágalo-usted-mismo, y hemos construido una API segura con tres niveles de acceso - acceso público, acceso privado y acceso privado-focalizado.

Hay muchas cosas más que Auth0 puede hacer por tus APIs y también para las aplicaciones de tus clientes. Hoy sólo hemos raspado la superficie, y depende de tí y tu equipo cuando se trabajan en escenarios de la vida real el explorar el potencial de sus servicios.

El código completo está disponible en Github.

¡Gracias por leer! Si te gusta mi estilo de enseñanza, puedes Suscribirte a mi newsletter semanal para desarrolladores y constructores y obtener un email semanal lleno de contenido relevante.