最近閱讀了《ASP.NET Core 技術內幕與項目實戰——基於DDD與前後端分離》(作者楊中科)的第八章,對於Core入門的我來說體會頗深,整理相關筆記。 JWT:全稱“JSON web toke”,目前流行的跨域身份驗證解決方案; 標識框架(identity):由ASP.NET Core提供的框 ...
最近閱讀了《ASP.NET Core 技術內幕與項目實戰——基於DDD與前後端分離》(作者楊中科)的第八章,對於Core入門的我來說體會頗深,整理相關筆記。
JWT:全稱“JSON web toke”,目前流行的跨域身份驗證解決方案;
標識框架(identity):由ASP.NET Core提供的框架,它採用RBAC(role-based access control)策略,內置了對用戶、角色等表的管理即相關介面,從而簡化了系統開發,使用EF Core對資料庫進行操作。
註意:本書全篇採用“模型驅動開發”
一、JWT 實現登錄的流程如下:
1、使用標識框架(identity)生成資料庫
2、客戶端向伺服器端發送用戶名、密碼等請求登錄
3、伺服器端校驗用戶名、密碼,如果校驗成功,則從資料庫中取出這個用戶的 ID、角色等用戶相關信息。
4、伺服器端採用只有伺服器端才知道的密鑰來對用戶信息的JSON字元串進行簽名,形成簽名數據。
5、伺服器端把用戶信息的JSON 字元串和簽名拼接到一起形成JWT,然後發送給客戶端。
6、客戶端保存伺服器端返回的 JWT,並且在客戶端每次向伺服器端發送請求的時候都帶上這個JWT。每次伺服器端收到瀏覽器請求中攜帶的JWT後,伺服器端用密鑰對JWT 的簽名進行校驗,如果校驗成功,伺服器端則從JWT 中的JSON 字元串中讀取出用戶的信息。這樣伺服器端就知道這個請求對應的用戶了,也就實現了登錄的功能。
二、實現過程及代碼如下:
1、通過nuget安裝必須的包:
Microsoft.AspNetCore.Identity.EntityFrameworkCore ---如果該包安裝報錯,請切換低版本
Microsoft.AspNetCore.EntityFrameworkCore.SqlServer
Microsoft.AspNetCore.EntityFrameworkCore.Tools
Microsoft.AspNetCore.Authentication.JwtBearer
2、通過標識框架(identity)配置生成資料庫
(1)創建User類和Role類分別再繼承IdentityUser<long>和IdentityRole<long>
public class User:IdentityUser<long> { //創建時間 public DateTime CreationTime { get; set; } //昵稱 public string? NickName { get; set; } //JWT版本(解決JWT撤回問題) public long JWTVersion { get; set; } }
public class Role:IdentityRole<long>{}
(2)新建IdContext類,通過該類操作資料庫
public class IdDbContext : IdentityDbContext<User, Role, long> { public IdDbContext(DbContextOptions<IdDbContext> options) : base(options) { } protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); modelBuilder.ApplyConfigurationsFromAssembly(this.GetType().Assembly); } }
(3)在Program.cs中向依賴註入容器中註冊與標識框架相關的服務,並對其選項進行配置(該代碼參考了文章:https://www.cnblogs.com/nullcodeworld/p/16717260.html)
IServiceCollection services = builder.Services; //對IdDbContext進行配置 services.AddDbContext<IdDbContext>(opt => { string connStr = builder.Configuration.GetConnectionString("Default"); Console.WriteLine("字元連接:"+connStr); opt.UseSqlServer(connStr); }); services.AddDataProtection(); //調用AddIdentityCore添加標識框架的一些重要的基礎服務 //(我們沒有調用AddIdentity方法,因為AddIdentity方法實現的初始化 // 比較適合傳統的MVC模式的項目,而現在我們推薦用前後端分離開發模式。) services.AddIdentityCore<User>(options => { // 對密碼複雜度苛刻設置 options.Password.RequireDigit = false; options.Password.RequireLowercase = false; options.Password.RequireNonAlphanumeric = false; options.Password.RequireUppercase = false; options.Password.RequiredLength = 6; options.Tokens.PasswordResetTokenProvider = TokenOptions.DefaultEmailProvider; options.Tokens.EmailConfirmationTokenProvider = TokenOptions.DefaultEmailProvider; }); var idBuilder = new IdentityBuilder(typeof(User), typeof(Role), services); //因為UserManager、RoleManager等服務被創建的時候需要註入非常多的服務, //所以我們在使用標識框架的時候也需要註入和初始化非常多的服務 idBuilder.AddEntityFrameworkStores<IdDbContext>() .AddDefaultTokenProviders() .AddRoleManager<RoleManager<Role>>() .AddUserManager<UserManager<User>>();
(4)我們不要忘記配置在appsetting.json中配置資料庫連接字元串(這裡我們在連接字元串後面加上了:Encrypt=False;以解決下一步資料庫遷移中出現的報錯:“.....證書鏈是由不受信任的頒發機構頒發的”)
"ConnectionStrings": { "Default": "Data Source=.;Database=Identity;User ID=sa;Password=123456;MultipleActiveResultSets=True;Encrypt=False" }
(5)在【程式包管理器控制台】中執行命令:Add-Migration Init,再執行Update-Database執行資料庫遷移代碼,如果在這一步中出現錯誤請先仔細檢查以上步驟(可能會提示“No Dbcontext was found in assembly”等錯誤),檢查確定沒有步驟上設置問題,我們新建一個MyDesignTimeDbContextFactory類繼承IDesignTimeDbContextFactory<IdDbContext>,具體代碼如下
class MyDesignTimeDbContextFactory : IDesignTimeDbContextFactory<IdDbContext> { public IdDbContext CreateDbContext(string[] args) { DbContextOptionsBuilder<IdDbContext> builder = new(); string connStr = Environment.GetEnvironmentVariable("ConnectionStrings:Default"); builder.UseSqlServer(connStr); return new IdDbContext(builder.Options); } }
到這我們已經完成了標識框架的配置
3、使用JWT實現登錄操作
(1)在配置appsetting.json中創建JWt的密鑰:SigningKey、過期時間:ExpireSeconds兩個配置項;再創建一個對應該節點的配置類JWTOptions
"JWT": { "SigningKey": "這裡請自定義輸入一串複雜的密鑰", "ExpireSeconds": "86400" }
public class JWTOptions { public string SigningKey { get; set; } public int ExpireSeconds { get; set; } }
(2)在Program.cs中對JWT進行配置(註意該代碼請添加在builder.Build之前)
//jwt驗證授權 services.Configure<JWTOptions>(builder.Configuration.GetSection("JWT"));//獲取配置文件的JWT的key和過期時間放到JWTOptions類中 services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(x => { var jwtOpt = builder.Configuration.GetSection("JWT").Get<JWTOptions>(); byte[] keyBytes = Encoding.UTF8.GetBytes(jwtOpt.SigningKey); var secKey = new SymmetricSecurityKey(keyBytes); x.TokenValidationParameters = new() { ValidateIssuer = false, ValidateAudience = false, ValidateLifetime = true, ValidateIssuerSigningKey = true, IssuerSigningKey = secKey }; });
(3)在Program.cs中的app.UseAuthorization之前添加app.UseAuthentication
app.UseAuthentication();
(4)創建JWTController類,在其中增加登錄並且創建JWT的操作方法(PS:這裡的[FromServices]特性實現對 Controller.Action 單獨註入,當只有單個方法需要該依賴,可以採用這個特性)
[Route("api/[controller]/[action]")] [ApiController] public class JWTController : ControllerBase { private readonly UserManager<User> _userManager; public JWTController(UserManager<User> userManager) { _userManager = userManager; } /// <summary> /// 生成token的方法 /// </summary> /// <param name="claims"></param> /// <param name="options"></param> /// <returns></returns> private static string BuildToken(IEnumerable<Claim> claims, JWTOptions options) { //token到期時間 DateTime expires = DateTime.Now.AddSeconds(options.ExpireSeconds); //取出配置文件的key byte[] keyBytes = Encoding.UTF8.GetBytes(options.SigningKey); //對稱安全密鑰 var secKey = new SymmetricSecurityKey(keyBytes); //加密證書 var credentials = new SigningCredentials(secKey, SecurityAlgorithms.HmacSha256Signature); //jwt安全token var tokenDescriptor = new JwtSecurityToken(expires: expires, signingCredentials: credentials, claims: claims); return new JwtSecurityTokenHandler().WriteToken(tokenDescriptor); } /// <summary> /// 前端獲取token的介面 /// </summary> /// <param name="req"></param> /// <param name="jwtOptions"></param> /// <returns></returns> [HttpPost] public async Task<IActionResult> Login2(LoginRequest req, [FromServices] IOptions<JWTOptions> jwtOptions) { string userName = req.UserName; string password = req.Password; var user = await _userManager.FindByNameAsync(userName); if (user == null) { return NotFound($"用戶名不存在{userName}"); } var success = await _userManager.CheckPasswordAsync(user, password); if (!success) { return BadRequest("Failed"); } user.JWTVersion++;//版本號 await _userManager.UpdateAsync(user);//先把資料庫用戶版本號更新!!!! var claims = new List<Claim>(); claims.Add(new Claim(ClaimTypes.NameIdentifier, user.Id.ToString())); claims.Add(new Claim(ClaimTypes.Name, user.UserName)); claims.Add(new Claim(ClaimTypes.Version, user.JWTVersion.ToString())); var roles = await _userManager.GetRolesAsync(user); foreach (string role in roles) { claims.Add(new Claim(ClaimTypes.Role, role)); } string jwtToken = BuildToken(claims, jwtOptions.Value); return Ok(jwtToken); } }
(5)創建Test2Controller,實現一個測試方法,並且在控制器類上添加[Authorize],這個特性表示該控制器類下所有操作方法都需要登錄後才能訪問,也可以單獨添加在方法上表示該方法需要登錄後訪問,這是很重要的一步
[Route("api/[controller]")] [ApiController] [Authorize] public class Test2Controller : ControllerBase { [HttpGet] public IActionResult Hello() { string id = this.User.FindFirst(ClaimTypes.NameIdentifier)!.Value; string userName = this.User.FindFirst(ClaimTypes.NameIdentifier)!.Value; IEnumerable<Claim> roleClaims = this.User.FindAll(ClaimTypes.Role); string roleNames = string.Join(',', roleClaims.Select(c => c.Value)); return Ok($"id={id},userName={userName},roleNames ={roleNames}"); } }
(6)Swagger沒有提供設置自定義HTTP請求報文頭(也就是JWT生成的token)的方式,因此傳遞Authoriation報文介面,我們可以通過對OpenAPI進行配置,使其可以傳遞Authoriation報文,至此也可以使用Postman這種軟體工具調試
builder.Services.AddSwaggerGen(c => { var scheme = new OpenApiSecurityScheme() { Description = "Authorization header. \r\nExample: 'Bearer 12345abcdef'", Reference = new OpenApiReference { Type = ReferenceType.SecurityScheme, Id = "Authorization" }, Scheme = "oauth2", Name = "Authorization", In = ParameterLocation.Header, Type = SecuritySchemeType.ApiKey, }; c.AddSecurityDefinition("Authorization", scheme); var requirement = new OpenApiSecurityRequirement(); requirement[scheme] = new List<string>(); c.AddSecurityRequirement(requirement); });
重啟項目,Swagger界面右上角增加了一個【Authorize】按鈕,在對話框中輸入“Bearer 登錄生成獲取的token”(註意:Bearer後面加一個空格再粘貼token)
到這個時候,我們再去請求Test2介面,順利獲取到內容
(7)解決JWT無法提取撤回的問題。我們解決方案思路採用書上的原方案:在用戶表中增加一個整數類型的列JWTVersion,它代表最後次發放出去的令牌的版本號;每次登錄、發放令牌的時候,我們都讓JWTVersion 的值自增,同時將JWTVersion 的值也放到JWT 的負載中當執行禁用用戶、撤回用戶的令牌等操作的時候,我們讓這個用戶對應的JWTVersion的值自增,當伺服器端收到客戶端提交的JWT後,先把JWT中的JWTVersion值和資料庫中的JWTVersion值做比較,如果JWT中JWTVersion的值小於資料庫中JWTVersion的值,就說明這個JWT過期了,這樣我們就實現了JWT的撤回機制。由於我們在用戶表中保存了JWTVersion值,因此這種方案本質上仍然是在伺服器端保存狀態,這是繞不過去的,只不過這種方案是一種缺點比較少的妥協方案。
(在前面的操作中我們已經給User類新增了“JWTVersion”這個版本欄位,在前面“JWTController ”控制器中的“Login2”方法中也完成了方案相應操作)
接下來新增一個操作過濾器JWTValidationFilter並且繼承IAsyncActionFilter,實現對所有操作方法中JWT的檢查操作
public class JWTValidationFilter : IAsyncActionFilter { private IMemoryCache memCache; private UserManager<User> userMgr; public JWTValidationFilter(IMemoryCache memCache, UserManager<User> userMgr) { this.memCache = memCache; this.userMgr = userMgr; } public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) { var claimUserId = context.HttpContext.User.FindFirst(ClaimTypes.NameIdentifier); //對於登錄介面等沒有登錄的,直接跳過 if (claimUserId == null) { await next(); return; } long userId = long.Parse(claimUserId!.Value); string cacheKey = $"JWTValidationFilter.UserInfo.{userId}"; User user = await memCache.GetOrCreateAsync(cacheKey, async e => { e.AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(5); return await userMgr.FindByIdAsync(userId.ToString()); }); if (user == null) { var result = new ObjectResult($"UserId({userId}) not found"); result.StatusCode = (int)HttpStatusCode.Unauthorized; context.Result = result; return; } //jwt資料庫中保存的版本號 var claimVersion = context.HttpContext.User.FindFirst(ClaimTypes.Version); long jwtVerOfReq = long.Parse(claimVersion!.Value); //由於記憶體緩存等導致的併發問題, //假如集群的A伺服器中緩存保存的還是版本為5的數據,但客戶端提交過來的可能已經是版本號為6的數據。因此只要是客戶端提交的版本號>=伺服器上取出來(可能是從Db,也可能是從緩存)的版本號,那麼也是可以的 if (jwtVerOfReq >= user.JWTVersion) { await next(); } else { var result = new ObjectResult($"JWTVersion mismatch"); result.StatusCode = (int)HttpStatusCode.Unauthorized; context.Result = result; return; } } }
(8)把過濾器JWTValidationFilter註冊到Program.cs中的全局過濾器中,並且不要忘記註冊記憶體緩存
//過濾器 builder.Services.Configure<MvcOptions>(ops => { ops.Filters.Add<JWTValidationFilter>(); }); //記憶體緩存 builder.Services.AddMemoryCache();
到這裡基本的使用就完成了,如有錯誤歡迎指正!!
(該代碼參考了文章:https://www.cnblogs.com/nullcodeworld/p/16717260.html)
(參考了書籍《ASP.NET Core 技術內幕與項目實戰——基於DDD與前後端分離》(作者楊中科)的第八章)