翼度科技»论坛 编程开发 .net 查看内容

ASP.NET Core使用JWT+标识框架(identity)实现登录验证

7

主题

7

帖子

21

积分

新手上路

Rank: 1

积分
21
最近阅读了《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和IdentityRole
  1. public class User:IdentityUser<long>
  2.     {
  3.         //创建时间
  4.         public DateTime CreationTime { get; set; }
  5.         //昵称
  6.         public string? NickName { get; set; }
  7.         //JWT版本(解决JWT撤回问题)
  8.         public long JWTVersion { get; set; }
  9.     }
复制代码
  1. public class Role:IdentityRole<long>{}
复制代码
(2)新建IdContext类,通过该类操作数据库
  1. public class IdDbContext : IdentityDbContext<User, Role, long>
  2.     {
  3.         public IdDbContext(DbContextOptions<IdDbContext> options) : base(options)
  4.         {
  5.         }
  6.         protected override void OnModelCreating(ModelBuilder modelBuilder)
  7.         {
  8.             base.OnModelCreating(modelBuilder);
  9.             modelBuilder.ApplyConfigurationsFromAssembly(this.GetType().Assembly);
  10.         }
  11.     }
复制代码
(3)在Program.cs中向依赖注入容器中注册与标识框架相关的服务,并对其选项进行配置(该代码参考了文章:https://www.cnblogs.com/nullcodeworld/p/16717260.html)
  1. IServiceCollection services = builder.Services;
  2. //对IdDbContext进行配置
  3. services.AddDbContext<IdDbContext>(opt =>
  4. {
  5.     string connStr = builder.Configuration.GetConnectionString("Default");
  6.     Console.WriteLine("字符连接:"+connStr);
  7.     opt.UseSqlServer(connStr);
  8. });
  9. services.AddDataProtection();
  10. //调用AddIdentityCore添加标识框架的一些重要的基础服务
  11. //(我们没有调用AddIdentity方法,因为AddIdentity方法实现的初始化
  12. // 比较适合传统的MVC模式的项目,而现在我们推荐用前后端分离开发模式。)
  13. services.AddIdentityCore<User>(options =>
  14. {
  15.     // 对密码复杂度苛刻设置
  16.     options.Password.RequireDigit = false;
  17.     options.Password.RequireLowercase = false;
  18.     options.Password.RequireNonAlphanumeric = false;
  19.     options.Password.RequireUppercase = false;
  20.     options.Password.RequiredLength = 6;
  21.     options.Tokens.PasswordResetTokenProvider = TokenOptions.DefaultEmailProvider;
  22.     options.Tokens.EmailConfirmationTokenProvider = TokenOptions.DefaultEmailProvider;
  23. });
  24. var idBuilder = new IdentityBuilder(typeof(User), typeof(Role), services);
  25. //因为UserManager、RoleManager等服务被创建的时候需要注入非常多的服务,
  26. //所以我们在使用标识框架的时候也需要注入和初始化非常多的服务
  27. idBuilder.AddEntityFrameworkStores<IdDbContext>()
  28.     .AddDefaultTokenProviders()
  29.     .AddRoleManager<RoleManager<Role>>()
  30.     .AddUserManager<UserManager<User>>();
复制代码
(4)我们不要忘记配置在appsetting.json中配置数据库连接字符串(这里我们在连接字符串后面加上了:Encrypt=False;以解决下一步数据库迁移中出现的报错:“.....证书链是由不受信任的颁发机构颁发的”)
  1. "ConnectionStrings": {
  2.     "Default": "Data Source=.;Database=Identity;User ID=sa;Password=123456;MultipleActiveResultSets=True;Encrypt=False"
  3.   }
复制代码
(5)在【程序包管理器控制台】中执行命令:Add-Migration Init,再执行Update-Database执行数据库迁移代码,如果在这一步中出现错误请先仔细检查以上步骤(可能会提示“No Dbcontext was found in assembly”等错误),检查确定没有步骤上设置问题,我们新建一个MyDesignTimeDbContextFactory类继承IDesignTimeDbContextFactory,具体代码如下
  1. class MyDesignTimeDbContextFactory : IDesignTimeDbContextFactory<IdDbContext>
  2. {
  3.     public IdDbContext CreateDbContext(string[] args)
  4.     {
  5.         DbContextOptionsBuilder<IdDbContext> builder = new();
  6.         string connStr = Environment.GetEnvironmentVariable("ConnectionStrings:Default");
  7.         builder.UseSqlServer(connStr);
  8.         return new IdDbContext(builder.Options);
  9.     }
  10. }
复制代码
到这我们已经完成了标识框架的配置
3、使用JWT实现登录操作

(1)在配置appsetting.json中创建JWt的密钥:SigningKey、过期时间:ExpireSeconds两个配置项;再创建一个对应该节点的配置类JWTOptions
  1. "JWT": {
  2.     "SigningKey": "这里请自定义输入一串复杂的密钥",
  3.     "ExpireSeconds": "86400"
  4.   }
复制代码
  1. public class JWTOptions
  2.     {
  3.         public string SigningKey { get; set; }
  4.         public int ExpireSeconds { get; set; }
  5.     }
复制代码
(2)在Program.cs中对JWT进行配置(注意该代码请添加在builder.Build之前)
  1. //jwt验证授权
  2. services.Configure<JWTOptions>(builder.Configuration.GetSection("JWT"));//获取配置文件的JWT的key和过期时间放到JWTOptions类中
  3. services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
  4. .AddJwtBearer(x =>
  5. {
  6.     var jwtOpt = builder.Configuration.GetSection("JWT").Get<JWTOptions>();
  7.     byte[] keyBytes = Encoding.UTF8.GetBytes(jwtOpt.SigningKey);
  8.     var secKey = new SymmetricSecurityKey(keyBytes);
  9.     x.TokenValidationParameters = new()
  10.     {
  11.         ValidateIssuer = false,
  12.         ValidateAudience = false,
  13.         ValidateLifetime = true,
  14.         ValidateIssuerSigningKey = true,
  15.         IssuerSigningKey = secKey
  16.     };
  17. });
复制代码
(3)在Program.cs中的app.UseAuthorization之前添加app.UseAuthentication
  1. app.UseAuthentication();
复制代码
(4)创建JWTController类,在其中增加登录并且创建JWT的操作方法(PS:这里的[FromServices]特性实现对 Controller.Action 单独注入,当只有单个方法需要该依赖,可以采用这个特性)
  1. [Route("api/[controller]/[action]")]
  2.     [ApiController]
  3.     public class JWTController : ControllerBase
  4.     {
  5.         private readonly UserManager<User> _userManager;
  6.         public JWTController(UserManager<User> userManager)
  7.         {
  8.             _userManager = userManager;
  9.         }
  10.         /// <summary>
  11.         /// 生成token的方法
  12.         /// </summary>
  13.         /// <param name="claims"></param>
  14.         /// <param name="options"></param>
  15.         /// <returns></returns>
  16.         private static string BuildToken(IEnumerable<Claim> claims, JWTOptions options)
  17.         {
  18.             //token到期时间
  19.             DateTime expires = DateTime.Now.AddSeconds(options.ExpireSeconds);
  20.             //取出配置文件的key
  21.             byte[] keyBytes = Encoding.UTF8.GetBytes(options.SigningKey);
  22.             //对称安全密钥
  23.             var secKey = new SymmetricSecurityKey(keyBytes);
  24.             //加密证书
  25.             var credentials = new SigningCredentials(secKey, SecurityAlgorithms.HmacSha256Signature);
  26.             //jwt安全token
  27.             var tokenDescriptor = new JwtSecurityToken(expires: expires, signingCredentials: credentials, claims: claims);
  28.             return new JwtSecurityTokenHandler().WriteToken(tokenDescriptor);
  29.         }
  30.         /// <summary>
  31.         /// 前端获取token的接口
  32.         /// </summary>
  33.         /// <param name="req"></param>
  34.         /// <param name="jwtOptions"></param>
  35.         /// <returns></returns>
  36.         [HttpPost]
  37.         public async Task<IActionResult> Login2(LoginRequest req, [FromServices] IOptions<JWTOptions> jwtOptions)
  38.         {
  39.             string userName = req.UserName;
  40.             string password = req.Password;
  41.             var user = await _userManager.FindByNameAsync(userName);
  42.             if (user == null)
  43.             {
  44.                 return NotFound($"用户名不存在{userName}");
  45.             }
  46.             var success = await _userManager.CheckPasswordAsync(user, password);
  47.             if (!success)
  48.             {
  49.                 return BadRequest("Failed");
  50.             }
  51.             user.JWTVersion++;//版本号
  52.             await _userManager.UpdateAsync(user);//先把数据库用户版本号更新!!!!
  53.             var claims = new List<Claim>();
  54.             claims.Add(new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()));
  55.             claims.Add(new Claim(ClaimTypes.Name, user.UserName));
  56.             claims.Add(new Claim(ClaimTypes.Version, user.JWTVersion.ToString()));
  57.             var roles = await _userManager.GetRolesAsync(user);
  58.             foreach (string role in roles)
  59.             {
  60.                 claims.Add(new Claim(ClaimTypes.Role, role));
  61.             }
  62.             string jwtToken = BuildToken(claims, jwtOptions.Value);
  63.             return Ok(jwtToken);
  64.         }
  65.     }
复制代码
(5)创建Test2Controller,实现一个测试方法,并且在控制器类上添加[Authorize],这个特性表示该控制器类下所有操作方法都需要登录后才能访问,也可以单独添加在方法上表示该方法需要登录后访问,这是很重要的一步
  1. [Route("api/[controller]")]
  2.     [ApiController]
  3.     [Authorize]
  4.     public class Test2Controller : ControllerBase
  5.     {
  6.         [HttpGet]
  7.         public IActionResult Hello()
  8.         {
  9.             string id = this.User.FindFirst(ClaimTypes.NameIdentifier)!.Value;
  10.             string userName = this.User.FindFirst(ClaimTypes.NameIdentifier)!.Value;
  11.             IEnumerable<Claim> roleClaims = this.User.FindAll(ClaimTypes.Role);
  12.             string roleNames = string.Join(',', roleClaims.Select(c => c.Value));
  13.             return Ok($"id={id},userName={userName},roleNames ={roleNames}");
  14.         }
  15.     }
复制代码
(6)Swagger没有提供设置自定义HTTP请求报文头(也就是JWT生成的token)的方式,因此传递Authoriation报文接口,我们可以通过对OpenAPI进行配置,使其可以传递Authoriation报文,至此也可以使用Postman这种软件工具调试
  1. builder.Services.AddSwaggerGen(c =>
  2. {
  3.     var scheme = new OpenApiSecurityScheme()
  4.     {
  5.         Description = "Authorization header. \r\nExample: 'Bearer 12345abcdef'",
  6.         Reference = new OpenApiReference
  7.         {
  8.             Type = ReferenceType.SecurityScheme,
  9.             Id = "Authorization"
  10.         },
  11.         Scheme = "oauth2",
  12.         Name = "Authorization",
  13.         In = ParameterLocation.Header,
  14.         Type = SecuritySchemeType.ApiKey,
  15.     };
  16.     c.AddSecurityDefinition("Authorization", scheme);
  17.     var requirement = new OpenApiSecurityRequirement();
  18.     requirement[scheme] = new List<string>();
  19.     c.AddSecurityRequirement(requirement);
  20. });
复制代码
重启项目,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的检查操作
  1. public class JWTValidationFilter : IAsyncActionFilter
  2.     {
  3.         private IMemoryCache memCache;
  4.         private UserManager<User> userMgr;
  5.         public JWTValidationFilter(IMemoryCache memCache, UserManager<User> userMgr)
  6.         {
  7.             this.memCache = memCache;
  8.             this.userMgr = userMgr;
  9.         }
  10.         public async Task   OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
  11.         {
  12.             var claimUserId = context.HttpContext.User.FindFirst(ClaimTypes.NameIdentifier);
  13.             //对于登录接口等没有登录的,直接跳过
  14.             if (claimUserId == null)
  15.             {
  16.                 await next();
  17.                 return;
  18.             }
  19.             long userId = long.Parse(claimUserId!.Value);
  20.             string cacheKey = $"JWTValidationFilter.UserInfo.{userId}";
  21.             User user = await memCache.GetOrCreateAsync(cacheKey, async e => {
  22.                 e.AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(5);
  23.                 return await userMgr.FindByIdAsync(userId.ToString());
  24.             });
  25.             if (user == null)
  26.             {
  27.                 var result = new ObjectResult($"UserId({userId}) not found");
  28.                 result.StatusCode = (int)HttpStatusCode.Unauthorized;
  29.                 context.Result = result;
  30.                 return;
  31.             }
  32.             //jwt数据库中保存的版本号
  33.             var claimVersion = context.HttpContext.User.FindFirst(ClaimTypes.Version);            
  34.             long jwtVerOfReq = long.Parse(claimVersion!.Value);
  35.             //由于内存缓存等导致的并发问题,
  36.             //假如集群的A服务器中缓存保存的还是版本为5的数据,但客户端提交过来的可能已经是版本号为6的数据。因此只要是客户端提交的版本号>=服务器上取出来(可能是从Db,也可能是从缓存)的版本号,那么也是可以的
  37.             if (jwtVerOfReq >= user.JWTVersion)
  38.             {
  39.                 await next();
  40.             }
  41.             else
  42.             {
  43.                 var result = new ObjectResult($"JWTVersion mismatch");
  44.                 result.StatusCode = (int)HttpStatusCode.Unauthorized;
  45.                 context.Result = result;
  46.                 return;
  47.             }
  48.         }
  49.     }
复制代码
(8)把过滤器JWTValidationFilter注册到Program.cs中的全局过滤器中,并且不要忘记注册内存缓存
  1. //过滤器
  2. builder.Services.Configure<MvcOptions>(ops =>
  3. {
  4.     ops.Filters.Add<JWTValidationFilter>();
  5. });
  6. //内存缓存
  7. builder.Services.AddMemoryCache();
复制代码
到这里基本的使用就完成了,如有错误欢迎指正!!
(该代码参考了文章:https://www.cnblogs.com/nullcodeworld/p/16717260.html)
(参考了书籍《ASP.NET Core 技术内幕与项目实战——基于DDD与前后端分离》(作者杨中科)的第八章)

来源:https://www.cnblogs.com/luoxiwen/archive/2023/08/26/17035879.html
免责声明:由于采集信息均来自互联网,如果侵犯了您的权益,请联系我们【E-Mail:cb@itdo.tech】 我们会及时删除侵权内容,谢谢合作!

本帖子中包含更多资源

您需要 登录 才可以下载或查看,没有账号?立即注册

x

举报 回复 使用道具