ASP.NET Core 6 提供了一組 Minimal APIs 可以大幅簡化啟動 ASP.NET Core 應用程式的程式碼,但由於註冊服務到 DI 容器的 API 被簡化了,因此程式的寫法有些差異。今天這篇文章,我打算重新撰寫 如何在 ASP.NET Core 3 使用 Token-based 身分驗證與授權 (JWT) 這篇文章,改以 ASP.NET Core 6 Minimal APIs 來進行實作。
重點摘要
要在 ASP.NET Core 專案實現 Token-based 的身分驗證與授權,最簡單的方式就是透過 JWT 進行實作,整個實作的過程大致分成三個部分:
- 產生合法有效的 JWT Token
- 驗證合法有效的 JWT Token
- 限制特定 API 只能在通過 JWT 驗證的 HTTP 要求才能存取
本篇文章原本想嘗試用 Jwt.Net 來進行實作,但實作完成後才發現有許多缺陷,這套沒辦法處理複雜格式的 JWT Payload,它只能處理簡單的格式 (只有一層 JSON 物件的資料結構),他只能轉型成 Dictionary<string, string>
型別而已。如果我們要儲存多重角色的資訊在 JWT Token 中時,透過 Jwt.Net 目前版本還無法處理。不過,大部分 JWT Token 的 Payload 確實都只有簡單 Key-Value 而已,用 Dictionary<string, string>
就能處理。我實作的範例程式放在這裡:https://github.com/doggy8088/AspNetCore6JwtNetAuthN
初始化專案
本篇文章以 ASP.NET Core 6 為主,所以我們先建立一個最陽春的 Web API 專案:
-
先建立 global.json
檔案,將 .NET SDK 限定在 6.0.102 版本
dotnet new globaljson --sdk-version 6.0.102
-
建立 JwtAuthDemo
專案
dotnet new webapi -n JwtAuthDemo -minimal
cd JwtAuthDemo
# 加入 Git 版控
dotnet new gitignore
git init
git add .
git commit -m "Initial commit"
-
安裝 Microsoft.AspNetCore.Authentication.JwtBearer
套件
dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer
# 加入 Git 版本
git commit -m "dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer" -a
-
使用 VS Code 開啟專案
code .
請記得安裝我的 .NET Core Extension Pack 擴充套件
產生合法有效的 JWT Token
-
加入 JwtHelpers
輔助類別
這部分我寫了一個 JwtHelpers.cs 檔案,裡面有個 GenerateToken()
方法,所有說明直接放在程式碼註解中。
-
加入 appsettings.json
組態設定
上述範例程式由於需要讀取組態設定,因此我們要加入一些組態設定到 appsettings.json
檔案中。
{
"JwtSettings": {
"Issuer": "JwtAuthDemo",
"SignKey": "01234567890123456789012345678901"
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}
你也可以透過其他組態提供者提供該設定 (例如:環境變數、命令列參數、User Secrets、...)。
-
將 JwtHelpers
類別註冊進 .NET 的 DI 容器中 (IServiceCollection
)
請寫在 var app = builder.Build();
之前:
builder.Services.AddSingleton<JwtHelpers>();
-
加入一個 LoginViewModel
模型類別 (用來登入時的模型繫結)
record LoginViewModel(string Username, string Password);
-
加入身份認證與授權相關的 Minimal APIs 到專案中
// 登入並取得 JWT Token
app.MapPost("/signin", (LoginViewModel login, JwtHelpers jwt) =>
{
if (ValidateUser(login))
{
var token = jwt.GenerateToken(login.Username);
return Results.Ok(new { token });
}
else
{
return Results.BadRequest();
}
})
.WithName("SignIn")
.AllowAnonymous();
// 取得 JWT Token 中的所有 Claims
app.MapGet("/claims", (ClaimsPrincipal user) =>
{
return Results.Ok(user.Claims.Select(p => new { p.Type, p.Value }));
})
.WithName("Claims")
.RequireAuthorization();
// 取得 JWT Token 中的使用者名稱
app.MapGet("/username", (ClaimsPrincipal user) =>
{
return Results.Ok(user.Identity?.Name);
})
.WithName("Username")
.RequireAuthorization();
// 取得使用者是否擁有特定角色
app.MapGet("/isInRole", (ClaimsPrincipal user, string name) =>
{
return Results.Ok(user.IsInRole(name));
})
.WithName("IsInRole")
.RequireAuthorization();
// 取得 JWT Token 中的 JWT ID
app.MapGet("/jwtid", (ClaimsPrincipal user) =>
{
return Results.Ok(user.Claims.FirstOrDefault(p => p.Type == "jti")?.Value);
})
.WithName("JwtId")
.RequireAuthorization();
bool ValidateUser(LoginViewModel login)
{
return true;
}
record LoginViewModel(string Username, string Password);
目前的程式碼已經可以透過 Web API 取得 JWT Token,我們可以把網站跑起來之後,透過 cURL 進行測試:
-
啟動網站
dotnet watch run
-
測試登入並取得 JWT Token
PowerShell
$body = '{"Username": "will", "Password": "123"}'
$header = @{ "Content-Type"="application/json" }
Invoke-RestMethod -Uri "https://localhost:7255/signin" -Method 'Post' -Body $body -Headers $header | ConvertTo-Json
Bash
curl -X POST https://localhost:7255/signin -H 'Content-Type: application/json' --data '{"Username": "will", "Password": "123"}'
你應該會得到類似以下結果:
{
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ3aWxsIiwianRpIjoiZDRhNGNjNDYtZTI3MC00NDkyLTk0OTItNWU0OWE1MDJjYjYwIiwicm9sZXMiOlsiQWRtaW4iLCJVc2VycyJdLCJuYmYiOjE2NDQ2ODQ3NTAsImV4cCI6MTY0NDY4NjU1MCwiaWF0IjoxNjQ0Njg0NzUwLCJpc3MiOiJKd3RBdXRoRGVtbyJ9.eLw3ed6z6IPdWBbxNrd9W8Azd0oxkkXiVU-za6N3Gc8"
}
驗證合法有效的 JWT Token
知道如何簽發 Token 之後,接下來就要讓你的 ASP.NET Core 能夠認得使用者傳入的 Bearer Token,這部分需要加入身份認證的服務宣告,也要加入 Middleware 設定。
-
加入 builder.Services
服務宣告部分
這裡不但要加入 AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
,也要加入 AddAuthorization()
builder.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 = builder.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(builder.Configuration.GetValue<string>("JwtSettings:SignKey")))
};
});
builder.Services.AddAuthorization();
-
加入身份認證與授權的 Middleware 宣告
請加入以下兩行在 app.UseHttpsRedirection();
後面:
app.UseAuthentication();
app.UseAuthorization();
限制特定 API 只能在通過 JWT 驗證的 HTTP 要求才能存取
這部分我們的程式已經在文章稍早就寫好了,這裡特別列出啟用身份授權的程式碼。
-
取得 JWT Token 中的所有 Claims 資訊 (需登入才能使用)
若要取得以前 ASP.NET Core 中常見的 User
物件,你必須注入一個 ClaimsPrincipal user
參數,透過這個傳入的物件就可以取得早期 User
屬性下的所有資訊。
app.MapGet("/claims", (ClaimsPrincipal user) =>
{
return Results.Ok(user.Claims.Select(p => new { p.Type, p.Value }));
})
.WithName("Claims")
.RequireAuthorization();
程式碼最後一行的 .RequireAuthorization()
即代表該 Minimal API 必須通過授權才能執行,否則就會得到 HTTP 401
的回應。
-
取得 JWT Token 中的使用者名稱 (需登入才能使用)
app.MapGet("/username", (ClaimsPrincipal user) =>
{
return Results.Ok(user.Identity?.Name);
})
.WithName("Username")
.RequireAuthorization();
-
取得 JWT Token 中的 JWT ID (需登入才能使用)
app.MapGet("/jwtid", (ClaimsPrincipal user) =>
{
return Results.Ok(user.Claims.FirstOrDefault(p => p.Type == "jti")?.Value);
})
.WithName("JwtId")
.RequireAuthorization();
-
取得使用者是否擁有特定角色 (需登入才能使用)
app.MapGet("/isInRole", (ClaimsPrincipal user, string name) =>
{
return Results.Ok(user.IsInRole(name));
})
.WithName("IsInRole")
.RequireAuthorization();
用戶端在取得 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;
接著,你只要將這個尚未到期的JWT Token 唯一碼加入到自訂的黑名單中,再透過一個篩選器(Filters)過濾掉黑名單中的 API 要求即可!
由於 JWT 的 exp
到期時間是 JWT 規格中強制要求檢查的項目,如果超過到期日的時候該 JWT Token 就會自動失效,因此你在加入「黑名單」的時候,應該也要連同時間一起寫入,並定期清理所有無效的 jti
清單,以免暫存的空間被佔滿。
相關連結