﻿# 如何在 ASP.NET Core 3 使用 Token-based 身分驗證與授權 (JWT)

我在兩個月前所撰寫的 [如何在 ASP.NET Core 2.2 使用 Token-based 身分驗證與授權 (JWT)](/post/2019/10/13/How-to-use-JWT-token-based-auth-in-aspnet-core-22) 文章，雖然到了 ASP․NET Core 3 之後所有程式碼都是可以用的，但是專案架構設定的過程變得有點不太一樣，因此寫這篇文章說明其差異。

### ASP․NET Core 3.0 移除了大量內建 NuGet 套件

你從 [What's new in ASP.NET Core 3.0](https://learn.microsoft.com/en-us/aspnet/core/release-notes/aspnetcore-3.0?view=aspnetcore-3.1) 的 [Use the ASP.NET Core shared framework](https://learn.microsoft.com/en-us/aspnet/core/release-notes/aspnetcore-3.0?view=aspnetcore-3.1#use-the-aspnet-core-shared-framework) 章節可以發現，以前你在 **ASP․NET Core 2.x** 專案需要特別在 `*.csproj` 專案檔中特別加入 `Microsoft.AspNetCore.App` 的套件參考(`<PackageReference />`)，但是從 ASP․NET Core 3.0 開始就不需要了。也就是說 `Microsoft.AspNetCore.App` 已經成為了 **ASP.NET Core 3.0 shared framework** 的一部分，當你安裝好 .NET Core SDK 或 .NET Runtime 的時候，就已經先安裝好了。

> 只要你 `*.csproj` 專案檔的第一行參考 `<Project Sdk="Microsoft.NET.Sdk.Web">` 就代表已經包含了 `Microsoft.AspNetCore.App` 中繼套件！

不過，你更需要知道的是，從 **ASP․NET Core 3.0** 開始，這個 `Microsoft.AspNetCore.App` 中繼套件移除了大量以前內建的 NuGet 套件，以至於你從 **ASP․NET Core 2.x** 升級上來的專案將會無法順利編譯。被移除的 NuGet 套件中，其中比較值得一提的有：

-   [Newtonsoft.Json](https://www.nuget.org/packages/Newtonsoft.Json/) (Json.NET)
-   [Entity Framework Core](https://learn.microsoft.com/en-us/ef/core/)
-   [**Microsoft.AspNetCore.Authentication.JwtBearer**](https://www.nuget.org/packages/Microsoft.AspNetCore.Authentication.JwtBearer)

> 完整的移除套件清單，可以從 [Assemblies being removed from Microsoft.AspNetCore.App 3.0 #3755](https://github.com/aspnet/AspNetCore/issues/3755) 看到。

沒錯，你應該有發現 [**Microsoft.AspNetCore.Authentication.JwtBearer**](https://www.nuget.org/packages/Microsoft.AspNetCore.Authentication.JwtBearer) 就在這次升級的過程中被移除，因此我的這篇文章要能順利實作，必須手動加裝這個套件才行：

```bash
dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer
```

### 移除 System.IdentityModel.Tokens.Jwt 套件

由於 `Microsoft.AspNetCore.Authentication.JwtBearer` 套件的 `3.1.0` 版本，已經把 `System.IdentityModel.Tokens.Jwt` 設定為相依套件，因此你完全可以直接從 `*.csproj` 專案檔中移除 `System.IdentityModel.Tokens.Jwt` 套件參考。

以下是新版專案的 `*.csproj` 內容，變得相當乾淨清爽：

```xml
<Project Sdk="Microsoft.NET.Sdk.Web">

  <PropertyGroup>
    <TargetFramework>netcoreapp3.1</TargetFramework>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="3.1.0" />
  </ItemGroup>

</Project>
```

### 請小心意外載入的 Newtonsoft.Json 套件

因為 .NET Core 3.0 內建 [System.Text.Json](https://learn.microsoft.com/en-us/dotnet/api/system.text.json?view=netcore-3.1) 命名空間，用來更有效率的處理 JSON 序列化與反序列化，並完整移除所有套件對 [Newtonsoft.Json](https://www.nuget.org/packages/Newtonsoft.Json) 的依賴。

當你進一步分析 [Microsoft.AspNetCore.Authentication.JwtBearer](https://www.nuget.org/packages/Microsoft.AspNetCore.Authentication.JwtBearer) 套件的相依性時，會發現 [Newtonsoft.Json](https://www.nuget.org/packages/Newtonsoft.Json) 套件竟然被包含在內，這非常有可能是一顆隱藏版的地雷。

![image](https://stwillblogassets.blob.core.windows.net/files/images/external/user-images.githubusercontent.com/4202bb3d70ca3c4eedcd-70877702-895b4800-1ff9-11ea-882c-29f2ea5be34b.png)

我們經常使用的 `[JsonIgnore]` 屬性，到了 .NET Core 3.0 要記得選用 `System.Text.Json.Serialization` 命名空間，但是如果你不小心把 `Newtonsoft.Json` 套件載入的話，在選擇命名空間時，就要小心不要選錯了！

![image](https://stwillblogassets.blob.core.windows.net/files/images/external/user-images.githubusercontent.com/1e518dd0c9e45e37e27e-70878402-3fc02c80-1ffc-11ea-9f1a-7afaa670c9ae.png)

### 重點摘要

要在專案中採用 JWT 進行 Token-based 的**身分驗證**與**授權**實作，其中只包含了三個部分：

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

### 初始化專案

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

1.  先建立 `global.json` 檔案，將 .NET Core SDK 限定在 `3.1.100` 版本
    
    ```bash
    dotnet new globaljson --sdk-version 3.1.100
    ```
    
2.  建立 `JwtAuthDemo` 專案
    
    ```bash
    dotnet new webapi -n JwtAuthDemo
    cd JwtAuthDemo
    ```
    
3.  安裝 `Microsoft.AspNetCore.Authentication.JwtBearer` 套件
    
    ```bash
    dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer
    ```
    

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

這部分我寫了一個 `Helpers/JwtHelpers.cs` 檔案，裡面有個 `GenerateToken()` 方法，所有說明直接放在程式碼註解中：

```csharp
using Microsoft.Extensions.Configuration;
using Microsoft.IdentityModel.Tokens;
using System;
using System.Collections.Generic;
using System.Security.Claims;
using System.Text;
using System.IdentityModel.Tokens.Jwt;

namespace JwtAuthDemo.Helpers
{
    public class JwtHelpers
    {
        private readonly IConfiguration Configuration;

        public JwtHelpers(IConfiguration configuration)
        {
            this.Configuration = configuration;
        }
        public string GenerateToken(string userName, int expireMinutes = 30)
        {
            var issuer = Configuration.GetValue<string>("JwtSettings:Issuer");
            var signKey = Configuration.GetValue<string>("JwtSettings:SignKey");

            // 設定要加入到 JWT Token 中的聲明資訊(Claims)
            var claims = new List<Claim>();

            // 在 RFC 7519 規格中(Section#4)，總共定義了 7 個預設的 Claims，我們應該只用的到兩種！
            //claims.Add(new Claim(JwtRegisteredClaimNames.Iss, issuer));
            claims.Add(new Claim(JwtRegisteredClaimNames.Sub, userName)); // User.Identity.Name
            //claims.Add(new Claim(JwtRegisteredClaimNames.Aud, "The Audience"));
            //claims.Add(new Claim(JwtRegisteredClaimNames.Exp, DateTimeOffset.UtcNow.AddMinutes(30).ToUnixTimeSeconds().ToString()));
            //claims.Add(new Claim(JwtRegisteredClaimNames.Nbf, DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString())); // 必須為數字
            //claims.Add(new Claim(JwtRegisteredClaimNames.Iat, DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString())); // 必須為數字
            claims.Add(new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())); // JWT ID

            // 網路上常看到的這個 NameId 設定是多餘的
            //claims.Add(new Claim(JwtRegisteredClaimNames.NameId, userName));

            // 這個 Claim 也以直接被 JwtRegisteredClaimNames.Sub 取代，所以也是多餘的
            //claims.Add(new Claim(ClaimTypes.Name, userName));

            // 你可以自行擴充 "roles" 加入登入者該有的角色
            claims.Add(new Claim("roles", "Admin"));
            claims.Add(new Claim("roles", "Users"));

            var userClaimsIdentity = new ClaimsIdentity(claims);

            // 建立一組對稱式加密的金鑰，主要用於 JWT 簽章之用
            var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(signKey));

            // HmacSha256 有要求必須要大於 128 bits，所以 key 不能太短，至少要 16 字元以上
            // https://stackoverflow.com/questions/47279947/idx10603-the-algorithm-hs256-requires-the-securitykey-keysize-to-be-greater
            var signingCredentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256Signature);

            // 建立 SecurityTokenDescriptor
            var tokenDescriptor = new SecurityTokenDescriptor
            {
                Issuer = issuer,
                //Audience = issuer, // 由於你的 API 受眾通常沒有區分特別對象，因此通常不太需要設定，也不太需要驗證
                //NotBefore = DateTime.Now, // 預設值就是 DateTime.Now
                //IssuedAt = DateTime.Now, // 預設值就是 DateTime.Now
                Subject = userClaimsIdentity,
                Expires = DateTime.Now.AddMinutes(expireMinutes),
                SigningCredentials = signingCredentials
            };

            // 產出所需要的 JWT securityToken 物件，並取得序列化後的 Token 結果(字串格式)
            var tokenHandler = new JwtSecurityTokenHandler();
            var securityToken = tokenHandler.CreateToken(tokenDescriptor);
            var serializeToken = tokenHandler.WriteToken(securityToken);

            return serializeToken;
        }
    }
}
```

上述範例程式由於需要讀取組態設定，因此所需的 `issuer` 與 `signKey` 請記得調整 `appsettings.json` 設定檔，或透過[其他組態提供者](https://learn.microsoft.com/en-us/aspnet/core/fundamentals/configuration/?view=aspnetcore-3.1#providers)提供該設定 (例如：[環境變數](https://learn.microsoft.com/en-us/aspnet/core/fundamentals/configuration/?view=aspnetcore-3.1#environment-variables-configuration-provider)、[命令列參數](https://learn.microsoft.com/en-us/aspnet/core/fundamentals/configuration/?view=aspnetcore-3.1#command-line-configuration-provider)、[User Secrets](https://learn.microsoft.com/en-us/aspnet/core/security/app-secrets?view=aspnetcore-3.1&tabs=windows)、...)：

```json
{
  "JwtSettings": {
    "Issuer": "JwtAuthDemo",
    "SignKey": "1Zl4h9703IzROikK3@uK&&OEb"
  },
  ...
}
```

你還要記得將 `JwtHelpers` 類別註冊進 .NET Core 的 DI 容器中(`IServiceCollection`)，也就是 `Startup.cs` 檔案的 `public void ConfigureServices(IServiceCollection services)` 方法內：

```csharp
services.AddSingleton<JwtHelpers>();
```

以下是 Web API 的設計範例，為了方便示範，我將 `TokenController` 與相關類別全部寫在一起，原始碼如下：

```csharp
using System.Linq;
using JwtAuthDemo.Helpers;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;

namespace JwtAuthDemo.Controllers
{
    [Authorize]
    [ApiController]
    public class TokenController : ControllerBase
    {
        private readonly JwtHelpers jwt;

        public TokenController(JwtHelpers jwt)
        {
            this.jwt = jwt;
        }

        [AllowAnonymous]
        [HttpPost("~/signin")]
        public ActionResult<string> SignIn(LoginViewModel login)
        {
            if (ValidateUser(login))
            {
                return jwt.GenerateToken(login.Username);
            }
            else
            {
                return BadRequest();
            }
        }

        private bool ValidateUser(LoginViewModel login)
        {
            return true; // TODO
        }

        [HttpGet("~/claims")]
        public IActionResult GetClaims()
        {
            return Ok(User.Claims.Select(p => new { p.Type, p.Value }));
        }

        [HttpGet("~/username")]
        public IActionResult GetUserName()
        {
            return Ok(User.Identity.Name);
        }

        [HttpGet("~/jwtid")]
        public IActionResult GetUniqueId()
        {
            var jti = User.Claims.FirstOrDefault(p => p.Type == "jti");
            return Ok(jti.Value);
        }
    }

    public class LoginViewModel
    {
        public string Username { get; set; }
        public string Password { get; set; }
    }
}
```

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

知道如何簽發 Token 之後，接下來就要讓你的 ASP.NET Core 能夠認得使用者傳入的 Bearer Token，這部分只要設定好 `Startup.ConfigureServices()` 與 `Startup.Configure()` 即可。

1.  `public void ConfigureServices(IServiceCollection services)`
    
    ```csharp
    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 = 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(Configuration.GetValue<string>("JwtSettings:SignKey")))
            };
        });
    ```
    
2.  `public void Configure(IApplicationBuilder app, IWebHostEnvironment env)`
    
    ```csharp
    app.UseAuthentication();
    ```
    
    > 請務必設定在 `app.UseAuthorization();` 之前！(口訣: `先驗證`、`再授權`)
    

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

這部分就更簡單啦！直接用 ASP.NET Core 內建的 `[Authorize]` 篩選器即可達成，如下範例：

```csharp
[Authorize]
[HttpGet("~/claims")]
public IActionResult GetClaims()
{
    return Ok(User.Claims.Select(p => new { p.Type, p.Value }));
}

[Authorize]
[HttpGet("~/username")]
public IActionResult GetUserName()
{
    return Ok(User.Identity.Name);
}
```

### 用戶端在取得 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;
```

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

### 相關連結

-   [本篇文章的完整範例原始碼 (ASP․NET Core 3.1)](https://github.com/doggy8088/JwtAuthDemo/tree/aspnetcore30)
    -   [本篇文章範例原始碼從 ASP․NET Core 2.2 升級到 ASP․NET Core 3.1 的修訂紀錄](https://github.com/doggy8088/JwtAuthDemo/commit/60ddbc816498c49934108f74aaaffce140466650)
-   [JSON Web Tokens - jwt.io](https://jwt.io/)
    -   [RFC 7519 - JSON Web Token (JWT)](https://tools.ietf.org/html/rfc7519)
-   [What's new in ASP.NET Core 3.0 |> Assemblies removed from the ASP.NET Core shared framework](https://learn.microsoft.com/en-us/aspnet/core/release-notes/aspnetcore-3.0?view=aspnetcore-3.1#assemblies-removed-from-the-aspnet-core-shared-framework)
    -   [Assemblies being removed from Microsoft.AspNetCore.App 3.0 · Issue #3755 · aspnet/AspNetCore](https://github.com/aspnet/AspNetCore/issues/3755)
    -   [Stop producing packages for shared framework assemblies in 3.0 · Issue #3756 · aspnet/AspNetCore](https://github.com/aspnet/AspNetCore/issues/3756)
    -   [Breaking changes to Microsoft.AspNetCore.App in 3.0 · Issue #325 · aspnet/Announcements](https://github.com/aspnet/Announcements/issues/325)
-   [Serialize and deserialize JSON using C# - .NET | Microsoft Docs](https://learn.microsoft.com/en-us/dotnet/standard/serialization/system-text-json-overview)
    -   [System.Text.Json](https://learn.microsoft.com/en-us/dotnet/api/system.text.json?view=netcore-3.1) 命名空間
    -   [Try the new System.Text.Json APIs | .NET Blog](https://devblogs.microsoft.com/dotnet/try-the-new-system-text-json-apis/)
