ASP.NET Core Blazor WebAssembly: Authentifizierung und Autorisierung mit Keycloak in Aktion

Im ersten Teil der Artikelserie haben wir uns angesehen, wie sich ein Blazor-WebAssembly-Client mit Hilfe eines Identity Providers (IDP) sicher mit einer Web API kommunizieren kann. Hierzu wurde IdentityServer als IDP genutzt. In diesem Teil widmen wir uns einem alternativen IDP, nämlich Keycloak. Wie auch im vorherigen Artikel, betrachten wir hier die Authentifizierung des Blazor-Clients und wie wir die Client-UI anhand von Autorisierungsinformationen anpassen können.

In diesem Artikel:

Artikelserie

  1. Authentifizierung und Autorisierung mit IdentityServer in Aktion 
  2. Authentifizierung und Autorisierung mit Keycloak in Aktion

Version Information:

  • .NET: 6.0.100
  • Keycloak 15.0.2
  • AntDesign: 0.10.2

Über einer Demoapplikation wird nachvollziehbar, wie ein Blazor-Client sich bei einem Keycloak anmelden kann. Zusätzlich werden wir Informationen über den aktuellen Nutzer abfragen und nutzen, indem wir bestimmte Bereiche nur für autorisierte Nutzer zugänglich machen. Mit einem Access Token, den wir vom Keycloak Server erhalten, können wir dann eine Token-gesicherte Web-API aufrufen. Weitere Informationen über Keycloak und auch die Konfiguration von Clients, Claims und Policies, finden sich in den Webinaren meines Kollegen Boris Willhelms.

Der gesamte Source Code zu diesem Artikel befindet sich im zugehörigen GitHub Repository.

Hinweis: Da es sich hier um meinen zweiten Artikel über die Authentifizierung und Autorisierung handelt, gehe ich nicht auf die Authentifizierungsarchitektur in Blazor WebAssembly ein.

Blazor-WebAssembly-Client konfigurieren

Wie auch schon beim IdentityServer, müssen zuerst die nötigen Parameter konfiguriert werden, die für die Kommunikation mit Keycloak erforderlich sind. Dazu wird eine appsettings.json-Datei im wwwroot-Ordner erstellt und folgende Konfiguration hinzugefügt:

				
					{
  "Oidc": {
    "Authority": "http://localhost:8080/auth/realms/BlazorKeycloak",
    "ClientId": "blazor-keycloak-web-frontend",
    "PostLogoutRedirectUri": "https://localhost:5001/",
    "DefaultScopes": [
      "roles"
    ],
    "ResponseType": "code"
  }
}
				
			

Folgende Parameter werden in der Konfigurationsdatei gesetzt:

  • Authority ist die URL des Keycloak Servers, die sowohl für die Umleitung als auch für die Überprüfung der Signatur der Tokens und der Identität, die sie ausgegeben hat, verwendet wird.
  • ClientId ist der eindeutige Name für die Anwendung.
  • PostLogoutRedirectUri URL, an die wir weitergeleitet werden wollen, sobald wir uns ausloggen.
  • DefaultScopes sind die Scopes, die wir während des Anmeldevorgangs anfordern möchten, wie z.B. die E-Mail-Adresse oder die Profilinformationen. Wir verwenden hier:

    • openid, um anzuzeigen, dass wir die Überprüfung der Identität des Benutzers mit OpenID Connect durchführen möchten.
    • profile, um einige grundlegende Informationen des Benutzers abzurufen, z.B. seinen Namen.
  • ResponseType ist der von uns verwendete Grant Type, hier der Authorization-Code-Grant.

Mit der Extension-Methode AddOidcAuthentication fügen wir die SPA-Authentifizierungsfunktionalität der ServiceCollection hinzu. Innerhalb der Methode wird die von uns erzeugte Konfiguration der ProviderOptions-Eigenschaft zugewiesen.

				
					builder.Services.AddOidcAuthentication(options =>
{
  builder.Configuration.Bind("Oidc", options.ProviderOptions);
});
				
			

Nachdem wir alles erledigt haben, um die Konfiguration für die Keycloak-Authentifizierung dem Client verfügbar zu machen, müssen wir zum Schluss die RemoteAuthenticatorView in einer Blazor-Page hinzufügen. Sie ist der zentrale Dreh- und Angelpunkt für die benutzerzentrierte Interaktion mit dem Authentifizierungssystem. Hierzu existiert in der Demoapplikation eine Seite Authentication.razor. Um nicht für jede Aktion eine neue Seite anlegen zu müssen, wird die gewünschte Aktion (wie login oder logout) als Parameter an die Seite mit übergeben.

				
					
@page "/authentication/{action}"

@using Microsoft.AspNetCore.Components.WebAssembly.Authentication

<RemoteAuthenticatorView Action="@Action" />
				
			
				
					// Authentication.razor.cs

public partial class Authentication
{
    [Parameter] public string Action { get; set; }
}
				
			

Auf der Startseite der Anwendung befindet sich ein Login-Button, der mit der Aktion login die Route auhentication/login aufruft und somit den Authentifizierungsprozess startet.

				
					

@page "/"

<div class="login-container">
  <Button Type="primary" OnClick="BeginSignIn">Anmelden</Button>
</div>
				
			
				
					// Index.razor.cs

public partial class Index
{
    [Inject] private NavigationManager Navigation { get; set; }
    
    //...

    private void BeginSignIn()
    {
        Navigation.NavigateTo("authentication/login");
    }
}
				
			

Bis zu diesem Punkt unterscheidet sich die Implementierung mit dem Keycloak Server nicht von der mit dem IdentityServer. Im nächsten Abschnitt widmen wir uns der sicheren Kommunikation mit den Web APIs.

Sicher mit den Web APIs kommunizieren

Um sicher mit dem Server bzw. mit den Web APIs zu kommunizieren, nutzen wir in der Beispielanwendung einen Access Token. Das Token wird als Authorization-Header den HTTP-Requests hinzugefügt. Da es derzeit noch ein Problem mit der AuthorizationMessageHandler-Klasse gibt (GitHub Issue), müssen wir eine eigene MessageHandler-Klasse erstellen. Damit nicht bei jedem Aufruf der Web API das Token manuell hinzugefügt werden muss, implementieren wir eine eigene Klasse, welche von DelegatingHandler ableitet.

				
					public class CustomAuthorizationHeaderHandler : DelegatingHandler
{
    private readonly IAccessTokenProviderAccessor _accessor;

    public CustomAuthorizationHeaderHandler(IAccessTokenProviderAccessor accessor)
    {
        _accessor = accessor ?? throw new ArgumentNullException(nameof(accessor));
    }

    protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
    {
        var accessTokenResult = await _accessor.TokenProvider.RequestAccessToken();
        if (accessTokenResult.TryGetToken(out var accessToken) && !String.IsNullOrWhiteSpace(accessToken.Value))
        {
            request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken.Value);
        }
        return await base.SendAsync(request, cancellationToken);
    }
}
				
			

In der CustomAuthorizationHeaderHandler-Klasse aus dem obigen Code-Beispiel wird die Methode SendAsync überschrieben um das Access Token dem aktuellen HTTP-Request anzuhängen. Das aktuelle Access Token wird mit Hilfe des TokenProvider abgefragt. Ist ein Token vorhanden, wird dieses als Authorization-Header dem HTTP-Request hinzugefügt.

Abschließen registrieren wir einen HttpClient in der ServiceCollection in der Program.cs.

				
					// Program.cs

builder.Services
    .AddHttpClient("WebAPI", client => client.BaseAddress = new Uri("http://localhost:5002/"))
    .AddHttpMessageHandler<CustomAuthorizationHeaderHandler>();
builder.Services.AddScoped(sp => sp.GetRequiredService<IHttpClientFactory>()
    .CreateClient("WebAPI"));
				
			

Mit der Extension-Methode AddHttpMessageHandler können wir die CustomAuthorizationHeaderHandler-Klasse dem HttpClient als MessageHandler bekanntmachen. Dadurch wird bei jedem HTTP-Request die Methode SendAsync durchlaufen, welche das Access Token dem Request hinzufügt.

Um die HttpClient-Klasse einzusetzen, muss diese lediglich mit Hilfe von Dependency Injection der Klasse bzw. Komponente bereitgestellt werden.

				
					// DataService.cs

public DataService(HttpClient client, NavigationManager navigationManager)
{
    _client = client ?? throw new ArgumentNullException(nameof(client));
    _navigationManager = navigationManager ?? throw new ArgumentNullException(nameof(navigationManager));
}

// ... code...

private async Task<IEnumerable<T>> GetCollectionAsync<T>(string path,
        CancellationToken cancellationToken = default)
{
    return await _client.GetFromJsonAsync<IEnumerable<T>>($"{path}", cancellationToken); 
}

				
			

In der Methode GetCollectionAsync werden über die Methode GetFromJsonAsync<T> aus dem Microsoft.Extentsions.Http-Package, die aktuellen Daten von der API angefragt. Durch den oben erstellten MessageHandler wird dem HTTP-Request das Access Token hinzugefügt, wie wir im folgenden Screenshot (Chrome DevTools) erkennen können.

Nachdem wir nun erfolgreich eine Web API authentifiziert vom Client aus aufrufen können, wollen wir im nächsten Schritt Nutzerdaten abrufen und verarbeiten.

Arbeiten mit Rollen

Beim Arbeiten mit Rollen ist allem voran wichtig zu wissen, dass bei Keycloak die Rollen in einem etwas anderen Format an den Client gesendet werden, wie es z.B. bei IdentityServer der Fall ist.

				
					{
  // IdentityServer
  "client_id": "blazor-spa",
  "given_name": "Bob",
  "roles": ["admin", "user"]
}
				
			
				
					{
  // Keycloak
  "profile": {
    //...
    "resource_access": {
      "blazor-keycloak-web-api": {
        "roles": ["admin", "user"]
      }
    }
  }
}
				
			

Im obigen Code-Beispiel sehen wir zuerst die Variante des IdentityServer. Dieser sendet die Rollen des Nutzers als String-Array an den Client. Im zweiten Beispiel wird das JSON-Format des Keycloak Servers dargestellt. Hier ist ein deutlicher Unterschied zu erkennen. Die Rollen werden hier zusätzlich noch einer bestimmten Ressource zugewiesen. Dadurch werden dem Nutzer nur die Rollen zurückgegeben, die für die angefragte Ressource auch zulässig sind. Beispiel:

  • Ein Nutzer besitzt die Rollen usercustomer und writer
  • Die angefragte Resource besitzt die Rollen user und writer –> So würden im Access Token für den Nutzer nur die Rollen user und writer zurückgegeben.

Infolgedessen müssen sowohl im Client als auch in der Web API Anpassungen durchgeführt werden, um das Format des Keycloak Servers verarbeiten zu können.

Web API

Damit die API mit den Rollen arbeiten kann, müssen wir die Claims des Access Tokens in ein anderes Format umwandeln. Hierzu bietet das Package Microsoft.AspNetCore.Authentication das Interface IClaimsTransformation. Das Interface beinhaltet die Methode TransformAsync, in welcher die Claims angepasst werden können bevor diese verarbeitet bzw. weiterverwendet werden.

				
					public class KeycloakRolesClaimsTransformation : IClaimsTransformation
{
	private readonly string _roleClaimType;
	private readonly string _audience;
	
	public KeycloakRolesClaimsTransformation(string roleClaimType, string audience)
	{
		_roleClaimType = roleClaimType;
		_audience = audience;
	}

	/// <summary>
	/// Bietet einen zentralen Transformationspunkt, um den angegebenen Principal zu ändern.
	/// Note: Dies wird bei jedem AuthenticateAsync-Aufruf ausgeführt, daher ist es sicherer, einen
    /// einen neuen ClaimsPrincipal zurückzugeben, wenn Ihre Transformation nicht identisch ist.
	/// </summary>
	public Task<ClaimsPrincipal> TransformAsync(ClaimsPrincipal principal)
	{
		var result = principal.Clone();
		if (result.Identity is not ClaimsIdentity identity)
		{
			return Task.FromResult(result);
		}

		var resourceAccessValue = principal.FindFirst("resource_access")?.Value;
		if (String.IsNullOrWhiteSpace(resourceAccessValue))
		{
			return Task.FromResult(result);
		}

		using var resourceAccess = JsonDocument.Parse(resourceAccessValue);
		var clientRoles = resourceAccess
			.RootElement
			.GetProperty("blazor-keycloak-web-api")
			.GetProperty("roles");

		foreach (var role in clientRoles.EnumerateArray())
		{
			var value = role.GetString();
			if (!String.IsNullOrWhiteSpace(value))
			{
				identity.AddClaim(new Claim("roles", value));
			}
		}

		return Task.FromResult(result);
	}
}
				
			

In der Beispielanwendung wird nach dem Claim resource_access gesucht. Dieses Claim beinhaltet die Rollen des Nutzers für die angefragte Ressource, wie wir es im vorherigen JSON-Beispiel gesehen haben. Existiert das Claim, werden die aktuellen Rollen einem neuen Claim zugewiesen. Das neue Claim hat den Namen roles. Dieses Claim ist notwendig damit Klassen wie z.B. das Authorize-Attribute die Rollen auswerten kann.

API-Controller Beispiel:

				
					// ContributionsController.cs

[HttpDelete("{id}")]
[Authorize(Roles = "admin")]
public IActionResult DeleteContribution([FromRoute] int id, CancellationToken cancellationToken = default)
{
    _contributionService.RemoveContribution(id);
    return Ok();
}
				
			

Client

Im Blazor-Client haben wir das gleiche Szenario wie auch in der Web API. Doch leider kann das IClaimsTransformation-Interface hier nicht eingesetzt werden. Im Client muss daher eine eigene Klasse erstellt werden, welche von der AccountClaimsPrincipalFactory<RemoteUserAccount>– Klasse ableitet. Diese Klasse kommt aus dem Package Microsoft.AspNetCore.Components.WebAssembly.Authentication, welches Blazor zur Authentifizierung und Autorisierung nutzt.

				
					public class CustomAccountFactory : AccountClaimsPrincipalFactory<RemoteUserAccount>
{
    public CustomAccountFactory(IAccessTokenProviderAccessor accessor, HttpClient httpClient)
        : base(accessor)
    {
    }

    public async override ValueTask<ClaimsPrincipal> CreateUserAsync(
                RemoteUserAccount account, RemoteAuthenticationUserOptions options)
    {
        var initialUser = await base.CreateUserAsync(account, options);        
        return (initialUser.Identity != null && initialUser.Identity.IsAuthenticated) 
            ? await KeycloakClaimsHelper.TransformRolesAsync(initialUser, "blazor-keycloak-web-api") 
            : initialUser;
    }
}
				
			

Wie überschreiben wird die CreateUserAsync-Methode. In dieser wird dann, wie auch in der Web API, mit Hilfe der Extension Methode TransformRolesAsync, dem Nutzer das Claim roles hinzugefügt.

Hinweis: Die Methode TransformRolesAsync kommt aus einer geteilten Klassenbibliothek, welche sowohl von der API als auch dem Client genutzt wird. Dadurch wird sichergestellt, dass beide Projekte die gleiche Umwandlung der Claims durchführen.

Zuletzt müssen wir die Klasse noch der ServiceCollection hinzufügen.

				
					// Program.cs

builder.Services.AddScoped(typeof(AccountClaimsPrincipalFactory<RemoteUserAccount>), typeof(CustomAccountFactory));
				
			

Nachdem wir nun sowohl die Web API als auch den Client für die Nutzung von Rollen vorbereitet haben, können wir prüfen, ob der angemeldete Nutzer sich auch in der richtigen Rolle befindet.

Dazu können wir im Client die Authorized-Komponente nutzen. Die Komponente beinhaltet einen Parameter, welcher an die Kindkomponenten weitergegeben wird. Der Parameter ist vom Typ AuthenticationState und kann über den Namen context, innerhalb der Komponente abgerufen werden.

				
					<AuthorizeView>
  <Authorized>
      <Sider Collapsible Collapsed=@collapsed NoTrigger>
          
          <Menu Theme="MenuTheme.Dark" 
                Mode="MenuMode.Inline" 
                SelectedKeysChanged="SelectedKeyChanged" 
                DefaultSelectedKeys="@selectedKeys">
              <MenuItem Key="conferences">
                  <Icon Type="heat-map" Theme="outline" />
                  <span>Contributions</span>
              </MenuItem>
              
              @if (context.User.IsInRole("admin"))
              {
                  <MenuItem Key="speakers">
                      <Icon Type="usergroup-add" Theme="outline" />
                      <span>Speaker</span>
                  </MenuItem>
              }
          </Menu>
      </Sider>
      
  </Authorized>
</AuthorizeView>
				
			

Im obigen Code-Beispiel wird mit der Methode IsInRole geprüft, ob der Nutzer die admin-Rolle besitzt. Ist dies der Fall, wird der Menüpunkt Speaker angezeigt, wie wir auf dem unteren Beispiel des folgenden Screenshots sehen können.

Doch nicht nur innerhalb der AuthorizeView-Komponente können wir die Rollen einsetzten. Mit Hilfe des Authorize-Attributes, welches wir schon aus der Web API kennen, ist es möglich, dass Nutzer einzelne Routen nur mit bestimmten Rollen aufrufen können. Um das Authorize-Attribute einzusetzen, muss dies am Anfang einer Razor-Page hinzugefügt werden.

				
					@page "/speakers"
@attribute [Authorize(Roles = "admin")]


				
			

In diesem Code-Beispiel wird die Route /speakers definiert. Zusätzlich wird das Authorize-Attribute mit dem Parameter Roles hinzugefügt. Wie auch im vorherigen Beispiel wird hier geprüft werden, ob der Nutzer authentifiziert ist und die admin-Rolle besitzt.

Abschließend muss die RouteView durch die AuthorizeRouteView ersetzt werden, damit das Authorize-Attribute auf den Routen genutzt werden kann.

				
					<CascadingAuthenticationState>
	<Router AppAssembly="@typeof(App).Assembly">
		<Found Context="routeData">
            
			<AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)" />
			<FocusOnNavigate RouteData="@routeData" Selector="h1" />
		</Found>
		<NotFound>
			<LayoutView Layout="@typeof(MainLayout)">
				<p role="alert">Sorry, there's nothing at this address.</p>
			</LayoutView>
		</NotFound>
	</Router>
</CascadingAuthenticationState>
				
			

Im Screenshot der Beispielanwendung sehen wir das Ergebnis eines Nutzers, welcher ohne admin Rolle versucht, die Route aufzurufen. Zudem ist zu erkennen, dass in der Navigation auf der linken Seite der Menüpunkt für Speakers nicht angezeigt wird, was wir im vorherigen Schritt über die Authorized-Komponente festgelegt haben.

Fazit

Anhand einer illustrativen Beispielanwendung konnten wir sehen, dass die Implementierung der Authentifizierung mit Keycloak sich nicht groß unterscheidet von der vorherigen Implementierung mit dem IdentityServer.

Jedoch bei der Autorisierung, ist durch das Zuweisen von Rollen an einzelnen Ressourcen bei Keycloak, ein wenig mehr Aufwand in der Implementierung notwendig. Wie wir gesehen haben, mussten sowohl im Client als auch in der Web API hierfür Interfaces überschrieben und Klassen erweitert werden.

Danach konnten aber auch mit dem Keycloak Server als IDP alle Authentifizierungs- und Autorisierungsmechanismen, wie das Authorize-Attribute oder die AuthorizeRouteView-Komponente, wie gewohnt eingesetzt werden.

Kostenloser
Newsletter

Aktuelle Artikel, Screencasts, Webinare und Interviews unserer Experten für Sie

Verpassen Sie keine Inhalte zu Angular, .NET Core, Blazor, Azure und Kubernetes und melden Sie sich zu unserem kostenlosen monatlichen Dev-Newsletter an.

Newsletter Anmeldung
Diese Artikel könnten Sie interessieren
Database Access with Sessions
.NET
KP-round

Data Access in .NET Native AOT with Sessions

.NET 8 brings Native AOT to ASP.NET Core, but many frameworks and libraries rely on unbound reflection internally and thus cannot support this scenario yet. This is true for ORMs, too: EF Core and Dapper will only bring full support for Native AOT in later releases. In this post, we will implement a database access layer with Sessions using the Humble Object pattern to get a similar developer experience. We will use Npgsql as a plain ADO.NET provider targeting PostgreSQL.
15.11.2023
Old computer with native code
.NET
KP-round

Native AOT with ASP.NET Core – Overview

Originally introduced in .NET 7, Native AOT can be used with ASP.NET Core in the upcoming .NET 8 release. In this post, we look at the benefits and drawbacks from a general perspective and perform measurements to quantify the improvements on different platforms.
02.11.2023
ASP.NET Core
favicon

Architektur-Modernisierung: Migration von WCF zu gRPC mit ASP.NET Core – ein pragmatischer Ansatz

Viele Projekte mit verteilten Anwendungen in der .NET-Welt basieren noch auf der Windows Communication Foundation (WCF). Doch wie kommt man weg von der "Altlast" und wie stellt man seinen Code auf sowohl moderne als auch zukunftssichere Beine? Eine mögliche Lösung ist gRPC.

13.04.2023
Blazor
favicon

gRPC Code-First mit ASP.NET Core 7 und Blazor WebAssembly

Wie in allen anderen browserbasierten Single-Page-Application (SPA) Frameworks, ist Blazor WebAssembly JSON-over-HTTP (über Web- oder REST-APIs) die bei weitem häufigste Methode, um Daten auszutauschen und serverseitige Vorgänge auszulösen. Der Client sendet eine HTTP-Anfrage mit JSON-Daten an eine URL, mitunter über unterschiedliche HTTP-Verben. Anschließend führt der Server eine Operation aus und antwortet mit einem HTTP-Statuscode und den resultierenden JSON-Daten. Warum sollte man das ändern? Nun, es gibt Gründe - vor allem wenn man in einem geschlossenen System ist und .NET sowohl im Frontend als auch im Backend einsetzt.
30.03.2023
Blazor
favicon

Blazor WebAssembly in .NET 7: UI-Performance-Optimierung auf Komponentenebene

Stockende UI, keine Reaktion nach dem Klick auf einen Button oder einer Eingabe in einem Feld - dies sind nur wenige Beispiele alltäglicher Probleme, die der Nutzung von Client-Anwendungen im Allgemeinen, und bei Webanwendungen im Speziellen, immer wieder auftreten können. In diesem Artikel schauen wir uns an, wie wir komponentenbasierte UIs in Blazor WebAssembly optimieren können, um dadurch eine für die Benutzer zufriedenstellende Geschwindigkeit und ein flüssiges UI zu bekommen.
29.03.2023
Blazor
sg

Understanding and Controlling the Blazor WebAssembly Startup Process

There are a lot of things going on in the background, when a Blazor WebAssembly application is being started. In some cases you might want to take a bit more control over that process. One example might be the wish to display a loading screen for applications that take some time for initial preparation, or when users are on a slow internet connection. However, in order to control something, we need to understand what is happening first. This article takes you down the rabbit hole of how a Blazor WASM application starts up.
07.03.2023