前言
這是StarBlog系列在2023年的第二篇更新?
這幾個月都在忙,更新變得很不勤快,但是拖著不更新我的心裡更慌,很久沒寫,要開頭就變得很難?
說回正題,之前的文章裡,我們已經把部落格關鍵的介面都開發完成了,但還少了一個最關鍵的「認證授權」,少了這東西,網站就跟篩子一樣,誰都可以來新增和刪除資料,亂套了~
關於「認證授權」的知識,會比較複雜,要學習這塊的話,建議分幾步:
- 基礎概念
- AspNetCore 的 Identity 框架
其他框架,如 IdentityServer
關於基礎概念可以看看我之前寫的這篇: Asp-Net-Core學習筆記:身份認證入門
PS:Identity 框架的還沒寫好?
為了避免當復讀機,本文就不涉及太多概念的東西了,建議先看完上面那篇再來開始使用JWT~
JWT
前面介紹文章的CRUD介面時,涉及到修改的介面,都加了 [Authorize]
特性,表示需要登入才能訪問,本文就以最簡單的方式來實現這個登入認證功能。
在 AspNetCore 中,使用 JWT 的工作流程大概如下:
- JWT就是一個Base64編碼的字串,分為 head/payload/sign 三個部分(sign簽名是使用特定秘鑰生成的,別人無法偽造,所以就算修改了payload部分的資訊,後端校驗也不會透過)
- 使用者登入時,後端可以在裡面存一些類似使用者ID、郵箱、手機號之類的資料,然後把這串東西返回給前端儲存,注意不要把不能被客戶端知道的資訊放在裡面
(也可以對payload進行加密) - 之後呼叫需要登入的介面時,都要帶上這個JWT(一般是放在 HTTP Header 裡面)
- 這串東西只有後端能解析,後端拿到之後就知道使用者的身份了
JWT 還有其他一些特性,比如說是沒有狀態的,這就很符合我們用的 RESTFul 介面了,不像傳統使用 session 和 cookies 那樣,原版 JWT 只要簽發之後,在有效期結束前就不能取消,使用者也沒法登出,為了避免洩露 JWT token 導致安全問題,一般過期時間都設定得比較短。(這個不能取消的問題,也是可以透過曲線救國解決的,不過不在本文的討論範圍哈)
初步接觸 JWT
OK,說了那麼多,還是開始來寫程式碼吧
生成 JWT
要生成的話很簡單,不需要什麼額外的配置,幾行程式碼就搞定了
public LoginToken GenerateLoginToken(User user) {
var claims = new List<Claim> {
new(JwtRegisteredClaimNames.Sub, user.Id), // User.Identity.Name
new(JwtRegisteredClaimNames.GivenName, user.Name),
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), // JWT ID
};
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes("jwt key"));
var signCredential = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
var jwtToken = new JwtSecurityToken(
issuer: "jwt issuer 簽發者",
audience: "jwt audience 接受者",
claims: claims,
expires: DateTime.Now.AddDays(7),
signingCredentials: signCredential);
return new LoginToken {
Token = new JwtSecurityTokenHandler().WriteToken(jwtToken),
Expiration = TimeZoneInfo.ConvertTimeFromUtc(jwtToken.ValidTo, TimeZoneInfo.Local)
};
}
最開始的 claims
就是前面說的後端往JWT裡面存的資料
"The set of claims associated with a given entity can be thought of as a key. The particular claims define the shape of that key; much like a physical key is used to open a lock in a door. In this way, claims are used to gain access to resources." from MSDN
Claim
的構造方法可以接收 key
和 value
引數,都是字串
對於 key
,.Net 提供了一些常量,在 JwtRegisteredClaimNames
和 ClaimTypes
類裡邊,這倆的區別就是後者是老的,一般在Windows體系下使用,比如說同樣是 Name
這個 key
ClaimTypes.Name = "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name"
JwtRegisteredClaimNames.Name = "name"
我們是在 JWT 裡面設定 Claim,用 JwtRegisteredClaimNames
就好了
參考:https://stackoverflow.com/questions/50012155/jwt-claim-names
從 JWT 中讀取資訊
也就是讀取放在裡面的各個 Claim
在正確配置 Authentication
服務和 JwtBearer
之後,已登入的客戶端請求過來,後端可以在 Controller 裡面拿到 JWT 資料
像這樣
var name = HttpContext.User.FindFirst(JwtRegisteredClaimNames.Name)?.Value;
還可以用 System.Security.Claims.PrincipalExtensions
的擴充套件方法 FindFirstValue
直接拿到字串值。
吐槽:如果對應的 Claim 不存在的話,這個擴充套件方法返回的值是
null
,但不知道為啥,他原始碼用的是string
作為返回值型別,而不是string?
,真是令人遺憾
使用 JWT 保護介面
瞭解 JWT 的使用方式之後,終於可以把 JWT 應用到部落格專案中了~
配置JWT引數
為了避免硬編碼,我們把 JWT 需要的 Issuer
, Audience
, Key
三個引數寫在配置裡面
形式如下
"Auth": {
"Jwt": {
"Issuer": "starblog",
"Audience": "starblog-admin-ui",
"Key": "F2REaFzQ6xA9k77EUDLf9EnjK5H2wUot"
}
}
接著需要定義一個類來方便對映配置。
在 StarBlog.Web/Models/Config
下新增 Auth.cs
public class Auth {
public Jwt Jwt { get; set; }
}
public class Jwt {
public string Issuer { get; set; }
public string Audience { get; set; }
public string Key { get; set; }
}
註冊一下
builder.Services.Configure<Auth>(configuration.GetSection(nameof(Auth)));
配置 Authentication 服務
這部分程式碼比較多,寫成擴充套件方法,避免 Program.cs
檔案程式碼太多
新增 StarBlog.Web/Extensions/ConfigureAuth.cs
檔案
public static class ConfigureAuth {
public static void AddAuth(this IServiceCollection services, IConfiguration configuration) {
services.AddScoped<AuthService>();
services.AddAuthentication(options => {
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(options => {
var authSetting = configuration.GetSection(nameof(Auth)).Get<Auth>();
options.TokenValidationParameters = new TokenValidationParameters {
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuer = true,
ValidateIssuerSigningKey = true,
ValidIssuer = authSetting.Jwt.Issuer,
ValidAudience = authSetting.Jwt.Audience,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(authSetting.Jwt.Key)),
ClockSkew = TimeSpan.Zero
};
});
}
}
然後在 Program.cs
裡,需要使用這個擴充套件方法來註冊服務
builder.Services.AddAuth(builder.Configuration);
還得配置一下中介軟體,這個順序很重要,需要使用身份認證保護的介面或資源,必須放到這倆 Auth...
中介軟體的後面。
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
// ...
app.MapControllerRoute(...);
app.Run();
封裝登入邏輯
還是那句話,為了方便使用balabala……
新建 StarBlog.Web/Services/AuthService.cs
檔案
public class AuthService {
private readonly Auth _auth;
private readonly IBaseRepository<User> _userRepo;
public AuthService(IOptions<Auth> options, IBaseRepository<User> userRepo) {
_auth = options.Value;
_userRepo = userRepo;
}
public LoginToken GenerateLoginToken(User user) {
var claims = new List<Claim> {
new(JwtRegisteredClaimNames.Sub, user.Id), // User.Identity.Name
new(JwtRegisteredClaimNames.GivenName, user.Name),
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), // JWT ID
};
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_auth.Jwt.Key));
var signCredential = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
var jwtToken = new JwtSecurityToken(
issuer: _auth.Jwt.Issuer,
audience: _auth.Jwt.Audience,
claims: claims,
expires: DateTime.Now.AddDays(7),
signingCredentials: signCredential);
return new LoginToken {
Token = new JwtSecurityTokenHandler().WriteToken(jwtToken),
Expiration = TimeZoneInfo.ConvertTimeFromUtc(jwtToken.ValidTo, TimeZoneInfo.Local)
};
}
}
因為篇幅關係,只把關鍵的生成 JWT 程式碼貼出來,還有一些獲取使用者資訊啥的程式碼,還不是最終版本,接下來隨時會修改,而且也比較簡單,就沒有放出來~
再來寫個登入介面
新增 StarBlog.Web/Apis/AuthController.cs
檔案
[ApiController]
[Route("Api/[controller]")]
[ApiExplorerSettings(GroupName = ApiGroups.Auth)]
public class AuthController : ControllerBase {
private readonly AuthService _authService;
public AuthController(AuthService authService) {
_authService = authService;
}
/// <summary>
/// 登入
/// </summary>
[HttpPost]
[ProducesResponseType(typeof(ApiResponse<LoginToken>), StatusCodes.Status200OK)]
public async Task<ApiResponse> Login(LoginUser loginUser) {
var user = await _authService.GetUserByName(loginUser.Username);
if (user == null) return ApiResponse.Unauthorized("使用者名稱不存在");
if (loginUser.Password != user.Password) return ApiResponse.Unauthorized("使用者名稱或密碼錯誤");
return ApiResponse.Ok(_authService.GenerateLoginToken(user));
}
}
之後我們請求這個介面,如果使用者名稱和密碼正確的話,就可以拿到 JWT token 和過期時間
{
"statusCode": 200,
"successful": true,
"message": "Ok",
"data": {
"token": "eyJhbGciOiJIUzI1NiIsInR123I6IkpXVCJ9.eyJ1c2VybmFtZSI6ImRlYWxpIiwibmFC1kYJ9.DaJEmBAVdXks8MOedVee4xxrB-RvUSg2wIJGc30HGkk",
"expiration": "2023-05-04T22:29:04+08:00"
},
"errorData": null
}
接下來,請求新增了 [Authorize]
的介面時,需要在 HTTP header 裡面加上:
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR123I6IkpXVCJ9.eyJ1c2VybmFtZSI6ImRlYWxpIiwibmFC1kYJ9.DaJEmBAVdXks8MOedVee4xxrB-RvUSg2wIJGc30HGkk
配置swagger支援
加了 [Authorize]
之後,在swagger裡就沒法除錯介面了,得用 postman 之類的工具,新增 HTTP header
不過swagger這麼好用的工具肯定不會那麼蠢,它是可以配置支援 JWT 的
新增 nuget 包 Swashbuckle.AspNetCore.Filters
然後編輯 StarBlog.Web/Extensions/ConfigureSwagger.cs
來配置一下(上一篇關於swagger的還沒忘記吧?)
在 AddSwaggerGen
裡面,新增配置程式碼
var security = new OpenApiSecurityScheme {
Description = "JWT模式授權,請輸入 \"Bearer {Token}\" 進行身份驗證",
Name = "Authorization",
In = ParameterLocation.Header,
Type = SecuritySchemeType.ApiKey
};
options.AddSecurityDefinition("oauth2", security);
options.AddSecurityRequirement(new OpenApiSecurityRequirement {{security, new List<string>()}});
options.OperationFilter<AddResponseHeadersFilter>();
options.OperationFilter<AppendAuthorizeToSummaryOperationFilter>();
options.OperationFilter<SecurityRequirementsOperationFilter>();
搞定。這樣swagger頁面右上角就多了個鎖頭圖示,點選就可以輸入 JWT token
不過有一點不方便的是,每個介面分組都要輸入一次,切換了就得重新輸入了…
但至少不用postman了~
參考資料
- https://stackoverflow.com/questions/50012155/jwt-claim-names
- https://stackoverflow.com/questions/47923652/what-is-the-best-practice-for-fetching-user-data-after-validating-jwt-in-net-co
- https://github.com/mattfrear/Swashbuckle.AspNetCore.Filters
系列文章
- 基於.NetCore開發部落格專案 StarBlog - (1) 為什麼需要自己寫一個部落格?
- 基於.NetCore開發部落格專案 StarBlog - (2) 環境準備和建立專案
- 基於.NetCore開發部落格專案 StarBlog - (3) 模型設計
- 基於.NetCore開發部落格專案 StarBlog - (4) markdown部落格批次匯入
- 基於.NetCore開發部落格專案 StarBlog - (5) 開始搭建Web專案
- 基於.NetCore開發部落格專案 StarBlog - (6) 頁面開發之部落格文章列表
- 基於.NetCore開發部落格專案 StarBlog - (7) 頁面開發之文章詳情頁面
- 基於.NetCore開發部落格專案 StarBlog - (8) 分類層級結構展示
- 基於.NetCore開發部落格專案 StarBlog - (9) 圖片批次匯入
- 基於.NetCore開發部落格專案 StarBlog - (10) 圖片瀑布流
- 基於.NetCore開發部落格專案 StarBlog - (11) 實現訪問統計
- 基於.NetCore開發部落格專案 StarBlog - (12) Razor頁面動態編譯
- 基於.NetCore開發部落格專案 StarBlog - (13) 加入友情連結功能
- 基於.NetCore開發部落格專案 StarBlog - (14) 實現主題切換功能
- 基於.NetCore開發部落格專案 StarBlog - (15) 生成隨機尺寸圖片
- 基於.NetCore開發部落格專案 StarBlog - (16) 一些新功能 (監控/統計/配置/初始化)
- 基於.NetCore開發部落格專案 StarBlog - (17) 自動下載文章裡的外部圖片
- 基於.NetCore開發部落格專案 StarBlog - (18) 實現本地Typora文章打包上傳
- 基於.NetCore開發部落格專案 StarBlog - (19) Markdown渲染方案探索
- 基於.NetCore開發部落格專案 StarBlog - (20) 圖片顯示最佳化
- 基於.NetCore開發部落格專案 StarBlog - (21) 開始開發RESTFul介面
- 基於.NetCore開發部落格專案 StarBlog - (22) 開發部落格文章相關介面
- 基於.NetCore開發部落格專案 StarBlog - (23) 文章列表介面分頁、過濾、搜尋、排序
- 基於.NetCore開發部落格專案 StarBlog - (24) 統一介面資料返回格式
- 基於.NetCore開發部落格專案 StarBlog - (25) 圖片介面與檔案上傳
- 基於.NetCore開發部落格專案 StarBlog - (26) 整合Swagger介面文件
- 基於.NetCore開發部落格專案 StarBlog - (27) 使用JWT保護介面