ASP.NET Identity Grundlagen

Benutzer Authentifzierung begleitet den Entwickler im Web ein Leben lang. Kann eine Desktop Anwendung in der Regel sich auf den angemeldeten Windows User verlassen, so wird das im Web schon schwierig. Windows nutzt NTLM und dafür braucht es ein lokales Netzwerk.

Sobald man Web Apps (oder auch mobile Apps) abgesichert nutzen will landet man beim Kundenwunsch Single Sign On (SSO). Eine Anmeldung für alles. Hier hat sich OAuth2 zum Standard etabliert und zig Anbieter buhlen darum ihre Tokens (Achtung Erklärung später) anbieten zu dürfen. So auch Microsoft als B2C Dienst oder in Unternehmensanwendung mit einer Brücke zum Active Directory –Azure AD.

In diesem Artikel geht es allerdings um die konkrete Implementierung in ASP.NET core (bis 7) mittels Identity. Es gibt verschiedene Wege dies in ein Visual Studio Projekt einzubinden. Hier werde ich aber von Grund auf aufschlüsseln was genau im Hintergrund passiert.

Wir starten mit einem leeren (oder beliebigen) ASP.NET Project in Visual Studio.

Das was wir als nächstes tun, erledigt entweder ein Wizard oder ist in einer DLL ausprogrammiert die per Wizard eingefügt wird.

Um den Status des angemeldeten Benutzers zu visualisieren wird ein Partial angelegt. Dies erfüllt zudem die Aufgabe die Navigation für Login und Logout bereit zu stellen.

   1:  <ul class="navbar-nav">
   2:      @if (User.Identity.IsAuthenticated)
   3:      {
   4:          <li class="nav-item">
   5:              <span class="nav-text text-dark">Hello @User.Identity.Name!</span>
   6:          </li>
   7:          <li class="nav-item">
   8:              <a class="nav-link text-dark" asp-page="/account/SignOut">Sign out</a>
   9:          </li>
  10:      }
  11:      else
  12:      {
  13:          <li class="nav-item">
  14:              <a class="nav-link text-dark"  asp-page="/account/Login">Sign in</a>
  15:          </li>
  16:      }
  17:  </ul>

Wir sehen das User Objekt das Zugriff auf verschiedene Eigenschaften wie den Login Name bietet.

Das Partial nennen wir _LoginPartial und speichern es im Shared Folder. Dann fügen wir in Layout.cshtml am Ende des Bootstrap Navigations Menü das Partial auch ein

<partial name="_LoginPartial" />

Ruft man nun die Seite auf, wird man im Menü einen neuen Eintrag sehen, zu den man aber nicht hin navigieren kann.

   1:  namespace BlogAuth.Pages.Account
   2:  {
   3:      public class Credentials
   4:      {
   5:          public string UserName { get; set; }
   6:          public string Password { get; set; }
   7:          public bool Remember { get; set; }
   8:      }
   9:  }

Als nächstes legt man das Bootstrap basierte Login Formular an. Ebenso im Account Verzeichnis als login.cshtml

   1:  @page
   2:  @model BlogAuth.Pages.Account.LoginModel
   3:  @{
   4:  }
   5:  <div class="row">
   6:      <div class="col-md-4">
   7:          <section>
   8:              <form id="account" method="post">
   9:                  <h2>Login</h2>
  10:                  <hr />
  11:                  <div asp-validation-summary="ModelOnly" class="text-danger" role="alert"></div>
  12:                  <div class="form-floating mb-3">
  13:                      <input asp-for="Input.UserName" class="form-control" autocomplete="username" aria-required="true" placeholder="name@ppedv.de" />
  14:                      <label asp-for="Input.UserName" class="form-label">UserName</label>
  15:                      <span asp-validation-for="Input.UserName" class="text-danger"></span>
  16:                  </div>
  17:                  <div class="form-floating mb-3">
  18:                      <input asp-for="Input.Password" class="form-control" autocomplete="current-password" aria-required="true" placeholder="password" />
  19:                      <label asp-for="Input.Password" class="form-label">Password</label>
  20:                      <span asp-validation-for="Input.Password" class="text-danger"></span>
  21:                  </div>
  22:                  <div class="checkbox mb-3">
  23:                      <label asp-for="Input.Remember" class="form-label">
  24:                          <input class="form-check-input" asp-for="Input.Remember" />
  25:                          @Html.DisplayNameFor(m => m.Input.Remember)
  26:                      </label>
  27:                  </div>
  28:                  <div>
  29:                      <button id="login-submit" type="submit" class="w-100 btn btn-lg btn-primary">Log in</button>
  30:                  </div>
  31:   
  32:              </form>
  33:          </section>
  34:      </div>
  35:  </div>
  36:  @section Scripts {
  37:      <partial name="_ValidationScriptsPartial" />
  38:      }

Nun fehlt noch ein wenig C# Login Logik. Minimal um das Formular zu validieren und einfach mal einen Testlauf des Projekts zu starten.

   1:      [BindProperty]
   2:      public Credentials Input { get; set; }
   3:     
   4:      public async Task<IActionResult> OnPostAsync(string returnUrl)
   5:      {
   6:          if (!ModelState.IsValid)
   7:          {
   8:              return BadRequest(ModelState);
   9:          }
  10:   
  11:   
  12:          if (Input.UserName == "admin" && Input.Password == "x")
  13:          {
  14:              return RedirectToPage(returnUrl);
  15:          }
  16:          else
  17:          {
  18:              return Page();
  19:          }
  20:      }
  21:  }

Das sieht dann im Browser wie folgt aus

auth1

und erzeugt einen Fehler beim Login      "The returnUrl field is required." Hier beginnt schon die ASP.NET Identity Magie und unser Forschungsprojekt.

Wenn man eine Page oder Service schützen möchte, muss man das Authorize Attribut anwenden. Das kann im Razor View erfolgen

@page
@using Microsoft.AspNetCore.Authorization
@attribute [Authorize]

oder im Code vor der entsprechenden HTTP Methode oder Page Klasse

   1:    [Authorize(Roles ="Admins")]
   2:    public class AdminModel : PageModel

Ruft man nun diese Website auf, erhält man wiederum eine Fehlermeldung.

invalidOperationException: No authenticationScheme was specified, and there was no DefaultChallengeScheme found. The default schemes can be set using either AddAuthentication(string defaultScheme) or AddAuthentication(Action<AuthenticationOptions> configureOptions).

Es gibt noch einiges zu tun. Nämlich in der HTTPPipeline des Kestrel Web Servers die nötige Konfiguration vornehmen. Ob es mir gefällt oder nicht (es gefällt mir nicht) das geht nur im Code der program.cs. Wie schon seit Urzeiten von Benutzerverwaltung gilt auch hier zu klären, wer bin ich (Authentication) und was darf ich (Authorization). Wie in ASP.NET Core Dependency Injection üblich muss zuerst das Objekt auf der Dependency Liste (IServiceCollection) angemeldet werden.

builder.Services.AddAuthentication().AddCookie("MyCookie");
builder.Services.AddAuthorization();

Die Konfiguration der Objekte lassen wir noch unberücksichtigt. Allerdings erinnern Sie sich noch an die Fehlermeldung bezüglich des Authentication Schemas. Um das zu lösen müssen, wir angeben welches wir wollen. Da gäbe es OAuth oder wie hier gewählt Cookie. Das bedeutet das ein Cookie genutzt wird um alles Notwendige (später Token genannt) zu speichern.

Letztendlich müssen die beiden Objekte auch genutzt werden. Auch dies in der program.cs ein Stück später. Die Reihenfolge spielt nun eine Rolle!

app.UseAuthentication();
app.UseAuthorization();

Der Zeitpunkt kurz inne zu halten. Bzw den Breakpoint (ich nehme hier index die Get Methode)

auth2

Wir sehen am User Objekt eine Identity. Die braucht man auch im den anonymen Case abzubilden. Auch kein User ist ein User- im IIS liefert den der W3C Prozess.

Wir sehen auch Claims und Roles. Das ist ein Punkt der vielleicht neu für Sie ist. In der Windows Benutzer Verwaltung ist man Mitglied in der Rolle Admin. Dieses Konzept passt in offenen Authentifizierungssystemen nicht. Man wählt (alternativ Role geht auch noch) einen Claim den ein User besitzt. Oder eher wahrscheinlich viele Claims. Oft fühlen sich diese ident zu Rollen an. Die Claims werden im Token gespeichert. Das Token erhält man nach Authentifizierung. Speicherort des Tokens ist hier ein Cookie.

Um eine Analogie zu bemühen. Sie erhalten von der Behörde einen Führerschein, der begrenzt gültig ist. Mit dieser Plastikkarte (=Token) können sie jederzeit die verschiedenen Fahrberechtigungen nachweisen. Ganz ohne bei der Behörde jedes mal anrufen zu müssen.

Die Helper Klasse die uns das sichere Token erzeugt, heißt SignIn. Wir haben schon festgestellt (per Debug) das es ein Identity Objekt gibt. Daraus formen wir einen ClaimsPrinzipal mit dem man SignIn Füttert. Da der Token durch unsere Konfiguration in einem Cookie landet, auch noch der Name desselben (immer der gleiche von program.cs bis hier). Tja und dann die Liste der Claims. Es gibt eine Reihe vordefinierte und hier der frei definierte “admins”. Da Key Value mit Fake Wert true.

Entsprechend ergänzen wir den Code in der Login.cshtml.cs

   1:    if (Input.UserName == "admin" && Input.Password == "x")
   2:    {
   3:        var claims = new List<Claim>
   4:        {
   5:            new Claim(ClaimTypes.Name,Input.UserName),
   6:            new Claim(ClaimTypes.Email, "test@ppedv.de"),
   7:            new Claim("admins","true")
   8:        };
   9:        var identity = new ClaimsIdentity(claims, "MyCookie");
  10:        var principal = new ClaimsPrincipal(identity);
  11:        var authpro = new AuthenticationProperties { IsPersistent = Input.Remember };
  12:        await HttpContext.SignInAsync("MyCookie", principal, authpro);
  13:        return RedirectToPage(returnUrl);

Tatsächlich, die Webanwendung leitet den Benutzer bei Aufruf der “/Privacy” Page auf Login um. Nach erfolgreichen Login erscheint der Name im Menü.

auth3

Ja ja, Design Note 2.

Der Debugger verrät nun, wir sind Authentifiziert und die Claims sind da.

auth5

Das ist selbst dann der Fall, wenn die Debug Session endet und neu gestartet wird. Die Anmeldung ist persistent. Jedenfalls theoretisch.

Ein Blick in die Browser Tools (F12- Application – Cookies) verrät, das Cookie ist da und enthält verschlüsselt Daten.

auth6

Allerdings wer genau hinsieht, wird erkennen, das die Cookie Gültigkeit auf die Session beschränkt ist. Hier ist ein Eingriff in die Konfiguration des Webservers (Seufz, ja program.cs) nötig.

   1:  builder.Services.AddAuthentication().AddCookie("MyCookie",
   2:  o => {
   3:   o.ExpireTimeSpan = TimeSpan.FromDays(1);
   4:  });

Nun klappts auch mit dem Cookie länger.

auth7

Wenden wir uns den Rechten zu. Wir erkennen, das das Authorize Attribut Roles oder Policy als Parameter erlaubt. Claims suchen wir vergeblich.

Trägt man nun eine Rolle zum Test in die Privacy Page ein [Authorize(Roles ="admins")] wird man nach Zugriff auf eine nicht existierende Url umgeleitet.

Account/AccessDenied?ReturnUrl=%2FPrivacy

Da ist sie wieder die Identity Magie. Also füttern wir diese und legen die fehlende Seite einfach an und zeigen dem Benutzer was sinnvolles. Tatsächlich spricht einiges dafür den HTTP Status Code 403 zurück zu geben. Ist aber kein muss.

Das löst natürlich das ursprüngliche Problem nicht, wie man die Rechte im Claim überprüft. Und wieder gehts in die Program.cs, wo eine Policy hinzugefügt werden muss.

   1:  builder.Services.AddAuthorization(o =>
   2:  {
   3:      o.AddPolicy("IsAdmin", p => p.RequireClaim("admins"));
   4:  });

Dort können beliebig viele und beliebig komplexe Bedingungen formuliert und kombiniert werden.

Eine winzige Änderung in der privacy Razor Page auf  [Authorize(Policy ="isAdmin")] und schon gehts. Seitennotiz- groß Kleinschreibung scheint egal zu sein.

Claims haben eine Lebenszeit. Fügt man nun weitere Eigenschaften hinzu, werden diese nicht im Cookie aktualisiert.

Wenn Sie mit ein neues ASP.NET Core Projekt von Grund auf mit Authentication starten, werden keine cshtml Dateien angelegt, sondern ein Nuget Paket Microsoft.AspNetCore.Identity.UI genutzt.

Kommentare sind geschlossen