為什麼會寫這一篇
在開發 ASP.NET Core Web API 時,JWT(JSON Web Token)是一種常見的認證方式。然而,在 Google 搜尋相關教學時,我發現大多數的範例都使用 ASP.NET Core Identity,但在企業內部系統中,很多公司會使用 自行設計權限與認證機制,在我的經驗中,系統登入/登出 是非常常見的功能,因此我想結合我的經驗實作一個 JWT 的認證。如果你希望在 ASP.NET Core Web API 中自行管理 JWT 認證,這篇文章會是一個不錯的參考。
本篇重點
- Rider
- JWT(JSON Web Token)
- Entity Framework Core
- 登入/登出/註冊
- Web API(Application Programming Interface)
閱讀說明書
本篇適合
- 有 C# 基礎
- 有 JSON 基礎
- 有 API 基礎知識
- 有 ASP.NET Core(MVC) 基礎
JWT(JSON Web Token)
JWT 全名是 JSON Web Token,是一種 輕量級的認證與授權機制,常用於 Web API 的身份驗證。它是一種 基於 Token 的驗證方式,不需要在伺服器端維護使用者的登入狀態,適合 無狀態(stateless) 應用,例如 RESTful API。
無狀態(stateless)
指 伺服器不會記住客戶狀態,使用者登入的資訊會記錄在客戶端的瀏覽器(使用 Cookie)
與之相對的就是 有狀態(stateful),早期 JSON 還沒發成熟時,很多系統會 將使用者登入資訊記錄在伺服器(使用 Session)來進行身份驗證,由於資料都是記錄在伺服器,所以容易造成伺服器運算效能問題,同時要記錄使用者登入資訊、進行使用者身份驗證……。
當然,由於使用者登入資訊記錄在使用者電腦瀏覽器中,會造成管理上的問題以及資訊安全風險,因些需要進行一些設定來防止資料外洩,下面實作會再介紹。
格式
接下來說明一下 JWT 的格式,由三個部分組成,每一個部分用「.」區隔
用來說明 Token 的類型及加密所使用的演算法,最後由 Base64Url 編碼整段 Header
- typ:Token 的類型,通常是 JWT(JSON Web Token)
- alg:加密演算法,通常是 SH256(HMAC SHA256)
1
2
3
4
| {
"typ": "JWT",
"alg": "SH256"
}
|
Payload
JWT 的主要資訊,通常包含使用者的登入資訊(Claims), 同樣也使用 Base64Url 編碼整個資訊
- iss:發行這個 Token 的單位,通常是 Server 自己
- sub:JWT 的使用者,通常會放使用者的 ID
- name:使用者的名稱,通常是使用者帳號
- aud:JWT 的接收單位,如果有開放給第三方使用的話,這裡會是呼叫端的 domain
- exp:JWT 的有效期限,以 UNIX Timestamp 表示,會是一串數字,為了安全性考量,不會讓使用者無限期登入
- iat:JWT 的產生時間,同上是一串數字,也就是這個 Token 是什麼時候建立的
其他通常會放企業自訂的資訊,如使用者角色「role」、部門「department」等
1
2
3
4
5
6
7
8
9
10
| {
"iss": "https://serverdomain.com/",
"sub": "6dced4f8-e22a-4a5e-87d9-81e8de14dc24",
"name": "useraccount@mail.com",
"aud": "https://thirdpartydomain.com",
"exp": 1712682600,
"iat": 1712675400,
"role": "admin",
"department": "IT"
}
|
由於 Payload 資訊使用 Base64Url 編碼(非加密),所以是可以還原的,因此不建議加入機密資料,如:使用者密碼、或身份證明資訊。
Signature
中文應該是翻成數位簽章之類的,就是將 Header,Payload 的資料加起來,用 Header 的加密演算法及伺服器的 Private Key 加密的結果,最後用這個結果確認登入資料有沒有被竄改。
程式邏輯以 JavaScript 表示看起來會像下面這樣
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| // 假設有 3 個方法
// 1. createSignature,負責產生 Signature
// 2. base64Url,將物件進行 base64 編碼
// 3. HMACSHA256,將資料進行 SHA256 加密
// 參數傳入一個 jwt 物件
createSignature(jwt) {
// 加密的 private key
string secretKey = "thisissecretkey";
// 加密資料
return HMACSHA256(
`${base64Url(jwt.Header)}.${base64Url(jwt.Payload)}`,
secretKey
);
}
|
PrivateKey(私鑰)是加密的關鍵之一,應該保存在伺服器,不可以外流
PrivateKey 及 Base64 非本編重點,這邊就不多加說明了
實作
其實我自己也是第一次作 JWT 驗證功能,最下方有我實作時看的影片,可以參考,但由於我的 .NET 版本是 8 版,所以有些地方略略有不同。
開發工具
- Rider
本來想用 Visual Studio 開發,不過由於要紀錄自己開發的過程,所以希望介面可以有善一點,最後決定使用 Rider 來作開發,也順便體驗 Rider 開發的感覺與 VS Code 有什麼不同。 - Google Cloud
主要作為資料庫,因為我的作業系統是 MacOS,所以使用 MS SQL Server 有一些不方便,所以在雲端上開了一個 MS SQL Server 作為資料庫連線使用,且在現實情境下,大部分的資料庫不會在同一個 Server,這邊也當作模擬真實的情景。
建立專案
開啟 Rider 新增一個 Web API 專案,輸入資訊如下
- Solution name:方案名稱
- Project name:專案名稱
- Solution directory:方案要存放的目錄
- Target framework:.NET 的版本,選「
net8.0
」就是 .NET8 - Language:使用的語言,選「
C#
」 - Create Git repository:要不要上傳版本控制,自己決定
- Template:專案範本,選「
Web API
」
其他可以不要動,如下圖

接著可以執行,按下右上角 綠色 bug 旁邊有一個 綠色 play

如果有看到 Swagger 的畫面而且網頁的主標題是專案名稱就是專案建立成功

開啟 Program.cs
,將預設的 api 程式碼清掉,保留部分如下
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
| WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
// Add services to the container.
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
WebApplication app = builder.Build();
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseHttpsRedirection();
app.Run();
|
安裝套件
我採用 Database-First 方式開發,也就是先建立資料模型再產生資料庫,其中會使用到 ORM(Object Relation Mapping) 物件關連對映的技術,所以會用到幾個 Nuget 的套件
- EntityFrameworkCore.Design
- EntityFrameworkCore.SqlServer
可以採用指令安裝或介面安裝
介面安裝
在 Rider 左下有一欄選單,可以找到 NuGet

在搜尋框中輸入套件名稱「EntityFrameworkCore.SqlServer」,通常第一個就會是我們要的結果,在 NuGet 視窗右半邊會看到版本,由於 .NET8 可以相容到 9.0.3 的版本,所以版本選「9.0.3」,當然你也可以選擇適合你本機環境的版本,如果你不是使用 .NET8 的話
在右半邊下方會看到專案的列表,我目前只有一個專案,所以只會出現一個,接著在要安裝的專案按下「+」就會安裝了,如下圖

會出現提示安裝的對話框,按下確定,另外一個套件再操作一次同樣的步驟

指令安裝
在 Rider 開啟終端機(Terminal)

輸入安裝指令如下,我撰寫這一篇文的時候是最新穩定版是 9.0.3,之後可能會更新,可以在上方 安裝套件 找到最新資訊
1
2
| dotnet add package Microsoft.EntityFrameworkCore.SqlServer --version 9.0.3
dotnet add package Microsoft.EntityFrameworkCore.Design --version 9.0.3
|
建立資料模型
以 ORM 的概念來說,可以把一個 Entity 想像成一張資料表
在專案目錄下新增一個 Entities
的目錄,參考下圖,在「class/interface」選項下有一個「Directory」的選項,點擊後輸入目錄名稱 Entities
,這個目錄將存放所有與資料庫互動的資料模型檔
Employee
以註冊、登入、登出這三個功能來說,一張員工的資料表已經夠了
並 Entities
目錄下新增 Employee.cs
檔案,作為員工資料模型,如下圖


員工資料模型內容如下
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
| using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace JWT_Authentication_API.Entities;
/// <summary>
/// 員工資料模型
/// </summary>
public class Employee
{
/// <summary>
/// 員工資料識別(PK)
/// </summary>
[Key]
// 告訴資料庫這是自動產值的欄位,讓資料庫自行產生 key 值
// 這樣程式就不用處理了
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public Guid Id { get; set; }
/// <summary>
/// 員工信箱:必要欄位
/// </summary>
[Required]
public string Email { get; set; } = string.Empty;
/// <summary>
/// 密碼:必要欄位,且是加密過的值
/// </summary>
[Required]
public string PasswordHash { get; set; } = string.Empty;
/// <summary>
/// 員工資料建立的時間,預設值是建立的當下
/// </summary>
public DateTimeOffset CreatedOn { get; set; } = DateTimeOffset.UtcNow;
/// <summary>
/// 員工資料修改的間,預設值是修改的當下
/// </summary>
public DateTimeOffset? ModifiedOn { get; set; }
}
|
欄位說明如註解,特別說明幾個重點
- Email:員工的信箱,一般來而會使用員工的英文名字 + 公司內部的 mail server,因此已包含了姓名相關的資訊,所以為了教學我簡化了姓名「Name」欄位
- CreateOn, ModifyOn:公司內部有時候會有某筆資料出問題的,經由某位員工或使用者反應,如登入失敗,為了要追查使用軌跡,會加入相關的時間欄位
- Password:密碼應該要加密後再存入資料庫,所以這個欄位會存放加密後的值。
建立 DBContext
在建立資料模型有提到,在 ORM 中一個 Entity 是資料表的概念,那 DBContext 就是一個資料庫的概念,所以要建立一個專屬資料庫的類別作為資料庫的 Mapping,這個類別需要繼承「DBContext」
下面在 Entities
中建立一個 AppDbContext.cs
的類別
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| using Microsoft.EntityFrameworkCore;
namespace JWT_Authentication_API.Entities;
/// <summary>
/// Database Context
/// </summary>
/// <param name="options"> 資料庫連線相關設定,以 DI 形式 </param>
public class AppDbContext(DbContextOptions<AppDbContext> options)
: DbContext(options)
{
/// <summary>
/// 員工資料表
/// </summary>
public DbSet<Employee> Employees { get; set; }
}
|
建立實體資料庫
資料模型及 DBContext 都建立好了之後,就可以建立實體資料庫了,在根目錄下開啟 appsettings.json
並**新增 ConnectionString(資料庫的連線字串)**如下
由於連線字串涉及我的資料庫帳號密碼,這邊需要改成自己的資料庫連線字串,參數說明如下
- Server:資料庫伺服器的 IP,如果像我一樣架在雲端,就會是雲端主機的對外 IP,「,」後面接資料庫的 port,通常都是 1433。
- Database:資料庫的名稱,也就是要連線到的目標資料庫
- user:資料庫使用者的帳號,如果有建立「sa」的話就可以寫 sa
sa(System Admin),是微軟 SQL Server 預設的最高權限帳號,通常安裝資料庫的時候會一起設定。 - password:使用者密碼
- TrustServerCertificate:是否信任伺服器的 SSL 憑證,如果 True 就不會檢查 Server 的憑證,可以自己決定
1
2
3
4
5
6
7
8
9
10
11
12
| {
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"ConnectionString": {
"AppDb": "Server=YourServerIP,1433;Database=YourDatabaseName;user=username;password=YourDatabasePassword;TrustServerCertificate=True;"
}
}
|
注入資料庫服務
開啟根目錄 Program.cs
,注入資料庫服務
1
2
3
4
5
6
7
| // Add SQL Server Database Service
// builder.Configuration 會參考到 `appsettings.json`
// GetConnectionString 方法會抓到資料庫連線字串,參數傳入上面設定的連線字串名字
builder.Services.AddDbContext<AppDbContext>(optionsBuilder =>
{
optionsBuilder.UseSqlServer(builder.Configuration.GetConnectionString("AppDb"));
});
|
新增 Migration
接著在專案右鍵 → Entity Framework Core → Add Migration

按下「OK」

會在下方看到 EF Core 建立 Migration 的結果,如果沒有錯誤訊息(如下),就是成功了

成功後會看到專案下出現了 Migration 的目錄,並且出現兩個檔案,是紀錄資料模型的變更,每更新一次模型,就會再多出兩個檔,其中一個檔案會以「timestamp_MigrationName」命名

更新資料庫
接下來要將變更的模型,對映到資料庫,以產生實際的資料表
在專案右鍵 → Entity Framework Core → Update Database

出現更新對話框,Target migration,要設成上面產生的 migration 檔,確認後按下 OK

會在下方 EF Core 的視窗中看到執行更新資料庫的過程及執行的 SQL 語法,如果沒有看到錯誤訊息,就是成功了

驗證資料庫
Rider 提供了一個資料庫的圖型介面,讓我們可以確認資料庫的內容,在最右邊會看到一個資料庫的 icon,點擊後有一個「+」,選擇 Connect to Database

出現對話框後,選擇「use connection string」,將 建立實體資料庫 中的連線字串值(AppDB 不用)複製並貼上

貼上後等它跑一下,會出現 Test Connection,可以測試連線字串正確性,但一般來說,在貼上的時候,Rider 就會自動幫你測試了,確認無誤後可以按下「Connect to Database」連線到資料庫

就可以順利看到資料庫了

資料庫建立完成後,就可以開始實作「註冊」、「登入」、「登出」功能了
AuthController
首先新增一個 Controllers
目錄,在目錄中再新增 AuthController.cs


新增完成後,將下 AuthController.cs
預設的方法刪掉,並宣告成 ApiController 如下
1
2
3
4
5
6
7
8
9
10
11
12
| using Microsoft.AspNetCore.Mvc;
namespace JWT_Authentication_API.Controllers;
/// <summary>
/// 將 AuthController 宣告成為 ApiController
/// 並定義路由規則(網址)=> domain/api/auth
/// </summary>
[ApiController, Route("api/[controller]")]
public class AuthController : Controller
{
}
|
- ApiController:將目標控制器宣告成 API 控制器
- Route:定義路由規則,[controller] 會變成目標控制器的名字,最後與 domain 組合成 API 網址
註冊
在 AuthController.cs
中新增註冊方法 register,並且使用 HttpPost 方式呼叫,其中參數 “register” 是路由的一部分,會加在網址的最後,有寫過 ASP.NET NVC 的話,很像傳統 MVC 的路由機制 Controller/Action,所以這個 [HttpPost(“register”)] 最後會變成 domain/api/auth/register
這個網址
1
2
3
4
5
| [HttpPost("register")]
public ActionResult Register()
{
}
|
RegisterDto
接著需要一個 model 來負責接收註冊資料,在根目錄下新增 Models
目錄,在目錄下新增 RegisterDto.cs
如果有寫過 ASP.NET MVC 的話,也可以把它想像成是 ViewModels
,不過因為 Api 沒有 View 的部分,我怕混淆所以改放在 Models
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| namespace JWT_Authentication_API.Models;
/// <summary>
/// 註冊使用的資料模型
/// </summary>
public class RegisterDto
{
/// <summary>
/// 使用者帳號
/// </summary>
public string Email { get; set; } = string.Empty;
/// <summary>
/// 使用者密碼(未加密)
/// </summary>
public string Password { get; set; } = string.Empty;
}
|
HttpPostRegister
回到 AuthController.cs
補上註冊方法的程式碼如下,說明如註解
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
| /// <summary>
/// 註冊 API
/// </summary>
/// <param name="registerDto"> 使用者傳送的員工註冊資料 </param>
/// <returns> 註冊完成的員工資料 </returns>
[HttpPost("register")]
public ActionResult Register(RegisterDto registerDto)
{
// 驗證註冊資料
if (string.IsNullOrEmpty(registerDto.Email)
|| string.IsNullOrEmpty(registerDto.Password))
return BadRequest("Please provide 'Email' and 'Password'");
// 建立員工資料
Employee employee = new() { Email = registerDto.Email };
// 將密碼加密
employee.PasswordHash = new PasswordHasher<Employee>()
.HashPassword(employee, registerDto.Password);
// 回傳建立完成的員工資料
return Ok(employee);
}
|
測試註冊
安裝 scalar
在 NuGet 中搜尋「scalar」找到「Scalar.AspNetCore」並安裝

在 Program.cs
進行 scalar 的服務注入,影片中使用的版本是 .NET 9
的版本配置方式,我的環境是 .NET 8
的版本,所以略有不同
可以參考 Scalar 基本設定 及 .NET 8 的 Scalar 設定方式
開啟 Program.cs
,改寫如下,其中 if 區塊是原本就有的,只需要更改區塊內的內容
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
| // Add services to the container.
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
// 補上服務對控制器的注入
builder.Services.AddControllers();
#region Swagger Service
// Add Swagger service
builder.Services
.AddEndpointsApiExplorer()
.AddSwaggerGen();
#endregion
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
// See https://github.com/scalar/scalar/blob/main/integrations/aspnetcore/README.md#usage
app.UseSwagger(options =>
options.RouteTemplate = "swagger/{documentName}/swagger.json"
);
// 因為要使用 scalar 的 UI,所以這邊將 Swagger UI 關閉
// app.UseSwaggerUI();
// See https://github.com/scalar/scalar/blob/main/documentation/integrations/dotnet.md#openapi-document-route
app.MapScalarApiReference(options =>
options.WithOpenApiRoutePattern("swagger/{documentName}/swagger.json"));
}
// 啟用控制器的 Route
app.MapControllers();
|
參考 建立專案 的執行方式執行網站,將網址改成 http://localhost:7274/scalar/v1
,你的 port 可能跟我的不一樣

有兩種測試方式
- 執行後直接測試
- 執行後使用 Postman 測試
執行後測試
這裡先採用執行後測試的方法,在上面執行的網頁畫面,點 Auth
下面的 Api 網址,再按下「Test Request」

接著輸入要註冊的帳號密碼如下圖,只要有看到 200 及使用者註冊資料回傳就算成功了

當然也可以測試,沒有帳號或沒有密碼的情況,看看驗證訊息是否正確,參考 Register 方法 的第 10 ~ 12 行,有得到預期的驗證訊息就算成功了

登入
登入功能會涉及到資料庫,因為需要將員工的註冊資料儲存到資料庫,並在登入時進行驗證,所以會複雜一些
LoginDto
跟註冊一樣,需要一個 Model 來紀錄傳送的登入資訊,所以在 Model
目錄下新增 LoginDto.cs
由於教學的場景假設「註冊」與「登入」都只有傳送「帳號/密碼」,所以可以偷懶一點直接繼承 RegisterDto.cs
,但其他真實的場景可能會有一些差異,因些不建議直接繼承,且基於可讀性原則,我還是建一個 LoginDto
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| namespace JWT_Authentication_API.Models;
/// <summary>
/// 使用者登入的資料模型
/// </summary>
public class LoginDto
{
/// <summary>
/// 使用者帳號
/// </summary>
public string Email { get; set; } = string.Empty;
/// <summary>
/// 使用者密碼(未加密)
/// </summary>
public string Password { get; set; } = string.Empty;
}
|
偷懶的作法
1
2
3
4
5
6
| namespace JWT_Authentication_API.Models;
public class LoginDto: RegisterDto
{
}
|
分層架構
接下來要將 Register
方法做一些修改,將使用者註冊資料存到資料庫中,好讓之後的「登入」方法可以使用,並採用分層架構,讓程式碼可讀性更高
首先將建立員工資料的部分抽出來,不直接將資料存取功能放在 Controller,在專案下建立 Services
及 Interfaces
兩個目錄
- Services:用來放存取資料的相關服務
- Interfaces:服務的介面,供服務實作
當然分層架構還可以再抽出一層 Repository
不過這篇為教學簡化,所以只先抽 Service
IEmployeeService
在 Interfaces
目錄下新增 IEmployeeService.cs
,新增員工服務介面,用來定義員工資料的存取功能,註冊就是新增一筆員工資料,所以在介面加入方法定義如下
- 新增員工
- 取得員工資料:用來檢查是否有重複註冊
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
| using JWT_Authentication_API.Models;
namespace JWT_Authentication_API.Interfaces;
/// <summary>
/// 員工資料存取介面
/// </summary>
public interface IEmployeeService
{
/// <summary>
/// 新增員工資料
/// </summary>
/// <param name="registerDto"> 員工註冊資料 </param>
/// <returns> 註冊結果 </returns>
Task<bool> AddEmployeeAsync(RegisterDto registerDto);
/// <summary>
/// 依帳號取得員工資料
/// </summary>
/// <param name="email"> 員工帳號 </param>
/// <returns> 員工資料,如果找不到就 null </returns>
Task<RegisterDto?> GetEmployeeByEmailAsync(string email);
}
|
EmployeeService
在 Services
目錄新增一個 EmployeeService.cs
並實作 IEmployeeService.cs
介面定義的方法如下
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
| using System.ComponentModel.DataAnnotations;
using JWT_Authentication_API.Entities;
using JWT_Authentication_API.Interfaces;
using JWT_Authentication_API.Models;
using Microsoft.AspNetCore.Identity;
namespace JWT_Authentication_API.Services;
/// <summary>
/// 員工資料存取服務
/// </summary>
public class EmployeeService(AppDbContext dbContext) : IEmployeeService
{
private readonly AppDbContext _appDb = dbContext;
/// <summary>
/// 新增員工資料
/// </summary>
/// <param name="registerDto"> 員工註冊資料 </param>
/// <returns> 註冊結果 </returns>
public async Task<bool> AddEmployeeAsync(RegisterDto registerDto)
{
// 這裡也可以不用檢查,`Controller` 檢查過一次了
// 但基於安全性,我再檢查一次
if (string.IsNullOrEmpty(registerDto.Email)
|| string.IsNullOrEmpty(registerDto.Password))
throw new ValidationException("Email or password is required");
// Create new employee data.
Employee employee = new() { Email = registerDto.Email };
// Hash password
employee.PasswordHash = new PasswordHasher<Employee>()
.HashPassword(employee, registerDto.Password);
// Insert into database
await _appDb.Employees.AddAsync(employee);
// Save changes
var result = await _appDb.SaveChangesAsync();
return result > 0;
}
/// <summary>
/// 依帳號取得員工資料
/// </summary>
/// <param name="email"> 註冊信箱/登入信箱 </param>
/// <returns> 員工資料 或 null </returns>
public async Task<RegisterDto?> GetEmployeeByEmailAsync(string email)
{
var employee = await _appDb.Employees
.FirstOrDefaultAsync(e => e.Email == email);
return employee == null
? null
: new RegisterDto
{
Email = employee.Email,
Password = employee.PasswordHash
};
}
}
|
修改註冊方法
回到 AuthController
的 Register
方法並加入員工服務物件的注入,修改如下
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
| using JWT_Authentication_API.Interfaces;
using JWT_Authentication_API.Models;
using Microsoft.AspNetCore.Mvc;
namespace JWT_Authentication_API.Controllers;
/// <summary>
/// 將 AuthController 宣告成為 ApiController
/// 並定義路由規則(網址)=> domain/api/auth
/// </summary>
/// <param name="employeeService"> 員工資料存取服務 </param>
[ApiController, Route("api/[controller]")]
public class AuthController(IEmployeeService employeeService) : Controller
{
/// <summary>
/// 員工資料存取的服務
/// </summary>
private readonly IEmployeeService _employeeService = employeeService;
/// <summary>
/// 註冊 API
/// </summary>
/// <param name="registerDto"> 使用者傳送的員工註冊資料 </param>
/// <returns> 註冊完成的員工資料 </returns>
[HttpPost("register")]
public async Task<ActionResult> RegisterAsync(RegisterDto registerDto)
{
// 驗證註冊資料
if (string.IsNullOrEmpty(registerDto.Email)
|| string.IsNullOrEmpty(registerDto.Password))
return BadRequest("Please provide 'Email' and 'Password'");
// 先查詢有沒有重複的員工資料
var employee = await _employeeService.GetEmployeeByEmailAsync(registerDto.Email);
// 如果沒有就透過服務註冊員工資料
var registerResult =
employee == null &&
await _employeeService.AddEmployeeAsync(registerDto);
// 回傳註冊結果
return registerResult
? Ok("Register successfully!")
: employee != null
? BadRequest("User already exists!")
: BadRequest("Failed to register user");
}
}
|
Employee DI
因為 AuthController
使用了 EmployeeService
依賴注入,所以需要去 Program.cs
註冊 EmployeeService
的服務,讓 AuthController
可以透過建構函式注入服務
特別注意一定要新增在 builder.Build()
的上面任一地方,因為 呼叫 Build 方法後,就不能在註冊服務了
1
2
3
4
5
| #region CustomService
builder.Services.AddScoped<IEmployeeService, EmployeeService>();
#endregion
WebApplication app = builder.Build();
|
Login
正式開始實作登入,在 AuthController.cs
新增 Login
方法,接收參數為使用者輸入的帳號密碼
登入的部分可以分成兩步
- 檢查有沒有註冊這個員工,上面在 分層架構 已經完成了,如果沒有就回傳錯誤訊息。
- 驗證員工帳號密碼:如果第 1 步有找到註冊的員工資料,就要驗證輸入的帳號密碼,最後回傳驗證結果。
詳細流程如下
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
| ┌─────┐
│Start│
└──┬──┘
_______▽________ _________________
╱ ╲ ╱ ╲ ┌─────────────────────┐
╱ Missing Username ╲__________________________________________╱ Missing Username? ╲___│Username is required!│
╲ or Password ╱yes ╲ ╱yes└──────────┬──────────┘
╲________________╱ ╲_________________╱ │
│no │no │
┌────────▽───────┐ ┌──────────▽─────────┐ │
│Check Username │ │Password is required│ │
│and PasswordHash│ └──────────┬─────────┘ │
└────────┬───────┘ │ │
_______▽________ │ │
╱ ╲ ┌──────────────────┐ │ │
╱ Invalid Username ╲_____________________│Username not found│ │ │
╲ ╱yes └─────────┬────────┘ │ │
╲________________╱ │ │ │
│no │ │ │
┌──────────▽──────────┐ │ │ │
│Validate PasswordHash│ │ │ │
└──────────┬──────────┘ │ │ │
_________▽__________ │ │ │
╱ ╲ ┌──────────────┐ │ │ │
╱ Invalid PasswordHash ╲___│Wrong Password│ │ │ │
╲ ╱yes└───────┬──────┘ │ │ │
╲____________________╱ │ │ │ │
│no │ │ │ │
┌─────────▽────────┐ │ │ │ │
│Login successfully│ │ │ │ │
└─────────┬────────┘ │ │ │ │
└───────────────────────┴┬────────────────┴────────────────────┴────────────────────────┘
┌─▽─┐
│End│
└───┘
|
新增一個 [HttpPost]
的 LoginAsync
方法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| /// <summary>
/// 登入
/// </summary>
/// <param name="loginDto"> 使用者的輸入資料 </param>
/// <returns> 登入結果 </returns>
[HttpPost("login")]
public async Task<string> LoginAsync(LoginDto loginDto)
{
// 登入資料驗證
if (string.IsNullOrEmpty(loginDto.Email) ||
string.IsNullOrEmpty(loginDto.Password))
return "Please provide 'Email' and 'Password'";
// 檢查員工帳號
if (await _employeeService.GetEmployeeByEmailAsync(loginDto.Email) == null)
return "User does not exist!";
// 檢查員工密碼並回傳登入結果
return result;
}
|
驗證員工密碼
員工密碼的檢查屬於「驗證」的範疇,所以不能寫在 Employee
的相關服務及定義
在 Interface
目錄下新增 IAuthService.cs
,在 Services
目錄下新增 AuthService.cs
目錄結構如下
1
2
3
4
5
| JwtAuthenticationAPI.csproj
├─Services
│ └─AuthService.cs
└─Interfaces
└─IAuthService.cs
|
在 IAuthService.cs
新增驗證密碼方法定義
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| using JWT_Authentication_API.Models;
namespace JWT_Authentication_API.Interfaces;
/// <summary>
/// 驗證服務介面
/// </summary>
public interface IAuthService
{
/// <summary>
/// 驗證員工登入
/// </summary>
/// <param name="loginDto"> 員工登入資料 </param>
/// <returns> 驗證結果</returns>
Task<bool> ValidateUserAsync(LoginDto loginDto);
}
|
在 AuthService.cs
實作驗證方法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
| using JWT_Authentication_API.Interfaces;
using JWT_Authentication_API.Models;
using Microsoft.AspNetCore.Identity;
namespace JWT_Authentication_API.Services;
/// <summary>
/// 驗證服務
/// </summary>
public class AuthService(IEmployeeService employeeService): IAuthService
{
/// <summary>
/// 員工資料存取服務
/// </summary>
private readonly IEmployeeService _employeeService = employeeService;
/// <summary>
/// 驗證員工登入密碼
/// </summary>
/// <param name="loginDto"> 員工登入資料 </param>
/// <returns> 驗證結果 </returns>
public async Task<bool> ValidateUserAsync(LoginDto loginDto)
{
if(string.IsNullOrEmpty(loginDto.Email)
|| string.IsNullOrEmpty(loginDto.Password))
throw new ArgumentException($"Invalid {nameof(loginDto.Email)} or {nameof(loginDto.Password)}!");
// 取得員工資料進行驗證
var employee = await _employeeService.GetEmployeeByEmailAsync(loginDto.Email);
if (employee == null) throw new NullReferenceException("Employee not found!");
// 回傳驗證結果
return new PasswordHasher<RegisterDto>().VerifyHashedPassword(employee,
employee.Password, loginDto.Password)
== PasswordVerificationResult.Success;
}
}
|
最後再回到 AuthController.cs
注入驗證的服務物件,並在 LoginAsync
方法加入驗證的程式
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
| using JWT_Authentication_API.Interfaces;
using JWT_Authentication_API.Models;
using Microsoft.AspNetCore.Mvc;
namespace JWT_Authentication_API.Controllers;
/// <summary>
/// 將 AuthController 宣告成為 ApiController
/// 並定義路由規則(網址)=> domain/api/auth
/// </summary>
/// <param name="employeeService"> 員工資料存取服務 </param>
/// <param name="authService"> 登入驗證服務 </param>
[ApiController, Route("api/[controller]")]
public class AuthController(
IEmployeeService employeeService,
IAuthService authService) : Controller
{
/// <summary>
/// 員工資料存取的服務
/// </summary>
private readonly IEmployeeService _employeeService = employeeService;
/// <summary>
/// 登入驗證的服務
/// </summary>
private readonly IAuthService _authService = authService;
/// <summary>
/// 註冊 API
/// </summary>
/// <param name="registerDto"> 使用者傳送的員工註冊資料 </param>
/// <returns> 註冊完成的員工資料 </returns>
[HttpPost("register")]
public async Task<ActionResult> RegisterAsync(RegisterDto registerDto)
{
// 驗證註冊資料
if (string.IsNullOrEmpty(registerDto.Email)
|| string.IsNullOrEmpty(registerDto.Password))
return BadRequest("Please provide 'Email' and 'Password'");
// 先查詢有沒有重複的員工資料
var employee = await _employeeService.GetEmployeeByEmailAsync(registerDto.Email);
// 如果沒有就註冊新員工
var registerResult =
employee == null &&
await _employeeService.AddEmployeeAsync(registerDto);
// 回傳註冊結果
return registerResult
? Ok("Register successfully!")
: employee != null
? BadRequest("User already exists!")
: BadRequest("Failed to register user");
}
/// <summary>
/// 登入
/// </summary>
/// <param name="loginDto"> 使用者的輸入資料 </param>
/// <returns> 登入結果 </returns>
[HttpPost("login")]
public async Task<string> LoginAsync(LoginDto loginDto)
{
// 登入資料驗證
if (string.IsNullOrEmpty(loginDto.Email) ||
string.IsNullOrEmpty(loginDto.Password))
return "Please provide 'Email' and 'Password'";
// 檢查員工帳號
if (await _employeeService.GetEmployeeByEmailAsync(loginDto.Email) == null)
return "User does not exist!";
// 檢查員工密碼並回傳登入結果
return await _authService.ValidateUserAsync(loginDto)
? "jwt-token"
: "Login failed";
}
}
|
基本上,就完成了,下面 JWT 實作的時候會再改成所產生的 JWT,最後因為有注入 IAuthService
驗證相關的服務,所以要去 Program.cs
註冊如下
1
2
3
4
5
| #region CustomService
builder.Services
.AddScoped<IEmployeeService, EmployeeService>()
.AddScoped<IAuthService, AuthService>();
#endregion
|
測試 Login
這邊示範用 Postman 進行測試,請先執行專案,確認執行成功後開啟 Postman,並按下「+」新增一個 Request 分頁

將 Request 改成 「POST」,並輸入 API 網址,可以參考 測試註冊 章節最後一張圖的網址
下方選擇「Body」,在 Body 下方圈選「raw」,row 旁邊下拉改成「JSON」
並輸入要註冊的帳號密碼如下,完成後按下「Send」,結果就會出現在下方,如下圖
1
2
3
4
| {
"email": "",
"password": ""
}
|
要測試登入,所以一定要先註冊一個使用者,也順便用 Postman 測試註冊功能



將網址改成登入的 API 網址,測試登入


JWT
接下來要實作產生 JWT 的方法,並將上一節 LoginAsync
方法回傳值改成所產生的 JWT
下圖簡單的說明 JWT 的驗證流程
1, 2, 3 上一節已經實作了,不再說明
- 如果登入登入成功,就合回傳一個合法的 JWT
- 使用者需要存取授權的資源時,會將 JWT 傳送至 Server 進行驗證
- 如果驗證通過,就會回傳授權的資源;
如果未通過有兩種情況- 正確的 Token,但權限不夠,如:業務部門無法存取會計部門的帳務資料
會回傳 Http 403 - 錯誤的 Token,如 Token 過期或遭竄改,會回傳 Http 401(未授權)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
| ┌───────────┐ ┌──────────┐ ┌────────┐
│Client User│ │Web Server│ │Database│
└─────┬─────┘ └────┬─────┘ └───┬────┘
│ │ │
│ 1. Login with Username and Password │ │
│────────────────────────────────────>│ │
│ │ │
│ │2. Check user data and validate│
│ │──────────────────────────────>│
│ │ │
│ │ 3. Valid user data │
│ │<──────────────────────────────│
│ │ │
│ 4. Send JWT(Bearer token) │ │
│<────────────────────────────────────│ │
│ │ │
│ 5. Request for protected data │ │
│ Send Bearer token │ │
│────────────────────────────────────>│ │
│ │ │
│ 6. Token is valid and │ │
│ Send protected data │ │
│<────────────────────────────────────│ │
│ │ │
│6. Token is invalid or no permission │ │
│ return Http 401 or Http 403 │ │
│<────────────────────────────────────│ │
┌─────┴─────┐ ┌────┴─────┐ ┌───┴────┐
│Client User│ │Web Server│ │Database│
└───────────┘ └──────────┘ └────────┘
|
安裝 JwtBearer
開啟 NuGet,輸入 Microsoft.AspNetCore.Authentication.JwtBearer
,並安裝,安裝流程可以參考 安裝套件,記得安裝符合自已的 .NET 版本,我的本機是 .NET 8 所以我只能安裝 8.x.x 的版本,詳細可以參考 NuGet JwtBearer
修改登入方法
由上面的流程圖可以知道,JWT 的驗證,會回傳 Http 的結果,因此要統一每個 API 回傳的型別,原 LoginAsync
方法回傳的登入的相關訊息(string),現在為了回傳 Http XXX
要修改回傳資料的型別如下
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
| /// <summary>
/// 登入
/// </summary>
/// <param name="loginDto"> 使用者的輸入資料 </param>
/// <returns> 登入結果 </returns>
[HttpPost("login")]
// 原本回傳型別是 Task<string> 是上一節為了測試而簡化
// 改成 Task<ActionResult<string>>,為了可以回傳 Http 結果
// Ok, BadRequest 都是繼承自 ActionResult 的類別
// 所以可以用 ActionResult 封裝
// Ok 會變成 Http 200
// BadRequest 會變成 Http 400
public async Task<ActionResult<string>> LoginAsync(LoginDto loginDto)
{
// 登入資料驗證
if (string.IsNullOrEmpty(loginDto.Email) ||
string.IsNullOrEmpty(loginDto.Password))
return BadRequest("Please provide 'Email' and 'Password'");
// 檢查員工帳號
if (await _employeeService.GetEmployeeByEmailAsync(loginDto.Email) == null)
return BadRequest("User does not exist!");
// 檢查員工密碼並回傳登入結果
if (!await _authService.ValidateUserAsync(loginDto))
return BadRequest("Login failed!");
// 產生 Jwt 方法
return Ok("JWT Token");
}
|
JWT 設定
回故一下,JWT 所需要的加載的資料內容,參考 JWT Payload,有一些資料是相對靜態的,所以可以設定在 appsettings.json
中,從設定檔中讀取,如 iss(發行單位),到期時間。
這要要特別說明一下 到期時間,通常會是指從 發行 JWT 之後,持續多久(月,天,分),並不是指一個故定的到期時間點,所以這個持續多久是可以固定下來的,因此可以寫在設定檔。如果是某一固定時間點的話,會變成不管什麼時候發行的 Token,都會在那一個時間點失效,即使是在有效期之後發行的也是一樣,會造成還沒發行就失效了,所以不合理。
開啟 appsettings.json
,新增「JwtOptions」設定,如明如下
- Expiry:有效期限(分),我預設是分鐘,比較方便測試,有需要也可以當成天
- Issuer:發行單位,我輸入這個專案的名稱
- SecretKey:加密金鑰,隨便輸入的字,不一定要跟我一樣,另外這只是因為教學紀錄的專案,並沒有涉及機密資料,實際的產品應用上,是不能洩漏加密金鑰的
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| {
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"JwtOptions": {
"Expiry": 5,
"Issuer": "JWT-Authentication-API",
"SecretKey": "This-is-secret-key-for-JWT-Authentication"
}
}
|
加載 JWT 設定
有兩種方式可以讀取設定檔
- 使用 Configuration 注入
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| // 用建構函數傳入
public class TargetClass(IConfiguration configuration)
{
// 在要被注入的類別中宣告 Configuration 的物件
private readonly IConfiguration _configuration = configuration;
public void Foo() {
// 讀取設定值 "第一層Key:第二層Key"
var secretKey = _configuration.GetValue<string>("JwtSettings:SecretKey");
// 或
// 用 GetSection 方法先取得第一層的物件
// 再用 GetValue 取得第二層 Key 的設定值
var secretKey = _configuration.GetSection("JwtSettings").GetValue<string>("SecretKey");
// 上面兩種寫法效果是一樣的
}
}
|
- 自定義一個設定類別,將設定值物件化
我採用第二個方法,為強化資料型別管理,但會比較麻煩,可以自行取舍
定義一個設定類別
在專案下新增 Options
目錄,用來存放設定物件的類別,在目錄下新增一個 JwtOptions.cs
,並依 appsettings.json
的相關設定定義資料欄位如下
※ 同樣都是承載資料的模型物件,為什麼不放在 Models
目錄?因為 Settings 或 Options 相關的資料物件只會用在程式中資料的設定,不會與資料庫的資庫互動,這樣語意更清析
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
| namespace JWT_Authentication_API.Options;
/// <summary>
/// Jwt 的設定物件
/// </summary>
public class JwtOptions
{
/// <summary>
/// 有效期限(分),預設 10 分鐘
/// </summary>
public int Expiry { get; set; } = 10;
/// <summary>
/// 發行單位
/// </summary>
public string Issuer { get; set; } = "JWT_Authentication_API";
/// <summary>
/// 加密金鑰
/// </summary>
public string SecretKey { get; set; } = Guid.NewGuid().ToString();
}
|
接下來在 Program.cs
中註冊這個自定定的 JwtOption
類別,並封裝在 IOptions 的類別中,在 AddScoped
方法的後面加入註冊方法如下
1
2
3
4
5
6
7
| #region CustomService
builder.Services
.AddScoped<IEmployeeService, EmployeeService>()
.AddScoped<IAuthService, AuthService>()
// 註冊這個 JwtOptions 的物件,並封裝成 IOptions 型別,讓其他類別可以注入使用
.Configure<JwtOptions>(builder.Configuration.GetSection(nameof(JwtOptions)));
#endregion
|
設定的前置就完成了,可以在 Jwt 相關類別中使用
產生 JWT
為了產生合法的 JWT,在專案下新增一個 Helper
目錄,用來存放輔助的相關類別,在目錄下新增一個 JwtHelper.cs
用來加載 JWT 的設定並產生 JWT
注入 JwtOptions
先注入 JWT 的相關設定
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| using JWT_Authentication_API.Options;
using Microsoft.Extensions.Options;
namespace JWT_Authentication_API.Helper;
/// <summary>
/// JSON Web Token 輔助工具
/// 注入 JwtOptions
/// </summary>
public class JwtHelper(IOptions<JwtOptions> jwtOptions)
{
/// <summary>
/// Jwt 的相關設定
/// </summary>
private readonly JwtOptions _jwtOptions = jwtOptions.Value;
}
|
將使用者登入資訊加入 Payload 中
使用者資訊會使用 List<Claim>
中,一個 Claim
就是使用者的其中一項資料,
可以想成 出國用的護照
- 護照號碼:是一個 Claim
- 中文姓名:是一個 Claim
- 英文姓名(拼音):是一個 Claim
- 照片:是一個 Claim
- 國籍:是一個 Claim
而上面這麼多個 Claim 就會組成護照,變成出國用的身分證明,只是在網路的世界,使用者的身份證明變成了 List<Claim>
型式,而在 JWT 的應用場景中,Claim 變成了 JWT 所需要的資料,在 JwtHelper
中新增一個 CreateJwt
方法,並傳入使用者資料,寫入 payload,如下
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
| /// <summary>
/// 產生 JWT
/// </summary>
/// <param name="loginDto"> 使用者的登入資訊 </param>
/// <returns> JSON Web Token </returns>
public string CreateJwt(LoginDto loginDto)
{
var now = DateTimeOffset.UtcNow;
// 設定 Payload
List<Claim> claims = [
// 發行單位
new(JwtRegisteredClaimNames.Iss, _jwtOptions.Issuer),
// 使用者帳號作為識別
new(JwtRegisteredClaimNames.Sub, loginDto.Email),
// Token 的有效期限,從現在開始到 5 分鐘後
new(JwtRegisteredClaimNames.Exp, $"{now.AddMinutes(_jwtOptions.Expiry)
.ToUnixTimeSeconds()}"),
// 這個 JWT 的識別
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
// 這個 JWT 的發行時間
new(JwtRegisteredClaimNames.Iat, $"{now.ToUnixTimeSeconds()}")
];
// 產生使用者身分證明
ClaimsIdentity userClaimsIdentity = new(claims);
// 產生私鑰供後續加密使用
SymmetricSecurityKey securityKey = new(Encoding.UTF8.GetBytes(_jwtOptions.SecretKey));
// 產生數位簽章憑證,使用 SHA256 加密演算
SigningCredentials credentials = new(securityKey, SecurityAlgorithms.HmacSha256);
// 產生 JWT
JwtSecurityToken securityToken = new(
issuer: _jwtOptions.Issuer, // issuer
claims: userClaimsIdentity.Claims, // payload
signingCredentials: credentials, // signature
expires: now.AddMinutes(_jwtOptions.Expiry).UTCDateTime); // expiry time
// 輸出 JWT 並轉換成字串
return new JwtSecurityTokenHandler().WriteToken(securityToken);
}
|
在 Program.cs
中,註冊 JwtHelper
,因為 JwtHelper 的設定是固定不變的,所以不像 EmployeeService
只會存在某些特定的 Controller 中,一但 Request 的生命週期結束,依賴的 Service 就必需要結束,所以 JwtHelper 要使用 AddSingleton
來註冊
1
2
| // 註冊 JwtHelper
builder.Services.AddSingleton<JwtHelper>();
|
回到 AuthController
將產生 JWT 的部分補上
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
| /// <summary>
/// 將 AuthController 宣告成為 ApiController
/// 並定義路由規則(網址)=> domain/api/auth
/// </summary>
/// <param name="employeeService"> 員工資料存取服務 </param>
/// <param name="authService"> 登入驗證服務 </param>
/// <param name="jwtHelper"> JWT 輔助工具 </param>
[ApiController, Route("api/[controller]")]
public class AuthController(
IEmployeeService employeeService,
IAuthService authService,
JwtHelper jwtHelper) : Controller
{
/// <summary>
/// 員工資料存取的服務
/// </summary>
private readonly IEmployeeService _employeeService = employeeService;
/// <summary>
/// 登入驗證的服務
/// </summary>
private readonly IAuthService _authService = authService;
/// <summary>
/// JWT 輔助工具,負責生成 JWT
/// </summary>
private readonly JwtHelper _jwtHelper = jwtHelper;
#region 註冊
......
#endregion
#region 登入
/// <summary>
/// 登入
/// </summary>
/// <param name="loginDto"> 使用者的輸入資料 </param>
/// <returns> 登入結果 </returns>
[HttpPost("login")]
public async Task<ActionResult<string>> LoginAsync(LoginDto loginDto)
{
// 登入資料驗證
if (string.IsNullOrEmpty(loginDto.Email) ||
string.IsNullOrEmpty(loginDto.Password))
return BadRequest("Please provide 'Email' and 'Password'");
// 檢查員工帳號
if (await _employeeService.GetEmployeeByEmailAsync(loginDto.Email) == null)
return BadRequest("User does not exist!");
// 檢查員工密碼並回傳登入結果
if (!await _authService.ValidateUserAsync(loginDto))
return BadRequest("Login failed!");
// 產生 Jwt
var jwt = _jwtHelper.CreateJwt(loginDto);
return Ok(jwt);
}
#endregion
}
|
驗證 JWT
完成 JWT 的部分後,就可以來測試了,由於在 Login.已經註冊過 peter 這個帳號了,現在用 peter 這個帳號來測試會不會回傳 JWT,也可以順便知道資料庫的運作,是不是真會存在 peter 這筆帳號資料

如果有看到 JWT 成功回傳,就代表登入方法成功了,同時也確認資料庫存取是沒有問題的
接著請把 JWT 複製,貼到 JWT IO 的網站,它會協助驗證 JWT 的格式有沒有正確

由上面的結果可以知道,JWT 結果和我寫入的一樣,這樣登入功能就完成了,這邊順便說一下,右下方的 Secret 驗證,其實是驗證金鑰的正確性,一般來說不會把金鑰公開,不過我這邊為了範例,我測試一下,把 appsettings.json
的 SecretKey
複製貼上

會發現 SecretKey 也是 ok 的,這樣就驗證完成了
登出
最後要來做登出功能了,這邊採用比較簡單且直觀的方式「黑名單」,也就是將已經使用過的 JWT 寫入黑名單來代表登出,這樣就無法再次使用一樣的 token 進行其他操作。
為了建立黑名單,要先建立一個「黑名單的資料模型」,在 Entities
目錄新增 TokenBlackList.cs
如下
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
| using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace JWT_Authentication_API.Entities;
/// <summary>
/// JWT 的黑名單
/// </summary>
public class TokenBlackList
{
/// <summary>
/// 資料識別(PK)
/// </summary>
[Required, DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public Guid Id { get; set; }
/// <summary>
/// 使用過的 Token
/// </summary>
[Required]
public string Token { get; set; } = string.Empty;
/// <summary>
/// 資料新增的時間,也是 Token 過期的時間
/// </summary>
public DateTimeOffset CreateTime { get; set; } = DateTimeOffset.Now;
}
|
接著在 AppDbContext.cs
加入 TokenBlackList
的對映參考,加完程式碼會變成下面這樣
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| using Microsoft.EntityFrameworkCore;
namespace JWT_Authentication_API.Entities;
/// <summary>
/// Database Context
/// </summary>
/// <param name="options"></param>
public class AppDbContext(DbContextOptions<AppDbContext> options)
: DbContext(options)
{
/// <summary>
/// 員工資料表
/// </summary>
public DbSet<Employee> Employees { get; set; }
/// <summary>
/// Token 黑名單資料表
/// </summary>
public DbSet<TokenBlackList> TokenBlackLists { get; set; }
}
|
然後參考 新增 Migration 加入新的資料表,然後為了要新增 Token 到黑名單,再新增相關服務及介面
在 Interfaces
目錄下新增 ITokenService.cs
在 Services
目錄下新增 TokenService.cs
並實作 ITokenService
ITokenService
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| namespace JWT_Authentication_API.Interfaces;
/// <summary>
/// Token 相關服務的介面
/// </summary>
public interface ITokenService
{
/// <summary>
/// 新增 token 到黑名單
/// </summary>
/// <param name="token"> 要新增的 token </param>
/// <returns> 新增結果 </returns>
Task<bool> AddTokenToTokenBlackListAsync(string token);
}
|
TokenService
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
| using JWT_Authentication_API.Entities;
using JWT_Authentication_API.Interfaces;
namespace JWT_Authentication_API.Services;
/// <summary>
/// Token 資料存取服務
/// </summary>
/// <param name="context"> 資料庫物件 </param>
public class TokenService(AppDbContext context): ITokenService
{
/// <summary>
/// 資料庫物件
/// </summary>
private readonly AppDbContext _appDb = context;
/// <summary>
/// 新增 Token 到黑名單
/// </summary>
/// <param name="token"> 要新增的 Token </param>
/// <returns> 新增結果 </returns>
public async Task<bool> AddTokenToTokenBlackListAsync(string token)
{
await _appDb.TokenBlackLists.AddAsync(new TokenBlackList { Token = token });
var result = await _appDb.SaveChangesAsync();
return result > 0;
}
}
|
註冊 TokenService
Program.cs
1
2
3
4
5
6
7
8
9
10
11
12
13
| #region CustomService
builder.Services
// 註冊 EmployeeService
.AddScoped<IEmployeeService, EmployeeService>()
// 註冊 AuthService
.AddScoped<IAuthService, AuthService>()
// 註冊 TokenService
.AddScoped<ITokenService, TokenService>()
// 註冊這個 JwtOptions 的物件,並封裝成 IOptions 型別,讓其他類別可以注入使用
.Configure<JwtOptions>(builder.Configuration.GetSection(nameof(JwtOptions)));
// 註冊 JwtHelper
builder.Services.AddSingleton<JwtHelper>();
#endregion
|
新增登出功能
回到 AuthController.cs
加入 ITokenService
的注入,並新增 LogoutAsync
方法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
| /// <summary>
/// 將 AuthController 宣告成為 ApiController
/// 並定義路由規則(網址)=> domain/api/auth
/// </summary>
/// <param name="employeeService"> 員工資料存取服務 </param>
/// <param name="authService"> 登入驗證服務 </param>
/// <param name="jwtHelper"> JWT 輔助工具 </param>
[ApiController, Route("api/[controller]")]
public class AuthController(
IEmployeeService employeeService,
IAuthService authService,
ITokenService tokenService,
JwtHelper jwtHelper) : Controller
{
......
/// <summary>
/// Token 相關服務
/// </summary>
private readonly ITokenService _tokenService = tokenService;
#region 註冊
......
#endregion
#region 登入
......
#endregion
#region 登出
/// <summary>
/// 登出
/// </summary>
/// <returns> 登出結果 </returns>
[HttpPost("logout")]
public async Task<IActionResult> LogoutAsync()
{
// 從 header 讀取 JWT
var token = $"{HttpContext.Request.Headers.Authorization}"
.Replace("Bearer", string.Empty, StringComparison.OrdinalIgnoreCase)
.Trim();
if (string.IsNullOrEmpty(token))
return BadRequest("Not token provided!");
// 將 JWT 加入黑名單
var result = await _tokenService.AddTokenToTokenBlackListAsync(token);
if (!result) return BadRequest("Logout failed!");
return Ok("Logout successfully!");
}
#endregion
}
|
測試登出
最後來測試登出功能,啟動專案並執行 Postman,網址輸入登出的,下面的頁籤選「Authorization」,Type 選第三個「Bearer Token」

接著按下「Send」

會發現出現沒 Token 的訊息,因為我們沒有提供 Token(上面的輸入框),同時也代表登出的檢查有成功,現在先去登入取得一個 Token 並複製下來,參考 驗證 JWT,貼上「Token 輸入框」,按下 Send

如果有看到登出訊息就是成功了

到這邊,註冊、登入、登出就完成了,下篇下會介紹及實作 Refresh Token,並將專案加入角色驗證,讓專案更完整。