我喜欢你,是那种一想到你的名字,心里动辄海啸山鸣的喜欢。 --zhu
Session缺点
1、对于分布式集群环境,Session数据保存在服务器内存中就不合适了,应该放到一个中心状态服务器上。ASP.NET Core支持Session采用Redis、Memcached。
2、中心状态服务器有性能问题。
JWT
JWT(JSON Web Token)是一种开放标准(RFC 7519),用于在网络上以 JSON 对象的形式安全地传输信息。
JWT 通常用于在用户和服务器之间传递身份验证信息,以便在用户进行跨域访问时进行身份验证。
JWT 由三部分组成,它们用点号(.)连接在一起,形成一个紧凑的字符串。
这三部分分别是:
1、Header(头部):包含了描述 JWT 的元数据,例如令牌的类型(即JWT),以及所使用的签名算法等信息。
2、Payload(载荷):包含了有关用户或其他实体的信息,以及其他元数据。Payload 可以包含称为 “声明” 的键值对,用于描述实体的一些属性。声明分为注册声明、公共声明和私有声明。
3、Signature(签名):使用头部中指定的算法对头部和载荷进行签名,以确保数据的完整性和验证发送方的身份。签名是由编码后的头部、编码后的载荷、密钥和指定的算法生成的。
JWT 具有很多优点,例如很方便在不同的域之间进行身份验证、减少服务器端的存储压力、以及支持跨语言和跨平台使用等等。
通过本文,可以详细了解如何利用 ASP.NET Core 标识(Identity)框架生成 JWT Token。
1、JWT把登录信息(也称作令牌)保存在客户端。
2、为了防止客户端的数据造假,保存在客户端的令牌经过了签名处理,而签名的密钥只有服务器端才知道,每次服务器端收到客户端提交过来的令牌的时候都要检查一下签名。
3、基于JWT如何实现“登录”。
1)引用以下 Nuget 包:
System.IdentityModel.Tokens.Jwt
2)生成 JWT token
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
// Claim代表用户信息
// 一个Claim就代表一条用户信息
// Claim有两个主要的属性:Type 和 Value
// 它们都是 string 类型的,Type 代表用户信息的类型,Value 代表用户信息的值
// Type可以取任意值
// 不过,一般 Type 的值都取自 ClaimTypes 类中的成员
// 好处是可以更方便地与其他系统对接
// 下面代码创建了5个Claim对象
var claims = new List<Claim>();
claims.Add(new Claim(ClaimTypes.NameIdentifier, "6"));
claims.Add(new Claim(ClaimTypes.Name, "yzk"));
claims.Add(new Claim(ClaimTypes.Role, "User"));
claims.Add(new Claim(ClaimTypes.Role, "Admin"));
claims.Add(new Claim("PassPort", "E90000082")); //自定义的PassPort为E90000082的用户护照信息
// 对 JWT 进行签名的密钥
string key = "fasdfad&9045dafz222#fadpio@0232";
// 设置令牌的过期时间
DateTime expires = DateTime.Now.AddDays(1);
//根据过期时间、多个 Claim 对象、密钥来生成 JWT
byte[] secBytes = Encoding.UTF8.GetBytes(key);
var secKey = new SymmetricSecurityKey(secBytes);
var credentials = new SigningCredentials(secKey, SecurityAlgorithms.HmacSha256Signature); //算法
var tokenDescriptor = new JwtSecurityToken(claims: claims, expires: expires, signingCredentials: credentials);
string jwt = new JwtSecurityTokenHandler().WriteToken(tokenDescriptor);
Console.WriteLine(jwt);
JWT实现登录
一、流程:
- 客户端向服务器端发送用户名、密码等请求登录。
- 服务器端校验用户名、密码,如果校验成功,则从数据库中取出这个用户的ID、角色等用户相关信息。
- 服务器端采用只有服务器端才知道的密钥来对用户信息的 JSON 字符串进行签名,形成签名数据。
- 服务器端把用户信息的 JSON 字符串和签名拼接到一起形成JWT,然后发送给客户端。
- 客户端保存服务器端返回的 JWT,并且在客户端每次向服务器端发送请求的时候都带上这个 JWT。
- 每次服务器端收到浏览器请求中携带的 JWT 后,服务器端用密钥对JWT的签名进行校验,如果校验成功,服务器端则从 JWT 中的 JSON 字符串中读取出用户的信息。
二、代码实现
- Nuget包
Microsoft.AspNetCore.Authentication.JwtBearer
Microsoft.AspNetCore.Identity.EntityFrameworkCore
Microsoft.EntityFrameworkCore.SqlServer
Microsoft.EntityFrameworkCore.Tools
- appsettings.json 文件,配置数据库连接字符串和JWT的密钥、过期时间
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"ConnectionStrings": {
"Default": "Server=(localdb)\\mssqllocaldb;Database=IdentityTestDB;Trusted_Connection=True;MultipleActiveResultSets=true"
},
"JWT": {
"SigningKey": "fasdfad&9045dafz222#fadpio@0232",//密钥
"ExpireSeconds": "86400" //过期时间(秒)
}
}
- 创建JWT配置实体类 JWTOptions
public class JWTOptions
{
public string SigningKey { get; set; }
public int ExpireSeconds { get; set; }
}
- Program.cs 文件,在 builder.Build 之前,编写代码对 JWT 进行配置
// 注入 JWT 配置
services.Configure<JWTOptions>(builder.Configuration.GetSection("JWT"));
// 注入 JwtBearer 配置
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
};
});
- Program.cs 文件,在 app.UseAuthorization 之前,添加身份验证中间件
// 使用 Authentication 中间件,放在 UseAuthorization 之前
app.UseAuthentication();
- 创建继承 IdentityRole 的 User 和 Role 实体类
using Microsoft.AspNetCore.Identity;
public class User: IdentityUser<long>
{
public DateTime CreationTime { get; set; }
public string? NickName { get; set; }
}
public class Role: IdentityRole<long>
{
}
- 创建继承 IdentityDbContext 的上下文类
using Microsoft.EntityFrameworkCore;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
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);
}
}
- 如果数据表还没创建,执行数据库迁移命令
- 创建登录请求的参数实体类 LoginRequest
public record LoginRequest(string UserName, string Password);
- 打开登录请求控制器,编写 Login API,在其中创建 JWT
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
namespace ASPNETCore_JWT1.Controllers
{
[ApiController]
[Route("[controller]/[action]")]
public class Test1Controller : ControllerBase
{
private readonly UserManager<User> userManager;
//注入 UserManager
public Test1Controller(UserManager<User> userManager)
{
this.userManager = userManager;
}
// 生成 JWT
private static string BuildToken(IEnumerable<Claim> claims, JWTOptions options)
{
DateTime expires = DateTime.Now.AddSeconds(options.ExpireSeconds);
byte[] keyBytes = Encoding.UTF8.GetBytes(options.SigningKey);
var secKey = new SymmetricSecurityKey(keyBytes);
var credentials = new SigningCredentials(secKey, SecurityAlgorithms.HmacSha256Signature);
var tokenDescriptor = new JwtSecurityToken(
expires: expires, signingCredentials:
credentials,
claims: claims);
var result = new JwtSecurityTokenHandler().WriteToken(tokenDescriptor);
return result;
}
// 在方法中注入 IOptions<JWTOptions>
// 只需要返回 JWT Token 即可,其它的身份验证中间件会处理
[HttpPost]
public async Task<IActionResult> Login(
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}");
}
if (await userManager.IsLockedOutAsync(user))
{
return BadRequest("LockedOut");
}
var success = await userManager.CheckPasswordAsync(user, password);
if (!success)
{
return BadRequest("Failed");
}
var claims = new List<Claim>();
claims.Add(new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()));
claims.Add(new Claim(ClaimTypes.Name, user.UserName));
var roles = await userManager.GetRolesAsync(user);
foreach (string role in roles)
{
claims.Add(new Claim(ClaimTypes.Role, role));
}
var jwtToken = BuildToken(claims, jwtOptions.Value);
return Ok(jwtToken);
}
}
}
- 打开其它控制器,在类上添加 [Authorize] 这个特性
using Microsoft.AspNetCore.Mvc;
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
namespace ASPNETCore_JWT1.Controllers
{
// [Authorize] 特性标识此控制器的方法需要身份授权才能访问
// 授权中间件会处理其它的
[ApiController]
[Route("[controller]/[action]")]
[Authorize]
public class Test2Controller : Controller
{
[HttpGet]
public IActionResult Hello()
{
// ControllerBase中定义的ClaimsPrincipal类型的User属性代表当前登录用户的身份信息
// 可以通过ClaimsPrincipal的Claims属性获得当前登录用户的所有Claim信息
// this.User.Claims
string id = this.User.FindFirst(ClaimTypes.NameIdentifier)!.Value;
string userName = this.User.FindFirst(ClaimTypes.Name)!.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}");
}
}
}
- Program.cs 文件,配置 Swagger,支持发送 Authorization 报文头
// 配置 Swagger 支持 Authorization
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);
});
三、运行
1.访问/Test1/Login,获取JWT Token,复制下这个值
2.然后访问/Test2/Hello,不带 JWT Token,将收到 401 信息
3.在 Swagger 上的 Authorization 输入 JWT Token,重新访问/Test2/Hello,将返回正确的结果
(如果是在 Postman 等第三方,要在 Header 上加上参数 Authorization=bearer {JWT Token})
四、总结
- 如果其中某个操作方法不想被验证,可以在这个操作方法上添加 [AllowAnonymous] 特性。
- 对于客户端获得的 JWT,在前端项目中,可以把令牌保存到 Cookie、LocalStorage 等位置,从而在后续请求中重复使用,而对于移动App、PC客户端,可以把令牌保存到配置文件中或者本地文件数据库中。当执行【退出登录】操作的时候,我们只要在客户端本地把 JWT 删除即可。
- 在发送请求的时候,只要按照 HTTP 的要求,把 JWT 按照 “Bearer {JWT Token}” 格式放到名字为 Authorization 的请求报文头中即可。
- 从 Authorization 中取出令牌,并且进行校验、解析,然后把解析结果填充到 User 属性中,这一切都是 ASP.NET Core 完成的,不需要开发人员自己编写代码。
JWT拓展
JWT缺点:
1、到期前,令牌无法被提前撤回。什么情况下需要撤回?用户被删除了、禁用了;令牌被盗用了;单设备登录。
2、需要JWT撤回的场景用传统Session更合适。
3、如果需要在JWT中实现,思路:用Redis保存状态,或者用refresh_token+access_token机制等。
思路详解:
在用户表中增加一个整数类型的列JWTVersion,代表最后一次发放出去的令牌的版本号;每次登录、发放令牌的时候,都让JWTVersion的值自增,同时将JWTVersion的值也放到JWT令牌的负载中;当执行禁用用户、撤回用户的令牌等操作的时候,把这个用户对应的JWTVersion列的值自增;当服务器端收到客户端提交的JWT令牌后,先把JWT令牌中的JWTVersion值和数据库中JWTVersion的值做一下比较,如果JWT令牌中JWTVersion的值小于数据库中JWTVersion的值,就说明这个JWT令牌过期了。
实现:
1、为用户实体User类增加一个long类型的属性JWTVersion。
2、修改登录并发放令牌的代码,把用户的JWTVersion属性的值自增,并且把JWTVersion的值写入JWT令牌。
3、编写一个操作筛选器,统一实现对所有的控制器的操作方法中JWT令牌的检查操作。把JWTValidationFilter注册到Program.cs中MVC的全局筛选器中。
优化:
每一次客户端和Controller的交互的时候,检查JWTVersion的筛选器都要查询数据库,性能太低,可以用缓存进行优化。