最近閱讀了《ASP.NET Core 技術(shù)內(nèi)幕與項(xiàng)目實(shí)戰(zhàn)——基于DDD與前后端分離》(作者楊中科)的第八章,對(duì)于Core入門的我來(lái)說(shuō)體會(huì)頗深,整理相關(guān)筆記。
JWT:全稱“JSON web toke”,目前流行的跨域身份驗(yàn)證解決方案;
標(biāo)識(shí)框架(identity):由ASP.NET Core提供的框架,它采用RBAC(role-based access control)策略,內(nèi)置了對(duì)用戶、角色等表的管理即相關(guān)接口,從而簡(jiǎn)化了系統(tǒng)開發(fā),使用EF Core對(duì)數(shù)據(jù)庫(kù)進(jìn)行操作。
注意:本書全篇采用“模型驅(qū)動(dòng)開發(fā)”
一、JWT 實(shí)現(xiàn)登錄的流程如下:
1、使用標(biāo)識(shí)框架(identity)生成數(shù)據(jù)庫(kù)
2、客戶端向服務(wù)器端發(fā)送用戶名、密碼等請(qǐng)求登錄
3、服務(wù)器端校驗(yàn)用戶名、密碼,如果校驗(yàn)成功,則從數(shù)據(jù)庫(kù)中取出這個(gè)用戶的 ID、角色等用戶相關(guān)信息。
4、服務(wù)器端采用只有服務(wù)器端才知道的密鑰來(lái)對(duì)用戶信息的JSON字符串進(jìn)行簽名,形成簽名數(shù)據(jù)。
5、服務(wù)器端把用戶信息的JSON 字符串和簽名拼接到一起形成JWT,然后發(fā)送給客戶端。
6、客戶端保存服務(wù)器端返回的 JWT,并且在客戶端每次向服務(wù)器端發(fā)送請(qǐng)求的時(shí)候都帶上這個(gè)JWT。每次服務(wù)器端收到瀏覽器請(qǐng)求中攜帶的JWT后,服務(wù)器端用密鑰對(duì)JWT 的簽名進(jìn)行校驗(yàn),如果校驗(yàn)成功,服務(wù)器端則從JWT 中的JSON 字符串中讀取出用戶的信息。這樣服務(wù)器端就知道這個(gè)請(qǐng)求對(duì)應(yīng)的用戶了,也就實(shí)現(xiàn)了登錄的功能。
二、實(shí)現(xiàn)過(guò)程及代碼如下:
1、通過(guò)nuget安裝必須的包:
Microsoft.AspNetCore.Identity.EntityFrameworkCore? ---如果該包安裝報(bào)錯(cuò),請(qǐng)切換低版本
Microsoft.AspNetCore.EntityFrameworkCore.SqlServer
Microsoft.AspNetCore.EntityFrameworkCore.Tools
Microsoft.AspNetCore.Authentication.JwtBearer
2、通過(guò)標(biāo)識(shí)框架(identity)配置生成數(shù)據(jù)庫(kù)
(1)創(chuàng)建User類和Role類分別再繼承IdentityUser<long>和IdentityRole<long>
public class User:IdentityUser<long> { //創(chuàng)建時(shí)間 public DateTime CreationTime { get; set; } //昵稱 public string? NickName { get; set; } //JWT版本(解決JWT撤回問(wèn)題) public long JWTVersion { get; set; } }
public class Role:IdentityRole<long>{}
(2)新建IdContext類,通過(guò)該類操作數(shù)據(jù)庫(kù)
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中向依賴注入容器中注冊(cè)與標(biāo)識(shí)框架相關(guān)的服務(wù),并對(duì)其選項(xiàng)進(jìn)行配置(該代碼參考了文章:https://www.cnblogs.com/nullcodeworld/p/16717260.html)
IServiceCollection services = builder.Services; //對(duì)IdDbContext進(jìn)行配置 services.AddDbContext<IdDbContext>(opt => { string connStr = builder.Configuration.GetConnectionString("Default"); Console.WriteLine("字符連接:"+connStr); opt.UseSqlServer(connStr); }); services.AddDataProtection(); //調(diào)用AddIdentityCore添加標(biāo)識(shí)框架的一些重要的基礎(chǔ)服務(wù) //(我們沒有調(diào)用AddIdentity方法,因?yàn)锳ddIdentity方法實(shí)現(xiàn)的初始化 // 比較適合傳統(tǒng)的MVC模式的項(xiàng)目,而現(xiàn)在我們推薦用前后端分離開發(fā)模式。) services.AddIdentityCore<User>(options => { // 對(duì)密碼復(fù)雜度苛刻設(shè)置 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); //因?yàn)閁serManager、RoleManager等服務(wù)被創(chuàng)建的時(shí)候需要注入非常多的服務(wù), //所以我們?cè)谑褂脴?biāo)識(shí)框架的時(shí)候也需要注入和初始化非常多的服務(wù) idBuilder.AddEntityFrameworkStores<IdDbContext>() .AddDefaultTokenProviders() .AddRoleManager<RoleManager<Role>>() .AddUserManager<UserManager<User>>();
(4)我們不要忘記配置在appsetting.json中配置數(shù)據(jù)庫(kù)連接字符串(這里我們?cè)谶B接字符串后面加上了:Encrypt=False;以解決下一步數(shù)據(jù)庫(kù)遷移中出現(xiàn)的報(bào)錯(cuò):“.....證書鏈?zhǔn)怯刹皇苄湃蔚念C發(fā)機(jī)構(gòu)頒發(fā)的”)
"ConnectionStrings": { "Default": "Data Source=.;Database=Identity;User ID=sa;Password=123456;MultipleActiveResultSets=True;Encrypt=False" }
(5)在【程序包管理器控制臺(tái)】中執(zhí)行命令:Add-Migration Init,再執(zhí)行Update-Database執(zhí)行數(shù)據(jù)庫(kù)遷移代碼,如果在這一步中出現(xiàn)錯(cuò)誤請(qǐng)先仔細(xì)檢查以上步驟(可能會(huì)提示“No Dbcontext was found in assembly”等錯(cuò)誤),檢查確定沒有步驟上設(shè)置問(wèn)題,我們新建一個(gè)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); } }
到這我們已經(jīng)完成了標(biāo)識(shí)框架的配置
3、使用JWT實(shí)現(xiàn)登錄操作
(1)在配置appsetting.json中創(chuàng)建JWt的密鑰:SigningKey、過(guò)期時(shí)間:ExpireSeconds兩個(gè)配置項(xiàng);再創(chuàng)建一個(gè)對(duì)應(yīng)該節(jié)點(diǎn)的配置類JWTOptions
"JWT": { "SigningKey": "這里請(qǐng)自定義輸入一串復(fù)雜的密鑰", "ExpireSeconds": "86400" }
public class JWTOptions { public string SigningKey { get; set; } public int ExpireSeconds { get; set; } }
(2)在Program.cs中對(duì)JWT進(jìn)行配置(注意該代碼請(qǐng)?zhí)砑釉赽uilder.Build之前)
//jwt驗(yàn)證授權(quán) services.Configure<JWTOptions>(builder.Configuration.GetSection("JWT"));//獲取配置文件的JWT的key和過(guò)期時(shí)間放到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)創(chuàng)建JWTController類,在其中增加登錄并且創(chuàng)建JWT的操作方法(PS:這里的[FromServices]特性實(shí)現(xiàn)對(duì) Controller.Action 單獨(dú)注入,當(dāng)只有單個(gè)方法需要該依賴,可以采用這個(gè)特性)
[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到期時(shí)間 DateTime expires = DateTime.Now.AddSeconds(options.ExpireSeconds); //取出配置文件的key byte[] keyBytes = Encoding.UTF8.GetBytes(options.SigningKey); //對(duì)稱安全密鑰 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++;//版本號(hào) await _userManager.UpdateAsync(user);//先把數(shù)據(jù)庫(kù)用戶版本號(hào)更新!?。。?/span> 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)創(chuàng)建Test2Controller,實(shí)現(xiàn)一個(gè)測(cè)試方法,并且在控制器類上添加[Authorize],這個(gè)特性表示該控制器類下所有操作方法都需要登錄后才能訪問(wèn),也可以單獨(dú)添加在方法上表示該方法需要登錄后訪問(wèn),這是很重要的一步
[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沒有提供設(shè)置自定義HTTP請(qǐng)求報(bào)文頭(也就是JWT生成的token)的方式,因此傳遞Authoriation報(bào)文接口,我們可以通過(guò)對(duì)OpenAPI進(jìn)行配置,使其可以傳遞Authoriation報(bào)文,至此也可以使用Postman這種軟件工具調(diào)試
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); });
重啟項(xiàng)目,Swagger界面右上角增加了一個(gè)【Authorize】按鈕,在對(duì)話框中輸入“Bearer 登錄生成獲取的token”(注意:Bearer后面加一個(gè)空格再粘貼token)
?
?到這個(gè)時(shí)候,我們?cè)偃フ?qǐng)求Test2接口,順利獲取到內(nèi)容
?
?(7)解決JWT無(wú)法提取撤回的問(wèn)題。我們解決方案思路采用書上的原方案:在用戶表中增加一個(gè)整數(shù)類型的列JWTVersion,它代表最后次發(fā)放出去的令牌的版本號(hào);每次登錄、發(fā)放令牌的時(shí)候,我們都讓JWTVersion 的值自增,同時(shí)將JWTVersion 的值也放到JWT 的負(fù)載中當(dāng)執(zhí)行禁用用戶、撤回用戶的令牌等操作的時(shí)候,我們讓這個(gè)用戶對(duì)應(yīng)的JWTVersion的值自增,當(dāng)服務(wù)器端收到客戶端提交的JWT后,先把JWT中的JWTVersion值和數(shù)據(jù)庫(kù)中的JWTVersion值做比較,如果JWT中JWTVersion的值小于數(shù)據(jù)庫(kù)中JWTVersion的值,就說(shuō)明這個(gè)JWT過(guò)期了,這樣我們就實(shí)現(xiàn)了JWT的撤回機(jī)制。由于我們?cè)谟脩舯碇斜4媪薐WTVersion值,因此這種方案本質(zhì)上仍然是在服務(wù)器端保存狀態(tài),這是繞不過(guò)去的,只不過(guò)這種方案是一種缺點(diǎn)比較少的妥協(xié)方案。
(在前面的操作中我們已經(jīng)給User類新增了“JWTVersion”這個(gè)版本字段,在前面“JWTController?”控制器中的“Login2”方法中也完成了方案相應(yīng)操作)
接下來(lái)新增一個(gè)操作過(guò)濾器JWTValidationFilter并且繼承IAsyncActionFilter,實(shí)現(xiàn)對(duì)所有操作方法中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); //對(duì)于登錄接口等沒有登錄的,直接跳過(guò) 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數(shù)據(jù)庫(kù)中保存的版本號(hào) var claimVersion = context.HttpContext.User.FindFirst(ClaimTypes.Version); long jwtVerOfReq = long.Parse(claimVersion!.Value); //由于內(nèi)存緩存等導(dǎo)致的并發(fā)問(wèn)題, //假如集群的A服務(wù)器中緩存保存的還是版本為5的數(shù)據(jù),但客戶端提交過(guò)來(lái)的可能已經(jīng)是版本號(hào)為6的數(shù)據(jù)。因此只要是客戶端提交的版本號(hào)>=服務(wù)器上取出來(lái)(可能是從Db,也可能是從緩存)的版本號(hào),那么也是可以的 if (jwtVerOfReq >= user.JWTVersion) { await next(); } else { var result = new ObjectResult($"JWTVersion mismatch"); result.StatusCode = (int)HttpStatusCode.Unauthorized; context.Result = result; return; } } }
(8)把過(guò)濾器JWTValidationFilter注冊(cè)到Program.cs中的全局過(guò)濾器中,并且不要忘記注冊(cè)內(nèi)存緩存
//過(guò)濾器 builder.Services.Configure<MvcOptions>(ops => { ops.Filters.Add<JWTValidationFilter>(); }); //內(nèi)存緩存 builder.Services.AddMemoryCache();
到這里基本的使用就完成了,如有錯(cuò)誤歡迎指正?。?/p>
(該代碼參考了文章:https://www.cnblogs.com/nullcodeworld/p/16717260.html)文章來(lái)源:http://www.zghlxwxcb.cn/news/detail-671865.html
(參考了書籍《ASP.NET Core 技術(shù)內(nèi)幕與項(xiàng)目實(shí)戰(zhàn)——基于DDD與前后端分離》(作者楊中科)的第八章)文章來(lái)源地址http://www.zghlxwxcb.cn/news/detail-671865.html
到了這里,關(guān)于ASP.NET Core使用JWT+標(biāo)識(shí)框架(identity)實(shí)現(xiàn)登錄驗(yàn)證的文章就介紹完了。如果您還想了解更多內(nèi)容,請(qǐng)?jiān)谟疑辖撬阉鱐OY模板網(wǎng)以前的文章或繼續(xù)瀏覽下面的相關(guān)文章,希望大家以后多多支持TOY模板網(wǎng)!