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:

ASP.NET Core Blazor WebAssembly: Authentifizierung und Autorisierung mit Keycloak in Aktion
Patrick Jahr ist Architekt bei Thinktecture. Sein Fokus liegt auf Backend-Systemen mit .NET Core und der Frontend-Entwicklung mit Angular.

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.

Diese Artikel könnten Sie interessieren
Unterschiede
.NET
Blazor WebAssembly vs. Blazor Server – Welche Unterschiede gibt es und wann wähle ich was?

Blazor WebAssembly vs. Blazor Server – Welche Unterschiede gibt es und wann wähle ich was?

Das Blazor Framework von Microsoft gibt es inzwischen in drei "Geschmacksrichtungen". Die erste ist Blazor WebAssembly, die zweite Blazor Server, und zu guter Letzt gibt es noch Blazor Hybrid. In diesem Artikel wollen wir uns die zwei "echten", also Browser-basierten, Web-Anwendungs-Szenarien WebAssembly und Server anschauen.
04.07.2022
Three different textured walls
.NET
Dependency Injection Scopes in Blazor

Dependency Injection Scopes in Blazor

The dependency injection system is a big part of how modern ASP.NET Core works internally: It provides a flexible solution for developers to structure their projects, decouple their dependencies, and control the lifetimes of the components within an application. In Blazor - a new part of ASP.NET Core - however, the DI system feels a bit odd, and things seem to work a bit differently than expected. This article will explain why this is not only a feeling but indeed the case in the first place and how to handle the differences in order to not run into problems later on.
31.05.2022
.NET
Asynchrone Operationen: Blazor WebAssembly für Angular-Entwickler – Teil 5 [Screencast]

Asynchrone Operationen: Blazor WebAssembly für Angular-Entwickler – Teil 5 [Screencast]

Eine Webanwendung will natürlich auch mit Daten gefüttert werden. Doch diese müssen irgendwo her kommen. Nichts liegt näher als diese von einer Web API zu laden. Dieser Screencast zeigt, wie asynchrone Operationen in Blazor funktionieren und welche gravierenden Unterschiede es zu Angular gibt.
26.05.2022
.NET
Typings: Blazor WebAssembly für Angular-Entwickler – Teil 4 [Screencast]

Typings: Blazor WebAssembly für Angular-Entwickler – Teil 4 [Screencast]

C# und TypeScript entstammen der Feder der selben Person. Doch sind sie deshalb auch gleich? In diesem Teil der Screencast-Serie erfahren Sie, wie mit Typen in den beiden Programmiersprachen verfahren wird und welche Unterschiede es gibt.
19.05.2022
.NET
Bindings: Blazor WebAssembly für Angular-Entwickler – Teil 3 [Screencast]

Bindings: Blazor WebAssembly für Angular-Entwickler – Teil 3 [Screencast]

Wer Komponenten einsetzt, steht früher oder später vor der Fragestellung, wie man Daten an die Komponente übergibt oder auf Ereignisse einer Komponente reagiert. In diesem Screencast wird gezeigt wie Bindings bei Komponenten funktionieren, also wie eine Komponente Daten von außerhalb benutzen und Rückmeldung bei Aktionen geben kann.
12.05.2022
.NET
Komponenten: Blazor WebAssembly für Angular-Entwickler – Teil 2 [Screencast]

Komponenten: Blazor WebAssembly für Angular-Entwickler – Teil 2 [Screencast]

Bei der Entwicklung einer Webapplikation kommt es ständig vor, dass UI-Teile immer und immer wieder verwendet werden. Damit nicht immer Copy & Paste verwendet werden muss, können diese Teile in Komponenten zusammengefasst werden.
05.05.2022