The Will Will Web

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

如何在 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 身分驗證與授權

簡介 System.IdentityModel.Tokens.Jwt 套件

其實微軟有提供一個專門處理 JWT Token 的 NuGet 套件:System.IdentityModel.Tokens.Jwt,目前最新為 5.5.0 版 (2019/10/13)。

這個 System.IdentityModel.Tokens.Jwt 套件隸屬於 Windows Azure Active Directory IdentityModel Extensions for .NET 專案的一部分,這是一個開放原始碼專案,從名字也可以看出來,這是一個專門針對 Azure AD 所打造的一個 .NET 身分認證模型的擴充專案,只是他剛好完整包含了 JWT Token 產生驗證的所有實作。

如果你想一探這個套件的原始碼,可以到 src/System.IdentityModel.Tokens.Jwt/ 這個原始碼路徑查看!

請注意:與這個套件相依的 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 版本

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

    dotnet new webapi -n JwtAuthDemo
    
  3. 無需特別安裝 System.IdentityModel.Tokens.Jwt 套件,因為已經內建在 Microsoft.AspNetCore.App 中繼套件中

    dotnet add package System.IdentityModel.Tokens.Jwt
    

產生合法有效的 JWT Token

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

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 與相關類別全部寫在一起,原始碼如下:

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)

    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)

    app.UseAuthentication();
    

    請務必設定在 app.UseMvc(); 之前!

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

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

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

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

相關連結

留言評論