在 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 身分驗證實作,其中只包含了三個部分:
- 產生合法有效的 JWT Token
- 驗證合法有效的 JWT Token
- 限制特定 API 只能在通過 JWT 驗證的 HTTP 要求才能存取
本篇文章最後也將會針對最多人問到的「當我用 JWT 登入之後,要怎樣才能立即登出?」
初始化專案
本篇文章以 ASP.NET Core 2.2 為主,所以我們先建立一個最陽春的 Web API 專案:
-
先建立 global.json
檔案,將 .NET Core SDK 限定在 2.2.402
版本
dotnet new globaljson --sdk-version 2.2.402
-
建立 JwtAuthDemo
專案
dotnet new webapi -n JwtAuthDemo
-
無需特別安裝 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()
即可。
-
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"))
};
});
-
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 要求即可。
相關連結