Artikelserie
- Echtzeitkommunikation in Action
- Absicherung der Echtzeitkommunikation
- Mehr Sicherheit bei Tokens ⬅
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();
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 {
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.