Artikelserie
- Authentifizierung und Autorisierung mit IdentityServer in Aktion
- Authentifizierung und Autorisierung mit Keycloak in Aktion ⬅
Version Information:
- .NET: 6.0.100
- Keycloak 15.0.2
- AntDesign: 0.10.2
Über einer Demoapplikation wird nachvollziehbar, wie ein Blazor-Client sich bei einem Keycloak 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 einem Access Token, den wir vom Keycloak Server erhalten, können wir dann eine Token-gesicherte Web-API aufrufen. Weitere Informationen über Keycloak und auch die Konfiguration von Clients, Claims und Policies, finden sich in den Webinaren meines Kollegen Boris Willhelms.
Der gesamte Source Code zu diesem Artikel befindet sich im zugehörigen GitHub Repository.
Hinweis: Da es sich hier um meinen zweiten Artikel über die Authentifizierung und Autorisierung handelt, gehe ich nicht auf die Authentifizierungsarchitektur in Blazor WebAssembly ein.
Blazor-WebAssembly-Client konfigurieren
Wie auch schon beim IdentityServer, müssen zuerst die nötigen Parameter konfiguriert werden, die für die Kommunikation mit Keycloak erforderlich sind. Dazu wird eine appsettings.json
-Datei im wwwroot
-Ordner erstellt und folgende Konfiguration hinzugefügt:
{
"Oidc": {
"Authority": "http://localhost:8080/auth/realms/BlazorKeycloak",
"ClientId": "blazor-keycloak-web-frontend",
"PostLogoutRedirectUri": "https://localhost:5001/",
"DefaultScopes": [
"roles"
],
"ResponseType": "code"
}
}
Folgende Parameter werden in der Konfigurationsdatei gesetzt:
Authority
ist die URL des Keycloak Servers, 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.PostLogoutRedirectUri
URL, an die wir weitergeleitet werden wollen, sobald wir uns ausloggen.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.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.
Mit der Extension-Methode AddOidcAuthentication
fügen wir die SPA-Authentifizierungsfunktionalität der ServiceCollection
hinzu. Innerhalb der Methode wird die von uns erzeugte Konfiguration der ProviderOptions
-Eigenschaft zugewiesen.
builder.Services.AddOidcAuthentication(options =>
{
builder.Configuration.Bind("Oidc", options.ProviderOptions);
});
Nachdem wir alles erledigt haben, um die Konfiguration für die Keycloak-Authentifizierung dem Client verfügbar zu machen, müssen wir zum Schluss 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
// Authentication.razor.cs
public partial class Authentication
{
[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 "/"
// Index.razor.cs
public partial class Index
{
[Inject] private NavigationManager Navigation { get; set; }
//...
private void BeginSignIn()
{
Navigation.NavigateTo("authentication/login");
}
}
Bis zu diesem Punkt unterscheidet sich die Implementierung mit dem Keycloak Server nicht von der mit dem IdentityServer. Im nächsten Abschnitt widmen wir uns der sicheren Kommunikation mit den Web APIs.
Sicher mit den Web APIs kommunizieren
Um sicher mit dem Server bzw. mit den Web APIs zu kommunizieren, nutzen wir in der Beispielanwendung einen Access Token. Das Token wird als Authorization
-Header den HTTP-Requests hinzugefügt. Da es derzeit noch ein Problem mit der AuthorizationMessageHandler
-Klasse gibt (GitHub Issue), müssen wir eine eigene MessageHandler
-Klasse erstellen. Damit nicht bei jedem Aufruf der Web API das Token manuell hinzugefügt werden muss, implementieren wir eine eigene Klasse, welche von DelegatingHandler
ableitet.
public class CustomAuthorizationHeaderHandler : DelegatingHandler
{
private readonly IAccessTokenProviderAccessor _accessor;
public CustomAuthorizationHeaderHandler(IAccessTokenProviderAccessor accessor)
{
_accessor = accessor ?? throw new ArgumentNullException(nameof(accessor));
}
protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
var accessTokenResult = await _accessor.TokenProvider.RequestAccessToken();
if (accessTokenResult.TryGetToken(out var accessToken) && !String.IsNullOrWhiteSpace(accessToken.Value))
{
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken.Value);
}
return await base.SendAsync(request, cancellationToken);
}
}
In der CustomAuthorizationHeaderHandler
-Klasse aus dem obigen Code-Beispiel wird die Methode SendAsync
überschrieben um das Access Token dem aktuellen HTTP-Request anzuhängen. Das aktuelle Access Token wird mit Hilfe des TokenProvider
abgefragt. Ist ein Token vorhanden, wird dieses als Authorization
-Header dem HTTP-Request hinzugefügt.
Abschließen registrieren wir einen HttpClient
in der ServiceCollection
in der Program.cs
.
// Program.cs
builder.Services
.AddHttpClient("WebAPI", client => client.BaseAddress = new Uri("http://localhost:5002/"))
.AddHttpMessageHandler();
builder.Services.AddScoped(sp => sp.GetRequiredService()
.CreateClient("WebAPI"));
Mit der Extension-Methode AddHttpMessageHandler
können wir die CustomAuthorizationHeaderHandler
-Klasse dem HttpClient
als MessageHandler
bekanntmachen. Dadurch wird bei jedem HTTP-Request die Methode SendAsync
durchlaufen, welche das Access Token dem Request hinzufügt.
Um die HttpClient
-Klasse einzusetzen, muss diese lediglich mit Hilfe von Dependency Injection der Klasse bzw. Komponente bereitgestellt werden.
// DataService.cs
public DataService(HttpClient client, NavigationManager navigationManager)
{
_client = client ?? throw new ArgumentNullException(nameof(client));
_navigationManager = navigationManager ?? throw new ArgumentNullException(nameof(navigationManager));
}
// ... code...
private async Task> GetCollectionAsync(string path,
CancellationToken cancellationToken = default)
{
return await _client.GetFromJsonAsync>($"{path}", cancellationToken);
}
In der Methode GetCollectionAsync
werden über die Methode GetFromJsonAsync<T>
aus dem Microsoft.Extentsions.Http
-Package, die aktuellen Daten von der API angefragt. Durch den oben erstellten MessageHandler
wird dem HTTP-Request das Access Token hinzugefügt, wie wir im folgenden Screenshot (Chrome DevTools) erkennen können.
Nachdem wir nun erfolgreich eine Web API authentifiziert vom Client aus aufrufen können, wollen wir im nächsten Schritt Nutzerdaten abrufen und verarbeiten.
Arbeiten mit Rollen
Beim Arbeiten mit Rollen ist allem voran wichtig zu wissen, dass bei Keycloak die Rollen in einem etwas anderen Format an den Client gesendet werden, wie es z.B. bei IdentityServer der Fall ist.
{
// IdentityServer
"client_id": "blazor-spa",
"given_name": "Bob",
"roles": ["admin", "user"]
}
{
// Keycloak
"profile": {
//...
"resource_access": {
"blazor-keycloak-web-api": {
"roles": ["admin", "user"]
}
}
}
}
Im obigen Code-Beispiel sehen wir zuerst die Variante des IdentityServer. Dieser sendet die Rollen des Nutzers als String
-Array an den Client. Im zweiten Beispiel wird das JSON-Format des Keycloak Servers dargestellt. Hier ist ein deutlicher Unterschied zu erkennen. Die Rollen werden hier zusätzlich noch einer bestimmten Ressource zugewiesen. Dadurch werden dem Nutzer nur die Rollen zurückgegeben, die für die angefragte Ressource auch zulässig sind. Beispiel:
- Ein Nutzer besitzt die Rollen
user
,customer
undwriter
- Die angefragte Resource besitzt die Rollen
user
undwriter
–> So würden im Access Token für den Nutzer nur die Rollenuser
undwriter
zurückgegeben.
Infolgedessen müssen sowohl im Client als auch in der Web API Anpassungen durchgeführt werden, um das Format des Keycloak Servers verarbeiten zu können.
Web API
Damit die API mit den Rollen arbeiten kann, müssen wir die Claims
des Access Tokens in ein anderes Format umwandeln. Hierzu bietet das Package Microsoft.AspNetCore.Authentication
das Interface IClaimsTransformation
. Das Interface beinhaltet die Methode TransformAsync
, in welcher die Claims
angepasst werden können bevor diese verarbeitet bzw. weiterverwendet werden.
public class KeycloakRolesClaimsTransformation : IClaimsTransformation
{
private readonly string _roleClaimType;
private readonly string _audience;
public KeycloakRolesClaimsTransformation(string roleClaimType, string audience)
{
_roleClaimType = roleClaimType;
_audience = audience;
}
///
/// Bietet einen zentralen Transformationspunkt, um den angegebenen Principal zu ändern.
/// Note: Dies wird bei jedem AuthenticateAsync-Aufruf ausgeführt, daher ist es sicherer, einen
/// einen neuen ClaimsPrincipal zurückzugeben, wenn Ihre Transformation nicht identisch ist.
///
public Task TransformAsync(ClaimsPrincipal principal)
{
var result = principal.Clone();
if (result.Identity is not ClaimsIdentity identity)
{
return Task.FromResult(result);
}
var resourceAccessValue = principal.FindFirst("resource_access")?.Value;
if (String.IsNullOrWhiteSpace(resourceAccessValue))
{
return Task.FromResult(result);
}
using var resourceAccess = JsonDocument.Parse(resourceAccessValue);
var clientRoles = resourceAccess
.RootElement
.GetProperty("blazor-keycloak-web-api")
.GetProperty("roles");
foreach (var role in clientRoles.EnumerateArray())
{
var value = role.GetString();
if (!String.IsNullOrWhiteSpace(value))
{
identity.AddClaim(new Claim("roles", value));
}
}
return Task.FromResult(result);
}
}
In der Beispielanwendung wird nach dem Claim resource_access
gesucht. Dieses Claim beinhaltet die Rollen des Nutzers für die angefragte Ressource, wie wir es im vorherigen JSON-Beispiel gesehen haben. Existiert das Claim, werden die aktuellen Rollen einem neuen Claim zugewiesen. Das neue Claim hat den Namen roles
. Dieses Claim ist notwendig damit Klassen wie z.B. das Authorize
-Attribute die Rollen auswerten kann.
API-Controller Beispiel:
// ContributionsController.cs
[HttpDelete("{id}")]
[Authorize(Roles = "admin")]
public IActionResult DeleteContribution([FromRoute] int id, CancellationToken cancellationToken = default)
{
_contributionService.RemoveContribution(id);
return Ok();
}
Client
Im Blazor-Client haben wir das gleiche Szenario wie auch in der Web API. Doch leider kann das IClaimsTransformation
-Interface hier nicht eingesetzt werden. Im Client muss daher eine eigene Klasse erstellt werden, welche von der AccountClaimsPrincipalFactory<RemoteUserAccount>
– Klasse ableitet. Diese Klasse kommt aus dem Package Microsoft.AspNetCore.Components.WebAssembly.Authentication
, welches Blazor zur Authentifizierung und Autorisierung nutzt.
public class CustomAccountFactory : AccountClaimsPrincipalFactory
{
public CustomAccountFactory(IAccessTokenProviderAccessor accessor, HttpClient httpClient)
: base(accessor)
{
}
public async override ValueTask CreateUserAsync(
RemoteUserAccount account, RemoteAuthenticationUserOptions options)
{
var initialUser = await base.CreateUserAsync(account, options);
return (initialUser.Identity != null && initialUser.Identity.IsAuthenticated)
? await KeycloakClaimsHelper.TransformRolesAsync(initialUser, "blazor-keycloak-web-api")
: initialUser;
}
}
Wie überschreiben wird die CreateUserAsync
-Methode. In dieser wird dann, wie auch in der Web API, mit Hilfe der Extension Methode TransformRolesAsync
, dem Nutzer das Claim roles
hinzugefügt.
Hinweis: Die Methode TransformRolesAsync
kommt aus einer geteilten Klassenbibliothek, welche sowohl von der API als auch dem Client genutzt wird. Dadurch wird sichergestellt, dass beide Projekte die gleiche Umwandlung der Claims durchführen.
Zuletzt müssen wir die Klasse noch der ServiceCollection
hinzufügen.
// Program.cs
builder.Services.AddScoped(typeof(AccountClaimsPrincipalFactory), typeof(CustomAccountFactory));
Nachdem wir nun sowohl die Web API als auch den Client für die Nutzung von Rollen vorbereitet haben, können wir prüfen, ob der angemeldete Nutzer sich auch in der richtigen Rolle befindet.
Dazu können wir im Client die Authorized
-Komponente nutzen. Die Komponente beinhaltet einen Parameter, welcher an die Kindkomponenten weitergegeben wird. Der Parameter ist vom Typ AuthenticationState
und kann über den Namen context
, innerhalb der Komponente abgerufen werden.
Im obigen Code-Beispiel wird mit der Methode IsInRole
geprüft, ob der Nutzer die admin
-Rolle besitzt. Ist dies der Fall, wird der Menüpunkt Speaker
angezeigt, wie wir auf dem unteren Beispiel des folgenden Screenshots sehen können.
Doch nicht nur innerhalb der AuthorizeView
-Komponente können wir die Rollen einsetzten. Mit Hilfe des Authorize
-Attributes, welches wir schon aus der Web API kennen, ist es möglich, dass Nutzer einzelne Routen nur mit bestimmten Rollen aufrufen können. Um das Authorize
-Attribute einzusetzen, muss dies am Anfang einer Razor
-Page hinzugefügt werden.
@page "/speakers"
@attribute [Authorize(Roles = "admin")]
In diesem Code-Beispiel wird die Route /speakers
definiert. Zusätzlich wird das Authorize
-Attribute mit dem Parameter Roles
hinzugefügt. Wie auch im vorherigen Beispiel wird hier geprüft werden, ob der Nutzer authentifiziert ist und die admin
-Rolle besitzt.
Abschließend muss die RouteView
durch die AuthorizeRouteView
ersetzt werden, damit das Authorize
-Attribute auf den Routen genutzt werden kann.
Sorry, there's nothing at this address.
Im Screenshot der Beispielanwendung sehen wir das Ergebnis eines Nutzers, welcher ohne admin
Rolle versucht, die Route aufzurufen. Zudem ist zu erkennen, dass in der Navigation auf der linken Seite der Menüpunkt für Speakers
nicht angezeigt wird, was wir im vorherigen Schritt über die Authorized
-Komponente festgelegt haben.
Fazit
Anhand einer illustrativen Beispielanwendung konnten wir sehen, dass die Implementierung der Authentifizierung mit Keycloak sich nicht groß unterscheidet von der vorherigen Implementierung mit dem IdentityServer.
Jedoch bei der Autorisierung, ist durch das Zuweisen von Rollen an einzelnen Ressourcen bei Keycloak, ein wenig mehr Aufwand in der Implementierung notwendig. Wie wir gesehen haben, mussten sowohl im Client als auch in der Web API hierfür Interfaces überschrieben und Klassen erweitert werden.
Danach konnten aber auch mit dem Keycloak Server als IDP alle Authentifizierungs- und Autorisierungsmechanismen, wie das Authorize
-Attribute oder die AuthorizeRouteView
-Komponente, wie gewohnt eingesetzt werden.