Web-Anwendungen sind in aller Munde und damit auch die damit verbundenen Technologien. Eine Sache, um die man sich da dann immer kümmern muss, ist die Sicherheit. Grundsätzlich ist im Internet alles für jeden zugänglich. Daher muss man sich hier besonders um dieses Thema Gedanken machen.
Eine damit verbundene Technologie ist die Authentifizierung mit einem Bearer-Token oder JWT-Token.
JWT steht dabei für JSON Web Token und ist ein gängiges Format um einen Token zwischen einem Server und einem Client auszutauschen. Das Format des Tokens ist grundsätzlich JSON und damit sieht man schon, dass das System aus der Javascript-Welt kommt.

Graue Theorie

Die Technik funktioniert folgendermaßen:
In der WebAPI gibt es einen offenen Endpunkt, über den sich ein Client authentifizieren kann. Zum Beispiel mit Benutzername und Passwort. Der Endpunkt liefert dem Client dann einen codierten Token zurück in dem dann unter Umständen auch noch weiter nützliche Informationen zum angemeldeten Benutzer enthalten sind.
Diesen Token muss dann der Client bei jedem Request im Auth-Header mit dem Schlüsselwort Bearer an den Server schicken, so dass der Server erkennen kann, dass dieser Request auch den entsprechenden Endpunkt verwenden darf. Wenn der Token fehlt, ungültig ist oder abgelaufen ist, dann liefert der Server einen entsprechenden Fehler zurück.

Wie ist ein JWT-Token aufgebaut?

Ein Token besteht immer aus drei Teilen:

Header

Hier stehen einige allgemeine Informationen zum Token, also um welchen Typ es sich handelt und welcher Algorhytmus in diesem Token verwenden wird. Das Ganze ist ein einfaches JSON Objekt, das mit base64 codiert ist. Um diesen Teil muss man sich meist keine oder nur wenig Gedanken machen, da dieser Teil in er Regel von den Frameworks automatisch verwaltet wird.

Payload

Der zweite Teil ist schon interessanter: Hier stehen die Claims, die man im Server festlegen kann. Also die - oben erwähnten - weiteren nützlichen Informationen. Auch hier handelt es sich lediglich um ein JSON-Objekt, das nur mit base64 codiert wurde und damit auch jeder Zeit vom Client wieder gelesen werden kann. Daher ganz wichtig: Niemals vertrauliche Daten in den Claims ablegen.
Zusätzlich stehen hier auch ein paar Standard-Informationen zum Token, wie zum Beispiel der Aussteller (Issuer), wie lange der Token gültig ist und so weiter.

Signature

Der dritte und letzte Teil ist dann eigentlich das Kernstück des Ganzen. Hier findet man die digitale Signatur, die aus den ersten beiden Teilen und einem SecretKey, den nur der Server kennt, erstellt wird. Das heißt, dass damit geprüft wird, ob der Inhalt der ersten beiden Teile valide ist und allgemein der Token erlaubt ist.

Beispiel

Hier haben wir ein kleines Beispiel, wie ein Token aussehen kann:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2MTQ5NzI3MzYsImlzcyI6IkRhc2hib2FyZCIsImF1ZCI6IkRhc2hib2FyZCJ9.vKTNevLFQtU_-Qk_202Jy17zoncVRjwPxczo96RC3Jg

Zum Decodieren von base64 zu einem lesbaren String verwende ich folgende Methode:


static string decodeBase64(string value)
{
    Byte[] bytes = Convert.FromBase64String(value);
    return Encoding.UTF8.GetString(bytes);
}

Die einzelnen Teile sind immer durch einen Punkt getrennt. Man kann also den ersten Teil nehmen und dekodieren:

{"alg":"HS256","typ":"JWT"}

Der zweite Teil sieht so aus:

{"exp":1614972736,"iss":"Dashboard","aud":"Dashboard"}

Da das Decodieren der Tokens recht einfach ist, gibt es auch im Internet eine große Auswahl an Seiten, auf denen man einen Token decodieren und prüfen kann.
Hier ein Beispiel: https://www.jsonwebtoken.io

Umsetzung in .NET Core WebAPI

In der WebAPI unterstützt uns das Baukasten-System des Frameworks sehr stark bei der Implementierung der JWT-Authentifizierung. Im Grunde muss man nur die notwendige Middleware in der startup.cs konfigurieren und aktivieren.
Wie sieht das aus:

Zuerst benötigen wir das folgende Nuget Package: Microsoft.AspNetCore.Authentication.JwtBearer
Falls es noch nicht installiert ist, müssen wir das über den Nuget-Paketmanager installieren.

Dann fügen wir in ConfigureServices() die Middleware für die Authentifizierung dazu:


var authBuilder = services.AddAuthentication(opt => {
    opt.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
    opt.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
});

Damit ist schonmal klar, dass für die Authentifizierung der JwtBearer verwendet werden soll. Diesen muss man aber jetzt noch konfigurieren und festlegen, welche Bestandteile davon für eine Gültigkeit geprüft werden sollen.


authBuilder.AddJwtBearer(options =>
{
    options.TokenValidationParameters = new TokenValidationParameters
    {
        ValidateIssuer = true,
        ValidateAudience = true,
        ValidateLifetime = true,
        ValidateIssuerSigningKey = true,
        ValidIssuer = "https://authserver.mydomain.com",
        ValidAudience = "https://application.mydomain.com",
        IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes("superSecretKey@345"))
    };
});

in diesem Fall ist das ganz einfach gehalten mit einem super sicheren SecretKey. Der gehört natürlich in einem echten System nicht hier her, sondern in einen verschlüsselten KeyStore oder etwas Ähnlichem. Auch die Werte für Issuer und Audience sind hier nur dummy Beispiel, damit man sieht, was es geben würde. Normalerweise steht im Issuer die Anwendung, die den Token ausstellt. Also der Login-Server zum Beispiel, wenn das System getrennt ist. In Audience dagegen steht die Anwendung, die den Token verwendet. In einfachen Anwendungsfällen haben diese Felder nur informativen Character. Aber auf großen geteilten Systemen kann man hier noch eine zusätzliche Sicherheitsebene damit aufbauen.
Die einzelnen Properties sind im Grunde selbsterklärend bzw. weiterführenden Informationen zu den Möglichkeiten kann man der Microsoft-Hilfe entnehmen.

Zu guter Letzt muss man die Middleware noch in der Methode configure() aktivieren:


...
app.UseRouting();
app.UseAuthentication(); // <--- hier
app.UseAuthorization();
...

Jetzt muss nur noch die Endpunkte bzw. Controll mit einem Attribut versehen, damit das System weiß, dass für diese Endpunkte eine Authentifizierung notwendig ist. Dazu fügt man einfach bei den Controller-Klasse oder bei den Endpunkten das Attribut [Authorize] dazu.

Beispiel:


[ApiController]
[Route("[controller]")]
[Authorize]
public class WeatherForecastController : ControllerBase
{
    ...
}

Das werden wir später noch weg rationalisieren, so dass standardmäßig alle Endpunkte eine Authentifizierung benötigen.

Endpunkt zum Authentifizieren

Jetzt brauchen wir noch einen Endpunkt, über den man sich überhaupt authentifizieren kann, damit man dann auch einen JWT-Token bekommt, den man für die anderen Endpunkte dann verwenden kann.

Wir legen uns dazu einen neuen Controller mit dem Namen AuthController an und in diesem einen Endpunkt Login


[Route("api/[controller]")]
[ApiController]
public class AuthController : ControllerBase
{
    [HttpPost]
    [Route("login")]
    public IActionResult Login([FromBody] LoginModel user)
    {

    }
}

Ich verwende hier ein ViewModel für die Login-Daten. Das Model ist ganz einfach aufgebaut:


public class LoginModel
{
    public string Username { get; set; }
    public string Password { get; set; }
}

Was muss jetzt eigentlich hier passieren? Eigentlich ganz einfach: Es muss geprüft werden, ob die Login-Daten korrekt sind und falls ja, muss ein Token erstellt werden, der die notwendigen Informationen enthält.
Das Ganze kann dann zum Beispiel so aussehen:


[HttpPost]
[Route("login")]
public IActionResult Login([FromBody] LoginModel user)
{
    if (user == null)
        return BadRequest("Invalid client request");

    if (user.Username == "username" && user.Password == "password")
    {
        var secretKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes("supersecretkeyyy"));
        var signinCredentials = new SigningCredentials(secretKey, SecurityAlgorithms.HmacSha256);

        var tokenOptions = new JwtSecurityToken(
            issuer: "Dashboard",
            audience: "Dashboard",
            claims: new List<Claim>(),
            expires: DateTime.Now.AddMinutes(5),
            signingCredentials: signinCredentials
        );
        var tokenString = new JwtSecurityTokenHandler().WriteToken(tokenOptions);
        return Ok(new { Token = tokenString });
    }
    else
        return Unauthorized();
}

Wichtig: Es gibt hier nicht das Attribut [Authorize], weil man sich ja mit diesem Endpunkt ohne Token authentifizieren will.

Wir verwenden also unseren super sicheren SecretKey und erstellen daraus ein Credentials-Objekt. Dann werden die Properties des Tokens mit unseren Daten befüllt, die wir benötigen. Unter anderem eben auch die oben erwähnten Claims, also weitere nützliche Informationen, die der Client auch auslesen kann und natürlich die Gültigkeit, wie lange der Token gültig sein soll. Hier nur 5 Minuten.
Über die Methode WriteToken wird letztendlich der Token dann erstellt und kann im Result an den Client zurück gegeben werden.

Swagger konfigurieren

Wenn man in seinem Projekt Swagger verwendet (wenn man das Projekt über die Konsole mit dotnet new webapi erstellt hat, ist Swagger automatisch vorhanden), wird man feststellen, dass man jetzt die Endpunkte in Swagger nicht mehr testen kann.
Dazu muss man Swagger erst erklären, wie man sich authentifizieren kann und das entsprechend in der startup.cs konfigurieren. Dazu fügt man in ConfigureServices eine SecurityDefinition und ein SecurityRequirement zu Swagger hinzu:


services.AddSwaggerGen(options =>
{
    options.SwaggerDoc("v1", new OpenApiInfo { Title = "DashboardApi", Version = "v1" });
    options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
    {
        In = ParameterLocation.Header,
        Name = "Authorization",
        Scheme = "Bearer"
    });
    options.AddSecurityRequirement(new OpenApiSecurityRequirement()
    {
        {
            new OpenApiSecurityScheme
            {
                Reference = new OpenApiReference
                {
                    Type = ReferenceType.SecurityScheme,
                    Id = "Bearer"
                }
            },
            new List<string>()
        }
    });
});

Jetzt gibt es auf der Swagger-Seite einen neuen Button Authorize über den man dann den Bearer Token eintragen kann.
Man führt zuerst den Login-Endpunkt mit seinen Benutzerdaten aus und kopiert sich das Result in den Zwischenspeicher. Dann clickt man den Button Authorize und trägt hier bei Value folgendes ein:

Bearer xyz

Wobei xyz natürlich der davor erstellte Token sein soll.

Standardmäßig gesperrt

Eigentlich funktioniert jetzt alles, wie es soll. Aber es gibt einen kleinen Nachteil, der durch eine kleine Unachtsamkeit gleich eine Sicherheitslücke öffnen kann. Man muss jetzt nämlich jeden Controller bzw. jeden Endpunkt mit dem AutorizeAttribute versehen, damit die Authentifizierung überhaupt greift.
Das ist relativ unschön, weil man ja eigentlich davon ausgeht, dass möglichst alles gesperrt sein sollte und nur die Endpunkte zum Anmelden öffentlich zugänglich sein sollten.

In früheren Versionen von .Net Core musste man dafür einen globalen AuthorizationFilter registrieren und darin das entsprechende Attribut quasi automatisch setzen. Ausgenommen wurden von diesem Filter lediglich die Endpunkte/Controller, die man explizit mit einem AllowAnonymousAttribute versehen hat.
Seit .Net Core 3.1 geht das zum Glück auch einfacher. Man muss jetzt lediglich in Configure() bei der Endpoint-Configuration sagen, dass die Endpunkte eine Authentifizierung benötigen sollen.

Das sieht dann so aus:


public void Configure(...)
{
    ...
    app.UseEndpoints(endpoints =>
    {
        endpoints
            .MapControllers()
            .RequireAuthorization(); // <--- hier
    });
    ...
}

Damit man jetzt den Login-Endpunkt allerdings verwenden kann, muss man diesen mit dem Attribut AllowAnonymous versehen:


[HttpPost]
[Route("login")]
[AllowAnonymous]
public IActionResult Login([FromBody] LoginModel user)
...