受保護 API 項目的思路是:
調用方先提交用戶名和密碼 (即憑證) 到登錄接口, 由登錄接口驗證憑證合法性, 如果合法, 返回給調用方一個Jwt token.
以后調用方訪問API時, 需要將該token 加到 Bearer Http 頭上, 服務方驗證該 token 是否有效, 如果驗證通過, 將允許其繼續訪問受控API.
===================================
本文目標
===================================
1. 實現一個未受保護的API
2. 網站開啟 CORS 跨域共享
3. 實現一個受保護的API
4. 實現一個密碼hash的接口(測試用)
5. 實現一個登錄接口
===================================
目標1: 實現一個未受保護的API
===================================
VS創建一個ASP.net core Host的Blazor wsam解決方案,其中 Server端項目即包含了未受保護的 WeatherForecast API接口.
稍微講解一下 ASP.Net Core API的路由規則.
下面代碼是模板自動生成的, Route 注解中的參數是 [controller], HttpGet 注解沒帶參數, 則該方法的url為 http://site/WeatherForecast,
GET http://localhost:5223/WeatherForecast HTTP/1.1 content-type: application/json
VS 插件 Rest Client 訪問的指令需要調整為:
GET http://localhost:5223/api/WeatherForecast/list HTTP/1.1 content-type: application/json
===================================
目標2: API網站開啟 CORS 跨域共享
===================================
默認情況下, 瀏覽器安全性是不允許網頁向其他域名發送請求, 這種約束稱為同源策略. 需要說明的是, 同源策略是瀏覽器端的安全管控, 但要解決卻需要改造服務端.
究其原因, 需要了解瀏覽器同源策略安全管控的機制, 瀏覽器在向其他域名發送請求時候, 其實並沒有做額外的管控, 管控發生在瀏覽器收到其他域名請求結果時, 瀏覽器會檢查返回結果中, 如果結果包含CORS共享標識的話, 瀏覽器端也會通過檢查, 如果不包含, 瀏覽器會拋出訪問失敗.
VS創建一個ASP.net core Host的Blazor wsam解決方案, wasm是托管ASP.net core 服務器端網站之內, 所以不會違反瀏覽器的同源策略約束. 模板項目中, 並沒有開啟CORS共享控制的代碼
一般情況下, 我們要將blazor wasm獨立部署的CDN上, 所以 api server 要開啟CORS.
Program.cs 文件中增加兩個小節代碼:
先為 builder 增加服務:
builder.Services.AddCors(option => { option.AddPolicy("CorsPolicy", policy => policy .AllowAnyOrigin() .AllowAnyHeader() .AllowAnyMethod()); });
其次, 需要在 web request 的pipleline 增加 UseCors() 中間件, pipeline 各個中間件順序至關重要,
Cors 中間件緊跟在 UseRouting() 之后即可.
app.UseRouting(); //Harry: enable Cors Policy, must be after Routing app.UseCors("CorsPolicy");
===================================
目標3: 實現一個受保護的API
===================================
增加一個獲取產品清單的API, 該API需要訪問方提供合法的JWT token才行.
步驟1: 增加nuget依賴包 Microsoft.AspNetCore.Authentication.JwtBearer
步驟2: 增加 product 實體類
public class Product { public int Id { get; set; } public string? Name { get; set; } public decimal Price { get; set; } }
步驟3: 增加ProductsController 類
using BlazorApp1.Shared; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; namespace BlazorApp1.Server.Controllers { [ApiController] [Route("[controller]")] [Authorize] public class ProductsController : ControllerBase { [HttpGet] public IActionResult GetProducts() { var products = new List<Product>() { new Product() { Id = 1, Name = "Wireless mouse", Price = 29.99m }, new Product() { Id = 2, Name = "HP printer", Price = 100 }, new Product() { Id = 3, Name = "Sony keyboard", Price = 20 } }; return Ok(products); } } }
注意加上了 Authorize 注解后訪問url, 得到 500 報錯, 提示需要加上相應的 Authorization 中間件.
app 增加 Authorization 中間件 app.UseAuthorization() 后, 測試包 401 錯誤, 說明授權這塊功能已經OK.
測試效果圖:
[Authorize] 注解的說明:
- [Authorize] 不帶參數: 只要通過身份驗證, 就能訪問
- [Authorize(Roles="Admin,User")], 只有 jwt token 的 Role Claim 包含 Admin 或 User 才能訪問, 這種方式被叫做基於role的授權
- [Authorize(Policy="IsAdmin"] , 稱為基於Claim的授權機制. 它屬於基於Policy策略的授權的簡化版, 簡化版的Policy 授權檢查是看Jwt token中是否包含 IsAdmin claim, 如包含則授權驗證通過.
- [Authorize(Policy="UserOldThan20"], 基於Policy策略的授權機制, 它是基於 claim 授權的高級版, 不是簡單地看 token是否包含指定的 claim, 而是可以采用代碼邏輯來驗證, 實現較為復雜, 需要先實現 IAuthorizationRequirement 和 IAuthorizationHander 接口.
- 基於資源的授權, 這種機制更靈活, 參見 https://andrewlock.net/resource-specific-authorisation-in-asp-net-core/
不管是基於Role還是基於Claim還是基於Policy的授權驗證, token中都需要帶有特定claim, token內的信息偏多, 帶來的問題是: 服務端簽發token較為復雜, 另外, token 中的一些信息很可能過期, 比如服務端已經對某人的角色做了修改, 但客戶端token中的角色還是老樣子, 兩個地方的role不一致, 使得授權驗證更復雜了.
我個人推薦的做法是, API 僅僅加上不帶參數的 [Authorize] , 指明必須是登錄用戶才能訪問, 授權這塊完全控制在服務端, 從token中提取userId, 然后查詢用戶所在的 userGroup 是否具有該功能. 這里的 userGroup 和 role 完全是一回事. accessString 和功能點是1:n的關系, 最好是能做到 1:1.
下面代碼是我推薦方案的偽代碼, 同時也展現 Claim / Claims /ClaimsIdentity /ClaimsPrincipal 幾個類的關系:
[HttpGet]
[Authorize] public IActionResult get(int productId) { //構建 Claims 清單 const string Issuer = "https://gov.uk"; var claims = new List<Claim> { new Claim(ClaimTypes.Name, "Andrew", ClaimValueTypes.String, Issuer), new Claim(ClaimTypes.Surname, "Lock", ClaimValueTypes.String, Issuer), new Claim(ClaimTypes.Country, "UK", ClaimValueTypes.String, Issuer), new Claim("ChildhoodHero", "Ronnie James Dio", ClaimValueTypes.String) }; //生成 ClaimsIdentity 對象 var userIdentity = new ClaimsIdentity(claims, "Passport"); //生成 ClaimsPrincipal 對象, 一般也叫做 userPrincipal var userPrincipal = new ClaimsPrincipal(userIdentity); object product = loadProductFromDb(productId); var hasRight = checkUserHasRight(userPrincipal, resource:product, acccessString: "Product.Get"); if (!hasRight) { return new UnauthorizedResult(); //返回401報錯 } else { return Ok(product); } } private bool checkUserHasRight(ClaimsPrincipal userPrincipal, object resource, string accessString) { throw new NotImplementedException(); // 自行實現 } private object loadProductFromDb(int id) { throw new NotImplementedException(); // 自行實現 }
===================================
目標4: 實現一個生成密碼hash的接口(測試用)
===================================
這個小節主要是為登錄接口做數據准備工作. 用戶的密碼不應該是明文形式保存, 必須存儲加密后的密碼.
一般的 Password hash 算法, 需要我們自己指定 salt 值, 然后為我們生成一個哈希后的密碼摘要. 校驗密碼時候, 需要將最初的salt值和用戶傳入的原始密碼, 通過同樣的哈希算法, 得到另一個密碼摘要, 如果兩個密碼摘要一致, 表明新傳入的原始密碼是對的.
Asp.net core提供的默認 PasswordHasher 類, 提供了方便而且安全的密碼hash算法, 具體的討論見 https://stackoverflow.com/questions/20621950/ , PasswordHasher 類 Rfc2898算法, 不需要我們指定 salt 值, 有算法本身生成一個隨機的salt值, 並將該隨機的 salt 值存在最終的密碼hash中的前一部分, 所以驗證時也不需要提供該salt 值.
該算法的特點是:
- 使用非常簡單, 做hash之前不需要准備 salt 值, 加密之后也不需要額外保存salt值,
- 同一個明文,多次做hash摘要會得到不同的結果.
下面是一個測試 controller 用於生成密碼hash值:
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; namespace BlazorApp1.Server.Controllers { [ApiController] [Route("[controller]")] public class TestController : ControllerBase { private readonly IConfiguration _configuration; public TestController(IConfiguration configuration)=>_configuration = configuration; [HttpPost("GenerateHashedPwd")] public string Generate([FromBody] string plainPassword) { var passwordHasher=new PasswordHasher<String>(); var hashedPwd = passwordHasher.HashPassword("",plainPassword); var verifyResult = passwordHasher.VerifyHashedPassword("", hashedPwd, plainPassword); Console.WriteLine(verifyResult); return hashedPwd; } } }
Rest client 指令:
POST http://localhost:5223/Test/GenerateHashedPwd HTTP/1.1 content-type: application/json "123abc"
得到的hash值為:
AQAAAAEAACcQAAAAEGVtM0HmzqITBdnkZNzbdDwM3u7zz2F5XQfRIJN/78/UGM9u8Lqcn/eh4zWlUbbDmQ==
===================================
目標5: 實現登錄API
===================================
(1) appsettings.json 配置文件中, 新增 Credentials 清單, 代表我們的用戶庫.
"Credentials": { "Email": "user@test.com", "Password": "AQAAAAEAACcQAAAAENsLEigZGIs6kEdhJ7X1d7ChFZ4TKQHHYZCDoLSiPYy/GpYw4lmMOalsn8g/7debnA==" }
(2) appsettings.json 配置文件中, 增加 jwt 配置項, 用於jwt token的生成和驗證.
jwt token 的生成是由新的 LoginController 實現,
jwt token的驗證是在 ASP.net Web的 Authentication 中間件完成的.
"Jwt": {
"Key": "ITNN8mPfS2ivOqr1eRWK0Rac3sRAchQdG8BUy0pK4vQ3\",", "Issuer": "MyApp", "Audience": "MyAppAudience", "TokenExpiry": "60" //minutes }
(3) 增加 Credentials 類, 用來傳入登錄的憑證信息.
public class Credentials
{
[Required]
public string Email { get; set; } [Required] public string Password { get; set; } }
(4) 增加一個登錄結果類 LoginResult:
public class LoginResult
{
public string? Token { get; set; } public string? ErrorMessage { get; set; } }
(5) 新增 LoginController API類
using BlazorApp1.Shared; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.IdentityModel.Tokens; using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; using System.Text; namespace BlazorApp1.Server.Controllers { [ApiController] [Route("[controller]")] public class LoginController : ControllerBase { private readonly IConfiguration _configuration; public LoginController(IConfiguration configuration)=>_configuration = configuration; [HttpPost("login")] public LoginResult Login(Credentials credentials) { var passed=ValidateCredentials(credentials); if (passed) { return new LoginResult { Token = GenerateJwt(credentials.Email), ErrorMessage = "" }; } else { return new LoginResult { Token = "", ErrorMessage = "Wrong password" }; } } bool ValidateCredentials(Credentials credentials) { var user = _configuration.GetSection("Credentials").Get<Credentials>(); var password = user.Password; var plainPassword = credentials.Password; var passwordHasher =new PasswordHasher<string>(); var result= passwordHasher.VerifyHashedPassword(null, password, plainPassword); return (result == PasswordVerificationResult.Success); } private string GenerateJwt(string email) { var jwtKey = Encoding.UTF8.GetBytes(_configuration["Jwt:Key"]); var securtiyKey = new SymmetricSecurityKey(jwtKey); var issuer = _configuration["Jwt:Issuer"]; var audience=_configuration["Jwt:Audience"]; var tokenExpiry = Convert.ToDouble( _configuration["Jwt:TokenExpiry"]); var token = new JwtSecurityToken( issuer: issuer, audience: audience, expires: DateTime.Now.AddMinutes(tokenExpiry), claims: new[] { new Claim(ClaimTypes.Name, email) }, signingCredentials: new SigningCredentials(securtiyKey, SecurityAlgorithms.HmacSha256) ); var tokenHandler = new JwtSecurityTokenHandler(); return tokenHandler.WriteToken(token); } } }
代碼說明:
- JwtSecurityToken 類的 claims 數組參數, 對應的是 JWT token payload key-value, 一個 claim 對應一個key-value, 可以指定多個claim, 這樣 jwt token的 payload 會變長.
代碼中的 JwtSecurityToken 類的 claims 參數, 其傳入值為 new[] { new Claim(ClaimTypes.Name, email) } , 說明 payload 僅有一個 claim 或者叫 key-value對, 其 key 為 name, value為郵箱號; 如果jwt token中要包含用戶的 Role, 可以再增加 new Claim(ClaimTypes.Role, "Admin")
- JwtSecurityTokenHandler 類其實很關鍵, 可以將 Token 對象轉成字符串, 也可以用它驗證 token 字符串是否合法.
(5) app 增加 Authentication 中間件
using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.ResponseCompression; using Microsoft.IdentityModel.Tokens; using System.Text; var builder = WebApplication.CreateBuilder(args); // Add services to the container. builder.Services.AddControllersWithViews(); builder.Services.AddRazorPages(); //Harry: Add Cors Policy service builder.Services.AddCors(option => { option.AddPolicy("CorsPolicy", policy => policy .AllowAnyOrigin() .AllowAnyHeader() .AllowAnyMethod()); }); //Harry: Read Jwt settings var jwtIssuser = builder.Configuration["Jwt:Issuer"]; var jwtAudience = builder.Configuration["Jwt:Audience"]; var jwtKey = Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Key"]); var securtiyKey = new SymmetricSecurityKey(jwtKey); //Harry: Add authentication service builder.Services.AddAuthentication("Bearer").AddJwtBearer(options => { options.TokenValidationParameters = new TokenValidationParameters { //驗證 Issuer ValidateIssuer = true, ValidIssuer = jwtIssuser, //驗證 Audience ValidateAudience = true, ValidAudience = jwtAudience, //驗證 Security key ValidateIssuerSigningKey = true, IssuerSigningKey = securtiyKey, //驗證有效性 ValidateLifetime = true, LifetimeValidator = (DateTime? notBefore, DateTime? expires, SecurityToken securityToken, TokenValidationParameters validationParameters) => { return expires<=DateTime.Now; } }; }); var app = builder.Build(); // Configure the HTTP request pipeline. if (app.Environment.IsDevelopment()) { app.UseWebAssemblyDebugging(); } else { app.UseExceptionHandler("/Error"); } app.UseBlazorFrameworkFiles(); app.UseStaticFiles(); app.UseRouting(); //Harry: enable Cors Policy, must be after Routing app.UseCors("CorsPolicy"); //Harry: authentication and authorization middleware to pipeline. must be after Routing/Cors and before EndPoint configuation app.UseAuthentication(); //Harry: add authorization middleware to pipeline. must be after Routing/Cors and before EndPoint configuation app.UseAuthorization(); app.MapRazorPages(); app.MapControllers(); app.MapFallbackToFile("index.html"); app.Run();
Rest client測試代碼:
GET http://localhost:5223/Products HTTP/1.1 content-type: application/json Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJodHRwOi8vc2NoZW1hcy54bWxzb2FwLm9yZy93cy8yMDA1LzA1L2lkZW50aXR5L2NsYWltcy9uYW1lIjoidXNlckB0ZXN0LmNvbSIsImV4cCI6MTYzNTAwNTcyMywiaXNzIjoiTXlBcHAiLCJhdWQiOiJNeUFwcEF1ZGllbmNlIn0.6rGq0Ouay9-3bvTDWVEouCHg4T7tDv129PQTha4GhP8
測試結果:
===================================
參考
===================================
https://www.mikesdotnetting.com/article/342/managing-authentication-token-expiry-in-webassembly-based-blazor
https://chrissainty.com/avoiding-accesstokennotavailableexception-when-using-blazor-webassembly-hosted-template-with-individual-user-accounts/
https://www.puresourcecode.com/dotnet/blazor/blazor-using-httpclient-with-authentication/
https://code-maze.com/using-access-token-with-blazor-webassembly-httpclient/#accessing-protected-resources
https://andrewlock.net/resource-specific-authorisation-in-asp-net-core/
https://www.cnblogs.com/wjsgzcn/p/12936257.html
https://www.cnblogs.com/ittranslator/p/making-http-requests-in-blazor-webassembly-apps.html