﻿# 如何在 ASP.NET Core 6 使用 Token-based 身份認證與授權 (JWT)

ASP.NET Core 6 提供了一組 [Minimal APIs](https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis?view=aspnetcore-6.0&WT.mc_id=DT-MVP-4015686) 可以大幅簡化啟動 ASP.NET Core 應用程式的程式碼，但由於註冊服務到 DI 容器的 API 被簡化了，因此程式的寫法有些差異。今天這篇文章，我打算重新撰寫 [如何在 ASP.NET Core 3 使用 Token-based 身分驗證與授權 (JWT)](/post/2019/12/16/How-to-use-JWT-token-based-auth-in-aspnet-core-31) 這篇文章，改以 ASP.NET Core 6 Minimal APIs 來進行實作。

### 重點摘要

要在 ASP.NET Core 專案實現 Token-based 的身分驗證與授權，最簡單的方式就是透過 JWT 進行實作，整個實作的過程大致分成三個部分：

1.  產生合法有效的 JWT Token
2.  驗證合法有效的 JWT Token
3.  限制特定 API 只能在通過 JWT 驗證的 HTTP 要求才能存取

> 本篇文章原本想嘗試用 [Jwt.Net](https://github.com/jwt-dotnet/jwt) 來進行實作，但實作完成後才發現有許多缺陷，這套沒辦法處理複雜格式的 JWT Payload，它只能處理簡單的格式 (只有一層 JSON 物件的資料結構)，他只能轉型成 `Dictionary<string, string>` 型別而已。如果我們要儲存**多重角色**的資訊在 JWT Token 中時，透過 [Jwt.Net](https://github.com/jwt-dotnet/jwt) 目前版本還無法處理。不過，大部分 JWT Token 的 Payload 確實都只有簡單 Key-Value 而已，用 `Dictionary<string, string>` 就能處理。我實作的範例程式放在這裡：[https://github.com/doggy8088/AspNetCore6JwtNetAuthN](https://github.com/doggy8088/AspNetCore6JwtNetAuthN)

### 初始化專案

本篇文章以 ASP.NET Core 6 為主，所以我們先建立一個最陽春的 Web API 專案：

1.  先建立 `global.json` 檔案，將 .NET SDK 限定在 6.0.102 版本
    
    ```bash
    dotnet new globaljson --sdk-version 6.0.102
    ```
    
2.  建立 `JwtAuthDemo` 專案
    
    ```bash
    dotnet new webapi -n JwtAuthDemo -minimal
    cd JwtAuthDemo
    
    # 加入 Git 版控
    dotnet new gitignore
    git init
    git add .
    git commit -m "Initial commit"
    ```
    
3.  安裝 `Microsoft.AspNetCore.Authentication.JwtBearer` 套件
    
    ```bash
    dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer
    
    # 加入 Git 版本
    git commit -m "dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer" -a
    ```
    
4.  使用 VS Code 開啟專案
    
    ```bash
    code .
    ```
    
    > 請記得安裝我的 [.NET Core Extension Pack](https://marketplace.visualstudio.com/items?itemName=doggy8088.netcore-extension-pack) 擴充套件
    

### 產生合法有效的 JWT Token

1.  加入 `JwtHelpers` 輔助類別
    
    這部分我寫了一個 [JwtHelpers.cs](https://github.com/doggy8088/AspNetCore6JwtAuthDemo/blob/main/JwtHelpers.cs) 檔案，裡面有個 `GenerateToken()` 方法，所有說明直接放在程式碼註解中。
    
2.  加入 `appsettings.json` 組態設定
    
    上述範例程式由於需要讀取組態設定，因此我們要加入一些組態設定到 `appsettings.json` 檔案中。
    
    ```json
    {
      "JwtSettings": {
        "Issuer": "JwtAuthDemo",
        "SignKey": "01234567890123456789012345678901"
      },
      "Logging": {
        "LogLevel": {
          "Default": "Information",
          "Microsoft.AspNetCore": "Warning"
        }
      },
      "AllowedHosts": "*"
    }
    ```
    
    > 你也可以透過[其他組態提供者](https://learn.microsoft.com/en-us/aspnet/core/fundamentals/configuration/?view=aspnetcore-6.0&WT.mc_id=DT-MVP-4015686)提供該設定 (例如：[環境變數](https://learn.microsoft.com/en-us/aspnet/core/fundamentals/configuration/?view=aspnetcore-6.0#environment-variables)、[命令列參數](https://learn.microsoft.com/en-us/aspnet/core/fundamentals/configuration/?view=aspnetcore-6.0#command-line)、[User Secrets](https://learn.microsoft.com/en-us/aspnet/core/security/app-secrets?view=aspnetcore-6.0&tabs=windows)、...)。
    
3.  將 `JwtHelpers` 類別註冊進 .NET 的 DI 容器中 (`IServiceCollection`)
    
    請寫在 `var app = builder.Build();` 之前：
    
    ```csharp
    builder.Services.AddSingleton<JwtHelpers>();
    ```
    
4.  加入一個 `LoginViewModel` 模型類別 (用來登入時的模型繫結)
    
    ```csharp
    record LoginViewModel(string Username, string Password);
    ```
    
5.  加入身份認證與授權相關的 Minimal APIs 到專案中
    
    ```csharp
    // 登入並取得 JWT Token
    app.MapPost("/signin", (LoginViewModel login, JwtHelpers jwt) =>
        {
            if (ValidateUser(login))
            {
                var token = jwt.GenerateToken(login.Username);
                return Results.Ok(new { token });
            }
            else
            {
                return Results.BadRequest();
            }
        })
        .WithName("SignIn")
        .AllowAnonymous();
    
    // 取得 JWT Token 中的所有 Claims
    app.MapGet("/claims", (ClaimsPrincipal user) =>
        {
            return Results.Ok(user.Claims.Select(p => new { p.Type, p.Value }));
        })
        .WithName("Claims")
        .RequireAuthorization();
    
    // 取得 JWT Token 中的使用者名稱
    app.MapGet("/username", (ClaimsPrincipal user) =>
        {
            return Results.Ok(user.Identity?.Name);
        })
        .WithName("Username")
        .RequireAuthorization();
    
    // 取得使用者是否擁有特定角色
    app.MapGet("/isInRole", (ClaimsPrincipal user, string name) =>
        {
            return Results.Ok(user.IsInRole(name));
        })
        .WithName("IsInRole")
        .RequireAuthorization();
    
    // 取得 JWT Token 中的 JWT ID
    app.MapGet("/jwtid", (ClaimsPrincipal user) =>
        {
            return Results.Ok(user.Claims.FirstOrDefault(p => p.Type == "jti")?.Value);
        })
        .WithName("JwtId")
        .RequireAuthorization();
    
    bool ValidateUser(LoginViewModel login)
    {
        return true;
    }
    
    record LoginViewModel(string Username, string Password);
    ```
    

目前的程式碼已經可以透過 Web API 取得 JWT Token，我們可以把網站跑起來之後，透過 cURL 進行測試：

1.  啟動網站
    
    ```bash
    dotnet watch run
    ```
    
2.  測試登入並取得 JWT Token
    
    PowerShell
    
    ```powershell
    $body = '{"Username": "will", "Password": "123"}'
    $header = @{ "Content-Type"="application/json" }
    
    Invoke-RestMethod -Uri "https://localhost:7255/signin" -Method 'Post' -Body $body -Headers $header | ConvertTo-Json
    ```
    
    Bash
    
    ```bash
    curl -X POST https://localhost:7255/signin -H 'Content-Type: application/json' --data '{"Username": "will", "Password": "123"}'
    ```
    
    你應該會得到類似以下結果：
    
    ```json
    {
      "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ3aWxsIiwianRpIjoiZDRhNGNjNDYtZTI3MC00NDkyLTk0OTItNWU0OWE1MDJjYjYwIiwicm9sZXMiOlsiQWRtaW4iLCJVc2VycyJdLCJuYmYiOjE2NDQ2ODQ3NTAsImV4cCI6MTY0NDY4NjU1MCwiaWF0IjoxNjQ0Njg0NzUwLCJpc3MiOiJKd3RBdXRoRGVtbyJ9.eLw3ed6z6IPdWBbxNrd9W8Azd0oxkkXiVU-za6N3Gc8"
    }
    ```
    

### 驗證合法有效的 JWT Token

知道如何簽發 Token 之後，接下來就要讓你的 ASP.NET Core 能夠認得使用者傳入的 Bearer Token，這部分需要加入身份認證的服務宣告，也要加入 Middleware 設定。

1.  加入 `builder.Services` 服務宣告部分
    
    這裡不但要加入 `AddAuthentication(JwtBearerDefaults.AuthenticationScheme)`，也要加入 `AddAuthorization()`
    
    ```csharp
    builder.Services
        .AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
        .AddJwtBearer(options =>
        {
            // 當驗證失敗時，回應標頭會包含 WWW-Authenticate 標頭，這裡會顯示失敗的詳細錯誤原因
            options.IncludeErrorDetails = true; // 預設值為 true，有時會特別關閉
    
            options.TokenValidationParameters = new TokenValidationParameters
            {
                // 透過這項宣告，就可以從 "sub" 取值並設定給 User.Identity.Name
                NameClaimType = "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier",
                // 透過這項宣告，就可以從 "roles" 取值，並可讓 [Authorize] 判斷角色
                RoleClaimType = "http://schemas.microsoft.com/ws/2008/06/identity/claims/role",
    
                // 一般我們都會驗證 Issuer
                ValidateIssuer = true,
                ValidIssuer = builder.Configuration.GetValue<string>("JwtSettings:Issuer"),
    
                // 通常不太需要驗證 Audience
                ValidateAudience = false,
                //ValidAudience = "JwtAuthDemo", // 不驗證就不需要填寫
    
                // 一般我們都會驗證 Token 的有效期間
                ValidateLifetime = true,
    
                // 如果 Token 中包含 key 才需要驗證，一般都只有簽章而已
                ValidateIssuerSigningKey = false,
    
                // "1234567890123456" 應該從 IConfiguration 取得
                IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(builder.Configuration.GetValue<string>("JwtSettings:SignKey")))
            };
        });
    
    builder.Services.AddAuthorization();
    ```
    
2.  加入身份認證與授權的 Middleware 宣告
    
    請加入以下兩行在 `app.UseHttpsRedirection();` 後面：
    
    ```csharp
    app.UseAuthentication();
    app.UseAuthorization();
    ```
    

### 限制特定 API 只能在通過 JWT 驗證的 HTTP 要求才能存取

這部分我們的程式已經在文章稍早就寫好了，這裡特別列出啟用身份授權的程式碼。

1.  取得 JWT Token 中的所有 Claims 資訊 (需登入才能使用)
    
    若要取得以前 ASP.NET Core 中常見的 `User` 物件，你必須注入一個 `ClaimsPrincipal user` 參數，透過這個傳入的物件就可以取得早期 `User` 屬性下的所有資訊。
    
    ```csharp
    app.MapGet("/claims", (ClaimsPrincipal user) =>
        {
            return Results.Ok(user.Claims.Select(p => new { p.Type, p.Value }));
        })
        .WithName("Claims")
        .RequireAuthorization();
    ```
    
    程式碼最後一行的 `.RequireAuthorization()` 即代表該 Minimal API 必須通過授權才能執行，否則就會得到 `HTTP 401` 的回應。
    
2.  取得 JWT Token 中的使用者名稱 (需登入才能使用)
    
    ```csharp
    app.MapGet("/username", (ClaimsPrincipal user) =>
        {
            return Results.Ok(user.Identity?.Name);
        })
        .WithName("Username")
        .RequireAuthorization();
    ```
    
3.  取得 JWT Token 中的 JWT ID (需登入才能使用)
    
    ```csharp
    app.MapGet("/jwtid", (ClaimsPrincipal user) =>
        {
            return Results.Ok(user.Claims.FirstOrDefault(p => p.Type == "jti")?.Value);
        })
        .WithName("JwtId")
        .RequireAuthorization();
    ```
    
4.  取得使用者是否擁有特定角色 (需登入才能使用)
    
    ```csharp
    app.MapGet("/isInRole", (ClaimsPrincipal user, string name) =>
        {
            return Results.Ok(user.IsInRole(name));
        })
        .WithName("IsInRole")
        .RequireAuthorization();
    ```
    

### 用戶端在取得 JWT Token 之後該如何強制登出

基本上，所有 API 都是無狀態的，因此當用戶端取得 Token 之後，以 JWT 的機制來看，唯一的失效方式就是等到 Token 超過**到期時間**才行！

但是，在 [RFC7519 JSON Web Token (JWT)](https://tools.ietf.org/html/rfc7519) 規格中，有要求每次發出的 JWT Token 中都必須包含一個 JWT ID (`jti`) 聲明(Claim)，而且每個 JWT Token 都不能重複，必須是**唯一值**！

因此，你還是可以透過 `user.Claims` 取得所有 `Claims` 聲明資訊，因此你可以透過以下程式碼取出當下 JWT Token 的 `jti` 聲明值：

```csharp
var jwt_id = user.Claims.FirstOrDefault(p => p.Type == "jti").Value;
```

接著，你只要將這個**尚未到期**的**JWT Token 唯一碼**加入到自訂的**黑名單**中，再透過一個**篩選器**(Filters)過濾掉**黑名單**中的 API 要求即可！

> 由於 JWT 的 `exp` 到期時間是 JWT 規格中強制要求檢查的項目，如果超過到期日的時候該 JWT Token 就會自動失效，因此你在加入「黑名單」的時候，應該也要連同時間一起寫入，並定期清理所有無效的 `jti` 清單，以免暫存的空間被佔滿。

### 相關連結

-   [本篇文章的完整範例原始碼 (ASP․NET Core 6)](https://github.com/doggy8088/AspNetCore6JwtAuthDemo)
-   [Minimal APIs overview | Microsoft Docs](https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis?view=aspnetcore-6.0&WT.mc_id=DT-MVP-4015686)
-   [JSON Web Tokens - jwt.io](https://jwt.io/)
    -   [RFC 7519 - JSON Web Token (JWT)](https://tools.ietf.org/html/rfc7519)
-   [如何在 ASP.NET Core 3 使用 Token-based 身分驗證與授權 (JWT)](/post/2019/12/16/How-to-use-JWT-token-based-auth-in-aspnet-core-31)
-   [如何在 ASP.NET Core 2.2 使用 Token-based 身分驗證與授權 (JWT)](/post/2019/10/13/How-to-use-JWT-token-based-auth-in-aspnet-core-22)
-   [Making a PowerShell POST request if a body param starts with '@' - Stack Overflow](https://stackoverflow.com/a/35732728/910074)
