In diesem Artikel

Seit der Version Blazor WebAssembly 3.2.0 enthält Blazor umfangreiche Unterstützung für clientseitige Authentifizierung, wodurch die Implementierung von OpenID Connect und OAuth2 in Single-Page-Applications (SPAs) deutlich vereinfacht wird. In diesem Artikel sehen wir uns an, wie wir Authentifizierung und Autorisierung in einem Blazor-WebAssembly-Client realisieren können. Zusätzlich werden wir die Client UI auf Basis von Nutzerrechten entsprechend dynamisch anpassen.

Version Information

  • ASP.NET Core Blazor: 3.2.0
  • ASP.NET Core: 3.1.3
  • IdentityServer 4
  • MatBlazor: 2.4.3

Anhand einer Demoapplikation wird nachvollziehbar, wie ein Blazor-WebAssembly-Client sich bei einem IdentityServer 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 dem Access Token, den wir vom IdentityServer erhalten, können wir dann eine token-gesicherte Web-API aufrufen oder eine sichere SignalR-Verbindung aufbauen. Weitere Informationen über den IdentityServer oder die Konfiguration der Clients, Claims und Policies, finden sich in der IdentityServer-Dokumentation.

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

Authentifizierungsarchitektur in Blazor WebAssembly

Bevor wir darin einsteigen, eine Authentifizierung und Autorisierung im Blazor-Client zu implementieren, betrachten wir zuerst die Security-Architektur innerhalb von Blazor WebAssembly:

Blazor: Authentifizierungsarchitektur

  • (1) Eingebettet in eine Razor-Seite wird im ersten Schritt die RemoteAuthenticatorView aufgerufen, die eine Aktion (z.B. login oder logout) entgegen nimmt.
  • (2) Anschließend nutzt diese Komponente den RemoteAuthenticationService, der als Brücke zwischen dem C#-Code und dem JavaScript-Code dient.
  • (3) Unter Verwendung der JsRuntime verwendet der RemoteAuthenticationService den AuthenticationService. Hier handelt es sich um eine JavaScript-Implementierung.
  • (4 & 5) Der AuthenticationService ruft in unserem Fall den IdentityServer auf, um sich über diesen zu authentifizieren und Tokens zurück zu erhalten.
  • (6 & 7) Ist das Ergebnis erfolgreich, speichert der AuthenticationService die Daten im Session Storage und gibt das Ergebnis zurück an den RemoteAuthenticationService.
  • (8 & 9) Zum Schluss teilt der RemoteAuthenticationService der RemoteAuthenticatorView mit, ob der Login erfolgreich war.

Blazor-Client erstellen und konfigurieren

Beim Erstellen eines neuen Blazor-WebAssembly-Projekts kann der Parameter -au angegeben werden. Er zeigt an, dass individuelle Benutzerkonten zur Authentifizierung genutzt werden sollen. Dies entspricht den notwendigen Voraussetzungen zur Nutzung von IdentityServer.

# Erstellen einer Blazor-WASM-Applikation mit individuellen Nutzerkonten
dotnet new blazorwasm -au individual -n BlazorClient

Diese Einstellung sieht einen Platzhalter für die OpenID-Verbindungskonfiguration in der Datei Program.cs vor:

public static async Task Main(string[] args)
{
  ...
  builder.Services.AddOidcAuthentication(options =>
  {
    // ... add options here
  });
  ...
}

An diesem Punkt können wir die Parameter konfigurieren, die für die Kommunikation mit IdentityServer erforderlich sind. Wir haben entweder die Möglichkeit, die Konfiguration direkt im Code zu hinterlegen, was für eine kurze Beispielimplementierung ausreichend ist, oder man verwendet für mehr Flexibilität eine Konfigurationsdatei. Dazu nutzen wir die vorhandene appsettings.json-Datei und fügen eine neue Konfiguration hinzu:

{
  "Oidc": {
    "Authority": "https://localhost:5001/",
    "ClientId": "blazor-spa",
    "DefaultScopes": [
      "openid",
      "profile"
    ],
    "PostLogoutRedirectUri": "/",
    "ResponseType": "code"
  }
}

Folgende Parameter werden in der Konfigurationsdatei gesetzt:

  • Authority ist die URL des IdentityServers, 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.
  • 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, und 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.
  • PostLogoutRedirectUri URL, an die wir weitergeleitet werden wollen, sobald wir uns ausloggen.

Die von uns hinzugefügte Konfiguration übergeben wir dann an die ProviderOptions-Eigenschaft.

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

Zum Schluss müssen wir 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 loginoder logout) als Parameter an die Seite mit übergeben.

@page "/authentication/{action}"

@using Microsoft.AspNetCore.Components.WebAssembly.Authentication

<RemoteAuthenticatorView Action="@Action" />

@code{
    [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 "/"

@inject NavigationManager Navigation

<div style="display: flex; justify-content:center; align-items: center; height: 100%;">
    <MatButton OnClick="@BeginSignIn" Unelevated="true" Style="background-color: #ff584f;">Login</MatButton>
</div>

@code {
    private void BeginSignIn(MouseEventArgs args)
    {
        Navigation.NavigateTo("authentication/login");
    }
}

Nachdem wir nun eine erfolgreiche Authentifizierung des Clients am IdentityServer durchführen können, wollen wir uns 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.

Erweitern von und Arbeiten mit Informationen über den Benutzer

Über den ID-Token können wir auf die vom IdentityServer bereit gestellten Informationen über den Benutzer zugreifen. Für den Fall, dass für unsere Clientanwendung eine Information über den Benutzer zusätzlich in den Token inkludiert werden soll, wie zum Beispiel die E-Mail-Adresse, können wir diese neue Eigenschaft im IdentityServer hinzufügen. Dazu passen wir die Konfiguration für den Client an (Config.cs):

public static IEnumerable<IdentityResource> IdentityResources =>
  new IdentityResource[]
  {
    new IdentityResources.OpenId(),
    new IdentityResources.Profile(),
    new IdentityResources.Email()
  };

public static IEnumerable<Client> Clients =>
  new Client[]
  {
    new Client
    {
      ClientId = "blazor-spa",
      AllowedScopes =
      {
          // Hier können neue Scopes hinzugefügt werden, welche vom Client dann abgerufen werden können
          IdentityServerConstants.StandardScopes.OpenId,
          IdentityServerConstants.StandardScopes.Profile,
          IdentityServerConstants.StandardScopes.Email
      },
      ...
    },
 };

Zusätzlich muss dieser anzufordernde Scope in der Konfiguration im Blazor-Client ergänzt werden:

{
  "DefaultScopes": [
    "openid",
    "profile",
    "email"
  ]
}

Haben wir alle Anpassungen gemacht, sind die neue Benutzerinformation über den ID-Token abrufbar.

Nun können wir über die context-Eigenschaft der AuthorizeView auf die Daten des aktuellen Benutzers zuzugreifen. Dafür nutzen wir die User-Property. Diese beinhaltet die Daten des aktuellen Benutzers sowie den aktuellen Zustand, ob ein Nutzer authentifiziert ist oder nicht. Im Beispiel unten sehen wir, dass wir innerhalb der View mit Authorized Inhalte darstellen können, die nur authentifizierte Benutzer sehen, beispielsweise seinen Namen und den Logout-Button. Wenn die Authentifizierung nicht erfolgreich war oder der Authentifizierungsprozess noch nicht gestartet wurde, zeigen wir via NotAuthorized den Login-Button an. Darüber hinaus können wir mit dem context auf den aktuellen AuthenticationState zugreifen, der den aktuellen Benutzer mit all seinen Ressourcen kennt.

<AuthorizeView>
    <Authorized>
        <p>@context.User.Identity.Name</p>
        <MatIconButton Icon="exit_to_app" OnClick="@BeginSignOut"></MatIconButton>
    </Authorized>
    <NotAuthorized>
        <MatButton OnClick="@BeginSignIn" Unelevated="true">Login</MatButton>
    </NotAuthorized>
</AuthorizeView>

Unterschiedliche Darstellung der Anwendung je nach Authentifizierungsstatus

Eine ähnliche Vorgehensweise können wir auch für ganze Seiten nutzen, in dem wir dort das Authorize-Attribut hinzufügen. Durch das Attribut kann die Seite nur durch einen authentifizierten Nutzer aufgerufen werden.

@page "/history"
@attribute [Authorize]

Sicher mit dem Server kommunizieren

Aufrufen einer gesicherten Web-API

Nachdem wir in unserem Blazor-Client auf den aktuellen Authentifizierungsstatus zugreifen können, möchten wir jetzt Daten von einer sicheren Web-API abrufen. Hierzu müssen wir den vom IdentityServer erhaltenen Access Token beim Aufrufen der Web-API-Methode als HTTP-Header hinzufügen.

Blazor bietet hier den Handler AuthorizationMessageHandler an, der mithilfe von Dependency Injection bereitgestellt werden kann. Dieser schaut, ob ein Token vorhanden ist und fügt diesen bei jedem HTTP-Request als Authorization-Header hinzu. Hierfür benötigen wir einen Verweis auf das Microsoft.Extension.Http-NuGet-Paket. Danach verwenden wir die IHttpClientFactory, um einen HttpClient für unsere API zu registrieren.

builder.Services.AddHttpClient("Blazor.API")
    .AddHttpMessageHandler(sp => 
    {
        var handler = sp.GetService<AuthorizationMessageHandler>()
            .ConfigureHandler(
                authorizedUrls: new[] { "http://localhost:5002" },
        return handler;
    });

Der obige Code definiert einen Client mit dem Namen Blazor.API, der intern den AuthorizationMessageHandler verwendet. In der Konfiguration tragen wir die authorizedUrls ein, die ein Access Token benötigen. Danach registrieren wir die Standard-HttpClient-Instanz im Dependency-Injection-Container:

builder.Services
    .AddScoped(services => services.GetRequiredService<IHttpClientFactory>()
    .CreateClient("Blazor.ServerAPI"));

Fortan wird automatisch beim Aufrufen der Web-API ein Authorization-Header mit gesendet, über den das serverseitige Web-API (bzw. die Middleware in ASP.NET Core) den Token auslesen und validieren kann.

Bearer Token im HTTP-Request

Absichern einer SignalR-Verbindung

Möchte man aus dem Blazor-Client eine sichere SignalR-Verbindung mit einem Server aufbauen, muss auch hier der Token mitgesendet werden. Hierzu müssen wir zuerst unsere SignalR-Verbindung konfigurieren. Mithilfe der Klasse HubConnectionBuilder können wir eine Instanz einer HubConnection erstellen und konfigurieren.

public class SignalRService 
{
    private IAccessTokenProvider _tokenProvider;
    // ...
    public async Task InitConnectionAsync()
    {
        // ...
        var accessTokenState = await _tokenProvider.RequestAccessToken();
        if (accessTokenState.TryGetToken(out var accessToken)) 
        {
            var apiBaseUrl = _configuration["api:baseUrl"];
            var accessTokenString = accessToken.Value;
            _hubConnection = new HubConnectionBuilder()
                .WithUrl($"{apiBaseUrl}tictactoe", options =>
                { 
                    options.AccessTokenProvider = () => Task.FromResult(accessTokenString);
                })
                .WithAutomaticReconnect(new[] { TimeSpan.Zero, TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(10) })
                .Build();
            //...
        }
    }
}

Die Übergabe des Tokens an die SignalR-Verbindung findet mithilfe eines IAccessTokenProviders statt. Der IAccessTokenProvider wird über Dependency Injection der Klasse SignalRService verfügbar gemacht. Dieser gibt den aktuellen Access Token zurück, der danach beim Aufbauen der Verbindung mitgesendet wird.

Im folgenden Bild sehen wir eine bestehende WebSocket-Verbindung, welche den Token als Parameter mit an den Server sendet.

Bearer Token beim Aufbau der SignalR-Verbindung

Hinweis: Das Token wird nur beim initialen Aufbau der Verbindung überprüft und gilt dann für die gesamte Lebenszeit der Verbindung.

Mehr über SignalR gibt es hier in meinem Artikel zu Echtzeitkommunikation in Aktion zum Nachlesen.

Nun kann der Client eine sichere Verbindung zum serverseitigen Hub aufbauen und sich ordnungsgemäß authentifizieren.

Arbeiten mit Rollen

Rollen im IdentityServer und in der API konfigurieren

Nachdem wir uns mit unserem Client authentifiziert haben, möchten wir uns mit dem Thema Autorisierung beschäftigen. Ist ein Nutzer nur für einen bestimmten Bereich in der Anwendung berechtigt, kann dies über Rollen geregelt werden. Diese müssen natürlich zuerst im IdentityServer konfiguriert werden.

Hierzu schauen wir uns die Klasse ProfileWithRoleIdentityResource im IdentityServer an, die die gewünschten UserClaims definiert:

public class ProfileWithRoleIdentityResource
        : IdentityResources.Profile
{
    public ProfileWithRoleIdentityResource()
    {
        this.UserClaims.Add(JwtClaimTypes.Name);
        this.UserClaims.Add(JwtClaimTypes.Subject);
        this.UserClaims.Add(JwtClaimTypes.WebSite);
        this.UserClaims.Add(JwtClaimTypes.Email);
        
        // Um die Rollen dem Identity Token hinzuzufügen, fügen wir den neuen Claim Role hier hinzu
        this.UserClaims.Add(JwtClaimTypes.Role);
    }
}

Hier ergänzen wir einen Claim des Typs Role hinzu. Durch diesen Claim können dem ID-Token Informationen über die Rollen des Benutzers hinzugefügt werden.

Im nächsten Schritt fügen wir den Claim Role auch bei den ApiResources hinzu, bei denen diese Information benötigt wird.

public static IEnumerable<ApiResource> Apis =>
    new ApiResource[]
    {
        new ApiResource
        {
            // ...
            UserClaims =
            {
                JwtClaimTypes.Name,
                JwtClaimTypes.Subject,
                // Hier fügen wir den neuen UserClaim für die Rollen hinzu
                JwtClaimTypes.Role
            },
            //...
        },
    };

Als letztes benötigt unser Testbenutzer im IdentityServer die Information über seine Rollen. Hierzu fügen wir in der Datei TestUser.cs zum Testen nur einem Benutzer eine Rolle hinzu. Die anderen Benutzer bekommen keine Rolle, sodass wir im Client später einen Unterschied erkennen können.

public class TestUsers
    {
        public static List<TestUser> Users = new List<TestUser>
        {
            new TestUser
            {
                SubjectId = "818727", Username = "alice", Password = "alice",
                Claims =
                {
                    new Claim(JwtClaimTypes.Name, "Alice Smith"),
                    new Claim(JwtClaimTypes.GivenName, "Alice"),
                    new Claim(JwtClaimTypes.FamilyName, "Smith"),
                    new Claim(JwtClaimTypes.Email, "AliceSmith@email.com"),
                    new Claim(JwtClaimTypes.EmailVerified, "true", ClaimValueTypes.Boolean),
                    new Claim(JwtClaimTypes.WebSite, "http://alice.com"),
                    new Claim(JwtClaimTypes.Address,
                        @"{ 'street_address': 'One Hacker Way', 'locality': 'Heidelberg', 'postal_code': 69118, 'country': 'Germany' }",
                        IdentityServerConstants.ClaimValueTypes.Json),
                    
                    // Die Rolle "ProUser" dient dazu, die Spielhistory abfragen zu dürfen.
                    new Claim(JwtClaimTypes.Role, "ProUser")
                }
            },
            // ...
    }

Jetzt sind alle Schritte im IdentityServer abgeschlossen und wir können uns der Web-API widmen. Hier ist nicht viel zu tun, wir müssen lediglich das Authorize-Attribute erweitern. Da es in unserer Beispielanwendung um das Spiel TicTacToe geht, gibt es für berechtigte Benutzer die Möglichkeit Ihre Spielhistorie abzurufen. Hierzu nutzen wir die Web-API GameHistory, welcher wir das Authorize-Attribute hinzufügen.

[Route("[Controller]")]
// Hier wird zusätzlich noch die Rolle angegeben, die der Nutzer besitzen muss, um die Web-API aufrufen zu dürfen.
[Authorize(Roles = "ProUser")]
public class GamesHistoryController : Controller
//...

Wie im Code zu sehen ist, müssen wir nur die Property Roles des Authorize-Attributes setzen. Nachdem das Attribut nun gesetzt ist, können nur Nutzer die Historie abrufen, welche auch dazu berechtigt sind. Dies wird in der UI dadurch ersichtlich, dass neben dem Benutzernamen noch ein Icon erscheint, welches auf die Historie weiter leitet.

user_history_btn

Im nächsten Schritt schauen wir uns an wie wir im Client über die Autorisierung Seiten oder bestimmte Teile einer Komponente für den Nutzer ein oder ausblenden können.

Berechtigung im Client prüfen

Folgende Beispielseite im Blazor-Client zeigt Informationen über den Benutzer an und gibt, sofern er eingeloggt ist, eine Liste der Claims aus.

@page "/userInfo"

<AuthorizeView Roles="ProUser">
    <Authorized>
        <p>Hallo @context.User.Identity.Name, hier ist eine Liste deiner Nutzerrechte</p>
        
            @foreach (var claim in context.User.Claims)
            {
                <div class="mat-elevation-z8 sr-card">
                    <p>@claim.Type: </p>
                    <span>@claim.Value</span>
                </div>
            }
        
    </Authorized>
    <NotAuthorized>
        @if(context.User.Identity.IsAuthenticated) {
            <p>Nur berechtigte Nutzer können diesen Bereich sehen</p>
        } else {
            <RedirectToLogin />
        }
    </NotAuthorized>
</AuthorizeView>

Informationen und Claims zu einem Benutzer

Im Moment wird der Role-Claim jedoch nicht für Autorisierungszwecke verwendet. Dazu müssen wir eine Einstellung im AuthenticationStateProvider in der Program.cs treffen:

builder.Services.AddOidcAuthentication(options =>
{
    builder.Configuration.Bind("Oidc",  options.ProviderOptions);
    
    // Hier teilen wir dem AuthenticationStateProvider mit, dass er den 'role'-Claim verwenden soll.
    // Dies dient dazu, dass wir diese auch später in unserer Komponente oder Seite nutzen und abfragen können.
    options.UserOptions.RoleClaim = "role";
});

Jetzt können wir die Rolle verwenden, um beispielsweise den Zugriff auf Seiten nur für berechtigte Nutzer zu autorisieren, zum Beispiel über das Authorize-Attribute:

@page "/history"
@attribute [Authorize(Roles = "ProUser")]

Wir können auch Teile der UI auf der Grundlage der Rolle ein- und ausblenden:

<AuthorizeView Roles="ProUser">
    <Authorized>
        <p>Nur berechtigte Nutzer können diesen Bereich sehen</p>
    </Authorized>
</AuthorizeView>

Natürlich kann es auch möglich sein, dass der Nutzer zwar authentifiziert aber nicht autorisiert ist:

<AuthorizeRouteView Roles="ProUser">
    <NotAuthorized>
        @if (context.User.Identity.IsAuthenticated)
        {
            <p>Leider sind Sie nicht autorisiert.</p>
        }
        else
        {
            <RedirectToLogin />
        }
    </NotAuthorized>
</AuthorizeRouteView>

Fazit

Anhand einer Beispielanwendung konnten wir sehen, wie wir Authentifizierung und Autorisierung in eine Blazor-WebAssembly-App integrieren. Hierfür setzen wir den IdentityServer4 über das OpenID-Connect-Protokoll ein.

Wir haben ein Zugriffstoken angefordert und es dazu verwendet, eine geschützte Web-API aufzurufen und den Token im Header des HTTP-Aufrufs zu übergeben. Weiterhin haben wir gesehen, wie das Hinzufügen von Role-Claims sowohl in ID-Tokens als auch in Access-Tokens im IdentityServer funktioniert und wie diese dann für die Verwendung in Blazor verfügbar sind. Zum Schluss haben wir dann noch sichergestellt, dass auch die Web-API eine rollenbasierte Autorisierung implementiert.

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

Related Articles

blazor
Blazor WebAssembly - Unleash the Power of Dynamic Template-Based UIs With Razor Engine
In general, you can divide template engines into two types. The relatively simple ones are using template strings with placeholders to be replaced by some concrete values. The other template engines can do everything the simple ones can but additionally provide means for control…
Pawel Gerr
blazor
Blazor WebAssembly - Changing the Log Level at Runtime
With Blazor WebAssembly we are now able to create single-page applications (SPA) using C# and the ASP.NET Core Framework. When coming from ASP.NET Core MVC, you may ask yourself what .NET features are available, limited, or not available when running in the browser. One of them…
Pawel Gerr
blazor
Blazor Components Deep Dive - Lifecycle Is Not Always Straightforward
When starting with new frameworks that have a lifecycle for their artifacts like components, then you may assume that the lifecycle is strictly linear. In other words, step A comes before step B comes before step C, and so on. Usually, this is the case until it is not. The…
Pawel Gerr
blazor
Running Your ASP.NET Core Blazor WebAssembly Application as a Progressive Web App (PWA)
As already outlined in my previous article, a Blazor application almost always has to integrate with the JavaScript world, and especially with the DOM layer in the browser. In this article, I am going to quickly show you how to transform your Blazor WebAssembly application into a…
Christian Weyer