In diesem Artikel

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.

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.

<!-- Authentication.razor -->
@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.

<!-- Index.razor -->

@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.

Request with AuthorizationHeader

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

Kostenloses Whitepaper zu Blazor WebAssembly

Christian Weyer hat zum Thema "ASP.NET Core Blazor WebAssembly - das SPA-Framework für .NET-Entwickler?" ein Whitepaper erstellt, in dem er alles Wissenswerte zusammengefasst hat.

Melden Sie sich kostenlos zu unserem Newsletter an, um das Whitepaper per E-Mail zu erhalten.

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 user, customer 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>
              <!-- Hier wird geprüft ob der aktuelle Nutzer, die Admin Rolle besitzt -->
              @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.

AuthorizeView Sample

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">
            <!--Hier wird die RouteView durch die AuthorizeRouteView ersetzt.-->
			<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>

Blazor: Not Authorized

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.

Wenn Sie auch weitere Artikel, Webinare und Screencasts unserer Experten nicht verpassen möchten, melden Sie sich hier zu unserem kostenlosen, monatlichen Dev-Newsletter an.

Related Articles

 | Patrick Jahr

Version Information: .NET SDK: 5.0.201 ASP.NET Core Blazor WebAssembly: 5.0.4 MudBlazor: 5.0.5 Der Beispiel-Code für diesen Artikel findet sich hier. In der Entwicklung von Webanwendungen, und so auch in der Entwicklung von Blazor WebAssembly SPAs, ist es wichtig, die Laufzeit…

Read article
 | Christian Weyer

Blazor WebAssembly 5 und die Kopplung an .NET 5 - guter Ausblick für die Zukunft Blazor WebAssembly 5 ist im November 2020 als Teil von .NET 5 sechs Monate nach dem Erscheinen der ersten offiziellen Version (3.2.0) released worden und profitiert an diversen Stellen von…

Read article
 | Patrick Jahr

Version Information .NET SDK 5.0.104 ASP.NET Core Blazor WebAssembly: 5.0.4 MudBlazor: 5.0.4 Der gesamte Source Code zur Beispielanwendung findet sich in diesem GitHub Repository. Exception Handling als Aufgabenstellung Der richtige Umgang mit Fehlern ist für die Erstellung einer…

Read article