Ready to Go Auth server with Nginx

February 14, 2020 • edited April 10, 2020

I got tired of seeing different implementations of authentication in different applications that tend to be insecure and hard to maintain. Usually, the best way to handle those situations is to delegate on someone with more experience like Google oauth2. But if you really need you own system… this approach should ease the work.

First thing, I will try to discourage you to actually do this to store your own passwords. Please, invest some time in watching this video from computerphile

Knowing this, I hope that you use this implementation being fully aware of what it means. Also keep in mind that I’m not an expert and I will be glad to hear about improvements in what I’m going to post here.

Sample App

Let’s create a simple docker-compose setup with a minimal application. I’m going to include https configuration because it’s not that hard. You can go here to learn how to create your own self signed certificate.

File structure

1
2
3
4
5
6
7
8
.
├── docker-compose.yaml
└── nginx
    ├── Dockerfile
    ├── localhost.conf
    ├── localhost.crt
    ├── localhost.key
    └── nginx.conf

docker-compose.yaml

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
version: "2.2"

services:
  nginx:
    image: nginx
    volumes:
      - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
      - ./nginx/localhost.crt:/etc/ssl/certs/localhost.crt:ro
      - ./nginx/localhost.key:/etc/ssl/private/localhost.key:ro
    ports:
      - 80:80
      - 443:443

  whoami:
    image: jwilder/whoami
    ports:
      - 8000:8000

nginx.conf

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
http {
    server {
        listen 80;
        server_name localhost;

        return 301 https://$server_name$request_uri;
    }

    server {
        listen 443 ssl http2;
        server_name localhost;

        ssl_certificate /etc/ssl/certs/localhost.crt;
        ssl_certificate_key /etc/ssl/private/localhost.key;

        ssl_protocols TLSv1.2 TLSv1.1 ;


        location / {
            proxy_pass http://whoami:8000;
        }

    }
}

Et voilà! If you run docker-compose up, you will be able to get the output of whoami image using https from your browser. BTW, whoami is the easiest application I can imagine. You can find more info here

Auth server

Quote from the Nginx official documentation. Please, read the docs ;)

NGINX and NGINX Plus can authenticate each request to your website with an external service. To perform authentication, NGINX makes an HTTP subrequest to an external server where the subrequest is verified. If the subrequest returns a 2xx response code, the access is allowed, if it returns 401 or 403, the access is denied. Such type of authentication allows implementing various authentication schemes, such as multifactor authentication, or allows implementing LDAP or OAuth authentication.

Now that you understand how Nginx works, let’s modify the configuration to protect our app with an auth server.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
http {
    server {
        listen 80;
        server_name localhost;

        return 301 https://$server_name$request_uri;
    }

    server {
        listen 443 ssl http2;
        server_name localhost;

        ssl_certificate /etc/ssl/certs/localhost.crt;
        ssl_certificate_key /etc/ssl/private/localhost.key;

        ssl_protocols TLSv1.2 TLSv1.1 ;

        location = /auth {
            internal;
            proxy_pass http://auth-server:8080;
            proxy_pass_request_body off;
            proxy_set_header Content-Length "";
            proxy_set_header X-Original-URI $request_uri;
        }

        location ~ ^/(login|logout) {
            proxy_pass http://auth-server:8080;
        }


        location / {
            proxy_pass http://whoami:8000;
            auth_request /auth;
            auth_request_set $auth_status $upstream_status;
        }
    }
}

Implementation

The process is really simple. You need the following three endpoint

  • login: POST that receives the user login information. typically from a html form.
  • logout: POST empty that just removes the session.
  • auth: GET to check if the user is logged in.

Let’s see how each handler looks like.

loginPOSTHandler

  1. Extracts the values from the html form.
  2. Tries to login the user with the given password.
  3. If succeeds, It creates a JWT with 5 minutes expiration time.
  4. Puts the user data inside.
  5. Signs it.
  6. Stores it in a cookie with the same expiration time.

Here is the code.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
func loginPOSTHandler(w http.ResponseWriter, r *http.Request) {
	username := r.FormValue("username")
	password := r.FormValue("password")

	user := loginUser(username, password)
	if user == nil {
		w.WriteHeader(http.StatusUnauthorized)
		return
	}

	expirationTime := time.Now().Add(5 * time.Minute)
	claims := &Claims{
		User: user,
		StandardClaims: jwt.StandardClaims{
			ExpiresAt: expirationTime.Unix(),
		},
	}

	token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
	tokenString, err := token.SignedString(jwtKey)
	if err != nil {
		w.WriteHeader(http.StatusInternalServerError)
		return
	}

	http.SetCookie(w, &http.Cookie{
		Name:     "token",
		Path:     "/",
		Value:    tokenString,
		Expires:  expirationTime,
		HttpOnly: true,
	})

	http.Redirect(w, r, basePath, http.StatusFound)
}

logoutPOSTHandler

This handler simply deletes the token cookies if exists.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
func logoutPOSTHandler(w http.ResponseWriter, r *http.Request) {
	http.SetCookie(w, &http.Cookie{
		Name:     "token",
		Path:     "/",
		MaxAge:   -1,
		HttpOnly: true,
	})

	http.Redirect(w, r, basePath, http.StatusFound)
}

authHandler

The purpose of the authHandler is to validate the token generated by the loginPOSTHandler. Because this is something that will be performed in other places, I’ve created the function getSession that given a requests, extracts the JWT claims or returns a nil claim if the token is not valid.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
func authHandler(w http.ResponseWriter, r *http.Request) {
	claims, err := getSession(r)
	if err != nil {
		w.WriteHeader(http.StatusBadRequest)
		return
	}
	if claims == nil {
		w.WriteHeader(http.StatusUnauthorized)
		return
	}
	encoder := json.NewEncoder(w)
	encoder.Encode(claims.User)
}

func getSession(r *http.Request) (*Claims, error) {
	c, err := r.Cookie("token")
	switch {
	case err == http.ErrNoCookie:
		return nil, nil
	case err != nil:
		return nil, fmt.Errorf("Could not get token cookie. cause %w", err)
	}

	tokenString := c.Value
	claims := &Claims{}

	tkn, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) {
		return jwtKey, nil
	})
	switch {
	case err == jwt.ErrSignatureInvalid:
		return nil, nil
	case err != nil:
		return nil, fmt.Errorf("Could not parse jwt, cause %w", err)
	case !tkn.Valid:
		return nil, nil
	}

	return claims, nil
}

In a real world problem, you will provably implement some cool frontend that communicates with this service. For simplicity, I’ve added a login GET handler that returns a the front end. This takes advantage of the go html/template package. See the source code to see the template code. I’m only displaying the name for the logout template.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
func loginGETHandler(w http.ResponseWriter, r *http.Request) {
	claims, err := getSession(r)
	if err != nil {
		w.WriteHeader(http.StatusBadRequest)
		return
	}

	pages := template.Must(template.ParseGlob("templates/*.tmpl"))

	switch {
	case claims != nil:
		pages.ExecuteTemplate(w, "logout.tmpl", claims)
	default:
		pages.ExecuteTemplate(w, "login.tmpl", nil)
	}

}
Click here to see all code from main.go
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
package main

import (
	"encoding/json"
	"flag"
	"fmt"
	"html/template"
	"log"
	"net/http"
	"time"

	"github.com/dgrijalva/jwt-go"
	"github.com/gorilla/mux"
)

// Create the JWT key used to create the signature
var jwtKey = []byte("my_secret_key")

type UserData struct {
	Name        string `json:"name,omitempty"`
	Email       string `json:"email,omitempty"`
	AccessLevel int    `json:"access_level,omitempty"`
}

type Claims struct {
	User *UserData `json:"user"`
	jwt.StandardClaims
}

var basePath string

func main() {
	flag.StringVar(&basePath, "base-path", "/", "indicates the base path where the app is running")
	flag.Parse()

	r := mux.NewRouter().
		PathPrefix(basePath).
		Subrouter()
	r.Path("/auth").Methods("GET").HandlerFunc(authHandler)
	r.Path("/login").Methods("POST").HandlerFunc(loginPOSTHandler)
	r.Path("/logout").Methods("POST").HandlerFunc(logoutPOSTHandler)
	r.PathPrefix("/").Methods("GET").HandlerFunc(loginGETHandler)

	http.Handle("/", r)

	log.Println("ready")
	http.ListenAndServe(":8080", nil)
}

func loginPOSTHandler(w http.ResponseWriter, r *http.Request) {
	username := r.FormValue("username")
	password := r.FormValue("password")

	user := loginUser(username, password)
	if user == nil {
		w.WriteHeader(http.StatusUnauthorized)
		return
	}

	expirationTime := time.Now().Add(5 * time.Minute)
	claims := &Claims{
		User: user,
		StandardClaims: jwt.StandardClaims{
			ExpiresAt: expirationTime.Unix(),
		},
	}

	token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
	tokenString, err := token.SignedString(jwtKey)
	if err != nil {
		w.WriteHeader(http.StatusInternalServerError)
		return
	}

	http.SetCookie(w, &http.Cookie{
		Name:     "token",
		Path:     "/",
		Value:    tokenString,
		Expires:  expirationTime,
		HttpOnly: true,
	})

	http.Redirect(w, r, basePath, http.StatusFound)
}

func loginGETHandler(w http.ResponseWriter, r *http.Request) {
	claims, err := getSession(r)
	if err != nil {
		w.WriteHeader(http.StatusBadRequest)
		return
	}

	pages := template.Must(template.ParseGlob("templates/*.tmpl"))

	switch {
	case claims != nil:
		pages.ExecuteTemplate(w, "logout.tmpl", claims)
	default:
		pages.ExecuteTemplate(w, "login.tmpl", claims)
	}

}

func loginUser(username string, password string) *UserData {
	// TODO: Now any user can login, implement proper validation
	return &UserData{
		Name: username,
	}
}

func logoutPOSTHandler(w http.ResponseWriter, r *http.Request) {
	http.SetCookie(w, &http.Cookie{
		Name:     "token",
		Path:     "/",
		MaxAge:   -1,
		HttpOnly: true,
	})

	http.Redirect(w, r, basePath, http.StatusFound)
}

func authHandler(w http.ResponseWriter, r *http.Request) {
	claims, err := getSession(r)
	if err != nil {
		w.WriteHeader(http.StatusBadRequest)
		return
	}
	if claims == nil {
		w.WriteHeader(http.StatusUnauthorized)
		return
	}
	encoder := json.NewEncoder(w)
	encoder.Encode(claims.User)
}

func getSession(r *http.Request) (*Claims, error) {
	c, err := r.Cookie("token")
	switch {
	case err == http.ErrNoCookie:
		return nil, nil
	case err != nil:
		return nil, fmt.Errorf("Could not get token cookie. cause %w", err)
	}

	tokenString := c.Value
	claims := &Claims{}

	tkn, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) {
		return jwtKey, nil
	})
	switch {
	case err == jwt.ErrSignatureInvalid:
		return nil, nil
	case err != nil:
		return nil, fmt.Errorf("Could not parse jwt, cause %w", err)
	case !tkn.Valid:
		return nil, nil
	}

	return claims, nil
}

Dockerize

Like any other go project, let’s use a simple multi-stage alpine image to get the minimal image size.

tip

Copy first go.mod and go.sum and use go mod download to take advantage of Docker cache. This will save tons of time downloading dependencies.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
FROM golang:alpine AS build

WORKDIR /app

COPY go.mod .
COPY go.sum .
RUN go mod download

COPY *.go .
RUN go build

FROM alpine as run

WORKDIR /app

COPY templates templates
COPY --from=build /app/auth-server /app/

RUN chmod +x auth-server
ENTRYPOINT [ "./auth-server" ]

Final docker-compose.yaml

Now we just have to add the auth-server to the docker compose and provide the base path to ensure that it works properly.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
version: "2.2"

services:
  nginx:
    image: nginx
    volumes:
      - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
      - ./nginx/localhost.crt:/etc/ssl/certs/localhost.crt:ro
      - ./nginx/localhost.key:/etc/ssl/private/localhost.key:ro
    ports:
      - 80:80
      - 443:443

  auth-server:
    build: auth-server
    command: ["--base-path", "/users/"]

  whoami:
    image: jwilder/whoami
    ports:
      - 8000:8000

Run it

Start the service with docker compose and ensure to build the images again.

1
docker-compose up --build

The workflow is:

  1. Navigate to https://localhost to see that you are not allowed.
  2. Navigate to https://localhost/users/ to see the login form.
  3. Login using the same name and password, IE: MetalBlueberry, MetalBluberry.
  4. Go again to https://localhost to see the whoami container.
  5. Go to https://localhost to see the logout form.
  6. Click logout
  7. Navigate to https://localhost to see that you are not allowed.
  8. Repeat until you get bored.

Final thoughts

How to protect the user/passwords or register new users is out of the scope, but I think you can easily extend this example to do whatever you want. Also, I would like to implement the “next” behaviour to properly redirect the users when they try to access to a protected page and instruct nginx to redirect users to login page if the page is protected. But again, this is out of the scope. Maybe in the near future.

The best thing about this approach is that you can protect anything without actually modifying it. If you have some legacy app or old code that do not implement user auth, this can easily add a new layer of protection without too much effort.

Conclusion

I hope you’ve enjoyed this post and I will appreciate any feedback in the comments bellow. I’m fairly new to the world of nginx and I’m discovering new things every day. For example, looks like there is a JWT validation directive for nginx that will effectively replace the auth endpoint implemented in this tutorial.

I’m learning Traefik at the same time and I’m enjoying it a lot. I know that it offers less features than nginx, but looks like a really good idea if you are dealing with docker-compose or kubernetes. I’m going to give it a try. From the docs, looks like it is compatible with the auth request pattern

.

And as always, Thanks for watching.

.

Sorry, I’ve been watching a lot of Michel Toys last week.

References

howtogodocker

Use Git to track Git versions

How to get bored