gRPC Tutorial Teil 6: JWT Client Credentials
In diesem Blog wird erläutert, wie die Authentifizierung und Autorisierung mit JSON Web Token (JWT) funktioniert. Der einfachste OAuth2-Flow, der Client Credential Flow, wird anhand eines Beispiels mit einem gRPC-Client / Server und einem minimalistischen Tokenserver erläutert.
JSON Web Token (JWT)
JSON Web Token (JWT) ist ein auf JSON basiertes und nach RFC 7519 genormtes Access-Token und wird zur Authentifizierung und Autorisierung eines Benutzers verwendet. Die Informationen im Token können überprüft und als vertrauenswürdig eingestuft werden, da sie digital signiert sind. JWTs können symmetrisch, mit einem geheimen Schlüssel (‘HS256’: HMAC mit SHA-256), oder asymmetrisch mit einem Public- / Private-Schlüsselpaar mit RSA (‘RS256’: RSA Signatur mit SHA-256) signiert werden (‘RS256’).
Man sollte sich bewusst sein, dass ein JWT nicht verschlüsselt ist und daher immer über eine sichere Verbindung gesendet werden sollte.
Ein JWT besteht aus drei Teilen: Header, Payload und Signatur. Alle drei Teile sind Base64 kodiert und durch einen Punkt getrennt.
Header.Payload.Signatur
Beispiel eines JWT:
Dekodiert vom Base64 Format ergibt das Beispiel oben:
JWT Header
Der Header hat üblicherweise nur zwei Felder, wovon das Feld ‘typ’ auf ‘JWT’ gesetzt ist. Das Feld ‘alg’ beschreibt, welche Verschlüsselungsmethode für die Signierung zum Einsatz kommt: ‘HS256’ für symmetrische Verschlüsselung oder ‘RS256’ (RSA mit SHA-256) für asymmetrische Verschlüsselung.
Beispiel:
{ "alg": "HS256", "typ": "JWT" }
JWT Payload
Die Payload-Sektion enthält die Claims, d.h. die zu übermittelnden Informationen.
Beispiel:
{ "iat": 1579623173, "jti": "de84fbc9-e47e-43f2-82ec-f03b05416450", "exp": 1579624973, "iss": "https://localhost:5001", "aud": "DemoService" }
Einige oft verwendete Claims:
iss | Issuer | der Aussteller des Tokens |
sub | Subject | definiert für wen oder was die Claims getätigt werden |
aud | Audience | die Zieldomäne, für die das Token ausgestellt wurde, bei uns die API |
exp | Expiration Time | Das Ablaufdatum des Tokens in Unixzeit, also der Anzahl der Sekunden seit 1970-01-01T00:00:00Z (‘Epoch time’) |
nbf | Not Before | Die Unixzeit, ab der das Token gültig ist |
iat | Issued At | Die Unixzeit, zu der das Token ausgestellt wurde |
jti | JWT ID | Eindeutige ID die Mehrfachverwendung des Tokens verhindert |
JWT Signatur
Die Signatur wird dadurch erzeugt, dass Header und Payload im Base64 kodierten und durch einen Punkt getrennten Format mit der spezifizierten Hashmethode gehashed wird:
var encodedString = base64UrlEncode(header) + "." + base64UrlEncode(payload); var hash = HMACSHA256(encodedString, secret);
Online JWT validieren/dekodieren: jwt.io
Die Seite jwt.io stellt den Inhalt eines JWT dar, das man einfach im linken Fenster (‘Encoded’) einfügen kann. Wenn man den Schlüssel besitzt, kann man diesen im Feld ‘Decoded / VERIFY SIGNATURE’ einfügen. Die Seite zeigt dann, ob die Signatur gültig ist.
JWT generieren und validieren/dekodieren
Mit dem NuGet-Paket ‘Microsoft.IdentityModels.Tokens.JWT’ kann man JWT generieren und validieren/dekodieren.
Beispiel:
public string CreateJwtToken() { var claims = new[] { new Claim(JwtRegisteredClaimNames.Iat, EpochTime.GetIntDate(DateTime.Now).ToString(), ClaimValueTypes.Integer64), new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), }; var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes("D9B31A4E97C40EC47D758490C801FFCDD39BCE3EF5E9C978E4FF9D34FA0F1967")); var signingCredentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); var jwtSecurityToken = new JwtSecurityToken( "https://localhost:5001", "DemoService", claims, expires: DateTime.Now.AddMinutes(30), signingCredentials: signingCredentials); return new JwtSecurityTokenHandler().WriteToken(jwtSecurityToken); } public ClaimsPrincipal ValidateDecodeJwtToken(string token) { var validationParams = new TokenValidationParameters() { ValidIssuer = "https://localhost:5001", ValidAudience = "DemoService", IssuerSigningKey = new SymmetricSecurityKey( Encoding.UTF8.GetBytes("D9B31A4E97C40EC47D758490C801FFCDD39BCE3EF5E9C978E4FF9D34FA0F1967")) }; var handler = new JwtSecurityTokenHandler(); SecurityToken securityToken; return handler.ValidateToken(token, validationParams, out securityToken); }
OAuth 2.0
OAuth 2.0 ist ein offenes Protokoll, welches eine standardisierte, sichere API-Autorisierung für Desktop-, Web- und Mobile-Anwendungen erlaubt. Die erste Version OAuth 1.0 wurde in 2007 veröffentlicht, die aktuelle Version OAuth 2.0 in 2010.
Der Ressourcenbesitzer kann mit Hilfe dieses Protokolls einer Anwendung (Client) den Zugriff auf seine Daten zulassen (Autorisierung), die von einem anderen Dienst (Resourcen-Server) bereitgestellt werden, ohne geheime Details seiner Zugangsberechtigung (Authentifizierung) dem Client preiszugeben. Der Ressourcenbesitzer kann so Dritten gestatten, in seinem Namen einen Dienst zu benutzen. Typischerweise wird dabei die Übermittlung von Passwörtern an Dritte vermieden.
OAuth 2.0 ist zu vergleichen mit einem Parkservice: der Ressourcenbesitzer gibt das Auto (die Ressource) an den Parkservice (Client) weiter. Er gibt dem Parkservice den Parkservice-Schlüssel. Moderne amerikanische Autos haben zwei Arten von Schlüsseln: einen Hauptschlüssel und einen Parkservice-Schlüssel mit eingeschränkten Möglichkeiten (z. B. eingeschränkte Kilometer, Kofferraum kann nicht geöffnet werden). In der Computerwelt ist der Hauptschlüssel Ihr Benutzername mit Kennwort (mit vollem Zugriff). Bei OAuth2 geht es darum, Clients einen Schlüssel für Ihre Ressourcen mit beschränktem Zugriff (das JWT-Token) zu geben, ohne den Hauptschlüssel herausgeben zu müssen.
In OAuth 2.0 gibt es 4 Arten von ‘Flows’. Die einfachste Art, der Client Credential Flow, wird in diesem Blog behandelt.
Client Credential Flow
Beim Client Credential Flow meldet der Client sich beim Identity-Server mit seinem ‘Secret’ (z.B. Benutzername und Passwort) an. Der Identity-Server authentifiziert den Benutzer und gibt ein JWT zurück. Dieses JWT enthält u.a. Zugriffsberechtigungen (z.B. Resourcen und Rolle) und ist signiert mit einem symmetrischen Key (‘HS256’) oder dem privaten Schlüssel eines Public-/Private-Key Paares (‘RS256’).
Der Client sendet jetzt bei jeder Resourcen-Server-Anfrage das JWT mit; entweder im HTTP-Header oder in der URL. Der Resourcen-Server validiert das Token mit Hilfe der Signatur und dem privaten symmetrischen Schlüssel (ist beim Resourcen-Server bekannt). Bei einer RS256 Signatur holt der Resourcen-Server über die Issuer-URL im JWT beim Identity-Server den Public-Key zur Validierung ab. Wenn die Validierung des JWTs in Ordnung ist, darf der Client auf die Resourcen zugreifen.
Beispiel: Client Credential Flow
Mit diesem Beispiel ‘RpcJwtClientCredentials’ wird die einfachste Art von OAuth 2.0, der Client Credential Flow, demonstriert. Der Quellcode kann hier als ZIP-Datei heruntergeladen werden.
Weil das JWT nicht verschlüsselt ist, muss die Kommunikation zwingend über eine gesicherte Verbindung laufen. Die Projekte im Beispiel benutzen den Kestrel-Server mit gesicherter Verbindung wie beschrieben in ‘gRPC Tutorial Teil 4: HTTP/2 über HTTPS’.
Obwohl dieses Beispiel mit einem gRPC-Server arbeitet, kann der gleiche Code auch zur Absicherung eines REST-Servers verwendet werden.
Die Solution hat 3 Teilnehmer:
- Der Client, welcher auf die Resourcen zugreifen möchte
- Der Identity-Server, der im Namen des Resourcenbesitzers ein JWT generiert
- Der Resourcen-Server, welcher die Resourcen bereitstellt.
Identity-Server
Im Identity-Server wird mit CreateToken() der Benutzername und das Passwort validiert. Wenn der Benutzer bekannt ist und das Passwort stimmt, wird ein JWT erstellt und zurückgegeben.
Hier ist der Code der IdentityController-Klasse:
[Route("~/", Name = "default")] [ApiController] public class IdentityController : ControllerBase { private readonly IServerUtil _serverUtil; private readonly ILogger<IdentityController> _logger; public IdentityController( ILogger<IdentityController> logger, IServerUtil serverUtil) { _serverUtil = serverUtil; _logger = logger; } [HttpGet] public string CreateToken(string username, string password) { string token = null; if (!_serverUtil.IsUserValid(username, password)) { _logger.LogError($"Invalid User ('{username}') or password supplied."); } else { token = _serverUtil.CreateJwtToken(username, password); _logger.LogInformation($"Generated toke for User '{username}'. Token {token}"); } return token; } }
Die Datei appsettings.json enthält die Werte zur Generierung des JWTs und zur Verschlüsselung der Verbindung (‘Certificate’, siehe ‘gRPC Tutorial Teil 4: HTTP/2 über HTTPS’).
{ … "JwtToken": { "Issuer": "https://localhost:5001", "Key": "D9B31A4E97C40EC47D758490C801FFCDD39BCE3EF5E9C978E4FF9D34FA0F1967", "Audience": "DemoService" }, "Certificate": { "File": "server.pfx", "Password": "P@ssw0rd" } }
Die Schnittstelle IServerUtil definiert die Methoden zur Validierung und JWT Generierung:
public interface IServerUtil { string CreateJwtToken(string username, string password); bool IsUserValid(string username, string password); }
Hier die Implementierung:
public class ServerUtil : IServerUtil { private readonly IConfiguration _config; private readonly ILogger<ServerUtil> _logger; public ServerUtil(IConfiguration config, ILogger<ServerUtil> logger) { _config = config; _logger = logger; } public string CreateJwtToken(string username, string password) { string token = null; try { var claims = new[] { new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), new Claim(JwtRegisteredClaimNames.UniqueName, username) }; var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_config["JwtToken:Key"])); var signingCredentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); var jwtSecurityToken = new JwtSecurityToken( _config["JwtToken:Issuer"], _config["JwtToken:Audience"], claims, expires: DateTime.Now.AddMinutes(30), signingCredentials: signingCredentials); token = new JwtSecurityTokenHandler().WriteToken(jwtSecurityToken); } catch (Exception e) { _logger.LogError(e.ToString()); } return token; } public bool IsUserValid(string username, string password) { // Dummy implementation: validate user credentials here return username=="Demo" && password=="P@ssw0rd"; } }
Die Validierung der User Credentials passiert in IsUserValid() und ist nicht implementiert. Nur der Benutzer ‘Demo’ mit Passwort ‘P@ssw0rd’ ist berechtigt, auf den Resourcen-Server zuzugreifen.
Das JWT wird mit der Funktionalität aus dem NuGet ‘System.IdentityModel.Tokens.Jwt’ für die Resource ‘DemoService’ erstellt (‘Audience’). Für Demozwecke werden ein paar Claims im Token festgelegt, welche durch den Resourcen-Server validiert werden können. Signiert wird mit dem symmetrischen Schlüssel ‘Key’ definiert in appsettings.json.
In Startup.cs wird die Implementation von IServerUtil am IoC-Container hinzugefügt:
public void ConfigureServices(IServiceCollection services) { services.AddTransient<IServerUtil, ServerUtil>(); services.AddControllers(); }
Nach dem Starten des Identity-Servers kann folgendermassen ein JWT abgeholt werden:
Der Output vom Identity-Server sieht folgendermassen aus:
Wenn man jetzt in jwt.io das JWT zusammen mit dem privaten Schlüssel einfügt, wird folgendes angezeigt:
Resourcen-Server
Im Resourcen-Server wird der Service wie bei REST mit dem Attribut [Authorize] abgesichert.
In DemoServiceRpc.cs befindet sich die Implementation des gRPC Demo-Service:
[Authorize] public class DemoServiceRpc : DemoService.DemoServiceBase { public override Task<NameMessage> ExchangeNames(NameMessage request, ServerCallContext context) { NameMessage result = new NameMessage { Name = "Hello from Server" }; return Task.FromResult(result); } }
Damit die Signatur und die einzelnen Claims kontrolliert werden können, muss der Resourcen-Server den symmetrischen Schlüssel und die zu erwarteten Inhalte der Claims kennen.
Diese werden in appsettings.json definiert. Auch hier wieder die Sektion ‘Certificate’ zum Konfigurieren des Kestrel-Servers für eine gesicherte Verbindung.
Der relevante Teil von appsettings.json:
{ "Logging": { "LogLevel": { "Default": "Information", "Microsoft": "Warning", "Microsoft.Hosting.Lifetime": "Information", "grpc": "Debug", "JwtBearerAuthentication": "Debug" } }, "AllowedHosts": "*", "JwtBearerAuth": { "Audience": "DemoService", "Issuer": "https://localhost:5001", "Key": "D9B31A4E97C40EC47D758490C801FFCDD39BCE3EF5E9C978E4FF9D34FA0F1967" }, "Certificate": { "File": "server.pfx", "Password": "P@ssw0rd" } }
Die Magie wird in Startup.cs konfiguriert. Mit AddAuthentication wird JWT-Bearer Authentication konfiguriert durch Zuweisen von ‘Bearer’ an DefaultAuthenticateScheme und DefaultChallengeScheme. Die Signatur wird validiert mit dem symmetrischen Schlüssel (‘IssuerSigningKey’) und die Claims ‘Issuer’ und ‘Audience’ werden verglichen mit den Werten wie in appsettings.json konfiguriert.
public class Startup { public Startup(IConfiguration configuration) { Configuration = configuration; } public IConfiguration Configuration { get; } public void ConfigureServices(IServiceCollection services) { services.AddAuthentication(cfg => { // Simply holds "Bearer" cfg.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; cfg.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; }) .AddJwtBearer(options => { options.SaveToken = true; options.RequireHttpsMetadata = false; options.TokenValidationParameters = new TokenValidationParameters() { ValidateIssuer = true, ValidateAudience = true, ValidAudience = Configuration["JwtBearerAuth:Audience"], ValidIssuer = Configuration["JwtBearerAuth:Issuer"], IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(Configuration["JwtBearerAuth:Key"])) }; options.Events = new JwtBearerEvents() { OnAuthenticationFailed = c => { _logger.LogDebug($"Exception: {c.Exception}"); return Task.CompletedTask; } }; }); services.AddAuthorization(); services.AddControllers(); services.AddGrpc(opt => { opt.EnableDetailedErrors = true; }); } public void Configure(IApplicationBuilder app, IWebHostEnvironment env, ILoggerFactory loggerFactory) { _logger = loggerFactory.CreateLogger("JwtBearerAuthentication"); … app.UseRouting(); app.UseAuthentication(); app.UseAuthorization(); app.UseEndpoints(endpoints => { endpoints.MapGrpcService<DemoServiceRpc>(); endpoints.MapControllers(); }); } }
Wenn die Validierung des JWTs fehlschlägt werden standardmässig keine weiteren Informationen ausgegeben. Durch Installieren eines Eventhandlers auf ‘OnAuthenticationFailed()’ in der Sektion ‘options.Events’, kann man schnell die Ursache der Validierungsprobleme ausfindig machen.
Der Client
Bevor der gRPC-Client erstellt wird, wird ein JWT Token beim Identity-Server angefragt. Der Code befindet sich in worker.cs:
private async Task<bool> GetToken() { if (string.IsNullOrEmpty(_token) == false) { return true; } try { using var identityClient = new HttpClient(); string identityUri = $"{_config["IdentityServer:Authority"]}?username={_config["Client:Username"]}&password={_config["Client:Password"]}"; _token = await identityClient.GetStringAsync(identityUri).ConfigureAwait(false); } catch (Exception e) { _logger.LogCritical(e, "Exception while getting JWT"); return false; } return true; }
Das JWT wird bei jeder Anfrage im Header mitgesendet. Dazu wird das HTTP-Headerfeld ‘Authorization’ auf ‘Bearer [JWT]’ gesetzt.
... _logger.LogInformation("Calling 'ExchangeNames()' at: {time}", DateTimeOffset.Now); NameMessage nameMessage = new NameMessage { Name = $"{Assembly.GetExecutingAssembly().FullName}" }; string tokenValue = $"Bearer {_token}"; var metadata = new Metadata() { { "Authorization", tokenValue } }; CallOptions callOptions = new CallOptions(metadata); var result = await _client.ExchangeNamesAsync(nameMessage, callOptions); _logger.LogInformation($"Server answered with {result.Name}"); ...
Wenn beispielsweise der Inhalt des JWT unterwegs manipuliert wird, generiert der Client folgende Ausgabe:
Dank des OnAuthenticationFailed Handlers im Resourcen-Server findet man jetzt einfach den Grund des Problems heraus:
Fazit
Obwohl der einfachste OAuth2 Flow, der Client Credential Flow, mit symmetrischem Schlüssel verwendet wird, zeigt dieser Blog, dass die Absicherung mit JWT nicht ganz trivial ist. Im nächsten Blog wird der IdentityServer4 verwendet, immer noch mit Client Credential Flow, aber mit Public-/Private-Key Authentifizierung und Rollen.