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

在 Microsoft Docs 官方文件中，幾乎找不到任何關於 Token-based 身分驗證的做法說明，網路上能找到的都是部落格文章，而且大家的實作方式雖然大同小異，但是大多沒交代細節，甚至有些不具意義的寫法。本篇文章將分享相對簡便的設定方法，順便解說一些技術細節，讓一個沒有實作身分驗證的 ASP.NET Core Web API 專案，可以快速的加入以 JWT 為主的 Token-based 驗證方式。

🔥 **本篇文章已有更新版本，請參考 [如何在 ASP.NET Core 3.1 使用 Token-based 身分驗證與授權](/post/2019/12/16/How-to-use-JWT-token-based-auth-in-aspnet-core-31)**

![](https://stwillblogassets.blob.core.windows.net/files/images/external/stwillblogassets.blob.core.windows.net/ce9318752241ebb3a056-image_3208.png)

### 簡介 System.IdentityModel.Tokens.Jwt 套件

其實微軟有提供一個專門處理 JWT Token 的 NuGet 套件：[System.IdentityModel.Tokens.Jwt](https://www.nuget.org/packages/System.IdentityModel.Tokens.Jwt/)，目前最新為 `5.5.0` 版 (2019/10/13)。

這個 **System.IdentityModel.Tokens.Jwt** 套件隸屬於 [Windows Azure Active Directory IdentityModel Extensions for .NET](https://github.com/AzureAD/azure-activedirectory-identitymodel-extensions-for-dotnet) 專案的一部分，這是一個開放原始碼專案，從名字也可以看出來，這是一個專門針對 Azure AD 所打造的一個 .NET 身分認證模型的擴充專案，只是他剛好完整包含了 JWT Token **產生**與**驗證**的所有實作。

如果你想一探這個套件的原始碼，可以到 [src/System.IdentityModel.Tokens.Jwt/](https://github.com/AzureAD/azure-activedirectory-identitymodel-extensions-for-dotnet/tree/dev/src/System.IdentityModel.Tokens.Jwt) 這個原始碼路徑查看！

> **請注意**：與這個套件相依的 [Microsoft.IdentityModel.Tokens](https://www.nuget.org/packages/Microsoft.IdentityModel.Tokens/) 套件，在 `5.1.0` 版本之前存在著一個**安全弱點** (Security Vulnerability)，如果你之前的專案有用到這個套件，請立即檢查版號，並立刻更新到 `5.1.1` 以上版本。

### 重點摘要

「授權」與「認證」本來就是個極為重要，而又相當複雜的主題，今天我們的文章不打算講解的過於廣泛，而想直接專注在撰寫 Web API 的實務上最常需要的 JWT 身分驗證需求。

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

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

本篇文章最後也將會針對最多人問到的「當我用 JWT 登入之後，要怎樣才能**立即**登出？」

### 初始化專案

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

1.  先建立 `global.json` 檔案，將 .NET Core SDK 限定在 `2.2.402` 版本
    
    ```bash
    dotnet new globaljson --sdk-version 2.2.402
    ```
    
2.  建立 `JwtAuthDemo` 專案
    
    ```bash
    dotnet new webapi -n JwtAuthDemo
    ```
    
3.  無需特別安裝 `System.IdentityModel.Tokens.Jwt` 套件，因為已經內建在 [Microsoft.AspNetCore.App](https://www.nuget.org/packages/Microsoft.AspNetCore.App) 中繼套件中
    
    ```bash
    dotnet add package System.IdentityModel.Tokens.Jwt
    ```
    
    ![](https://stwillblogassets.blob.core.windows.net/files/images/external/user-images.githubusercontent.com/36e99842fe149abe8792-66890218-e6e21100-f017-11e9-9f7e-62f87d314dd8.png)
    

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

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

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

namespace JwtAuthDemo.Controllers
{
    public class JwtHelpers
    {
        public static string GenerateToken(string issuer, string signKey, string userName, int expireMinutes)
        {
            // 設定要加入到 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 取代，所以也是多餘的
            // https://stackoverflow.com/a/45333209/910074
            //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/a/47280062/910074
            // 你不應該再使用 SecurityAlgorithms.HmacSha256 (已過時)
            // https://stackoverflow.com/a/41870180/910074
            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;
        }
    }
}
```

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

```csharp
using System;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;

namespace JwtAuthDemo.Controllers
{
    [ApiController]
    public class TokenController : ControllerBase
    {
        [HttpPost("~/signin")]
        public ActionResult<string> SignIn(LoginViewModel login)
        {
            // 以下變數值應該透過 IConfiguration 取得
            var issuer = "JwtAuthDemo";
            var signKey = "1234567890123456"; // 請換成至少 16 字元以上的安全亂碼
            var expires = 30; // 單位: 分鐘

            if (ValidateUser(login))
            {
                return JwtHelpers.GenerateToken(issuer, signKey, login.Username, expires);
            }
            else
            {
                return BadRequest();
            }
        }

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

    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 = "JwtAuthDemo", // "JwtAuthDemo" 應該從 IConfiguration 取得
    
                // 若是單一伺服器通常不太需要驗證 Audience
                ValidateAudience = false,
                //ValidAudience = "JwtAuthDemo", // 不驗證就不需要填寫
    
                // 一般我們都會驗證 Token 的有效期間
                ValidateLifetime = true,
    
                // 如果 Token 中包含 key 才需要驗證，一般都只有簽章而已
                ValidateIssuerSigningKey = false,
    
                // "1234567890123456" 應該從 IConfiguration 取得
                IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes("1234567890123456"))
            };
        });
    ```
    
2.  `public void Configure(IApplicationBuilder app, IHostingEnvironment env)`
    
    ```csharp
    app.UseAuthentication();
    ```
    
    > 請務必設定在 `app.UseMvc();` 之前！
    

### 限制特定 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 超過**到期時間**才行！

但是，因為你還是可以透過 `User.Claims` 取得所有 `Claims` 資訊，因此你可以透過以下程式碼取出當下 Token 的**唯一碼**：

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

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

### 相關連結

-   [本篇文章的完整範例原始碼](https://github.com/doggy8088/JwtAuthDemo)
-   [JSON Web Tokens - jwt.io](https://jwt.io)
-   [RFC 7519 - JSON Web Token (JWT)](https://tools.ietf.org/html/rfc7519)
-   [Windows Azure Active Directory IdentityModel Extensions for .NET](https://github.com/AzureAD/azure-activedirectory-identitymodel-extensions-for-dotnet)
    -   [ValidatingTokens](https://github.com/AzureAD/azure-activedirectory-identitymodel-extensions-for-dotnet/wiki/ValidatingTokens)
    -   [Microsoft.IdentityModel.Tokens/Validators.cs](https://github.com/AzureAD/azure-activedirectory-identitymodel-extensions-for-dotnet/blob/master/src/Microsoft.IdentityModel.Tokens/Validators.cs)
    -   [azure-activedirectory-identitymodel-extensions-for-dotnet wiki](https://github.com/AzureAD/azure-activedirectory-identitymodel-extensions-for-dotnet/wiki)
    -   [JwtRegisteredClaimNames Struct (System.IdentityModel.Tokens.Jwt) - Azure for .NET Developers | Microsoft Docs](https://learn.microsoft.com/en-us/dotnet/api/system.identitymodel.tokens.jwt.jwtregisteredclaimnames?WT.mc_id=DT-MVP-4015686&view=azure-dotnet)
    -   [IdentityModelEventSource.ShowPII = true;](https://github.com/AzureAD/azure-activedirectory-identitymodel-extensions-for-dotnet/wiki/PII)
-   [JwtBearerOptions Class (Microsoft.AspNetCore.Authentication.JwtBearer) | Microsoft Docs](https://learn.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.authentication.jwtbearer.jwtbeareroptions?WT.mc_id=DT-MVP-4015686)
-   [How to log out when using JWT - devgorilla - Medium](https://medium.com/devgorilla/how-to-log-out-when-using-jwt-a8c7823e8a6)
-   [Using Roles with the ASP.NET Core JWT middleware](https://web.archive.org/web/20211127125253/https://www.jerriepelser.com/blog/using-roles-with-the-jwt-middleware)
-   [Jwt.Net, a JWT (JSON Web Token) implementation for .NET](https://github.com/jwt-dotnet/jwt)
