Im Rahmen eines privaten Projekts habe ich mich genauer mit einem Modul für nginx beschäfigt. Dieses erlaubt es, zusätzlich Authentifizierung für Webanwendungen zu implementieren, ohne dass diese Anwendungen selbst geändert werden müssen - etwa wenn Drittanwendungen zusätzlich geschützt werden sollen.

Alle Konfigurations-Beispiele und Skripte sind auch über das Code-Repository für diesen Blogpost herunterladbar.

Warum über nginx?

Nicht immer hat man die Möglichkeit, Anwendungen anzupassen. In meinem Fall will ich den Zugriff auf eine Software nochmal zusätzlich absichern, da diese nur für meine persönliche Nutzung gedacht ist.

Die betroffene Anwendung selbst hat zwar bereits eine eigene Authentifizierung, der Ansatz per auth_request schützt jedoch zuverlässig auch gegen etwaige Sicherheitsprobleme in der Anwendung bzw. deren API. Ich bin in diesem Punkt etwas paranoider, da andere Systeme wie Wordpress häufig über deren (öffentlichen) APIs angegriffen wurden.

Überlegungen

Das auth_request-Modul ist recht flexibel, da es die eigentliche Authentifizierung an einen selbst definierbaren Webservice delegiert wird.

Im Blogpost welcher mich inspiriert hat wurde Vouch Proxy vorgeschlagen, eine fertige Lösung welche OAuth als Backend nutzt. Da ich jedoch gerne unabhängig von Dritten bin für eigene Infrastruktur, baue ich den Dienst selbst :-).

Der eigene Authentication-Dienst

Für die ersten Tests habe ich eine simple Flask-Anwendung gebaut, welche ein Login-Formular bereitstellt und mit statischen Usern arbeitet. Den Code der Anwendung ist vollständig im Code-Repository für diesen Blogpost verfügbar, ich gehe hier daher nur auf Ausschnitte ein.

Der Dienst stellt zwei HTTP-Endpunkte bereit: Zum einen den Benutzer-Login (/), zum anderen den Validierungs-Endpunkt für nginx (/validate).

Bei ersterem ist der Fantasie (bzw. den Anforderungen) kaum Grenzen gesetzt: Egal ob als Backend eine Datenbank oder z.B. LDAP genutzt wird, oder Funktionen wie 2FA integriert werden - alles ist möglich.

Der Validierungs-Endpunkt wird bei jedem Request den nginx erhält durchgeführt, und muss daher entsprechend performant sein. In meinem Beispiel verwende ich die itsdangerous-Bibliothek, welche es erlaubt Werte mit einem privaten Schlüssel zu signieren, und später wieder zu prüfen. Als Wert für den Cookie verwende ich im Beispiel den Benutzernamen. Dieser wird unter /validate als HTTP-Header zurück gegeben, was auch an die Anwendung durchgereicht werden kann.

Ein weiterer Vorteil dieses Ansatzes ist es, dass er stateless ist: Es ist keine Verwaltung der Tokens in einer Datenbank notwendig, und die Reaktionszeiten der Validierung sind dadurch stabil.

Boilerplate

Im ersten Schritt gilt es, eine Reihe von Modulen zu importieren sowie mehrere globale Objekte zu erzeugen.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import time

import bcrypt
from itsdangerous.url_safe import URLSafeTimedSerializer
from itsdangerous.exc import BadSignature, SignatureExpired
from flask import Flask, render_template, request, make_response

# Flask initialisieren
app = Flask(__name__)

# Den URL-Signierer initialisieren
serializer = URLSafeTimedSerializer("64215516cd4eb431f90020cb8efbf9410353f6e")

# Statisches Mapping aller Benutzer. Für eine produktive Anwendung würden wir
# diese Werte bei der Anmeldung aus einer Datenbank holen.
USERS = {
    # mitch:demo
    'mitch': b'$2b$12$/BOdvK3qy88BJPQgoR3SHup73flz13FfF213byXbWWlUR15YBcpGC',
}

# Name des Cookies und wie lange die Sitzungen gültig sein sollen.
COOKIE_NAME = "_custom_auth_token"
SESSION_LIFETIME = 3600 # seconds

Zur Erzeugung von zufälligen Tokens und Passwörtern ist openssl recht praktisch:

1
openssl rand -hex 32

Login-Endpunkt

Dieser Endpunkt wird aufgerufen, wenn Benutzer ohne gültigen Cookie von nginx auf unseren Webdienst umgeleitet werden. In unserem Fall ziehen wir die Benutzerinformationen aus dem statischen Objekt von oben, in einer realen Anwendung müssten wir die Infos aus einer Datenbank laden.

Ein mittlerweile häufiger Sicherheits-Vorschlag ist es, die beiden Meldungen für “Benutzer unbekannt” und “Passwort falsch” zusammenzufassen, um einem Angreifer nicht die Info zu geben ob ein Benutzer existiert oder nicht. In diesem Fall ist mir die Bedienbarkeit jedoch wichtiger als ein Prozent mehr an Sicherheit. In meiner Implementierung habe ich sowieso erzwingend TOTP integriert, was Brute Force praktisch unmöglich macht.

Als Wert für den Sitzungs-Cookie wird der signierte Benutzername verwendet, an sich interessiert in diesem Fall nur ob ein Benutzer authentifiziert ist - oder eben nicht. Je nach Anforderung könnte man jedoch auch z.B. ein JSON-Objekt mit zusätzlichen Informationen verwenden.

 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
@app.route("/", methods=['GET', 'POST'])
def login():
    error = None

    # Formular abgeschickt?
    if request.method == "POST":
        # Formular-Daten herausziehen        
        username = request.form['username']
        password = request.form['password']

        # Die URL haben wir im Formular signiert um sicherzustellen,
        # dass diese nicht zu einfach manipuliert werden kann.
        url = serializer.loads(request.form['url'])

        # Prüfen ob es den Benutzer gibt.
        # Achtung: Walrus-Operator gibt es erst seit Python 3.8!
        if pwhash := USERS.get(username):

            # Stimmt das Passwort?
            # .encode() da bcrypt nur mit bytes()-Instanzen arbeitet
            if bcrypt.checkpw(password.encode(), pwhash):

                # Alles in Ordnung, Cookie setzen und zurück leiten
                r = make_response('', 302)
                r.set_cookie(COOKIE_NAME, serializer.dumps(username))
                r.headers['location'] = url
                return r
            else:
                return render_template("login.html", error="Falsches Passwort")
        else:
            return render_template("login.html", error="Benutzer unbekannt")
    
    # Die URL zu welcher der User nach dem Request geschickt werden soll
    # @todo Hier sollte man gewisse Adressen whitelisten
    redirect_url = request.args.get('url')
    if not redirect_url:
        error = "Missing redirect URL"

    # Die Login-Seite zurückgeben
    return render_template("login.html",
        error=error,
        redirect_url=serializer.dumps(redirect_url),
    )

Validierungs-Endpunkt

Der Endpunkt zur Validierung ist ziemlich kurz und besteht primär aus dem Laden des Cookies sowie der Validierung dessen.

Die verschiedenen Rückgabe-Texte habe ich primär fürs Debugging integriert, da der Endpunkt in der Regel nicht öffentlich verfügbar ist, können Benutzer dessen Wert niemals einsehen.

Optional könnten mit der Rückgabe weiter HTTP-Header gesetzt werden, welche dann in nginx als Variablen zur Verfügung stehen. Dies erlaubt es, dass Authentifizierungs-Informationen an den geschützten Dienst übergeben werden.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
@app.route("/validate", methods=['GET', 'POST'])
def validate():
    if token := request.cookies.get(COOKIE_NAME):
        try:
            username = serializer.loads(token, max_age=SESSION_LIFETIME)
            return "OK", 200
        except BadSignature as e:
            return "BadSignature", 401
        except SignatureExpired as e:
            return "SignatureExpired", 401
    return "MissingCookie", 401

Konfiguration von nginx

Der folgende Block kann als eine einzelne Datei in der nginx-Konfiguration abgelegt werden, und dann bei Bedarf pro server {}-Block per include integriert werden.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
# Interner Endpunkt für die Authentifizierung
location = /auth-validate {
    internal;

    # This address is where the authentication service will be listening on
    proxy_pass http://127.0.0.1:5005/validate;
    proxy_pass_request_body off; # no need to send the POST body

    proxy_set_header Content-Length "";
    proxy_set_header X-Real-IP $remote_addr;
}

error_page 401 = @error401;

# If the user is not logged in, redirect them to the login URL
location @error401 {
    return 302 https://login.mydomain.com/?url=https://$http_host$request_uri;
}

auth_request /auth-validate;

Zusätzlich muss (als Subdomain) ein eigener virtueller Host konfiguriert werden, welcher den Authentifizierungs-Dienst anbietet:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
    server {
        server_name login.mydomain.com;

        ...

        client_max_body_size 512K;

        location / {
            proxy_pass http://127.0.0.1:5005;
        }
    }

Quellen und Referenzen