JWT

我喜欢你,是那种一想到你的名字,心里动辄海啸山鸣的喜欢。 --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实现登录
一、流程:

  1. 客户端向服务器端发送用户名、密码等请求登录。
  2. 服务器端校验用户名、密码,如果校验成功,则从数据库中取出这个用户的ID、角色等用户相关信息。
  3. 服务器端采用只有服务器端才知道的密钥来对用户信息的 JSON 字符串进行签名,形成签名数据。
  4. 服务器端把用户信息的 JSON 字符串和签名拼接到一起形成JWT,然后发送给客户端。
  5. 客户端保存服务器端返回的 JWT,并且在客户端每次向服务器端发送请求的时候都带上这个 JWT。
  6. 每次服务器端收到浏览器请求中携带的 JWT 后,服务器端用密钥对JWT的签名进行校验,如果校验成功,服务器端则从 JWT 中的 JSON 字符串中读取出用户的信息。

二、代码实现

  1. Nuget包
Microsoft.AspNetCore.Authentication.JwtBearer
Microsoft.AspNetCore.Identity.EntityFrameworkCore
Microsoft.EntityFrameworkCore.SqlServer
Microsoft.EntityFrameworkCore.Tools
  1. 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"  //过期时间(秒)
  }
}
  1. 创建JWT配置实体类 JWTOptions
public class JWTOptions
{
	public string SigningKey { get; set; }
	public int ExpireSeconds { get; set; }
}
  1. 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
		};
	});
  1. Program.cs 文件,在 app.UseAuthorization 之前,添加身份验证中间件
// 使用 Authentication 中间件,放在 UseAuthorization 之前
app.UseAuthentication();
  1. 创建继承 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>
{

}
  1. 创建继承 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);
	}
}
  1. 如果数据表还没创建,执行数据库迁移命令
  2. 创建登录请求的参数实体类 LoginRequest
public record LoginRequest(string UserName, string Password);
  1. 打开登录请求控制器,编写 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);
		}
	}
}
  1. 打开其它控制器,在类上添加 [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}");
		}
	}
}
  1. 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})

四、总结

  1. 如果其中某个操作方法不想被验证,可以在这个操作方法上添加 [AllowAnonymous] 特性。
  2. 对于客户端获得的 JWT,在前端项目中,可以把令牌保存到 Cookie、LocalStorage 等位置,从而在后续请求中重复使用,而对于移动App、PC客户端,可以把令牌保存到配置文件中或者本地文件数据库中。当执行【退出登录】操作的时候,我们只要在客户端本地把 JWT 删除即可。
  3. 在发送请求的时候,只要按照 HTTP 的要求,把 JWT 按照 “Bearer {JWT Token}” 格式放到名字为 Authorization 的请求报文头中即可。
  4. 从 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的筛选器都要查询数据库,性能太低,可以用缓存进行优化。

posted @ 2024-08-01 21:52  小脑虎爱学习  阅读(87)  评论(0)    收藏  举报