ASP.NET Core SignalR: Mehr Sicherheit bei Tokens – Teil 3

Im vorherigen Teil dieser Serie ging es um den Aufbau einer sicheren Verbindung zwischen Client und Server mithilfe von SignalR. Nun möchten wir uns zwei wichtigen Punkten im Umgang mit einem Authentifizierungstoken widmen. Wir schauen uns an, wie wir unsere Echtzeitkommunikation mit Hilfe eines Referenztoken absichern können und wie wir das Token aktualisieren können, wenn dieses während unserer Echtzeitverbindung abgelaufen ist.

In diesem Artikel:

Problemstellung

Bisher haben wir uns in den letzten zwei Artikel auf das JWT-Token beschränkt. Dies hat jedoch den Nachteil, dass es nur einmal beim IDP abgerufen werden muss. Danach ist das Token so lange valide, bis es abgelaufen ist. Dadurch können z.B. Änderung von Rechten erst beim nächsten Abrufen des JWT-Tokens gültig werden. Ein weiteres Problem in unserer Beispielanwendung ist die WebSocket-Verbindung. Diese sendet, wie im zweiten Artikel schon erwähnt wurde, nur beim Aufbau der Verbindung das Token an den Server. Daher werden auch hier sämtliche Änderungen ignoriert, während die Verbindung aufgebaut ist. Erst beim nächsten Verbindungsaufbau wird ein neues Token mitgesendet und die Änderungen können überprüft werden. In diesem Artikel schauen wir uns diese beiden Probleme anhand unserer Beispielanwendung näher an. Den gesamten Source Code zur Beispielanwendung findet sich hier.

Version Information:

  • .NET SDK: 5.0.201
  • ASP.NET Core: 5.0.4
  • ASP.NET Core Blazor WebAssembly: 5.0.4
  • IdentityServer: 4.1.1
  • Angular: 11.2.4

Referenztoken

Authentifizierungstoken können in zwei Varianten erhältlich sein:

  • in sich geschlossene JTW-Tokens
  • Referenztokens

JWT-Tokens sind ein offener Standard (RFC 7519) der zur sicheren Übertragung von Informationen zwischen zwei oder mehreren Parteien als JSON-Objekt definiert. Wird ein Token für eine API ausgestellt, kann diese das Token validieren. Hierfür muss die API nicht weiter mit dem IDP kommunizieren. Das Token ist dann so lange gültig, bis es abgelaufen ist.

Um diesen Prozess sicherer zu machen, können Referenztoken eingesetzt werden. Bei Verwendung von Referenztoken speichert der IDP den Inhalt des Tokens in einem Datenspeicher und gibt nur eine eindeutige Referenz für dieses Token an den Client zurück. Die API, die diese Referenz empfängt, muss bei jeder Validierung des Tokens eine Verbindung zum IDP öffnen.

Doch das Referenztoken hat auch einen großen Nachteil. Durch die ständige Kommunikation zwischen der API und dem IDP steigt die Last auf dem Server. Dies kann zum einen dazu führen, dass der IDP überlastet und die Antwortzeiten immer länger werden. Und jeder weiß, dass lange Antwortzeiten schnell zu Performance Problemen sowohl bei der API als auch im Client führen.

In unserer Beispielanwendung nutzen wir als IDP den IdentityServer. Um hier Referenztoken nutzen zu können, muss der IdentityServer angepasst werden. Mehr zu diesem Thema gibt es hier zum Nachlesen.

Hinweis: Nicht jeder IDP kann mit Referenztoken umgehen. Daher muss zuerst geprüft werden, ob euer IDP Referenztoken kennt.

SignalR für Referenztoken konfigurieren

Um Referenztoken in unserer Beispielanwendung einsetzten zu können, müssen wir eine Anpassung in der Startup.cs des Projekts SignalRSample.Api machen. Um den IdentityServer der API hinzuzufügen, wird in Zeile 84 die Extension-Methode AddIdentityServerAuthentication aufgerufen. Hier müssen wir eine Erweiterung implementieren, um das Referenztoken beim Aufbau der SignalR-Verbindung richtig verarbeiten zu können. Dazu fügen wir den TokenRetriever Handler hinzu. Der Handler ist eine statische Funktion die es uns ermöglicht das Token aus dem Authorization-Header oder aus dem Query-Parameter auslesen zu können.

				
					.AddIdentityServerAuthentication("token", options =>
{
    // ...
    options.TokenRetriever = req =>
    {
        if (req.Headers.TryGetValue("Authorization", out var headerValue))
        {
            var values = headerValue.ToString().Split(',');
            if (values.Length == 2)
            {
                return values[1];
            }

            return string.Empty;
        }

        // Dies wird für SignalR benötigt, da das Token im Querystring mit gesendet wird
        // anstelle von HTTP-Headern.
        if (req.Query.TryGetValue("access_token", out var queryValue))
        {
            return queryValue;
        }

        return string.Empty;
    };
});
				
			

Im obigen Code wird zuerst geprüft, ob das Token über den Authorization-Header mit gesendet wird. Ist dies der Fall, wird das Token zurückgeben. Wird das Token jedoch nicht als Header übermittelt, wird im zweiten Schritt geprüft, ob das Token als Query-Parameter gesetzt wurde. Hierzu wird nachgeschaut, ob der HTTP-Request einen Query-Parameter mit dem Namen access_token besitzt. Da wir bei SignalR eine WebSocket-Verbindung aufmachen, ist dieser Part für uns relevant, da das Token als Query-Parameter an die API übertragen wird. Durch die Erweiterung des TokenRetriever, können wir nun auch mit Referenztoken arbeiten, wenn wir eine SignalR-Verbindung aufbauen.

Läuft ein Token ab oder haben sich die Rechte für einen Nutzer geändert, wird so direkt erkannt, dass das Token nicht mehr gültig ist und die Verbindung wird beendet. Damit die Verbindung nicht dauerhaft erneut gestartet werden muss, schauen wir uns im nächsten Schritt an, was wir tun müssen, um einen Token zu aktualisieren.

Abgelaufenens Token aktualisieren

Änderungen im Hub-Code

Um einen Token aktualisieren zu können, müssen wir im ersten Schritt erkennen, ob das Token noch gültig ist. Dazu erweitern wir in unserer Beispielanwendung die Methode OnConnectedAsync der Klasse GamesHub.

				
					public override async Task OnConnectedAsync()
{
    var feature = Context.Features.Get<IConnectionHeartbeatFeature>();
    if (feature == null)
    {
        await _usersService.AddUserAsync(Context.ConnectionId, Context.User.SubId(), Context.User.UserName());
        await base.OnConnectedAsync();
        return;
    }

    var context = Context.GetHttpContext();
    if (context == null)
    {
        throw new InvalidOperationException(„The HTTP context cannot be resolved.“);
    }

    var result = await context.AuthenticateAsync(IdentityServerAuthenticationDefaults.AuthenticationScheme);
    if (result.Ticket == null)
    {
        Context.Abort();
        return;
    }

    var expiresClaim = result.Ticket.Principal.FindFirst(JwtClaimTypes.Expiration);
    if (!long.TryParse(expiresClaim.Value, out var expiresValue))
    {
        Context.Abort();
        return;
    }
    var expires = DateTimeOffset.FromUnixTimeSeconds(expiresValue);

    feature.OnHeartbeat(state =>
    {
        var (innerExpires, connection) = ((DateTimeOffset, HubCallerContext))state;
        if (innerExpires < DateTimeOffset.UtcNow)
        {
            connection.Abort();
        }
    }, (expires, Context));
    
    await _usersService.AddUserAsync(Context.ConnectionId, Context.User.SubId(), Context.User.UserName());
    await base.OnConnectedAsync();
}
				
			

Im ersten Teil des obigen Codes wird geprüft, ob der notwendige Service IConnectionHeartbeatFeature vorhanden ist. Mit diesem Service ist es möglich, sich an ein Heartbeat-Event zu abonnieren. Das Heartbeat-Event prüft in einem bestimmten Intervall, ob die Verbindung zwischen Client und Server noch vorhanden ist. Dadurch haben wir die Möglichkeit, bei jedem Heartbeat die aktuelle Connection bzw. das aktuelle Token zu überprüfen. In den nächsten beiden Schritten, wird geprüft, ob ein Token vorhanden ist. Ist ein Token vorhanden, wird geprüft, ob ein Ablaufdatum existiert. Sollte kein Token vorhanden sein oder das Token kein Ablaufdatum haben, wird die Verbindung vom Server abgebrochen. Ist ein Token mit Ablaufdatum vorhanden, wird dieses in der Variable expires gespeichert. So können wir nun das Datum bei jedem Heartbeat überprüfen, ob das Token noch valide ist. Ist das Token abgelaufen, wird die Verbindung vom Server mit dem Methoden Aufruf connection.Abort(); abgebrochen.

Änderungen im Client-Code

Wie wir im ersten Artikel schon gesehen haben, bietet uns die Library @microsoft/signalr die Methode withAutomaticReconnect. Wird die Verbindung vom Server abgebrochen, versucht der Client sich erneut mit dem Server zu verbinden. Die Library angular-oauth2-oidc, welche wir im zweiten Artikel eingesetzt haben um uns am IdentityServer zu Authentifizieren, bietet uns die Möglichkeit über den OAuthService mit der Methode getAccessToken() das aktuelle Token zu übergeben. Danach sollte die Verbindung wieder erfolgreich aufgebaut werden können. Schauen wir uns hierzu nochmal die Methode startConnection der Klasse signal-r.service.ts des Angular Clients nochmal an.

				
					public async startConnection(): Promise<void> {
  this.hubConnection = new HubConnectionBuilder()
          .withUrl(`${environment.apiBaseUrl}tictactoe`, {
            accessTokenFactory: () => this.oAuthService.getAccessToken()
          })
          .withAutomaticReconnect([0, 5000, 10000])
          .build();
  //...
};
				
			

Wie wir im obigen Code sehen, wird an die accessTokenFactory das aktuelle Token übergeben. Sollte die Verbindung nun abbrechen, wird durch den konfigurierten Reconnect versucht, die Verbindung wiederherzustellen. Da das Token nur beim Aufbau einer Verbindung mit gesendet wird, und danach nicht mehr, muss beim Reconnect über die accessTokenFactory das aktuelle Token übergeben werden. Dadurch ist das Token wieder aktuell und der Client kann eine sichere Verbindung zur API aufbauen.

Fazit

Im Artikel haben wir gesehen das JWT-Tokens uns einen Performance-Vorteil geben, da nur einmalig mit dem IDP kommuniziert werden muss. Dadurch haben wir aber auch gleichzeitig den Nachteil, dass wir nicht direkt auf Änderungen von Rechten reagieren können. Mit Referenztoken können wir diesen Nachteil beheben, da die API immer eine Verbindung zum IDP aufbauen muss, um das Token zu validieren. Jedoch muss hier darauf geachtet werden, dass der Server ausreichend Kapazitäten (CPU, RAM) besitzt, um die steigende Anzahl an Anfragen schnell beantworten zu können. Da es sonst bei hoher Anfrage schnell zu Performance-Problemen kommen kann.

Im zweiten Teil stellten wir fest, dass es wichtig ist die Aktualisierung des Tokens im Auge zu behalten, da sich auch während der Laufzeit des Tokens die Rechte eines Clients oder Users ändern können. Aus diesem Grund kann es bei dem Versuch eine neue Verbindung aufzubauen dazu kommen, dass diese abgelehnt wird. Daher ist es wichtig, auch während einer offenen WebSocket-Verbindung das Token zu validieren, um auf Änderungen schnellstmöglich reagieren zu können.

Der gesamte Source Code zu diesem Artikel befindet sich im zugehörigen GitHub Repository.

Mehr Artikel zu SignalR, Angular, ASP.NET Core
Kostenloser
Newsletter

Aktuelle Artikel, Screencasts, Webinare und Interviews unserer Experten für Sie

Verpassen Sie keine Inhalte zu Angular, .NET Core, Blazor, Azure und Kubernetes und melden Sie sich zu unserem kostenlosen monatlichen Dev-Newsletter an.

Newsletter Anmeldung
Diese Artikel könnten Sie interessieren
Low-angle photography of metal structure
AI
cl-neu

AI-Funktionen zu Angular-Apps hinzufügen: lokal und offlinefähig

Künstliche Intelligenz (KI) ist spätestens seit der Veröffentlichung von ChatGPT in aller Munde. Wit WebLLM können Sie einen KI-Chatbot in Ihre eigenen Angular-Anwendungen integrieren. Wie das funktioniert und welche Vor- und Nachteile WebLLM hat, lesen Sie hier.
26.02.2024
Database Access with Sessions
.NET
KP-round

Data Access in .NET Native AOT with Sessions

.NET 8 brings Native AOT to ASP.NET Core, but many frameworks and libraries rely on unbound reflection internally and thus cannot support this scenario yet. This is true for ORMs, too: EF Core and Dapper will only bring full support for Native AOT in later releases. In this post, we will implement a database access layer with Sessions using the Humble Object pattern to get a similar developer experience. We will use Npgsql as a plain ADO.NET provider targeting PostgreSQL.
15.11.2023
Old computer with native code
.NET
KP-round

Native AOT with ASP.NET Core – Overview

Originally introduced in .NET 7, Native AOT can be used with ASP.NET Core in the upcoming .NET 8 release. In this post, we look at the benefits and drawbacks from a general perspective and perform measurements to quantify the improvements on different platforms.
02.11.2023
Angular
SL-rund

Konfiguration von Lazy Loaded Angular Modulen

Die Konfigurierbarkeit unserer Angular-Module ist für den Aufbau einer wiederverwendbaren Architektur unerlässlich. Aber in der jüngsten Vergangenheit hat uns Angular seine neue modullose Zukunft präsentiert. Wie sieht das Ganze jetzt aus? Wie konfigurieren wir jetzt unsere Lazy-Komponenten? Lasst uns gemeinsam einen Blick darauf werfen.
03.08.2023
ASP.NET Core
favicon

Architektur-Modernisierung: Migration von WCF zu gRPC mit ASP.NET Core – ein pragmatischer Ansatz

Viele Projekte mit verteilten Anwendungen in der .NET-Welt basieren noch auf der Windows Communication Foundation (WCF). Doch wie kommt man weg von der "Altlast" und wie stellt man seinen Code auf sowohl moderne als auch zukunftssichere Beine? Eine mögliche Lösung ist gRPC.

13.04.2023
Blazor
favicon

gRPC Code-First mit ASP.NET Core 7 und Blazor WebAssembly

Wie in allen anderen browserbasierten Single-Page-Application (SPA) Frameworks, ist Blazor WebAssembly JSON-over-HTTP (über Web- oder REST-APIs) die bei weitem häufigste Methode, um Daten auszutauschen und serverseitige Vorgänge auszulösen. Der Client sendet eine HTTP-Anfrage mit JSON-Daten an eine URL, mitunter über unterschiedliche HTTP-Verben. Anschließend führt der Server eine Operation aus und antwortet mit einem HTTP-Statuscode und den resultierenden JSON-Daten. Warum sollte man das ändern? Nun, es gibt Gründe - vor allem wenn man in einem geschlossenen System ist und .NET sowohl im Frontend als auch im Backend einsetzt.
30.03.2023