@@ -3,6 +3,7 @@ date: "2025-03-15T16:43:21+08:00"
33draft : false
44title : ASP.NET Core 實作 JWT 認證
55description : 使用 JSON Web Token 實現安全的認證機制
6+ lastmod : 2025-03-21T01:56:49
67categories :
78 - WebSite
89tags :
@@ -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 的,這樣就驗證完成了
0 commit comments