Blazor WebAssembly, Teil 3: Authentifizierung und Autorisierung

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

In Pocket speichern vorlesen Druckansicht 1 Kommentar lesen
Lesezeit: 11 Min.
Von
  • Dr. Holger Schwichtenberg
Inhaltsverzeichnis

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