The Will Will Web

記載著 Will 在網路世界的學習心得與技術分享

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

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

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

你從 What's new in ASP.NET Core 3.0Use the ASP.NET 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 套件中,其中比較值得一提的有:

完整的移除套件清單,可以從 Assemblies being removed from Microsoft.AspNetCore.App 3.0 #3755 看到。

沒錯,你應該有發現 Microsoft.AspNetCore.Authentication.JwtBearer 就在這次升級的過程中被移除,因此我的這篇文章要能順利實作,必須手動加裝這個套件才行:

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 內容,變得相當乾淨清爽:

<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 命名空間,用來更有效率的處理 JSON 序列化與反序列化,並完整移除所有套件對 Newtonsoft.Json 的依賴。

當你進一步分析 Microsoft.AspNetCore.Authentication.JwtBearer 套件的相依性時,會發現 Newtonsoft.Json 套件竟然被包含在內,這非常有可能是一顆隱藏版的地雷。

image

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

image

重點摘要

要在專案中採用 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 版本

    dotnet new globaljson --sdk-version 3.1.100
    
  2. 建立 JwtAuthDemo 專案

    dotnet new webapi -n JwtAuthDemo
    cd JwtAuthDemo
    
  3. 安裝 Microsoft.AspNetCore.Authentication.JwtBearer 套件

    dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer
    

產生合法有效的 JWT Token

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

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;
        }
    }
}

上述範例程式由於需要讀取組態設定,因此所需的 issuersignKey 請記得調整 appsettings.json 設定檔,或透過其他組態提供者提供該設定 (例如:環境變數命令列參數User Secrets、...):

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

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

services.AddSingleton<JwtHelpers>();

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

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)

    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)

    app.UseAuthentication();
    

    請務必設定在 app.UseAuthorization(); 之前!(口訣: 先驗證再授權)

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

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

[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) 規格中,有要求每次發出的 JWT Token 中都必須包含一個 JWT ID (jti) 聲明(Claim),而且每個 JWT Token 都不能重複,必須是唯一值

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

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

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

相關連結

留言評論