Artikelserie
- Authentifizierung und Autorisierung mit IdentityServer in Aktion ⬅
- Authentifizierung und Autorisierung mit Keycloak in Aktion
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:
- (1) Eingebettet in eine Razor-Seite wird im ersten Schritt die
RemoteAuthenticatorView
aufgerufen, die eine Aktion (z.B.login
oderlogout
) 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 derRemoteAuthenticationService
denAuthenticationService
. 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 denRemoteAuthenticationService
. - (8 & 9) Zum Schluss teilt der
RemoteAuthenticationService
derRemoteAuthenticatorView
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 hieropenid
, um anzuzeigen, dass wir die Überprüfung der Identität des Benutzers mit OpenID Connect durchführen möchten, undprofile
, 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 login
oder logout
) als Parameter an die Seite mit übergeben.
@page "/authentication/{action}"
@using Microsoft.AspNetCore.Components.WebAssembly.Authentication
@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
Login
@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.
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 IdentityResources =>
new IdentityResource[]
{
new IdentityResources.OpenId(),
new IdentityResources.Profile(),
new IdentityResources.Email()
};
public static IEnumerable 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
@context.User.Identity.Name
Login
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()
.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()
.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.
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 IAccessTokenProvider
s 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.
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 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 Users = new List
{
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.
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"
Hallo @context.User.Identity.Name, hier ist eine Liste deiner Nutzerrechte
@foreach (var claim in context.User.Claims)
{
@claim.Type:
@claim.Value
}
@if(context.User.Identity.IsAuthenticated) {
Nur berechtigte Nutzer können diesen Bereich sehen
} else {
}
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:
Nur berechtigte Nutzer können diesen Bereich sehen
Natürlich kann es auch möglich sein, dass der Nutzer zwar authentifiziert aber nicht autorisiert ist:
@if (context.User.Identity.IsAuthenticated)
{
Leider sind Sie nicht autorisiert.
}
else
{
}
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.