In verteilten Architekturmodellen, wie Microservices oder Self-contained Systems (SCS), wird eine Anwendung aus mehreren, unabhängigen Systemen erstellt. Das hat viele bekannte Vorteile (optimierbare Anwendungen, unabhängige Teams, Wartbarkeit, Skalierbarkeit, …), aber auch eine Herausforderung: Wie funktioniert eine übergreifende Authentifizierung? Schließlich will sich der Benutzer nicht in jedem Teil-System neu registrieren und einloggen müssen.
Single Sign-On mit OpenID Connect
Die Idee: Es gibt ein System, welches sich ausschließlich um die Registrierung und die Authentifizierung eines Benutzers kümmert (Single Sign-On). Dieser Identity Provider stellt bei einer erfolgreichen Authentifizierung ein Token aus und alle anderen Systeme (Relying Parties) vertrauen diesem Token.
Das passende Protokoll hierfür ist OpenID Connect, das auf den Standards OAuth 2.0 und JWT basiert. OpenID Connect wurde ursprünglich für Social Logins entwickelt, um sich mit Facebook, Google oder Twitter auf einer fremden Webseite einzuloggen. Im Enterprise Umfeld ist es aber aus Datenschutz- und Sicherheitsgründen allgemein üblich, seinen eigenen Identity Provider zu verwenden, was mit OpenID Connect ebenfalls möglich ist.
Ein verbreiteter Authorization und Identity Server mit sehr guter OpenID Connect Unterstützung ist Keycloak (Open Source). Es gibt zudem einige weitere Produkte und Cloud-Anbieter.
Redirects, Refresh, Verification
Im Detail ist der OpenID Connect Authorization Code Flow leider relativ aufwendig:
Wenn ein registrierter Benutzer auf eine geschützte Ressource einer Anwendung zugreifen möchte (1), dann wird der Benutzer auf die Login-Maske des Identity Providers weitergeleitet (2). Dies geschieht über HTTP Redirects. Der Benutzer wird aufgefordert, sich einzuloggen (3) und gibt seine Credentials ein (4). Nach dem erfolgreichen Login (5) erfolgt ein weiterer Redirect zur Anwendung (6), bei der ein Authentication-Code mitgegeben wird.
Mit diesem Code kann die Anwendung dann ein Access-Token, ein ID-Token und das langlebige Refresh-Token anfordern (7), vgl. folgenden Beispiel-Aufruf. Das ID-Token liegt als JWT vor und beinhaltet die relevanten Benutzer-Informationen, wie Benutzername, Kundennummer und ggf. auch Berechtigungen.
Der große Vorteil ist dabei, dass das JWT vom Identity Provider signiert wurde und die Signatur effizient ohne einen Remote-Aufruf zum Identity Provider verifiziert werden kann, wenn das Shared-Secret (im HMAC-Verfahren) oder (besser!) der Public Key (im RS-Verfahren) der Relying Party bekannt ist, wobei diese den Public Key auch abrufen kann (8). Nach erfolgreicher Validierung des JWT (9), kann die Anwendung die Benutzerinformationen wie Benutzername, E-Mail-Adresse und Rollen aus dem Token auslesen.
Das Access-Token ist relativ kurzlebig (einige Minuten) und mit dem langlebigen Refresh-Token kann die Anwendung kurz vor Ablauf ein neues Access-Token anfordern (10). Dabei wird geprüft, dass die Session noch aktiv ist und der Account nicht gesperrt wurde (hier findet wieder ein Remote-Aufruf zum Identity Provider statt).
Die Prüfung, ob der authentifizierte Benutzer auf eine Ressource zugreifen darf, ist Aufgabe der Fachanwendung, wobei die zugewiesenen Rollen im ID-Token enthalten sein können.
Single Sign-Out
Da das Access-Token eine bestimmte Zeit gültig ist, ist ein frühzeitiger, systemübergreifender Logout nicht ohne Weiteres möglich.
Falls diese Anforderung besteht, muss die Session im Identity Provider invalidiert und die Access-Tokens bei allen Relying Parties gelöscht werden. OpenID Connect sieht hier die Spezifikationserweiterungen Session Management, Front-Channel Logout und Back-Channel Logout vor, die unter anderem auf IFrames und Logout-Token basieren.
Eine weitere Alternative: Vor einer kritischen Operation überprüft die Relying Party beim Identity Provider, ob das Access-Token noch gültig ist. Dies kann z. B. über den UserInfo-Endpoint durchgeführt werden.
Authentication Proxy
Die korrekte Umsetzung des Authentication Flows muss jede Relying Party sicherstellen. Damit dies nicht mehrfach implementiert werden muss (ggf. sogar in unterschiedlichen Plattformen), bietet es sich ein vorgeschalteter Authentication Proxy an. Dieser kümmert sich um Redirect, Refresh und Validierung und lässt nur authentifizierte Anfragen an die Fachanwendung durch. Dabei werden die Authentifizierungsinformationen und das Access-Token mit übergeben (typischerweise als Header-Felder). Die Anwendung kann sich damit auf die Umsetzung der Fachlichkeit konzentrieren. Im Container/Kubernetes-Umfeld verwendet man diesen Proxy als Sidecar.
Nach meinem Wissen ist lua-resty-openidc als NGINX Erweiterung hierfür derzeit am besten geeignet. Besonders hervorzuheben ist die Leeway-Unterstützung, also dass das Access-Token ein paar Sekunden vor Ablauf erneuert wird, sodass Folge-Aufrufe nicht plötzlich ein abgelaufenes Access-Token bekommen. Andere Implementierungen beschränken sich auf Social-Logins, haben keine Unterstützung für Refresh-Tokens oder akzeptieren none als Signatur-Algorithmus (Security-GAU!).
Schauen wir uns ein Beispiel für die Konfiguration an:
Im Wesentlichen wird hier die Discovery URL und die Client Credentials konfiguriert.
Der Proxy kümmert sich also um Redirect-Handling, Token-Refresh und Validierung der Tokens. Alle Session-Daten (Nonce, State, Redirect-URLs, Access-Token, ID-Token, Refresh-Token) werden hier übrigens in einem verschlüsselten Session-Cookie gespeichert. Load-Balancing-Probleme sollten wir also nicht haben. Nachteil: Die Request Größe steigt aufgrund des Cookies pro Request um etwa 4 KB. Alternativen wie Redis wären aber konfigurierbar.
Das gültige Access-Token (das hier dem ID-Token entspricht) wird an die Fachanwendung als Bearer-Token im Header Authorization
weitergegeben.
Außerdem wird in diesem Beispiel die E-Mail-Adresse aus dem Claim email
direkt in den Header X-User
geschrieben.
Damit müsste die Fachanwendung keine Unterstützung für JWT haben, sondern kann den Principal einfach aus dem Header-Feld lesen.
Eine weitere Überlegung: Verwendet man einen zentralen Auth-Proxy für alle Anwendungen oder soll jede Anwendung einen eigenen Auth-Proxy einsetzen? Beides ist möglich. Die Entscheidung sollte zur Macro-Architektur und der Infrastruktur passen.
Spring Security UserAuthenticationConverter
Ich verwende für Fachanwendungen häufig Spring Boot. Die Unterstützung für OAuth2 Resource Server und Bearer Tokens ist durch spring-security-oauth bzw. Backport für Spring Boot 2 gegeben. Die native OpenID Connect Unterstützung lässt leider noch auf etwas auf sich warten.
Standardmäßig mappt spring-security-oauth aus dem JWT den Claim user_name
als Spring Security Principal.
Da in OpenID Connect hierfür aber der Claim sub
vorgesehen ist, ist ein spezifischer UserAuthenticationConverter
sinnvoll:
Source Code
Ein komplettes Beispiel findet sich in Github: https://github.com/jochenchrist/auth-proxy