To have an authentication middleware inside Nginx itself has recently been an itch in my head. The idea is clean and straightforward. Read the request header and check the token, if it was verified let the request to proceed and otherwise block it. Aside from being cool and DevOps-y, it also has some great operational advantages. If you have a user-based platform that only authenticated users can access most of your endpoints, it protects you from malicious requests and DoS attacks, because in a regular platform that you pass incoming requests through a proxy pass, it’s much easier to take the authentication application down compare to the Nginx itself.
To begin, we need Nginx with Lua module enabled. You can either compile Nginx yourself and add Lua to it or you can use Openresty. For the sake of simplicity, I’m going with the second solution.
Note that if you’re using the docker image of Openresty, make sure you specify tags containing “fat” keyword so you have luarocks
(the package manager for Lua) built-in.
We set off with a very basic Nginx configuration file and add a location to it in order to fetch the incoming request and check the authentication.
We simply name this location /check
.
location = /check {
access_by_lua_file check-token.lua;
content_by_lua '
ngx.say("hello world !")
';
}
Now as you can see it contains two parts, one that calls access_by_lua_file
and pointing it to a Lua file and second with a simple content_by_lua that returns a simple response. In real life, this line may be pointing to your application upstream.
Inside the check-token.lua
file, we define a variable importing the Lua JSON package
, a variable containing the Authorization
header value and the third one with our jwt secret key
.
local jwt = require "resty.jwt"
local authentication_token = ngx.var.http_authorization
local secret = require “jwt-secret"
Note that I’ve created a Luafile returning my secret key, you can either do this or define it inside your main file.
And then I simply verify the given Authorization Token
. If it’s verified, it’ll continue to whatever the rest of the location block has and otherwise, it returns a FORBIDDEN
HTTP code.
if jwt:verify(secret, authentication_token).valid ~= true then
return ngx.exit(ngx.HTTP_FORBIDDEN)
end
You can check the result by calling a request to the location you’ve created, once with a valid token and once with an invalid one.
Okay if you were just looking to an authentication middleware, it’s done. But think about it, where would your user login? Presumably, through an application from another location you’ve defined inside your Nginx config, right? And you’re going to define the same jwt secret key
inside your application one more time, and once for every time you scale your application. The idea is not very likable and I decided to find a solution for it.
What I did was to define another location to get the login request, evaluate a login endpoint pointing to an application that has access to users info and can check whether the incoming info is correct or not, grab its response and finally generate a token containing the info it’s received from the application.
To do that we’d need two new locations, one responsible for getting the login request from the user,
location = /login {
rewrite_by_lua_file authenticator.lua;
}
and another one responsible for evaluating the request from authenticator application.
location = /authenticate {
internal;
proxy_pass http://flask-app:5000/login;
proxy_intercept_errors on;
}
Note that to make sure that the application only takes requests from its parent location, I made the location internal
.
Okay, now we need another Luafile to write, authenticator.lua
.
local jwt = require “resty.jwt" — jwt module for lua
local secret = require “jwt-secret" — the file containing the secret key
local authenticator = “/authenticate" — the path of the location to evaluate, one that's defined inside the Nginx configuration
local json = require ‘cjson' — a JSON module to decode upcoming response from the application and convert it to a Lua table
After defining these variables, we make our request.
ngx.req.read_body() — In order to pass the body, we should tell the nginx to read it
local request_options = {}
request_options["method"] = ngx.HTTP_POST — change it to whatever method your upstream is listening to
request_options["always_forward_body"] = true — it’s necessary to pass the body of request to the application
local res = ngx.location.capture(
authenticator,
request_options
) — and finally, we capture the result and save it inside a variable
Now we have the response from the app. 200
means that login was successful and otherwise it’s failed.
if res.status ~= 200 then
ngx.status = res.status
return ngx.say(res.body)
else
local resp_data = json.decode(res.body)
local jwt_token = jwt:sign(
secret,
{
header={typ="JWT", alg="HS256"},
payload=resp_data
}
)
local response = {token = jwt_token}
return ngx.say(json.encode(response))
end
I’ve created a git repository here containing the files we discussed above, a simple flask app, Dockerfiles for both Nginx and the app and a docker-compose to simply get it to action. Hope you enjoyed the approach. If you’ve done something like this one in production, I’d be thrilled if you share with us your experiments.