Skip to content

Commit 39c5b81

Browse files
committed
Add JWT implementation section in jwt-tutorial/index.md
1 parent 4796736 commit 39c5b81

File tree

4 files changed

+366
-0
lines changed

4 files changed

+366
-0
lines changed

content/posts/jwt-tutorial/index.md

Lines changed: 366 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ date: "2025-03-15T16:43:21+08:00"
33
draft: false
44
title: ASP.NET Core 實作 JWT 認證
55
description: 使用 JSON Web Token 實現安全的認證機制
6+
lastmod: 2025-03-21T01:56:49
67
categories:
78
- WebSite
89
tags:
@@ -1075,3 +1076,368 @@ builder.Services
10751076
![Login Success](postman-login-success.png)
10761077

10771078
![Login Failed Test](postman-login-failed-test.png)
1079+
1080+
## JWT
1081+
1082+
接下來要實作產生 JWT 的方法,並將上一節 `LoginAsync` 方法回傳值改成所產生的 JWT
1083+
1084+
下圖簡單的說明 JWT 的驗證流程\
1085+
1, 2, 3 上一節已經實作了,不再說明\
1086+
1087+
4. 如果登入登入成功,就合回傳一個合法的 JWT
1088+
5. 使用者需要存取授權的資源時,會將 JWT 傳送至 Server 進行驗證
1089+
6. 如果驗證通過,就會回傳授權的資源;\
1090+
如果未通過有兩種情況
1091+
- **正確的 Token,但權限不夠**,如:業務部門無法存取會計部門的帳務資料\
1092+
會回傳 **_[Http 403](https://developer.mozilla.org/zh-TW/docs/Web/HTTP/Reference/Status/403)_**
1093+
- **錯誤的 Token**,如 Token 過期或遭竄改,會回傳 **_[Http 401](https://developer.mozilla.org/zh-TW/docs/Web/HTTP/Reference/Status/401)_(未授權)**
1094+
1095+
```mermaid
1096+
┌───────────┐ ┌──────────┐ ┌────────┐
1097+
│Client User│ │Web Server│ │Database│
1098+
└─────┬─────┘ └────┬─────┘ └───┬────┘
1099+
│ │ │
1100+
│ 1. Login with Username and Password │ │
1101+
│────────────────────────────────────>│ │
1102+
│ │ │
1103+
│ │2. Check user data and validate│
1104+
│ │──────────────────────────────>│
1105+
│ │ │
1106+
│ │ 3. Valid user data │
1107+
│ │<──────────────────────────────│
1108+
│ │ │
1109+
│ 4. Send JWT(Bearer token) │ │
1110+
│<────────────────────────────────────│ │
1111+
│ │ │
1112+
│ 5. Request for protected data │ │
1113+
│ Send Bearer token │ │
1114+
│────────────────────────────────────>│ │
1115+
│ │ │
1116+
│ 6. Token is valid and │ │
1117+
│ Send protected data │ │
1118+
│<────────────────────────────────────│ │
1119+
│ │ │
1120+
│6. Token is invalid or no permission │ │
1121+
│ return Http 401 or Http 403 │ │
1122+
│<────────────────────────────────────│ │
1123+
┌─────┴─────┐ ┌────┴─────┐ ┌───┴────┐
1124+
│Client User│ │Web Server│ │Database│
1125+
└───────────┘ └──────────┘ └────────┘
1126+
```
1127+
1128+
### 安裝 JwtBearer
1129+
1130+
開啟 NuGet,輸入 **`Microsoft.AspNetCore.Authentication.JwtBearer`**,並安裝,安裝流程可以參考 **_[安裝套件](#安裝套件)_****記得安裝符合自已的 .NET 版本**,我的本機是 .NET 8 所以我只能安裝 8.x.x 的版本,詳細可以參考 **_[NuGet JwtBearer](https://www.nuget.org/packages/Microsoft.AspNetCore.Authentication.JwtBearer/)_**
1131+
1132+
### 修改登入方法
1133+
1134+
由上面的流程圖可以知道,JWT 的驗證,會回傳 Http 的結果,因此要統一每個 API 回傳的型別,原 `LoginAsync` 方法回傳的登入的相關訊息(string),現在為了回傳 `Http XXX` 要修改回傳資料的型別如下
1135+
1136+
```csharp
1137+
/// <summary>
1138+
/// 登入
1139+
/// </summary>
1140+
/// <param name="loginDto"> 使用者的輸入資料 </param>
1141+
/// <returns> 登入結果 </returns>
1142+
[HttpPost("login")]
1143+
// 原本回傳型別是 Task<string> 是上一節為了測試而簡化
1144+
// 改成 Task<ActionResult<string>>,為了可以回傳 Http 結果
1145+
// Ok, BadRequest 都是繼承自 ActionResult 的類別
1146+
// 所以可以用 ActionResult 封裝
1147+
// Ok 會變成 Http 200
1148+
// BadRequest 會變成 Http 400
1149+
public async Task<ActionResult<string>> LoginAsync(LoginDto loginDto)
1150+
{
1151+
// 登入資料驗證
1152+
if (string.IsNullOrEmpty(loginDto.Email) ||
1153+
string.IsNullOrEmpty(loginDto.Password))
1154+
return BadRequest("Please provide 'Email' and 'Password'");
1155+
1156+
// 檢查員工帳號
1157+
if (await _employeeService.GetEmployeeByEmailAsync(loginDto.Email) == null)
1158+
return BadRequest("User does not exist!");
1159+
1160+
// 檢查員工密碼並回傳登入結果
1161+
if (!await _authService.ValidateUserAsync(loginDto))
1162+
return BadRequest("Login failed!");
1163+
1164+
// 產生 Jwt 方法
1165+
1166+
return Ok("JWT Token");
1167+
}
1168+
```
1169+
1170+
### JWT 設定
1171+
1172+
回故一下,JWT 所需要的加載的資料內容,參考 **_[JWT Payload](#payload)_**,有一些資料是相對靜態的,所以可以設定在 `appsettings.json` 中,從設定檔中讀取,如 iss(發行單位),**到期時間**
1173+
1174+
這要要特別說明一下 **到期時間**,通常會是指從 **發行 JWT 之後,持續多久(月,天,分),並不是指一個故定的到期時間點,所以這個持續多久是可以固定下來的,因此可以寫在設定檔**。如果是某一固定時間點的話,會變成不管什麼時候發行的 Token,都會在那一個時間點失效,即使是在有效期之後發行的也是一樣,會造成還沒發行就失效了,所以不合理。
1175+
1176+
開啟 `appsettings.json`,新增「**JwtOptions**」設定,如明如下
1177+
1178+
- **Expiry:有效期限(分)**,我預設是分鐘,比較方便測試,有需要也可以當成天
1179+
- **Issuer:發行單位**,我輸入這個專案的名稱
1180+
- **SecretKey:加密金鑰,隨便輸入的字**,不一定要跟我一樣,另外這只是因**為教學紀錄的專案**,並沒有涉及機密資料,**實際的產品應用上,是不能洩漏加密金鑰的**
1181+
1182+
```json
1183+
{
1184+
"Logging": {
1185+
"LogLevel": {
1186+
"Default": "Information",
1187+
"Microsoft.AspNetCore": "Warning"
1188+
}
1189+
},
1190+
"AllowedHosts": "*",
1191+
"JwtOptions": {
1192+
"Expiry": 5,
1193+
"Issuer": "JWT-Authentication-API",
1194+
"SecretKey": "This-is-secret-key-for-JWT-Authentication"
1195+
}
1196+
}
1197+
```
1198+
1199+
### 加載 JWT 設定
1200+
1201+
有兩種方式可以讀取設定檔
1202+
1203+
1. 使用 Configuration 注入
1204+
1205+
```csharp
1206+
// 用建構函數傳入
1207+
public class TargetClass(IConfiguration configuration)
1208+
{
1209+
// 在要被注入的類別中宣告 Configuration 的物件
1210+
private readonly IConfiguration _configuration = configuration;
1211+
1212+
public void Foo() {
1213+
// 讀取設定值 "第一層Key:第二層Key"
1214+
var secretKey = _configuration.GetValue<string>("JwtSettings:SecretKey");
1215+
//
1216+
// 用 GetSection 方法先取得第一層的物件
1217+
// 再用 GetValue 取得第二層 Key 的設定值
1218+
var secretKey = _configuration.GetSection("JwtSettings").GetValue<string>("SecretKey");
1219+
1220+
// 上面兩種寫法效果是一樣的
1221+
}
1222+
}
1223+
```
1224+
1225+
2. 自定義一個設定類別,將設定值物件化
1226+
1227+
我採用第二個方法,為強化資料型別管理,但會比較麻煩,可以自行取舍
1228+
1229+
#### 定義一個設定類別
1230+
1231+
在專案下新增 `Options` 目錄,**用來存放設定物件的類別**,在目錄下新增一個 `JwtOptions.cs`,並依 `appsettings.json` 的相關設定定義資料欄位如下
1232+
1233+
※ 同樣都是承載資料的模型物件,為什麼不放在 `Models` 目錄?**因為 Settings 或 Options 相關的資料物件只會用在程式中資料的設定,不會與資料庫的資庫互動**,這樣語意更清析
1234+
1235+
```csharp
1236+
namespace JWT_Authentication_API.Options;
1237+
1238+
/// <summary>
1239+
/// Jwt 的設定物件
1240+
/// </summary>
1241+
public class JwtOptions
1242+
{
1243+
/// <summary>
1244+
/// 有效期限(分),預設 10 分鐘
1245+
/// </summary>
1246+
public int Expiry { get; set; } = 10;
1247+
1248+
/// <summary>
1249+
/// 發行單位
1250+
/// </summary>
1251+
public string Issuer { get; set; } = "JWT_Authentication_API";
1252+
1253+
/// <summary>
1254+
/// 加密金鑰
1255+
/// </summary>
1256+
public string SecretKey { get; set; } = Guid.NewGuid().ToString();
1257+
}
1258+
```
1259+
1260+
接下來在 `Program.cs` 中註冊這個自定定的 `JwtOption` 類別,**並封裝在 IOptions 的類別中**,在 `AddScoped` 方法的後面加入註冊方法如下
1261+
1262+
```csharp
1263+
#region CustomService
1264+
builder.Services
1265+
.AddScoped<IEmployeeService, EmployeeService>()
1266+
.AddScoped<IAuthService, AuthService>()
1267+
// 註冊這個 JwtOptions 的物件,並封裝成 IOptions 型別,讓其他類別可以注入使用
1268+
.Configure<JwtOptions>(builder.Configuration.GetSection(nameof(JwtOptions)));
1269+
#endregion
1270+
```
1271+
1272+
設定的前置就完成了,可以在 Jwt 相關類別中使用
1273+
1274+
### 產生 JWT
1275+
1276+
為了產生合法的 JWT,在專案下新增一個 `Helper` 目錄,用來存放**輔助的相關類別**,在目錄下新增一個 **`JwtHelper.cs` 用來加載 JWT 的設定並產生 JWT**
1277+
1278+
#### 注入 JwtOptions
1279+
1280+
先注入 JWT 的相關設定
1281+
1282+
```csharp
1283+
using JWT_Authentication_API.Options;
1284+
using Microsoft.Extensions.Options;
1285+
1286+
namespace JWT_Authentication_API.Helper;
1287+
1288+
/// <summary>
1289+
/// JSON Web Token 輔助工具
1290+
/// 注入 JwtOptions
1291+
/// </summary>
1292+
public class JwtHelper(IOptions<JwtOptions> jwtOptions)
1293+
{
1294+
/// <summary>
1295+
/// Jwt 的相關設定
1296+
/// </summary>
1297+
private readonly JwtOptions _jwtOptions = jwtOptions.Value;
1298+
}
1299+
```
1300+
1301+
#### 將使用者登入資訊加入 Payload 中
1302+
1303+
使用者**資訊會使用 `List<Claim>`** 中,**一個 `Claim` 就是使用者的其中一項資料**
1304+
可以想成 **出國用的護照**
1305+
1306+
- **護照號碼:是一個 Claim**
1307+
- **中文姓名:是一個 Claim**
1308+
- **英文姓名(拼音):是一個 Claim**
1309+
- **照片:是一個 Claim**
1310+
- **國籍:是一個 Claim**
1311+
1312+
**而上面這麼多個 Claim 就會組成護照,變成出國用的身分證明,只是在網路的世界,使用者的身份證明變成了 `List<Claim>` 型式,而在 JWT 的應用場景中,Claim 變成了 JWT 所需要的資料**,在 `JwtHelper` 中新增一個 `CreateJwt` 方法,並傳入使用者資料,寫入 payload,如下
1313+
1314+
```csharp
1315+
/// <summary>
1316+
/// 產生 JWT
1317+
/// </summary>
1318+
/// <param name="loginDto"> 使用者的登入資訊 </param>
1319+
/// <returns> JSON Web Token </returns>
1320+
public string CreateJwt(LoginDto loginDto)
1321+
{
1322+
var now = DateTimeOffset.UtcNow;
1323+
1324+
// 設定 Payload
1325+
List<Claim> claims = [
1326+
// 發行單位
1327+
new(JwtRegisteredClaimNames.Iss, _jwtOptions.Issuer),
1328+
// 使用者帳號作為識別
1329+
new(JwtRegisteredClaimNames.Sub, loginDto.Email),
1330+
// Token 的有效期限,從現在開始到 5 分鐘後
1331+
new(JwtRegisteredClaimNames.Exp, $"{now.AddMinutes(_jwtOptions.Expiry)
1332+
.ToUnixTimeSeconds()}"),
1333+
// 這個 JWT 的識別
1334+
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
1335+
// 這個 JWT 的發行時間
1336+
new(JwtRegisteredClaimNames.Iat, $"{now.ToUnixTimeSeconds()}")
1337+
];
1338+
// 產生使用者身分證明
1339+
ClaimsIdentity userClaimsIdentity = new(claims);
1340+
// 產生私鑰供後續加密使用
1341+
SymmetricSecurityKey securityKey = new(Encoding.UTF8.GetBytes(_jwtOptions.SecretKey));
1342+
// 產生數位簽章憑證,使用 SHA256 加密演算
1343+
SigningCredentials credentials = new(securityKey, SecurityAlgorithms.HmacSha256);
1344+
1345+
// 產生 JWT
1346+
JwtSecurityToken securityToken = new(
1347+
issuer: _jwtOptions.Issuer, // issuer
1348+
claims: userClaimsIdentity.Claims, // payload
1349+
signingCredentials: credentials, // signature
1350+
expires: now.AddMinutes(_jwtOptions.Expiry).DateTime); // expiry time
1351+
1352+
// 輸出 JWT 並轉換成字串
1353+
return new JwtSecurityTokenHandler().WriteToken(securityToken);
1354+
}
1355+
```
1356+
1357+
`Program.cs` 中,註冊 `JwtHelper`,因為 JwtHelper 的設定是固定不變的,所以不像 `EmployeeService` 只會存在某些特定的 Controller 中,一但 Request 的生命週期結束,依賴的 Service 就必需要結束,所以 **JwtHelper 要使用 `AddSingleton` 來註冊**
1358+
1359+
```csharp
1360+
// 註冊 JwtHelper
1361+
builder.Services.AddSingleton<JwtHelper>();
1362+
```
1363+
1364+
回到 `AuthController` 將產生 JWT 的部分補上
1365+
1366+
```csharp
1367+
/// <summary>
1368+
/// 將 AuthController 宣告成為 ApiController
1369+
/// 並定義路由規則(網址)=> domain/api/auth
1370+
/// </summary>
1371+
/// <param name="employeeService"> 員工資料存取服務 </param>
1372+
/// <param name="authService"> 登入驗證服務 </param>
1373+
/// <param name="jwtHelper"> JWT 輔助工具 </param>
1374+
[ApiController, Route("api/[controller]")]
1375+
public class AuthController(
1376+
IEmployeeService employeeService,
1377+
IAuthService authService,
1378+
JwtHelper jwtHelper) : Controller
1379+
{
1380+
/// <summary>
1381+
/// 員工資料存取的服務
1382+
/// </summary>
1383+
private readonly IEmployeeService _employeeService = employeeService;
1384+
/// <summary>
1385+
/// 登入驗證的服務
1386+
/// </summary>
1387+
private readonly IAuthService _authService = authService;
1388+
/// <summary>
1389+
/// JWT 輔助工具,負責生成 JWT
1390+
/// </summary>
1391+
private readonly JwtHelper _jwtHelper = jwtHelper;
1392+
1393+
#region 註冊
1394+
......
1395+
#endregion
1396+
1397+
#region 登入
1398+
/// <summary>
1399+
/// 登入
1400+
/// </summary>
1401+
/// <param name="loginDto"> 使用者的輸入資料 </param>
1402+
/// <returns> 登入結果 </returns>
1403+
[HttpPost("login")]
1404+
public async Task<ActionResult<string>> LoginAsync(LoginDto loginDto)
1405+
{
1406+
// 登入資料驗證
1407+
if (string.IsNullOrEmpty(loginDto.Email) ||
1408+
string.IsNullOrEmpty(loginDto.Password))
1409+
return BadRequest("Please provide 'Email' and 'Password'");
1410+
1411+
// 檢查員工帳號
1412+
if (await _employeeService.GetEmployeeByEmailAsync(loginDto.Email) == null)
1413+
return BadRequest("User does not exist!");
1414+
1415+
// 檢查員工密碼並回傳登入結果
1416+
if (!await _authService.ValidateUserAsync(loginDto))
1417+
return BadRequest("Login failed!");
1418+
1419+
// 產生 Jwt
1420+
var jwt = _jwtHelper.CreateJwt(loginDto);
1421+
1422+
return Ok(jwt);
1423+
}
1424+
#endregion
1425+
}
1426+
```
1427+
1428+
### 驗證 JWT
1429+
1430+
完成 JWT 的部分後,就可以來測試了,由於在 **_[Login](#測試-login)_**.已經註冊過 peter 這個帳號了,現在用 peter 這個帳號來測試會不會回傳 JWT,也可以順便知道資料庫的運作,是不是真會存在 peter 這筆帳號資料
1431+
1432+
![JWT Test](jwt-test.png)
1433+
1434+
如果有看到 JWT 成功回傳,就代表登入方法成功了,同時也確認資料庫存取是沒有問題的
1435+
接著請把 JWT 複製,貼到 **[JWT IO](https://jwt.io/)** 的網站,它會協助驗證 JWT 的格式有沒有正確
1436+
1437+
![JWT IO Validate](jwt-io.png)
1438+
1439+
由上面的結果可以知道,JWT 結果和我寫入的一樣,這樣登入功能就完成了,這邊順便說一下,右下方的 Secret 驗證,其實是驗證金鑰的正確性,一般來說不會把金鑰公開,不過我這邊為了範例,我測試一下,把 **_[`appsettings.json`](#jwt-設定)_**`SecretKey` 複製貼上
1440+
1441+
![JWT secret validation](jwt-secret-validation.png)
1442+
1443+
會發現 SecretKey 也是 ok 的,這樣就驗證完成了
373 KB
Loading
58 KB
Loading
670 KB
Loading

0 commit comments

Comments
 (0)