Authentifizierung mit Spring Boot und Azure AD/Entra ID

Authentifizierung mit Spring Boot und Azure AD/Entra ID

Christian Bielak

16. Juni 2024

Java

Spring Boot

Security

Übersicht

Microsofts Azure AD/Entra ID ist ein umfassendes Identity Management Produkt, das von vielen Organisationen weltweit eingesetzt wird. Es unterstützt verschiedene Login-Mechanismen und Kontrollen, die Benutzern organisationsweit ein Single Sign-On Erlebnis für das Anwendungsportfolio bieten.

Darüber hinaus lässt sich Azure AD/Entra ID gut mit bestehenden Active Directory Installationen integrieren, die bereits von vielen Unternehmen für Identity und Access Management in Unternehmensnetzwerken verwendet werden. Administratoren können so Benutzern Zugriff auf Anwendungen gewähren und deren Berechtigungen mit den gewohnten Tools verwalten.

Integration von Azure AD/Entra ID

Aus Sicht einer Spring Boot Anwendung verhält sich Azure AD/Entra ID wie ein OIDC-konformer Identity Provider. Das bedeutet, wir können es mit Spring Security nutzen, indem wir lediglich die erforderlichen Properties und Dependencies konfigurieren.

Um die Azure AD/Entra ID Integration zu veranschaulichen, implementieren wir einen Confidential Client, bei dem der Austausch des Authorization Code gegen den Access Token serverseitig erfolgt. Bei diesem Ablauf wird der Access Token nie an den Browser des Benutzers übermittelt, weshalb er als sicherer als die Public Client Alternative gilt.

Maven Dependencies

Zunächst fügen wir die erforderlichen Maven Dependencies für eine Spring Security basierte WebMVC Anwendung hinzu:

<dependency> 
	<groupId>org.springframework.boot</groupId> 
	<artifactId>spring-boot-starter-oauth2-client</artifactId			
	<version>3.3.0</version> 
</dependency> 
<dependency>
	<groupId>org.springframework.boot</groupId>
	<artifactId>spring-boot-starter-web</artifactId> 
	<version>3.3.0</version> 
</dependency>

Die neuesten Versionen dieser Dependencies sind im Maven Central Repository verfügbar.

Konfigurationseigenschaften

Als nächstes fügen wir die erforderlichen Spring Security Properties hinzu, um unseren Client zu konfigurieren. Es ist ratsam, diese Properties in einem eigenen Spring-Profil abzulegen, was die Wartung bei wachsender Anwendung erleichtert. Wir nennen dieses Profil "azuread", was den Zweck verdeutlicht. Dementsprechend fügen wir die zugehörigen Properties in der Datei application-azuread.yml hinzu:

spring:
  security:
    oauth2:
      client:
        provider:
          azure:
            issuer-uri: https://login.microsoftonline.com/your-tenant-id-comes-here/v2.0
        registration:
          azure-dev:
            provider: azure
            client-id: wird von Microsoft bereitgestellt
            client-secret: wird von Microsoft bereitgestellt
            scope:
            - openid
            - email
            - profile

Im provider-Abschnitt definieren wir einen azure-Provider. Azure AD/Entra ID unterstützt den OIDC-Standardmechanismus zur Endpoint Discovery, sodass wir nur die issuer-uri Property konfigurieren müssen.

Diese Property hat zwei Aufgaben: Erstens ist es die Basis-URI, an die der Client den Namen der Discovery-Ressource anhängt, um die tatsächliche URL zum Herunterladen zu erhalten. Zweitens wird sie auch verwendet, um die Authentizität eines JSON Web Token (JWT) zu überprüfen. Die iss-Claim eines vom Identity Provider erstellten JWT muss mit dem Wert der issuer-uri übereinstimmen.

Bei Azure AD/Entra ID hat die issuer-uri immer die Form https://login.microsoftonline.com/my-tenant-id/v2.0, wobei my-tenant-id der Bezeichner Ihres Tenant ist.

Im registration-Abschnitt definieren wir den azure-dev-Client, der den zuvor definierten Provider verwendet. Wir müssen auch die Client-Credentials über die Properties client-id und client-secret bereitstellen. Darauf kommen wir später in diesem Artikel zurück, wenn wir die Registrierung dieser Anwendung in Azure behandeln.

Schließlich definieren die scope-Properties die Berechtigungen, die dieser Client in Autorisierungsanfragen einbezieht. Hier fordern wir den profile-Scope an, der es dem Client ermöglicht, den Standard-Userinfo-Endpoint abzufragen. Dieser Endpoint liefert konfigurierbare Informationen zurück, die im Azure AD/Entra ID-Benutzerverzeichnis gespeichert sind, wie z.B. die bevorzugte Sprache und Locale-Daten des Benutzers.

Client-Registrierung

Wie bereits erwähnt, müssen wir unsere Client-Anwendung in Azure AD/Entra ID registrieren, um die tatsächlichen Werte für die erforderlichen Properties client-id und client-secret zu erhalten. Angenommen, wir haben bereits ein Azure-Konto, besteht der erste Schritt darin, sich in der Web-Konsole anzumelden und über das Menü oben links zur Seite des Azure Active Directory Service zu navigieren:

overview.png

 

Unterstützte Kontotypen: Hier haben wir je nach Zielgruppe der Anwendung verschiedene Optionen zur Auswahl. Für Anwendungen, die für den internen Gebrauch einer Organisation bestimmt sind, ist die erste Option ("Konten nur in diesem Organisationsverzeichnis") in der Regel das Richtige. Das bedeutet, dass auch wenn die Anwendung über das Internet zugänglich ist, sich nur Benutzer innerhalb der Organisation anmelden können.

Zu den weiteren verfügbaren Optionen gehört die Möglichkeit, auch Benutzer aus anderen Azure AD/Entra ID-gestützten Verzeichnissen wie Schulen oder Organisationen, die Office 365 verwenden, sowie persönliche Konten, die für Skype und/oder Xbox verwendet werden, zu akzeptieren.

Obwohl es nicht sehr üblich ist, können wir diese Einstellung auch später ändern. Wie in der Dokumentation angegeben, können Benutzer nach dieser Änderung jedoch Fehlermeldungen erhalten.

Im Abschnitt "Übersicht" finden wir die Tenant-ID, die wir in der issuer-uri Konfigurationseigenschaft verwenden müssen. Als nächstes klicken wir auf "App-Registrierungen", was uns zur Liste der vorhandenen Anwendungen führt. Danach klicken wir auf "Neue Registrierung", woraufhin das Registrierungsformular für Clients angezeigt wird. Hier müssen wir drei Informationen angeben:

  • Anwendungsname 
  • Unterstützte Kontotypen 
  • Redirect-URI

Gehen wir näher auf diese Punkte ein.

 

Anwendungsname: Der hier eingegebene Wert wird den Endbenutzern während des Authentifizierungsprozesses angezeigt. Daher sollten wir einen Namen wählen, der für die Zielgruppe sinnvoll ist. Nehmen wir einen sehr einfallslosen Namen: "Test App".

Wir müssen uns keine großen Gedanken über den richtigen Namen machen. Azure AD/Entra ID erlaubt es uns, ihn jederzeit zu ändern, ohne die registrierte Anwendung zu beeinflussen. Es ist jedoch wichtig zu beachten, dass dieser Name nicht eindeutig sein muss, es aber nicht klug ist, mehrere Anwendungen mit demselben Anzeigenamen zu verwenden.

 

Redirect-URI: Schließlich müssen wir eine oder mehrere Redirect-URIs angeben, die als Ziel für den Autorisierungsflow zulässig sind. Wir müssen eine "Plattform" auswählen, die mit der URI verbunden ist, was sich auf die Art der Anwendung bezieht, die wir registrieren:

Web: Austausch von Authorization Code gegen Access Token erfolgt im Backend SPA: Austausch von Authorization Code gegen Access Token erfolgt im Frontend Public Client: Wird für Desktop- und mobile Anwendungen verwendet.

In unserem Fall wählen wir die erste Option, da wir sie für die Benutzerauthentifizierung benötigen.

Was die URI betrifft, verwenden wir den Wert http://localhost:8080/login/oauth2/code/azure-dev. Dieser Wert stammt aus dem Pfad, den Spring Securitys OAuth Callback Controller verwendet, der standardmäßig den Response Code unter /login/oauth2/code/{registration-name} erwartet. Hier muss {registration-name} mit einem der Schlüssel übereinstimmen, die im registration-Abschnitt der Konfiguration vorhanden sind, was in unserem Fall azure-dev ist.

Wichtig ist auch, dass Azure AD/Entra ID für diese URIs HTTPS vorschreibt, aber es gibt eine Ausnahme für localhost. Das ermöglicht die lokale Entwicklung, ohne dass Zertifikate eingerichtet werden müssen. Später, wenn wir in die Zielbereitstellungsumgebung (z. B. Kubernetes-Cluster) wechseln, können wir zusätzliche URIs hinzufügen.

Beachten Sie, dass der Wert dieses Schlüssels keine direkte Beziehung zum Registrierungsnamen von Azure AD/Entra ID hat, obwohl es sinnvoll ist, einen Namen zu verwenden, der sich auf den Ort bezieht, an dem er verwendet wird.

Hinzufügen eines Client Secret

Sobald wir im ersten Registrierungsformular auf die Schaltfläche "Registrieren" klicken, sehen wir die Informationsseite des Clients:

app overview.png

Der Abschnitt "Wichtige Informationen" enthält auf der linken Seite die Anwendungs-ID, die der client-id-Eigenschaft in unserer Eigenschaftendatei entspricht. Um ein neues Client Secret zu generieren, klicken wir nun auf "Zertifikat oder Geheimnis hinzufügen", was uns zur Seite "Zertifikate und Geheimnisse" führt. Als Nächstes wählen wir die Registerkarte "Geheimer Clientschlüssel" und klicken auf "Neuer geheimer Clientschlüssel", um das Formular zur Erstellung des Geheimnisses zu öffnen:

add secret.png

Hier geben wir einen beschreibenden Namen für dieses Geheimnis an und definieren das Ablaufdatum. Wir können aus einer der vorkonfigurierten Dauern wählen oder die Option "Benutzerdefiniert" auswählen, mit der wir sowohl das Start- als auch das Enddatum festlegen können.

Zum Zeitpunkt des Verfassens dieses Artikels laufen Client Secrets nach maximal zwei Jahren ab. Das bedeutet, dass wir ein Verfahren zur Rotation der Geheimnisse einrichten müssen, vorzugsweise mit einem Automatisierungstool wie Terraform. Zwei Jahre mögen lang erscheinen, aber in Unternehmensumgebungen sind Anwendungen, die jahrelang laufen, bevor sie ersetzt oder aktualisiert werden, recht häufig.

Sobald wir auf "Hinzufügen" klicken, erscheint das neu erstellte Geheimnis in der Liste der Client-Anmeldeinformationen:

app secret.png

Wir müssen den Wert des Geheimnisses sofort an einem sicheren Ort kopieren, da er nicht mehr angezeigt wird, sobald wir diese Seite verlassen. In unserem Fall kopieren wir den Wert direkt in die Eigenschaftendatei der Anwendung unter der Eigenschaft client-secret.

In jedem Fall müssen wir uns daran erinnern, dass es sich um einen sensiblen Wert handelt! Beim Bereitstellen der Anwendung in einer Produktionsumgebung wird dieser Wert in der Regel über einen dynamischen Mechanismus wie ein Kubernetes Secret bereitgestellt.

Anwendungscode

Unsere Testanwendung hat einen einzigen Controller, der Anfragen an den Root-Pfad behandelt, Informationen über die eingehende Authentifizierung protokolliert und die Anfrage an eine Thymeleaf-Ansicht weiterleitet. Dort wird eine Seite mit Informationen über den aktuellen Benutzer gerendert.

Der eigentliche Code des Controllers ist trivial:

@Controller
@RequestMapping("/")
public class IndexController {

    @GetMapping
    public String index(Model model, Authentication user) {
        model.addAttribute("user", user);
        return "index";
    }
}

Der Ansichtscode verwendet das user-Modellattribut, um eine schöne Tabelle mit Informationen über das Authentication-Objekt und alle verfügbaren Claims zu erstellen.

Ausführen der Testanwendung

Nachdem alle Teile an Ort und Stelle sind, können wir die Anwendung nun ausführen. Da wir ein spezifisches Profil mit Azure AD/Entra ID-Eigenschaften verwendet haben, müssen wir es aktivieren. Wenn wir die Anwendung über das Maven-Plugin von Spring Boot ausführen, können wir dies mit der Eigenschaft spring-boot.run.profiles tun:

mvn -Dspring-boot.run.profiles=azuread spring-boot:run

Nun können wir einen Browser öffnen und auf http://localhost:8080 zugreifen. Spring Security erkennt, dass diese Anfrage noch nicht authentifiziert ist, und leitet uns zur generischen Anmeldeseite von Azure AD/Entra ID weiter.

Die spezifische Anmeldesequenz variiert je nach den Einstellungen der Organisation, besteht aber normalerweise darin, den Benutzernamen oder die E-Mail einzugeben und ein Geheimnis anzugeben. Falls konfiguriert, kann auch ein zweiter Authentifizierungsfaktor angefordert werden. Wenn wir jedoch derzeit bei einer anderen Anwendung im selben Azure AD/Entra ID-Mandanten im selben Browser angemeldet sind, wird die Anmeldesequenz übersprungen - darum geht es schließlich beim Single Sign-On.

Wenn wir zum ersten Mal auf unsere Anwendung zugreifen, zeigt Azure AD/Entra ID auch das Einwilligungsformular der Anwendung an.

Obwohl hier nicht behandelt, unterstützt Azure AD/Entra ID die Anpassung mehrerer Aspekte der Anmelde-Benutzeroberfläche, einschließlich gebietsschemasspezifischer Anpassungen. Darüber hinaus ist es möglich, das Autorisierungsformular vollständig zu umgehen, was bei der Autorisierung interner Anwendungen nützlich ist.

Sobald wir die Berechtigungen erteilt haben, sehen wir die Startseite unserer Anwendung, die hier teilweise dargestellt ist.

Wir sehen, dass wir bereits Zugriff auf grundlegende Informationen über den Benutzer haben, darunter seinen Namen, seine E-Mail-Adresse und sogar die URL, um sein Bild abzurufen. Es gibt jedoch ein lästiges Detail: Der Wert, den Spring für den Benutzernamen auswählt, ist nicht sehr benutzerfreundlich.

Sehen wir, wie wir dies verbessern können.

Benutzernamen-Mapping

Spring Security verwendet die Schnittstelle Authentication, um einen authentifizierten Principal darzustellen. Konkrete Implementierungen dieser Schnittstelle müssen die Methode getName() bereitstellen, die einen Wert zurückgibt, der oft als eindeutiger Bezeichner für den Benutzer innerhalb der Authentifizierungsdomäne verwendet wird.

Bei der JWT-basierten Authentifizierung verwendet Spring Security standardmäßig den Wert des Standard-sub-Claims als Name des Principal. Wenn wir uns die Claims ansehen, sehen wir, dass Azure AD/Entra ID dieses Feld mit einer internen Kennung füllt, die für Anzeigezwecke ungeeignet ist.

Glücklicherweise gibt es in diesem Fall eine einfache Lösung. Alles, was wir tun müssen, ist eines der verfügbaren Attribute auszuwählen und dessen Namen in die Eigenschaft user-name-attribute des Providers zu setzen:

spring:
  security:
    oauth2:
      client:
        provider:
          azure:
            issuer-uri: https://login.microsoftonline.com/xxxxxxxxxxxxx/v2.0
            user-name-attribute: name
            ... andere Eigenschaften ausgelassen

Hier haben wir den name-Claim gewählt, da er dem vollständigen Namen des Benutzers entspricht. Ein anderer geeigneter Kandidat ist das email-Attribut, das eine gute Wahl sein kann, wenn unsere Anwendung seinen Wert als Teil einer Datenbankabfrage verwenden muss.

Wir können nun die Anwendung neu starten und die Auswirkungen dieser Änderung sehen:

Jetzt viel besser!

Abrufen der Gruppenmitgliedschaft

Bei genauerer Betrachtung der verfügbaren Claims zeigt sich, dass keine Informationen über die Gruppenmitgliedschaften des Benutzers vorhanden sind. Die einzigen GrantedAuthority-Werte, die in der Authentication verfügbar sind, sind diejenigen, die mit den angeforderten Scopes verknüpft sind und als Teil der Client-Konfiguration enthalten sind.

Das mag ausreichen, wenn wir nur den Zugriff auf Organisationsmitglieder beschränken müssen. Häufig ist es jedoch der Fall, dass wir verschiedene Zugriffsebenen auf der Grundlage von Rollen gewähren, die dem aktuellen Benutzer zugewiesen sind. Außerdem ermöglicht die Zuordnung dieser Rollen zu Azure AD/Entra ID-Gruppen die Wiederverwendung verfügbarer Prozesse wie das Onboarding und/oder die Neuzuweisung von Benutzern.

Um dies zu erreichen, müssen wir Azure AD/Entra ID anweisen, die Gruppenmitgliedschaft in das idToken aufzunehmen, das wir während des Autorisierungsflusses erhalten.

Zunächst müssen wir zur Seite unserer Anwendung gehen und im rechten Menü "Token-Konfiguration" auswählen. Als nächstes klicken wir auf "Gruppenanspruch hinzufügen", woraufhin ein Dialogfeld geöffnet wird, in dem wir die für diesen Anspruchstyp erforderlichen Details definieren.

Wir werden eine reguläre Azure AD/Entra ID-Gruppe verwenden, also entscheiden wir uns für die erste Option ("Sicherheitsgruppen"). Dieses Dialogfeld enthält auch andere Konfigurationsoptionen für jeden der unterstützten Token-Typen. Wir belassen es vorerst bei den Standardwerten.

Sobald wir auf "Speichern" klicken, zeigt die Anspruchsliste der Anwendung den groups-Anspruch an.

Nun können wir zu unserer Anwendung zurückkehren, um die Auswirkungen dieser Konfiguration zu sehen.

Abbilden von Gruppen auf Spring Authorities

Der group-Claim enthält eine Liste von Objektbezeichnern, die den zugewiesenen Gruppen des Benutzers entsprechen. Spring bildet diese Gruppen jedoch nicht automatisch auf GrantedAuthority-Instanzen ab.

Dazu ist ein benutzerdefinierter OidcUserService erforderlich, wie in der Dokumentation zu Spring Security beschrieben. Unsere Implementierung, die online verfügbar ist, verwendet eine externe Map, um die Standard-OidcUser-Implementierung mit zusätzlichen Authorities anzureichern. Wir haben eine @ConfigurationProperties-Klasse verwendet, in die wir die erforderlichen Informationen eintragen:

  • Der Claim-Name, aus dem wir die Gruppenliste erhalten ("groups")
  • Ein Präfix für die von diesem Provider abgebildeten Authorities
  • Eine Map von Objektbezeichnern zu GrantedAuthority-Werten

Die Verwendung einer Strategie zur Abbildung von Gruppen auf Listen ermöglicht es uns, mit Situationen umzugehen, in denen wir vorhandene Gruppen verwenden möchten. Sie trägt auch dazu bei, die Rollenmenge der Anwendung von der Richtlinie zur Gruppenzuweisung zu entkoppeln.

So sieht eine typische Konfiguration aus:

testApp:
  jwt:
    authorization:
      group-to-authorities:
        "xxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx": TEST_APP_RW
        "xxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx": TEST_APP_RO,TEST_APP_ADMIN

Die Objektbezeichner sind auf der Seite "Gruppen" verfügbar.

Sobald diese Zuordnung erfolgt ist und wir unsere Anwendung neu starten, können wir unsere Anwendung testen. Dies ist das Ergebnis, das wir für einen Benutzer erhalten, der zu beiden Gruppen gehört. Er hat nun drei neue Authorities, die den zugeordneten Gruppen entsprechen.