akazuko

Traefik for AuthN + AuthZ

Adding authentication and authorization to your micro-services sitting behind traefik.

Tags: tech, auth, webdev


This was previously published by me at medium.

Aim

References

Intro

I recently started learning about how authN and authZ is solved in micro service architecture. Unlike monolith applications where session information is stored and available to be consumed across all APIs, in micro service architecture it is not feasible to store & sync this information per service. Thus, this creates a need for an API gateway via which all API calls to micro services are routed only if user is authenticated.

To achieve the same, I came across traefik which is a light weight reverse proxy & HTTP load-balancer. It provides automatic discovery of services across multiple providers for eg. docker, kubernetes, etc. So, let’s try to develop a small project with AuthZ and AuthN in place using traefik.

Implementing basic auth and foo service

Let’s start with writing the services foo & auth:

# folder per service
$ mkdir foo auth

# setup foo service
$ touch foo/main.py

# setup auth service
$ touch auth/main.py

We create a virtual env for testing where we ensure we add the following packages and then ensure that virtual env is activated:

Flask>=1.0.0
requests==2.22.0
Authlib==0.13

Now, we add minimal code to service foo:

from flask import Flask

app = Flask(__name__)

@app.route('/')
def index():
    return "<p>Hello, I am service foo!</p>"

if __name__ == '__main__':
    app.run(
        host='0.0.0.0',
        port=80
    )
# run the service foo
$ python foo/main.py

# validate the service is up and running
$ curl http://localhost/
<p>Hello, I am service foo!</p>

Let’s add the logic to service auth also now. Similarly like service foo, we define the main.py where we post creating the app, we wrap it with the OAuth object.

I am using authlib library, documentation for the same can be found here: https://docs.authlib.org/en/latest/client/flask.html

But, before we continue, we need to setup oauth2.0 Google API on GCP console. You can follow Google’s documentation / this medium article to setup the same. We need the following data post setup to get the auth app working:

GOOGLE_CLIENT_ID & GOOGLE_CLIENT_SECRET

import json
from datetime import datetime

from flask import (
    Flask, url_for,
    jsonify, request,
    redirect
)
from authlib.integrations.flask_client import OAuth

# Assumptions
# 1. models are available in models.py
# 2. models implement CRUD operations
# 3. OAuth2Token implements to_token method similar to
# https://docs.authlib.org/en/latest/client/frameworks.html#design-database
from models import User, OAuth2Token

# to serve it as http://localhost/rbac
BASE_API = '/rbac'

def fetch_token(name):
    '''
    Fetches token for a particular oauth2 provider.
    We are going to use it for google in this example.
    '''
    user_id = request.cookies.get('user_id')
    if not user_id:
        return None
    try:
        # assuming User implements get method
        current_user = User.get(id=user_id)
    except Exception:
        return None
    
    token = OAuth2Token.get(
        name=name,
        user=current_user
    )
    if not token:
        return None

    _token = token.to_token()
    if _token['expires_at'] < datetime.utcnow().timestamp():
        # remove the token if expired
        token.delete()
        return None
    return token

app = Flask(__name__)
app.secret_key = '' # SOME LONG SECRET KEY
app.config['GOOGLE_CLIENT_ID'] = ''
app.config['GOOGLE_CLIENT_SECRET'] = ''

oauth = OAuth(app, fetch_token=fetch_token)
oauth.register(
    'google',
    server_metadata_url='https://accounts.google.com/.well-known/openid-configuration',
    client_kwargs={'scope': 'openid profile email'}
)

# this is the url that is going to be hit everytime to authN/authZ
# the request made to API gateway
@app.route(BASE_API + '/login')
def login():
    # fetch token if already exists for the user
    # where the user is identified by cookie set
    # with value as some user id upon first successful authN
    token = fetch_token('google')
    
    # if such a token exists then use that directly and
    # add the token in resp header. We will later configure
    # API gateway to forward this header to the actual API call
    if token:
        resp = jsonify({'success': True})
        resp.headers['X-Auth-User'] = json.dumps(token.to_token())
        return resp

    # if we don't have the token, then let's authorize :)
    redirect_uri = f"http://localhost{url_for('authorize')}"
    return oauth.google.authorize_redirect(redirect_uri)

@app.route(BASE_API + '/authorize')
def authorize():
    # fetch the token and user info from the token
    token = oauth.google.authorize_access_token()
    userinfo = oauth.google.parse_id_token(token)
    
    # find user agains the email
    user = User.get(email=userinfo["email"])
    if not user:
        # if no such user exist, let's add it to our system
        user = User(
            given_name=userinfo["given_name"],
            last_name=userinfo["family_name"],
            email=userinfo["email"]
        )

    # we mark user as logged in now
    user.logged_in = True
    user.save()

    # we store the token now in our system to fetch it
    # for subsequent requests
    token = OAuth2Token(
        name="google",
        token_type=token["token_type"],
        access_token=token["access_token"],
        expires_at=token["expires_at"],
        user=user
    )
    token.save()

    # create a 200 response
    resp = resp = jsonify({'success': True})
    # set the user_id as cookie so that it can be used later
    # to identify the user and fetch token
    resp.set_cookie('user_id', str(user.pk))
    # set the token info for other services to consume in response header
    # we will configure traefik to forward this header in request of the
    # actual API call made to service foo
    resp.headers['X-Auth-User'] = json.dumps(token.to_token())
    return resp

if __name__ == '__main__':
    app.run(
        host='0.0.0.0',
        port=80
    )

You can choose to remove the database part and have the app not store/lookup user/token upon login/authorize. I am trying to cover an exhaustive example to give the complete picture :)

You can run the auth service the same way you ran foo service. To validate the authorization workflow, visit http://localhost/rbac/login on your machine. If all the data provided is correct and localhost is whitelisted in Google console, you should be redirected to Google’s authorization page. Upon successful authZ, you should get a JSON response with token info in header named X-Auth-User.

Adding the API Gateway — traefik in front of our micro services

We will make use of docker runtime here so that we can make use of autodiscovery of rules by traefik. To do so, we will perform the following steps:

We first ensure that our directory structure looks like the following:

foo/
    main.py
    Dockerfile
auth/
    main.py
    Dockerfile
deployment/
    docker-compose.yaml
    traefik.toml

We have already added foo/main.py and auth/main.py above. Let’s see what other files are supposed to look like:

# foo/Dockerfile

FROM python:3
RUN pip install Flask>=1.0.0
ADD . /app
WORKDIR /app
CMD ["python", "main.py"]
EXPOSE 80
# auth/Dockerfile

FROM python:3
RUN pip install Flask>=1.0.0 requests==2.22.0 Authlib==0.13
ADD . /app
WORKDIR /app
CMD ["python", "main.py"]
EXPOSE 80

To build docker images for these service you can perform the following.

# foo docker image
cd foo && docker build -t foo:test .

# auth docker images
cd auth && docker build -t auth:test .

Now, we populate the docker-compose.yaml to define the service deployment.

For more documentation on traefik’s forwardauth and rules specified in labels below, refer to the documentation.

version: '3'

services:
  reverse-proxy:
    image: traefik:v2.0
    ports:
      - "80:80"
      - "8080:8080"
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - $PWD/traefik.toml:/etc/traefik/traefik.toml

  auth:
    image: auth:test
    labels:
      - "traefik.http.routers.rbac.rule=PathPrefix(`/rbac`)"
      - "traefik.http.routers.rbac-unauthorized.rule=Path(`/rbac/authorize`)"
      - "traefik.http.middlewares.auth.forwardauth.address=http://rbac/rbac/login"
      - "traefik.http.middlewares.auth.forwardauth.trustForwardHeader=true"
      - "traefik.http.middlewares.auth.forwardauth.authResponseHeaders=X-Auth-User"
      - "traefik.http.routers.rbac.middlewares=auth@docker"
  
  foo:
    image: foo:test
    labels:
        - "traefik.http.routers.foo.rule=PathPrefix(`/foo`)"
        - "traefik.http.middlewares.auth.forwardauth.address=http://rbac/rbac/login"
        - "traefik.http.middlewares.auth.forwardauth.trustForwardHeader=true"
        - "traefik.http.middlewares.auth.forwardauth.authResponseHeaders=X-Auth-User"
        - "traefik.http.routers.rbac.middlewares=auth@docker"

we are still yet to define traefik.toml for configuring traefik:

[log]
    level = "debug"

[serversTransport]
    insecureSkipVerify = true

[api]
    insecure = true
    dashboard = true
    debug = true

[entryPoints]
    [entryPoints.http]
        address = ":80"

[providers]
    [providers.docker]
        endpoint = "unix:///var/run/docker.sock"

And, we are done with the setup now. So, let’s run.

# deploy the services
cd deployment && docker-compose up -d

To validate the oauth workflow, go to http://localhost/foo/ on your browser.

NOTE: Remember to either remove the user/token models usage completely or implement the required CRUD methods to enable the successful execution of the apps.