Adding authentication and authorization to your micro-services sitting behind traefik.
Tags: tech, auth, webdev
This was previously published by me at medium.
Aim
- Deploy API gateway in front of a micro-service (say foo)
- Delegate authN to another micro-service (say auth) for all requests
- Enable OAuth 2.0 Google authN in the micro-service auth
- Forward user info to other micro-services upon successful authN
References
- authlib: I developed my example based the one provided here
- traefik documentation for forwardAuth plugin
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:
- Dockerize both foo and auth service.
- Add docker-compose.yaml file defining how to bring up the services.
- Add labels to the services in docker-compose.yaml which traefik can discover to redirect API calls accordingly.
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.