zurück zum Artikel

Blazor WebAssembly, Teil 3: Authentifizierung und Autorisierung

Dr. Holger Schwichtenberg

Die MiracleList-Webanwendung soll im dritten Teil des Tutorials nun um einen Anmeldedialog zur Authentifizierung erweitert werden.

Bereits beim Anlegen eines Blazor-WebAssembly-Projekts in Visual Studio oder an der Kommandozeile mit dotnet new gibt es Optionen, eine Authentifizierung zu konfigurieren. Mit der Option ASP.NET Core Hosted in Verbindung mit Individual Accounts | Store user accounts in-app erhält man eine Mischung aus einer Blazor-WebAssembly-Anwendung mit einer in ASP.NET Core Razor (als serverseitige Lösung) realisierten Benutzeranmeldung und -verwaltung. Alternativ dazu kann man sich mit einem OIDC-Provider (Open ID Connect) verbinden.

Blazor-Tutorial

Der dritte des Tutorials zeigt, wie man ein beliebiges WebAPI-Backend zur Authentifizierung verwenden kann, auch wenn dieses nicht OIDC-kompatibel ist. Das MiracleList-Backend [6] realisiert einen eigenen Authentifizierungsmechanismus auf Basis von Benutzernamen und Kennwort zur Anmeldung, der dann für die folgenden WebAPI-Aufrufe ein Authentifizierungs-Token (als Zeichenkette) liefert. Im ersten Teil des Tutorials wurde die Klasse AuthenticationManager implementiert, die diesen Mechanismus in der Methode Login() kapselt. Bisher wurde sie in der Komponente Index.razor aufgerufen. Das soll nun auf eine eigenständige Anmeldeseite verlagert werden.

Die Abbildung 1 visualisiert die im MiracleList-Blazor-Frontend zu erstellende Anmeldeseite mit Eingabe für Benutzername und Kennwort sowie eine Schaltfläche. Listing 1 zeigt die zugehörige Razor-Template-Seite Login.razor und Listing 2 die passende Code-Behind-Datei.

Aus der Razor Component "Login.razor" erzeugte Anmeldeseite (Abb. 1)
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Authorization;
using System.Threading.Tasks;
 
namespace Web
{
 public class LoginModel : ComponentBase
 {
  [Inject] public NavigationManager NavigationManager { get; set; }
  [Inject] AuthenticationStateProvider asp { get; set; } = null;
 
  #region Properties für Datenbinding
  public string Username { get; set; }
  public string Password { get; set; }
  public string Message { get; set; }
  #endregion
 
  protected override async System.Threading.Tasks.Task OnInitializedAsync()
  {
   // Reaktion auf diese URL
   if (this.NavigationManager.Uri.ToLower().Contains("/logout"))
   {
    await ((AuthenticationManager)asp).Logout();
   }
  }
 
  /// <summary>
  /// Reaktion auf Benutzeraktion
  /// </summary>
  protected async Task Login()
  {
   Message = "Logging in...";
   bool ok = await (asp as AuthenticationManager).Login(Username, Password);
   if (ok) this.NavigationManager.NavigateTo("/main");
   else Message = "Login Error!";
  }
 } // end class Login
}

Listing 1: Razor-Template für die Anmeldeseite (Login.razor)

@page "/"
@page "/Logout"
@inherits LoginModel;
 
<div class="panel panel-primary">
 <div class="panel-heading">
  <h2>Benutzeranmeldung</h2>
 </div>
 <div class="panel-body">
  <div>MiracleList ist eine Beispielanwendung für eine Single-Page-Webapplication (SPA) mit ASP.NET Core Blazor Webassembly (@System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription) zur Aufgabenverwaltung.</div>
  <div>Autor: Dr. Holger Schwichtenberg, <a href="http://www.IT-Visions.de">www.IT-Visions.de</a>, 2018-@DateTime.Now.Year</div>
  <br />
  <div>
   Zur Benutzeranmeldung geben Sie die Kombination aus Ihrer E-Mail-Adresse und einem beliebigen Kennwort ein. Wenn es für diese E-Mail-Adresse noch kein Benutzerkonto gibt, wird automatisch ein neues Benutzerkonto angelegt mit einigen Beispielaufgaben.
  </div>
  <br>
 
  <div class="row">
   <div class="col-xs-12 form-group">
    <label for="name">E-Mail-Adresse:</label>
    <input name="name" @bind="this.Username" id="name" type="text" placeholder="Ihre E-Mail-Adresse" class="form-control">
   </div>
  </div>
 
  <div class="row">
   <div class="col-xs-12 form-group">
    <label for="password">Kennwort</label>
    <input name="password" @bind="this.Password" id="password" placeholder="Ihr Kennwort" type="password" class="form-control">
   </div>
  </div>
 
  <button @onclick="Login" class="btn btn-primary" type="submit" id="Anmelden" name="Anmelden">Anmelden</button>
  <span id="errorMsg" class="text-danger">@ErrorMsg</span>
 </div>
</div>
 
<text>MiracleList Tutorial v</text>@System.Reflection.Assembly.GetExecutingAssembly().GetName().Version.ToString()

Listing 2: Code-Behind-Datei für die Anmeldeseite (Login.razor.cs)

Im Programmcode sehen die Leserinnen und Leser erstmals in diesem Tutorial, wie man von einer URL auf eine andere umlenkt. Dazu lässt man sich eine Instanz der in Blazor vordefinierten Klasse NavigationManager

[Inject] public NavigationManager NavigationManager { get; set; }

injizieren und nutzt dann auf der Instanz die Methode NavigateTo() unter Angabe der relativen Ziel-URL:

this.NavigationManager.NavigateTo("/abc/def");

Zudem kann man mit dem NavigationManager auch die aktuelle URL abfragen. In Listing 1 sind für die Razor Component Login.razor direkt zwei verschiedene Routen mit @page-Direktiven definiert. Das ist gestattet in Blazor; es obliegt Softwareentwicklern, dem eine Bedeutung zu geben. Im Programmcode im Lebenszyklusereignis OnInitializedAsync() (Listing 2) wird daher unterschieden, dass beim Aufruf mit der relativen Adresse /logout eine Abmeldung stattfindet und dann wieder das Anmeldeformular erscheint. Das ist eine elegante Lösung, die erlaubt, dass man von überall her durch Aufruf der URL http://server/logout die Anmeldung ausführen kann.

Damit der überarbeitete Programmcode der Code-Behind-Datei Login.razor.cs wieder kompiliert, ist im AuthenticationManager eine Routine Logout() zu ergänzen, die die Operation /Logoff im Server ruft (s. Listing 3).

using System.Threading.Tasks;
using System;
using System.Security.Claims;   // NEU in Teil 3
using Microsoft.AspNetCore.Components.Authorization;   // NEU in Teil 3
using MiracleListAPI;
 
namespace Web
{
 /// <summary>
 /// Authentifizierung zum Debugging
 /// </summary>
 public class AuthenticationManager : AuthenticationStateProvider // Vererbung NEU in Teil 3
 {
  MiracleListAPI.MiracleListProxy proxy { get; set; }
 
  public AuthenticationManager(MiracleListAPI.MiracleListProxy proxy)
  {
   this.proxy = proxy;
  }
 
  public const string ClientID = "11111111-1111-1111-1111-111111111111";
  public LoginInfo CurrentLoginInfo = null;
 
  public string Token { get { return CurrentLoginInfo?.Token; } }
 
  /// <summary>
  /// Login to be called by Razor Component Login.razor
  /// </summary>
  public async Task<bool> Login(string username, string password)
  {
   bool result = false;
   CurrentLoginInfo = null;
   var l = new LoginInfo() { Username = username, Password = password, ClientID = AuthenticationManager.ClientID };
   try
   {
    CurrentLoginInfo = await proxy.LoginAsync(l);
    if (String.IsNullOrEmpty(CurrentLoginInfo.Token))
    {
     Console.WriteLine("Anmeldung NICHT erfolgreich: " + this.CurrentLoginInfo.Username);
    }
    else
    {
     result = true;
     Console.WriteLine("Anmeldung erfolgreich: " + this.CurrentLoginInfo.Username);
    }
   }
   catch (Exception ex)
   {
    Console.WriteLine("Anmeldefehler: " + ex.Message);
   }
   Notify();
   return result;
  }
 
  /// <summary>
  /// Logout to be called by Razor Component Login.razor
  /// </summary>
  public async Task Logout()
  {
   Console.WriteLine("Logout", this.CurrentLoginInfo);
   if (this.CurrentLoginInfo == null) return;
   var e = await proxy.LogoffAsync(this.CurrentLoginInfo.Token);
   if (e)
   {
    // Remove LoginInfo in RAM for clearing authenticaton state
    CurrentLoginInfo = null;
    Notify();
   }
   else
   {
    Console.WriteLine("Logout Error!");
   }
  }
 
  /// <summary>
  /// NEU in Teil 3: Notify Blazor infrastructure about new Authentication State
  /// </summary>
  private void Notify()
  {
   Console.WriteLine("Notify: " + CurrentLoginInfo?.Username);
   this.NotifyAuthenticationStateChanged(GetAuthenticationStateAsync());
  }
 
  // NEU in Teil 3
  public override async Task<AuthenticationState> GetAuthenticationStateAsync()
  {
   if (this.CurrentLoginInfo != null && !String.IsNullOrEmpty(this.CurrentLoginInfo.Token) && !String.IsNullOrEmpty(proxy.BaseUrl))
   {
    const string authType = "MiracleList WebAPI Authentication";
    var identity = new ClaimsIdentity(new[]
    {
    new Claim("Backend", proxy.BaseUrl),
    new Claim(ClaimTypes.Sid, this.CurrentLoginInfo.Token), // use SID claim for token
    new Claim(ClaimTypes.Name, this.CurrentLoginInfo.Username),
    }, authType);
 
    var cp = new ClaimsPrincipal(identity);
    var state = new AuthenticationState(cp);
    Console.WriteLine("GetAuthenticationStateAsync: " + this.CurrentLoginInfo.Username);
    return state;
   }
   else
   {
    Console.WriteLine("GetAuthenticationStateAsync: no user");
    var state = new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity()));
    return state;
   }
  }
 }
}

Listing 3: Erweiterte Klasse AuthenticationManager

Zudem ist die Login()-Methode umzugestalten: Anstelle die statischen Anmeldedaten zu nutzen, benötigt diese nun zwei vom Aufrufer setzbare Parameter für Benutzername und Kennwort:

public async Task<bool> Login(string username, string password)
{
   var l = new LoginInfo() { Username = username, Password = password, ClientID = AuthenticationManager.ClientID };
...
}

Der Aufruf von Login() in Index.razor.cs muss gänzlich entfallen. Aus

  protected override async Task OnInitializedAsync()
  {
   // TODO: Muss später im Anmeldebildschirm erfolgen
   if (await am.Login())
   {
   await ShowCategorySet();
   }
  }

wird also

  protected override async Task OnInitializedAsync()
  {
   await ShowCategorySet();
  }

Damit kompiliert die Anwendung wieder, beim Start läuft sie aber sofort auf einen Laufzeitfehler: Die Fehlermeldung "System.InvalidOperationException: The following routes are ambiguous: '/' in 'Web.Pages.Login' '/' in 'Web.Pages.Index'" ist dabei aussagekräftig: Jede Route lässt sich nur an genau eine Komponente vergeben. Es wurden aber für den Start der Anwendung zwei Wurzelkomponenten festgelegt. Daher gilt es, in der Datei Index.razor die Route zu ändern von

@page "/"

in

@page "/main"

Nun werden Entwickler beim Anwendungsstart auf das Anmeldeformular gelenkt und nach erfolgreicher Anmeldung per NavigationManager mit this.NavigationManager.NavigateTo("/main") zur Hauptkomponente Index.razor. Aber leider verhindert noch nichts, dass Benutzer später direkt http://server/main aufrufen und die Anmeldung umgehen.

ASP.NET Code Blazor enthält einige Standardmechanismen für die Autorisierung, die sowohl in Blazor Server als auch Blazor WebAssembly funktionieren:

Der sonst in ASP.NET Core mögliche Zugriff auf das Unterobjekt User im HttpContext-Objekt funktioniert in Blazor hingegen nicht. Man stattet also Index.razor mit dem Zusatz

@attribute [Authorize]

aus. Damit das kompiliert, brauchen Entwickler in _Imports.razor noch eine Zeile:

@using Microsoft.AspNetCore.Authorization

TaskEdit.razor braucht diese Direktive nicht, denn sie wird nur beim Routing ausgewertet und nicht beim Laden einer Teilkomponente. Login.razor darf die Direktive nicht erhalten, denn sonst kann niemand mehr die Anmeldeseite erreichen.

Allerdings ist zunächst festzustellen, dass diese Direktive noch keine Wirkung hat. Ebenso würde der kaskadierende Parameter AuthenticationStateTask immer nur null liefern. Ein Versuch, die Komponente <AuthorizeView> einzusetzen, bringt endlich eine verwertbare Fehlermeldung: "Unhandled exception rendering component: Cannot provide a value for property 'AuthorizationPolicyProvider' on type 'Microsoft.AspNetCore.Components.Authorization.AuthorizeView'. There is no registered service of type 'Microsoft.AspNetCore.Authorization.IAuthorizationPolicyProvider'."

Notwendig ist, die Standardautorisierungsmechanismen erst mal per Dependency Injection in Program.cs zu aktivieren:

services.AddAuthorizationCore();

Das führt aber nur zum nächsten Laufzeitfehler: "Unhandled exception rendering component: Authorization requires a cascading parameter of type Task<AuthenticationState>. Consider using CascadingAuthenticationState to supply this."

Das lässt sich heilen, indem man in der App.razor-Datei den bisherigen Inhalt in ein Tag <CascadingAuthenticationState> einbettet. Dieses besagt, dass Blazor den Authentifizierungszustand an alle Komponenten weitergeben soll.

<CascadingAuthenticationState>
 <Router AppAssembly="typeof(Program).Assembly">
  <Found Context="routeData">
   <RouteView RouteData="routeData" DefaultLayout="typeof(MainLayout)" />
  </Found>
  <NotFound>
   <h1>Page not found</h1>
  </NotFound>
 </Router>
</CascadingAuthenticationState>

Allerdings stößt man dann schon wieder auf einen neuen Fehler: “Unhandled exception rendering component: Cannot provide a value for property 'AuthenticationStateProvider' on type 'Microsoft.AspNetCore.Components.Authorization.CascadingAuthenticationState'. There is no registered service of type 'Microsoft.AspNetCore.Components.Authorization.AuthenticationStateProvider'.”

Dass die eingebauten Standardautorisierungsmechanismen immer noch nicht funktionieren, liegt daran, dass diese Mechanismen die Klasse AuthenticationManager gar nicht kennen. Sie soll nun so erweitert werden, dass sie sich in die Standardautorisierungsmechanismen von ASP.NET Core Blazor integriert.

Ein eigener Authentifizierungsprovider wird in Blazor über eine Klasse realisiert, die von AuthenticationStateProvider im Namensraum Microsoft.AspNetCore.Components.Authorization erbt. Diese Klasse ist im gleichnamigen NuGet-Paket realisiert, das hinzuzubinden ist. Für .NET 5.0 Release Candidate 2 muss man sie in der Package Manager Console ausführen:

Install-Package Microsoft.AspNetCore.Components.Authorization

Wer die aktuelle stabile Version von Blazor WebAsssembly verwenden will, lässt die Versionsangabe weg:

Install-Package Microsoft.AspNetCore.Components.Authorization

Ein selbst erstellter AuthenticationStateProvider muss zumindest die Methode GetAuthenticationStateAsync() implementieren:

public override async Task<AuthenticationState> GetAuthenticationStateAsync()

Rückgabe ist ein Objekt vom Typ AuthenticationState, wobei dieser wieder eine Instanz von ClaimsPrincipal kapselt, die ihrerseits ein Array von ClaimsIdentity-Objekten enthält. GetAuthenticationStateAsync() darf nicht NULL zurückliefern, auch wenn kein Benutzer angemeldet ist. Den Zustand, dass kein Benutzer angemeldet ist, muss man durch eine leere Objektstruktur signalisieren, die wie folgt zu erzeugen ist:

return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity()))

Listing 3 zeigt die erweiterte Klasse AuthenticationManager, die nun die Basisklasse AuthenticationStateProvider implementiert. Die Methode GetAuthenticationStateAsync() greift dabei einfach auf die vorhandene Authentifizierungsinformation in der Klasse LoginInfo zurück, die die Operation /Login geliefert hat. Das Token wird im Claim "SID" abgelegt, der Benutzername in "Name". Alle Komponenten haben über den kaskadierenden Parameter AuthenticationState Zugriff auf die Claims der angemeldeten Benutzer.

Wichtig ist, dass Entwickler jeweils nach einem An- und Abmelden der Benutzer die Methode NotifyAuthenticationStateChanged() in der Basisklasse AuthenticationStateProvider aufrufen. Ohne diesen Aufruf erfährt Blazor nichts von der Anmeldezustandsänderung, was wiederum dazu führt, dass weder die laufenden noch die zukünftig gestarteten Razor Components den aktuellen Anmeldestatus erhalten.

Damit die Blazor-Infrastruktur weiß, dass die Klasse AuthenticationManager als AuthenticationStateProvider verwenden werden soll, müssen Entwickler in Program.cs die Injizierung

services.AddScoped<AuthenticationManager>();

ersetzen durch

services.AddScoped<AuthenticationStateProvider, AuthenticationManager>();

Das bedeutet gleichzeitig, dass man in Komponenten nicht mehr eine Instanz von AuthenticationManager erwarten kann, sondern AuthenticationStateProvider injizieren muss, der dann per Type Cast in eine Instanz von AuthenticationManager verwandeln wird, damit man Zugriff auf das Token des angemeldeten Benutzers hat:

[Inject] AuthenticationStateProvider asp { get; set; } = null;
AuthenticationManager am { get { return (asp as AuthenticationManager); } }

Damit sind alle Fehlermeldungen beseitigt, und bei einem Direktaufruf von der relativen Adresse "/main" bleibt (bis auf die Titelzeile) der Inhalt der Webseite leer. Aber das ist auch noch nicht ganz, was man sich wünscht; es wäre besser, den Benutzer auf die Anmeldeseite zu lenken. Das können Entwickler in Blazor pro URL per Abfrage von IsAuthenticated() in AuthenticationStateTask.User.Identity selbst regeln, in diesem Fall in der Datei Index.razor.cs:

[Inject] public NavigationManager NavigationManager { get; set; }
[CascadingParameter] Task<AuthenticationState> authenticationStateTask { get; set; }
...
protected override async Task OnInitializedAsync()
{
   var user = (await authenticationStateTask).User;
   if (!user.Identity.IsAuthenticated) this.NavigationManager.NavigateTo("/");
   await ShowCategorySet();
}

Diese Programmcodezeilen bei größeren Anwendungen in jede per URL erreichbare Razor Component einzubauen, wäre lästig. Schöner ist eine zentrale Regelung über die [i]App.Razor-Datei. Hier müssen Entwickler innerhalb des Elements <Found> das feststehende Routing mit

<RouteView RouteData="routeData" DefaultLayout="typeof(MainLayout)" />

durch eine Umlenkung ersetzen im Fall, dass der Benutzer nicht autorisiert ist. Das schafft man mit <AuthorizeRouteView> und <NotAuthorized> in Verbindung mit NavigationManager.NavigateTo("/"):

@inject NavigationManager NavigationManager
...
<AuthorizeRouteView RouteData="@routeData"
                       DefaultLayout="@typeof(MainLayout)">
    <NotAuthorized>
     @{ 
      NavigationManager.NavigateTo("/", true);
     }
    </NotAuthorized>
</AuthorizeRouteView>

Nun wäre es noch schön, wenn Benutzer nach der Anmeldung sehen könnten, unter welchem Konto sie angemeldet sind. Dazu können Entwickler in MainLayout.razor in die weiße Kopfzeile <AuthorizeView> mit <Authorized> und <NotAuthorized> einbauen. Die Daten der angemeldeten Benutzer kann man innerhalb von <AuthorizeView> vereinfacht über die vordefinierte Property context auslesen, zum Beispiel @context.User.Identity.Name[code] (s. Listing 4).

<span class="col-xs-6 col-lg-9 col-sm-7 col-md-8 hidden-xs" style="vertical-align: middle">
 <AuthorizeView>
  <Authorized>
   <h4 class="loginstatus">User: @context.User.Identity.Name <a href="/logout">Logout</a></h4>
  </Authorized>
  <NotAuthorized>
   <h4 class="loginstatus">You're not signed in!</h4>
  </NotAuthorized>
 </AuthorizeView>
</span>

Listing 4: Verwendung von <AuthorizeView> in MainLayout.razor

Für kleine Displays verwendet man den Ausgabeblock erneut unten, dieses Mal aber fensterbreitenfüllend mit den CSS-Klassen [code]col-xs-12 hidden-sm hidden-md hidden-lg. Statt die Tags zu duplizieren, lagert man den Code aus Listing 4 besser in eine weitere Razor Component UserStatus.razor aus und verwendet diese Komponente dann an beiden Stellen via Tag:

<span class="col-xs-12 hidden-sm hidden-md hidden-lg">
 <UserStatus></UserStatus>
</span>

Die Authentifizierung ist damit fertig. Die Integration war nicht ganz trivial, aber Blazor ist hier sehr flexibel für alle Fälle. Im vierten Teil wird die Persistierung von Daten im Webbrowser hinzugefügt, denn User sollen nicht jedes Mal zur Eingabe ihrer Anmeldedaten gezwungen werden, insbesondere nicht nach einem Reload im Browser.

Dr. Holger Schwichtenberg
ist Chief Technology Expert bei der MAXIMAGO-Softwareentwicklung [7]. Mit dem Expertenteam bei www.IT-Visions.de [8] bietet er zudem Beratung und Schulungen im Umfeld von Microsoft-, Java- und Web-Techniken an. Er ist Autor zahlreicher Bücher, u. a. "ASP.NET Core Blazor: Moderne Single-Page-Web-Applications mit .NET, C# und Visual Studio".

(ane [9])


URL dieses Artikels:
https://www.heise.de/-4988529

Links in diesem Artikel:
[1] https://www.heise.de/ratgeber/Webprogrammierung-mit-Blazor-WebAssembly-Teil-1-Web-API-Aufrufe-und-Rendering-4932237.html
[2] https://www.heise.de/ratgeber/Blazor-WebAssembly-Teil-2-Eingabesteuerelemente-JavaScript-Interoperabilitaet-4971874.html
[3] https://www.heise.de/ratgeber/Blazor-WebAssembly-Teil-3-Authentifizierung-und-Autorisierung-4988529.html
[4] https://www.heise.de/ratgeber/Blazor-WebAssembly-Teil-4-Zustandsverwaltung-und-Nachladen-von-Modulen-5036983.html
[5] https://www.heise.de/ratgeber/Blazor-WebAssembly-Bidirektionale-Kommunikation-und-Benachrichtigungen-5069045.html
[6] http://miraclelistbackend.azurewebsites.net/
[7] https://www.maximago.de/
[8] https://www.it-visions.de/start.aspx
[9] mailto:ane@heise.de