Middleware Authentication for Service-Oriented Architectures with Nginx-Lua
Outline
- Series overview
- What we’re going to build
- Starting point (previous post)
- Build the thing
- Next steps (error handling? anything else?)
Introduction
In a Service-Oriented Architecture, you need to solve authentication in a way that’s uniform across all your apps.
A particularly awesome way to do this is to handle authentication in the “middleware” that sits between the internet and your services, possibly referred to as a “load balancer” or a “reverse proxy” in your architecture.
In this post I will explain the advantages (and disadvantages!) of this approach and show you how to build it using nginx and Lua (free, readily-available software). At the end of the post, you will have a load balancer configured to authenticate incoming requests to services with an authentication service running on your computer via Docker Compose. This will also serve as a great platform for you to experiment further with nginx and Lua!
Possible medium length intro?
Authenticating in the “middleware” that sits between the internet and your services has several advantages. You can guarantee that all services are checking authentication, and they are doing so correctly – security of your platform is not left in the hands of individual service maintainers! Architecturally, it’s nice that apps don’t have to deal with the concern of authentication, which is rarely relevant to a service’s actual function. And usefully, authentication is done in just one place, which makes it a lot easier to change. Authentication is one of or possibly the only service that every other service in your platform depends on, which can make it very hard to make changes even if each service is deferring to a centralised authentication service.
A downside of doing it this way is it complicates the minimum stack needed to test apps. If you need to test being logged in, now you need your service and an authentication service and a load balancer. A service dependency probably isn’t a big deal, but if you perform the authentication in an expensive enterprise appliance, as one company I worked at did, you might not relish the prospect of giving one out to each developer. It is common to use middleware appliances like these, which can complicate your deployment processes, testing, and make it difficult to keep your various environments in sync or even have the same code running in them at all. TODO: ALSO THEY’RE EXPENSIVE AS FUCK
I’m going to show you how instead of using an appliance you can hand off authentication to your authentication service using Nginx-Lua, and how you can have all of this – your services, your authentication service, and your load balancer – running in Docker. This significantly mitigates the drawbacks of the middleware authentication pattern: because Nginx is free, you can create as many load balancer instances as you need for staging and development environments, and because it’s configured like any other *nix daemon it easily integrates into your existing infrastructure deployment. This means you can have the exact same authentication code running in production, staging, and on every developer’s laptop.
At the end of this post you’ll have a small demo setup of middleware authentication, but what we build will also make a great platform for experimenting with Nginx-Lua in general. You might find more uses for it than just authentication!
Why handle authentication in middleware?
Note: If you are already convinced and want to skip to the tutorial, click here TODO
Authentication in a monolith is (relatively) simple. It is normally possible to register some kind of “handler” that runs before anything else to check if an incoming request is authenticated or not, and easily make this information available to any other code that needs to know if the user is logged in or not. If you have multiple services, it’s a lot less simple.
The most similar, and least sensible approach would be to duplicate that authentication checking logic across all services, so each individual service is responsible for checking authentication itself, and does this by reading a cookie and either looking up the session ID in a datastore (in the case of stateful sessions) or verifying the signature/HMAC on the session (in the case of stateless sessions TODO).
If you did this with stateful sessions and pointed each service to the same datastore for the sessions you’d be violating a cardinal rule of SOA, namely, to not share datastores between different services.
If instead you gave each service its own datastore (or secret key) then each user on your system would have a different session with each service. You’d probably need something like an SSO hand-off every time they navigated from one service to another, which would be a terrible experience.
Obviously a full SSO hand-off is a terrible user experience, every time you perform a login there is some chance of it failing, so there is some chance that as you click from one part of the site to another something goes wrong and you get logged out. Even when it works, it’s visibly slow as you are bounced around for the SSO. SSO is for auth between different parties, don’t use it within your own system. You might be thinking “this is ridiculous, no-one would do this, why don’t you skip to the sensible options already” but Experian do appear to be doing this on their website, so there.
You could have auth handled by each service with fewer drawbacks when using stateless sessions, especially if they are signed rather than MAC’d (so the key you are scattering across every service in your system is a public key, not a secret key), but it’s still pretty terrible in any case. Making any changes at all to your authentication would require carefully co-ordinated changes being made on every single service, at which point you’d probably be wishing you still had a monolith. You would also be relying on getting the details of authentication exactly right many times over, once for each service, and it only needs to go wrong once to have a serious security hole. Not ideal.
We can make this a lot more sensible in a fairly obvious and very classic SOA kind of way: create an authentication service responsible for doing all the nitty-gritty work here, and make other services talk to it. That is the way we separate other concerns in an SOA, after all, so why not auth?
This would look like: TODO DIAGRAM HERE
This is a perfectly good solution and it works really well. Now all the details of authentication are being handled in one place, so you only need to get it right once, and you can focus all your energies on getting it right and carefully examining and testing that code. It’s reasonably agile, as you can make changes to the implementation details without having to change anything else in your system, e.g. if you switch from stateless sessions to stateful you probably won’t need to change a single other service. That’s good!
One downside is that you still expose some details to each service. If you wanted to change the name of the cookie that sessions are stored in, for example, or to add a mobile app that sends its session token in a header instead of a cookie, then every service would need to be modified to grab the data from there instead to send to the authentication service.
Another downside is also one of the upsides of this approach: you don’t guarantee that requests get authenticated. How they are authenticated is out of the hands of your services, but whether or not they are authenticated is still up to them. Someone could forget! So you will need to figure out some way of ensuring each service is actually authenticating users. The upside, though, is that maybe not all requests need to be authenticated, so you have that flexibility. If your system has a lot of public pages maybe that’s useful?
You can fix these downsides by instead authenticating requests in the “middleware” (TODO is this the right term) that sits between your services and the internet:
TODO DIAGRAM HERE
In this arrangement, the authentication service still handles the details of authentication, but the interface details such as the cookie name are now only specified in one place: the load balancer. Services get told about authentication via a totally separate and internal protocol, so making changes to authentication can now happen without any changes needing to be made to services at all!
You also guarantee that all services are checking authentication, and they are doing so correctly – security of your platform is not left in the hands of individual service maintainers! Of course, this can also be a downside: if not all your services need to authenticate requests, it’s a little wasteful. You could mitigate this in cases where it’s clear-cut which services can skip auth, for example if you have a “public pages” service, or don’t want to authenticate any requests for static assets, it is very easy to exclude requests from authentication by URL or service.
Another downside of doing it this way is it complicates the minimum stack needed to test apps. If you need to test being logged in, now you need your service and an authentication service and a load balancer. A service dependency probably isn’t a big deal, but if you perform the authentication in an expensive enterprise appliance, as one company I worked at did, you might not relish the prospect of giving one out to each developer. It is common to use middleware appliances like these, which can complicate your deployment processes, testing, and make it difficult to keep your various environments in sync or even have the same code running in them at all. TODO: ALSO THEY’RE EXPENSIVE AS FUCK
TODO ALSO: maybe mention this probably isn’t a big deal, you probably already have a way for devs to run multiple services?
Side note: authentication-service-as-load-balancer
Occasionally, this problem is solved by routing all traffic through the authentication service, which handles authentication itself and adds that information to incoming requests before passing them on to the services.
Although popular, I really recommend against this for a number of reasons:
Most importantly, it involves building your own load balancer. Building a load balancer is no joke, and not an enterprise it makes sense for any company to enter into except maybe companies whose business is “building and selling load balancers” (and even then, most of them build worse load balancers than the free & open-source ones available). A load balancer is an incredibly complex thing, and not something you can YOLO in NodeJS (although you can get far enough this way that you may think this is a good idea, until you start to scale and it starts going horribly wrong).
It is instead far better to use two pieces of technology that can each play to their strengths: the load balancer can be the load balancer, and you only need to write a small amount of code in the awkward environment of a load balancer to offload most of the work to a service, written in some sensible language and framework for services. Writing an entire auth service in a load balancer environment would be painful, building a load balancer as an app would be painful.
Another problem is that it builds a dependency on your service for the entire platform, and any service you build is likely to be less resilient than something like Nginx. If you split out the authentication service, you have options available to you to keep the rest of the platform running even if the authentication service is down, as you will see later. If everything is running via the authentication service, that isn’t possible.
A worked example
Prerequisites
- Docker (is docker-compose still a separate dependency?)
- Passing familiarity with building and running Docker apps useful, not required. Maybe some helpful footnotes?
- A way of building web apps in some form. NodeJS if you’re going to follow my examples.
Planning
quick napkin diagram of what we’re going to build
The essential components we need to prove out our middleware authentication layer are:
- An authentication service
- A web service that responds differently to logged in vs logged out users (to test that the authentication is working)
- The middleware that checks incoming requests against the authentication service
We also need to agree on how these will all communicate, in particular:
- Which cookie in an incoming request represents the session ID. The middleware needs to know this so it knows which data to send to the authentication service, and whichever service handles logins needs to know this so it sets the right cookie on login.
- A protocol for the middleware and authentication service to talk to each other. The middleware needs to be able to ask the auth service “Is this a valid session, and if so, for whom?” and the authentication service needs to be able to answer that question.
- A protocol for the middleware to tell services behind it if a user is logged in and if so, which user it is.
In your environment you are likely to have extra requirements that will help shape these decisions.
First step: a service
If you already have a service that defers to an authentication service you can use it here. I didn’t have any to hand, so let’s create a fake one quickly so we can focus on the architecture.
A quick poll on IRC on which frameworks are the best for creating a tiny service quickly landed on ExpressJS, so let’s get going:
$ npm install express --save
package.json:
{
"dependencies": {
"express": "^4.16.4"
}
}
And with a little cribbing from the ExpressJS getting started guide, we have a simple Hello, World app that reacts differently depending on if you’re logged in or not:
app.js:
const express = require('express')
const app = express()
// Homepage. Say hello, and customise it a little if they're logged in.
app.get('/', (req, res) => {
console.log("Received a request to / with User-ID: " + req.header('User-Id'))
if(req.header('User-ID')) {
res.send("Hello, user number #" + req.header('User-ID') + "!")
}
else {
res.send("Hello, anonymous user!")
}
})
app.listen(3000, _ => console.log("Fake Service started"))
This app determines if the user is logged in by looking at the User-ID header. This is therefore the header the middleware will need to set, and our first protocol has been designed.
First, we can test it without the middleware:
node app.js
(in another terminal)
$ curl localhost:3000
Hello, anonymous user!
$ curl -H 'User-ID: 123' localhost:3000
Hello, user number #123!
This is great work, because users love it when you treat them like individuals rather than all the same. This is the kind of personal touch that can really drive engagement.
What’s not so great, though, is that anyone can log in as anyone else by setting a header. We could try to ban curl to resolve this security issue, but we’d really prefer something a bit more robust. For now, let’s make a note that only the middleware should be able to set this header, and put that sticky note in a really prominent place so we don’t forget about it.
Dockerize the Service
Soon we want to create a load balancer: a webserver set up to reverse proxy to the service. Installing a proxy server like that on your own machine is a bit intrusive though: you can usually only have one installed at a time, but if you have multiple apps or services you work on they might each need one configured differently. We’re going to solve this problem by putting the load balancer in a container with Docker, and that means it’ll be easiest to connect everything together if our service is also in a Docker container.
NodeJS has a good tutorial on how to Dockerize a Node.JS web app <- footnote maybe?
Create a file named Dockerfile in the service’s directory:
FROM node:14
WORKDIR /usr/src/app
# Copy only the package.json files before installing dependencies to take advantage of layer caching
COPY package*.json ./
RUN npm install
# Copy everything else
COPY . .
CMD ["node", "app.js"]
And now, from inside the service directory, create the image and run it:
$ cd service
$ docker build -t service .
$ docker run -t -p 3000:3000 service
You should find the service is running on port 3000 and works exactly the same as it did before.
Create the load balancer
Explain why OpenResty, doesn’t have to be, blahblahblah
mkdir load-balancer
Crap follows
question: dockerise from the start?
- basic hello, world
- respond differently based on some header?
- test with curl
2nd step: auth service
- basic hello world as above
- json api endpoint
- respond differently based on POST data
- extra dependency
- test with curl
maybe dockerize here if not earlier?
next step: docker-compose + nginx
- both services plus basic openresty image
- external port working and things ready to wire up
- config file for nginx
final step: actually write the Lua!
- ref documentation
- explain choice of block
- basic components breakdown
- show it working!
final words
- good base to build on
- needs error handling
- adapting to whatever your services / auth service actually expect