第四十五节:复习Session/Jwt原理、Jwt实操、Swagger中配置Jwt、Jwt撤回方案、双token方案
一. 复习
1. 旧的Session校验机制
(https://www.cnblogs.com/yaopengfei/p/10435032.html)
2. Session原理
(https://www.cnblogs.com/yaopengfei/p/8057176.html)
3. Jwt原理
(重点参考:https://www.cnblogs.com/yaopengfei/p/12162507.html)
样式:"xxxxxxxxxxxx.xxxxxxxxxxxxx.xxxxxxxxxxxxxxxx"由三部分组成.
(1).Header头部:{\"alg\":\"HS256\",\"typ\":\"JWT\"}基本组成,也可以自己添加别的内容,然后对最后的内容进行Base64编码.
(2).Payload负载:iss、sub、aud、exp、nbf、iat、jti基本参数,也可以自己添加别的内容,然后对最后的内容进行Base64编码.
(3).Signature签名:将Base64后的Header和Payload通过.组合起来,然后利用【Hmacsha256+密钥】进行加密, 形成的字符串作为第三部分。
二. 实操
1. 加密和解密测试
这里基于 【System.IdentityModel.Tokens.Jwt】程序集测试
下面代码,解密的时候不验证 aud 和 iss, ClockSkew = TimeSpan.Zero 代表校验过期时间的偏移量,即验证过期时间:(expires+该值),该值默认为5min,这里设置为0,表示生成token时的expries即为过期时间
/// <summary>
/// 测试加密和解密
/// </summary>
/// <returns></returns>
[HttpPost]
public string TestJwAndJm()
{
string secretKey = configuration["SecretKey"];
string token;
//加密
{
var tokenHandler = new JwtSecurityTokenHandler();
var key = Encoding.Default.GetBytes(secretKey);
var tokenDescriptor = new SecurityTokenDescriptor()
{
Subject = new ClaimsIdentity(new Claim[] {
new Claim("userId","00000000001"),
new Claim("userAccount","admin")
}),
Expires = DateTime.UtcNow.AddSeconds(10),
SigningCredentials = new SigningCredentials(new SymmetricSecurityKey(key), SecurityAlgorithms.HmacSha256Signature)
};
token = tokenHandler.WriteToken(tokenHandler.CreateToken(tokenDescriptor)); //将组装好的格式生成加密后的jwt字符串
Console.WriteLine("加密生成的token为:" + token);
}
//解密
bool result;
{
var tokenHandler = new JwtSecurityTokenHandler();
var key = Encoding.Default.GetBytes(secretKey);
var validationParameters = new TokenValidationParameters
{
ValidateAudience = false, //表示不验证aud
ValidateIssuer = false, //表示不验证iss
IssuerSigningKey = new SymmetricSecurityKey(key),
ClockSkew = TimeSpan.Zero //代表校验过期时间的偏移量,即验证过期时间:(expires+该值),该值默认为5min,这里设置为0,表示生成token时的expries即为过期时间
};
SecurityToken validatedToken; //解密后的对象
try
{
ClaimsPrincipal claimsPrincipal = tokenHandler.ValidateToken(token, validationParameters, out validatedToken);
result = true;
//获取payload中的数据
var jwtPayload = ((JwtSecurityToken)validatedToken).Payload.SerializeToJson();
Console.WriteLine("解密后的内容为:" + jwtPayload);
}
catch (SecurityTokenExpiredException)
{
//表示过期
result = false;
}
catch (SecurityTokenException)
{
//表示token错误
result = false;
}
}
return $"token:{token}, result:{result}";
}
2. 在webapi中测试
这里基于【Microsoft.AspNetCore.Authentication.JwtBearer】程序集测试
(1). 编写获取token的接口 GetToken()
/// <summary>
/// 获取Token
/// </summary>
/// <returns></returns>
[HttpPost]
public String GetToken()
{
string secretKey = configuration["SecretKey"];
string token;
//加密
{
var tokenHandler = new JwtSecurityTokenHandler();
var key = Encoding.Default.GetBytes(secretKey);
var tokenDescriptor = new SecurityTokenDescriptor()
{
Subject = new ClaimsIdentity(new Claim[] {
new Claim("userId","00000000001"),
new Claim("userAccount","admin")
}),
Expires = DateTime.UtcNow.AddMinutes(5),
SigningCredentials = new SigningCredentials(new SymmetricSecurityKey(key), SecurityAlgorithms.HmacSha256Signature)
};
token = tokenHandler.WriteToken(tokenHandler.CreateToken(tokenDescriptor)); //将组装好的格式生成加密后的jwt字符串
}
return token;
}
(2). 通过services.AddAuthentication("Bearer").AddJwtBearer() 注册jwt校验
//注册jwt校验
builder.Services.AddAuthentication("Bearer").AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = false,//是否验证Issuer
ValidateAudience = false,//是否验证Audience
ClockSkew = TimeSpan.Zero,//校验时间是否过期时,设置的时钟偏移量(默认是5min,这里设置为0,即用的是产生token时设置的过期时间)
IssuerSigningKey = new SymmetricSecurityKey(Encoding.Default.GetBytes(builder.Configuration["SecretKey"])),//拿到SecurityKey
};
});
(3). 开启认证中间件,app.UseAuthentication();
注意:必须在授权中间件UseAuthorization上面
(4). 编写两个接口 GetMsg1() GetMsg2(), 其中GetMsg1接口上添加 [Authorize] 表示开启jwt校验, GetMsg2不开启
/// <summary>
/// 测试Jwt校验
/// </summary>
/// <returns></returns>
[Authorize]
[HttpPost]
public string GetMsg1()
{
return "请求成功";
}
[HttpPost]
public string GetMsg2()
{
return "请求成功";
}
测试1:分别请求GetMsg1和GetMsg2接口,其中GetMsg1接口报401没有权限, 402接口则正常请求成功
测试2:
A. 先请求GetToken接口获取token
B. 通过postMan请求GetMsg1接口, 并且配置 Bearer Token, 请求成功. 详见:doc中的截图
3. swagger中配置jwt
背景:开启jwt校验后,swagger中无法请求接口了
解决方案:
(1). 在AddSwaggerGen中添加开启输入jwt校验的代码
//给swagger中配置开启jwt输入
builder.Services.AddSwaggerGen(c =>
{
var scheme = new OpenApiSecurityScheme()
{
Description = "Bearer认证, 即:说白了就是在Header中传递参数的时候('Authorization', 'Bearer ' + token),在值的前面加了一个Bearer和空格,然后在解析的时候需要隔离拿出来token值.",
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);
});
PS:通常我更习惯auth认证
builder.Services.AddSwaggerGen(options =>
{
//1. 通过反射开启注释
var xmlFilename = $"{Assembly.GetExecutingAssembly().GetName().Name}.xml";
options.IncludeXmlComments(Path.Combine(AppContext.BaseDirectory, xmlFilename));
//2. 支持jwt传递
var scheme = new OpenApiSecurityScheme()
{
Description = "普通的jwt校验, 即:说白了就是在Header中传递参数的时候多了一个:auth=token",
Reference = new OpenApiReference
{
Type = ReferenceType.SecurityScheme,
Id = "auth"
},
Scheme = "oauth2",
Name = "auth",
In = ParameterLocation.Header,
Type = SecuritySchemeType.ApiKey,
};
options.AddSecurityDefinition("auth", scheme);
var requirement = new OpenApiSecurityRequirement();
requirement[scheme] = new List<string>();
options.AddSecurityRequirement(requirement);
});
(2). 运行后,进入swagger页面,右上角会出现一个Authorize的按钮, 点击后进入, 输入token
(3). 重新请求getMsg1接口,就会自动携带token进行请求,该接口请求成功
三. Jwt撤回问题
1.需求
比如用户被删除了、禁用了; jwt被盗用了; 单设备登录等场景, 由于jwt是有有效期的, 所以经常会出现了前面的场景后, token还没失效,也就是说还能正常使用, 那么我现在的, 需求就是当出现这些场景,可以手动的控制jwt过期问题
2.解决方案1【不做探讨】
把所有生成的jwt都在服务器上存一份(可以存放到redis里), 然后每次比较都要先查询一下redis里是否存在请求传过来的jwt,然后再进行准确性校验,另外可以手动控制删除redis中想让失效的jwt。
3 .解决方案2【推荐】
在用户表中增加一个整数类型的列JWTVersion,代表最后一次发放出去的令牌的版本号;每次登录成功,发放令牌的时候,都让JWTVersion的值自增,同时将JWTVersion的值也放到JWT令牌的负载payLoad中; 当执行禁用用户,撤回用户的令牌等操作的时候,把这个用户对应的JWTVersion列的值自增即可; 当服务器端收到客户端提交的JWT令牌后,先把JWT令牌中的JWTVersion值和数据库中JWTVersion的值做一下比较,如果JWT令牌中JWTVersion的值小于数据库中JWTVersion的值,就说明这个JWT令牌过期了。
【补充:这套方案也适用于同一个账号不能同时访问系统的需求!!!】
4. 版本1:直接从DB中拿jwtVersion获取
思路:
(1). 编写登录接口CheckLogin, 登录成功后, 将jwtVerson++, 并将其存放到jwt的payload负载中
/// <summary>
/// 登录接口
/// </summary>
/// <param name="user"></param>
/// <returns></returns>
[HttpPost]
public async Task<IActionResult> CheckLogin(UserModel user)
{
//1.校验登录
var userData = dbContext.Set<UserInfo>().Where(u => u.userAccount == user.userAccount && u.userPwd == user.userPwd).FirstOrDefault();
if (userData != null)
{
//1.1登录成功jwtVersion需要自增1
userData.jwtVersion++;
string secretKey = configuration["SecretKey"];
//1.2加密
//额外的header参数也可以不设置
var extraHeaders = new Dictionary<string, object>
{
{"myName", "limaru" },
};
//过期时间(可以不设置,下面表示签名后 20分钟过期)
double exp = (DateTime.UtcNow.AddMinutes(20) - new DateTime(1970, 1, 1)).TotalSeconds;
var payload = new Dictionary<string, object>
{
{"userId", userData.id},
{"userAccount", userData.userAccount },
{"jwtVersion", userData.jwtVersion.ToString() },
{"exp",exp }
};
//1.3 进行JWT签名
var token = JWTHelp.JWTJiaM(payload, secretKey, extraHeaders);
//1.4 保存数据库
_ = await dbContext.SaveChangesAsync();
return Ok(new { status = "ok", msg = "登录成功", token });
}
else
{
return Ok(new { status = "error", msg = "登录失败" });
}
}
(2).编写过滤器:CheckJwt
A. 先校验是否有Skip标签 (为了保证完整性,该案例并无作用)
B. 校验JWT自身的准确性(非空、过期、错误等)
C. JWT自身校验通过后, 从jwt解密字符串中拿到jwtVersion、UserId ,根据UserId去数据库中查询JwtVersion, 如果为空, 校验直接不通过
D. 如果DB中的JwtVersion不为空,且客户端的版本 >= DB中的版本号,则表示校验通过; 反之检验失败
代码分享:
/// <summary> /// 版本1--纯DB操作 /// </summary> public class CheckJwt : IAsyncActionFilter { private readonly IConfiguration configuration; private readonly Core6xDBContext dbContext; public CheckJwt(IConfiguration configuration, Core6xDBContext dbContext) { this.configuration = configuration; this.dbContext = dbContext; } public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) { //1. 先判断是否有skip跳过标签 var isSkip = context.ActionDescriptor.EndpointMetadata.Any(x => x.GetType() == typeof(SkipAttribute)); if (isSkip) { await next(); return; } //2. 校验jwt自身的准确性 var token = context.HttpContext.Request.Headers["auth"].ToString(); //ajax请求传过来 if (token == "null" || string.IsNullOrEmpty(token)) { context.Result = new JsonResult(new { status = "error", msg = "非法请求,参数为空" }); return; } var result = JWTHelp.JWTJieM(token, configuration["SecretKey"]); if (result == "expired") { context.Result = new JsonResult(new { status = "error", msg = "非法请求,参数已经过期" }); return; } else if (result == "invalid") { context.Result = new JsonResult(new { status = "error", msg = "非法请求,未通过校验" }); return; } else if (result == "error") { context.Result = new JsonResult(new { status = "error", msg = "非法请求,未通过校验" }); return; } else { //3. 表示校验通过,接下来校验jwtVersion的准确性 var jwtModel = JsonConvert.DeserializeObject<JwtModel>(result); //3.1 获取数据库中该用户的jwtVersion long? myJwtVersion = dbContext.Set<UserInfo>().Where(u => u.id == jwtModel.userId).Select(u => u.jwtVersion).FirstOrDefault(); if (myJwtVersion==null) { context.Result = new JsonResult(new { status = "error", msg = "该用户不存在" }); return; } //3.2 客户端提交的版本 大于等于 DB的版本号, 验证通过 (客户端jwtVerson小于DB中的版本号, 则说明过期了) if (long.Parse(jwtModel.jwtVersion) >= myJwtVersion) { //表示校验通过,执行action中的业务 context.RouteData.Values.Add("auth", result); await next(); } else { context.Result = new JsonResult(new { status = "error", msg = "jwt版本号错误" }); return; } } }
测试:
访问CheckLogin获取token, 然后携带token访问GetMsg接口,此时请求成功; 然后去DB中找到这个用户将jwtVersion增加1后,再次访问GetMsg接口,则访问不通过,提示jwtVerson版本号错误
/// <summary> /// 版本1的验证 /// </summary> /// <returns></returns> [HttpPost] [TypeFilter(typeof(CheckJwt))] public string GetMsg() { return "恭喜你,访问成功了"; }
如下图
剖析:
上述方案在CheckJwt中过滤器中每次都要访问DB,性能很差, 可以考虑引入内存缓存来处理这个问题,提供性能.
5 版本2:引入内存缓存进行优化 【推荐】
思路:
使用IMemoryCache中的GetOrCreate方法,表示缓存存在,直接从缓存中读取内容并返回;缓存不存在,执行数据库读取操作→写入缓存→返回内容, 从而缓解了DB的压力
代码分享:
/// <summary> /// 版本2--引入内存缓存 /// </summary> public class CheckJwt2 : IAsyncActionFilter { private readonly IConfiguration configuration; private readonly Core6xDBContext dbContext; private readonly IMemoryCache cache; public CheckJwt2(IConfiguration configuration, Core6xDBContext dbContext, IMemoryCache cache) { this.configuration = configuration; this.dbContext = dbContext; this.cache = cache; } public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) { //1. 先判断是否有skip跳过标签 var isSkip = context.ActionDescriptor.EndpointMetadata.Any(x => x.GetType() == typeof(SkipAttribute)); if (isSkip) { await next(); return; } //2. 校验jwt自身的准确性 var token = context.HttpContext.Request.Headers["auth"].ToString(); //ajax请求传过来 if (token == "null" || string.IsNullOrEmpty(token)) { context.Result = new JsonResult(new { status = "error", msg = "非法请求,参数为空" }); return; } var result = JWTHelp.JWTJieM(token, configuration["SecretKey"]); if (result == "expired") { context.Result = new JsonResult(new { status = "error", msg = "非法请求,参数已经过期" }); return; } else if (result == "invalid") { context.Result = new JsonResult(new { status = "error", msg = "非法请求,未通过校验" }); return; } else if (result == "error") { context.Result = new JsonResult(new { status = "error", msg = "非法请求,未通过校验" }); return; } else { //3. 表示校验通过,接下来校验jwtVersion的准确性 var jwtModel = JsonConvert.DeserializeObject<JwtModel>(result); //3.1 获取数据库中该用户的jwtVersion 【此处引入缓存】 string cacheKey = $"JwtVersionCheck_{jwtModel.userId}"; //GetOrCreate用法:缓存存在,直接从缓存中读取内容并返回;缓存不存在,执行数据库读取操作→写入缓存→返回内容 long? myJwtVersion = cache.GetOrCreate(cacheKey, opt => { opt.AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(40); //设置绝对过期时间为20s return dbContext.Set<UserInfo>().Where(u => u.id == jwtModel.userId).Select(u => u.jwtVersion).FirstOrDefault(); }); if (myJwtVersion == null) { context.Result = new JsonResult(new { status = "error", msg = "该用户不存在" }); return; } //3.2 客户端提交的版本 大于等于 DB的版本号, 验证通过 (客户端jwtVerson小于DB中的版本号, 则说明过期了) if (long.Parse(jwtModel.jwtVersion) >= myJwtVersion) { //表示校验通过,执行action中的业务 context.RouteData.Values.Add("auth", result); await next(); } else { context.Result = new JsonResult(new { status = "error", msg = "jwt版本号错误" }); return; } } } }
测试:
/// <summary> /// 版本2的验证 /// </summary> /// <returns></returns> [HttpPost] [TypeFilter(typeof(CheckJwt2))] public string GetMsg2() { return "恭喜你,访问成功了"; }
访问CheckLogin获取token, 然后携带token访问GetMsg2接口,此时请求成功; 然后去DB中找到这个用户将jwtVersion增加1后,再次访问GetMsg2接口,由于缓存的过期时间为20s,还没有过期,
读取的JwtVersion是修改前的值,所以还是能访问通过;等待20s过后,再次访问GetMsg2接口,则访问不通过,提示jwtVerson版本号错误
剖析:
(1).虽然引入内存缓存可以缓解DB的压力,但是有利就有弊,在缓存没有过期的这段时间里,手动增加DB中的JwtVersion,是无法让客户端jwt失效的,当然通常缓存过期时间设置的不长,该问题也无可厚非,关系不大的.
(2).集群环境中,由于内存缓存等导致的并发问题,假如集群的A服务器中缓存保存的还是版本为5的数据,但客户端提交过来的可能已经是版本号为6的数据。 因此只要是客户端提交的版本号>=服务器上取出来(可能是从Db,也可能是从缓存)的版本号,那么也是可以的
6 版本3:使用Redis存储JwtVersion【非常推荐】
思路:
将JwtVerson存放到Redis里, 即可以环境DB压力,也可以解决集群环境下内存缓存的局限性
步骤:
(1). 基于【CSRedisCore3.8.3】程序集进行redis访问,这里采用简单粗暴的写法 (详细可以参考:https://www.cnblogs.com/yaopengfei/p/14211883.html)
(2). 在登录方法CheckLogin3中将 对该用户jwtVersion对应的key进行自增1, 然后将自增后的值写入payLoad中
/// <summary>
/// 登录接口--版本3使用
/// </summary>
/// <param name="user"></param>
/// <returns></returns>
[HttpPost]
public async Task<IActionResult> CheckLogin3(UserModel user)
{
//1.校验登录
var userData = dbContext.Set<UserInfo>().Where(u => u.userAccount == user.userAccount && u.userPwd == user.userPwd).FirstOrDefault();
if (userData != null)
{
//1.1登录成功redis里的jwtVersion需要自增1
var rds = new CSRedis.CSRedisClient(configuration["RedisStr"]);
string userKey = $"userInfo_{userData.id}";
var jwtVersion = rds.IncrBy(userKey); //获取自增后的jwtVersion
string secretKey = configuration["SecretKey"];
//1.2加密
//额外的header参数也可以不设置
var extraHeaders = new Dictionary<string, object>
{
{"myName", "limaru" },
};
//过期时间(可以不设置,下面表示签名后 20分钟过期)
double exp = (DateTime.UtcNow.AddMinutes(20) - new DateTime(1970, 1, 1)).TotalSeconds;
var payload = new Dictionary<string, object>
{
{"userId", userData.id},
{"userAccount", userData.userAccount },
{"jwtVersion", jwtVersion.ToString() },
{"exp",exp }
};
//1.3 进行JWT签名
var token = JWTHelp.JWTJiaM(payload, secretKey, extraHeaders);
//1.4 保存数据库
_ = await dbContext.SaveChangesAsync();
return Ok(new { status = "ok", msg = "登录成功", token });
}
else
{
return Ok(new { status = "error", msg = "登录失败" });
}
}
(3). 编写过滤器CheckJwt3, 与版本1的逻辑相同,只不过改为从redis中读取jwtVersion了
代码分享:
/// <summary> /// 版本3--基于Redis存储jwtVersion /// </summary> public class CheckJwt3 : IAsyncActionFilter { private readonly IConfiguration configuration; private readonly Core6xDBContext dbContext; public CheckJwt3(IConfiguration configuration, Core6xDBContext dbContext) { this.configuration = configuration; this.dbContext = dbContext; } public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) { //1. 先判断是否有skip跳过标签 var isSkip = context.ActionDescriptor.EndpointMetadata.Any(x => x.GetType() == typeof(SkipAttribute)); if (isSkip) { await next(); return; } //2. 校验jwt自身的准确性 var token = context.HttpContext.Request.Headers["auth"].ToString(); //ajax请求传过来 if (token == "null" || string.IsNullOrEmpty(token)) { context.Result = new JsonResult(new { status = "error", msg = "非法请求,参数为空" }); return; } var result = JWTHelp.JWTJieM(token, configuration["SecretKey"]); if (result == "expired") { context.Result = new JsonResult(new { status = "error", msg = "非法请求,参数已经过期" }); return; } else if (result == "invalid") { context.Result = new JsonResult(new { status = "error", msg = "非法请求,未通过校验" }); return; } else if (result == "error") { context.Result = new JsonResult(new { status = "error", msg = "非法请求,未通过校验" }); return; } else { //3. 表示校验通过,接下来校验jwtVersion的准确性 var jwtModel = JsonConvert.DeserializeObject<JwtModel>(result); //3.1 获取Redis该用户的jwtVersion var rds = new CSRedis.CSRedisClient(configuration["RedisStr"]); string userKey = $"userInfo_{jwtModel.userId}"; long? myJwtVersion = rds.Get<long?>(userKey); if (myJwtVersion==null) { context.Result = new JsonResult(new { status = "error", msg = "该用户不存在" }); return; } //3.2 客户端提交的版本 大于等于 Redis的版本号, 验证通过 (客户端jwtVerson小于Redis中的版本号, 则说明过期了) if (long.Parse(jwtModel.jwtVersion) >= myJwtVersion) { //表示校验通过,执行action中的业务 context.RouteData.Values.Add("auth", result); await next(); } else { context.Result = new JsonResult(new { status = "error", msg = "jwt版本号错误" }); return; } } } }
测试:
访问CheckLogin3获取token, 然后携带token访问GetMsg3接口,此时请求成功; 然后去Redis中找到这个用户将jwtVersion增加1后,再次访问GetMsg3接口,则访问不通过,提示jwtVerson版本号错误
剖析:
既缓解了DB的压力,同时还能解决集群问题
四. 双token方案
详见:
https://www.cnblogs.com/yaopengfei/p/12449213.html
!
- 作 者 : Yaopengfei(姚鹏飞)
- 博客地址 : http://www.cnblogs.com/yaopengfei/
- 声 明1 : 如有错误,欢迎讨论,请勿谩骂^_^。
- 声 明2 : 原创博客请在转载时保留原文链接或在文章开头加上本人博客地址,否则保留追究法律责任的权利。