在 上一篇 中讲到了在NetCore项目中如何进行全局的请求数据模型验证,只要在请求模型中加了验证特性,接口使用时只用将数据拿来使用,而不用去关心数据是否符合业务需求。
这篇中将讲些个人对于JWT的看法和使用,在网上也能找到很多相关资料和如何使用,基本都是直接嵌到 Startup 类中来单独使用。而博主是将jwt当做一个验证方法来使用。使用起来更加方便,并且在做验证时也更加的灵活。
1.什么是JWT?
Json web token (JWT), 是为了在网络应用环境间传递声明而执行的一种基于JSON的开放标准((RFC 7519)。JWT的声明一般被用来在身份提供者和服务提供者间传递被认证的用户身份信息,以便于从资源服务器获取资源,也可以增加一些额外的其它业务逻辑所必须的声明信息,该token也可直接被用于认证。
传统的session认证
我们知道,http协议本身是一种无状态的协议,而这就意味着如果用户向我们的应用提供了用户名和密码来进行用户认证,那么下一次请求时,用户还要再一次进行用户认证才行,因为根据http协议,我们并不能知道是哪个用户发出的请求,所以为了让我们的应用能识别是哪个用户发出的请求,我们只能在服务器存储一份用户登录的信息,这份登录信息会在响应时传递给浏览器,告诉其保存为cookie,以便下次请求时发送给我们的应用,这样我们的应用就能识别请求来自哪个用户了,这就是传统的基于session认证。
但是这种基于session的认证使应用本身很难得到扩展,随着不同客户端用户的增加,独立的服务器已无法承载更多的用户,而这时候基于session认证应用的问题就会暴露出来.
基于session认证所显露的问题
Session: 每个用户经过我们的应用认证之后,我们的应用都要在服务端做一次记录,以方便用户下次请求的鉴别,通常而言session都是保存在内存中,而随着认证用户的增多,服务端的开销会明显增大。
扩展性: 用户认证之后,服务端做认证记录,如果认证的记录被保存在内存中的话,这意味着用户下次请求还必须要请求在这台服务器上,这样才能拿到授权的资源,这样在分布式的应用上,相应的限制了负载均衡器的能力。这也意味着限制了应用的扩展能力。
CSRF: 因为是基于cookie来进行用户识别的, cookie如果被截获,用户就会很容易受到跨站请求伪造的攻击。
基于token的鉴权机制
基于token的鉴权机制类似于http协议也是无状态的,它不需要在服务端去保留用户的认证信息或者会话信息。这就意味着基于token认证机制的应用不需要去考虑用户在哪一台服务器登录了,这就为应用的扩展提供了便利。
流程上是这样的:
-
- 用户使用用户名密码来请求服务器
- 服务器进行验证用户的信息
- 服务器通过验证发送给用户一个token
- 客户端存储token,并在每次请求时附送上这个token值
- 服务端验证token值,并返回数据
JWT的构成
第一部分我们称它为头部(header),第二部分我们称其为载荷(payload, 类似于飞机上承载的物品),第三部分是签证(signature).
header
jwt的头部承载两部分信息:
-
- 声明类型,这里是jwt
- 声明加密的算法 通常直接使用 HMAC SHA256
完整的头部就像下面这样的JSON:
{
'typ': 'JWT',
'alg': 'HS256'
}
然后将头部进行base64加密(该加密是可以对称解密的),构成了第一部分.
playload
载荷就是存放有效信息的地方。这个名字像是特指飞机上承载的货品,这些有效信息包含三个部分
-
- 标准中注册的声明
- 公共的声明
- 私有的声明
标准中注册的声明 (建议但不强制使用) :
-
- iss: jwt签发者
- sub: jwt所面向的用户
- aud: 接收jwt的一方
- exp: jwt的过期时间,这个过期时间必须要大于签发时间
- nbf: 定义在什么时间之前,该jwt都是不可用的.
- iat: jwt的签发时间
- jti: jwt的唯一身份标识,主要用来作为一次性token,从而回避重放攻击。
公共的声明 :
公共的声明可以添加任何的信息,一般添加用户的相关信息或其他业务需要的必要信息.但不建议添加敏感信息,因为该部分可以直接base64解码,可以看到里面的信息
signature
jwt的第三部分是一个签证信息,这个签证信息由三部分组成:
-
- header (base64后的)
- payload (base64后的)
- secret
这个部分需要base64加密后的header和base64加密后的payload使用.
连接组成的字符串,然后通过header中声明的加密方式进行加盐secret
组合加密,然后就构成了jwt的第三部分。
将这三部分用 . 连接成一个完整的字符串,构成了最终的jwt。
注意:secret是保存在服务器端的,jwt的签发生成也是在服务器端的,secret就是用来进行jwt的签发和jwt的验证,所以,它就是你服务端的私钥,在任何场景都不应该流露出去。一旦客户端得知这个secret, 那就意味着客户端是可以自我签发jwt了。
2.如何将JWT 脱离出来生成与验证?
在任意类库(建议放在公用类中)的NuGet包管理中添加: System.IdentityModel.Tokens.Jwt 然后添加 TokenManager 类
/// <summary>
/// token管理类
/// </summary>
public class TokenManager
{
//私有字段建议放到配置文件中
/// <summary>
/// 秘钥 4的倍数 长度大于等于24
/// </summary>
private static string _secret = "levy0102030405060708asdf";
/// <summary>
/// 发布者
/// </summary>
private static string _issuer = "levy";
/// <summary>
/// 生成token
/// </summary>
/// <param name="tokenStr">需要签名的数据 </param>
/// <param name="expireHour">默认3天过期</param>
/// <returns>返回token字符串</returns>
public static string GenerateToken(string tokenStr, int expireHour = 3 * 24) //3天过期
{
var key1 = new SymmetricSecurityKey(Convert.FromBase64String(_secret));
var cred = new SigningCredentials(key1, SecurityAlgorithms.HmacSha256);
var claims = new[]
{
new Claim("sid",tokenStr),
//new Claim(ClaimTypes.Name,name), //示例 可使用ClaimTypes中的类型
};
var token = new JwtSecurityToken(
issuer: _issuer,//签发者
notBefore: DateTime.Now,//token不能早于这个时间使用
expires: DateTime.Now.AddHours(expireHour),//添加过期时间
claims: claims,//签名数据
signingCredentials: cred//签名
);
//解决一个不知什么问题的PII什么异常
IdentityModelEventSource.ShowPII = true;
return new JwtSecurityTokenHandler().WriteToken(token);
}
/// <summary>
/// 得到Token中的验证消息
/// </summary>
/// <param name="token"></param>
/// <param name="dateTime"></param>
/// <returns></returns>
public static string ValidateToken(string token, out DateTime dateTime)
{
dateTime = DateTime.Now;
var principal = GetPrincipal(token, out dateTime);
if (principal == null)
return default(string);
ClaimsIdentity identity = null;
try
{
identity = (ClaimsIdentity)principal.Identity;
}
catch (NullReferenceException)
{
return null;
}
//identity.FindFirst(ClaimTypes.Name).Value;
return identity.FindFirst("sid").Value;
}
/// <summary>
/// 从Token中得到ClaimsPrincipal对象
/// </summary>
/// <param name="token"></param>
/// <returns></returns>
private static ClaimsPrincipal GetPrincipal(string token, out DateTime dateTime)
{
try
{
dateTime = DateTime.Now;
var tokenHandler = new JwtSecurityTokenHandler();
var jwtToken = (JwtSecurityToken)tokenHandler.ReadToken(token);
if (jwtToken == null)
return null;
var key = Convert.FromBase64String(_secret);
var parameters = new TokenValidationParameters()
{
RequireExpirationTime = true,
ValidateIssuer = true,//验证创建该令牌的发布者
ValidateLifetime = true,//检查令牌是否未过期,以及发行者的签名密钥是否有效
ValidateAudience = false,//确保令牌的接收者有权接收它
IssuerSigningKey = new SymmetricSecurityKey(key),
ValidIssuer = _issuer//验证创建该令牌的发布者
};
//验证token
var principal = tokenHandler.ValidateToken(token, parameters, out var securityToken);
//若开始时间大于当前时间 或结束时间小于当前时间 则返回空
if (securityToken.ValidFrom.ToLocalTime() > DateTime.Now || securityToken.ValidTo.ToLocalTime() < DateTime.Now)
{
dateTime = DateTime.Now;
return null;
}
dateTime = securityToken.ValidTo.ToLocalTime();//返回Token结束时间
return principal;
}
catch (Exception e)
{
dateTime = DateTime.Now;
LogHelper.Logger.Fatal(e, "Token验证失败");
return null;
}
}
}
再到控制器中添加测试方法
[HttpGet]
[Route("testtoken")]
public ActionResult TestToken()
{
var token = TokenManager.GenerateToken("测试token的生成");
Response.Headers["token"] = token;
Response.Headers["Access-Control-Expose-Headers"] = "token";//一定要添加这一句 不然前端是取不到token字段的值的!更别提存store了。
return Succeed(token);
}
在这里必须得提的地方是 若是前后端分离的项目,由于存在跨域问题,必须得在返回header中多添加一个字段 Access-Control-Expose-Headers 该字段对应的值为前端需要取得字段的集合,以英文逗号分隔。
原因:在跨域访问时,XMLHttpRequest对象的getResponseHeader()方法只能拿到一些最基本的响应头,Cache-Control、Content-Language、Content-Type、Expires、Last-Modified、Pragma,如果要访问其他头,则需要服务器设置本响应头。Access-Control-Expose-Headers
头让服务器把允许浏览器访问的头放入白名单。不然容易出现前后端开发人员撕逼哦~
测试结果截图:
能看到数据能返回出来,在调试中也能看到。接着拿这个去访问接口。博主这里只是示例,具体业务视情况而定。
接下来我们拿生成的token去访问验证下是否能成功,在验证token的时候我们可以顺带看下token是否即将过期,若快要过期了就取一个新的token。当然这里有一个问题就是之前的token还可以使用。这里可以用其它手段来规避。如缓存过期token判断等。
[HttpPost] [Route("validtoken")] public ActionResult ValidToken([FromHeader]string token) { var str = TokenManager.ValidateToken(token, out DateTime date); if (!string.IsNullOrEmpty(str) || date > DateTime.Now) { //当token过期时间小于五小时,更新token并重新返回新的token if (date.AddHours(-5) > DateTime.Now) return Succeed($"Token字符串:{str},过期时间:{date}"); var nToken = TokenManager.GenerateToken(str); Response.Headers["token"] = nToken; token = nToken; Response.Headers["Access-Control-Expose-Headers"] = "token"; } else { return Fail(101, "未取得授权信息"); } return Succeed($"Token字符串:{str},过期时间:{DateTime.Now.AddHours(3 * 24)}"); }
测试结果:
3.问题与讨论~
JWT也存在很多疑问的地方,比如 1.被盗取了怎么办?2.用户处于失控状态下?等等问题。
建议:1.不在payload部分存放敏感信息,且尽可能使用https方式,防止被盗的可能性,且提醒用户有风险,不要在公共地方登陆。提供给用户token保存时间选择,若未选择长期保存则只存sessionStorage ,选了则存localStorage。
2.后端用户信息一般存于缓存之中,一般用户使用时间不会太长,所以后端缓存设置时间短(如2小时),当后端缓存过期了就根据payload部分数据来取用户信息存缓存, 用户信息添加稳定状态值来判断是否可用。
3.为解决2要使用payload部分的数据,为防止泄露,可进行AES 进行加密处理,当需要使用时取出在解密使用。
以上属个人想法。有什么问题欢迎提出,共同讨论。
后续补充:
后端使用:只需要新建 BaseUserController 来继承 BaseController 重写 OnActionExecuting 方法,在该方法中添加验证判断。如果在控制器中有某个接口不需要验证,但是又继承了 BaseUserController 的话,
可以在接口方法上加上 AllowAnonymousAttribute 属性来排除验证。 没有使用刷新和验证token的区分,个人觉得这两者都存在一样的问题,何不就用一个呢?
BaseUserController 类代码
/// <summary> /// 用户权限验证控制器 /// </summary> public abstract class BaseUserController : BaseController { // private UserModel _user; // /// <summary> // /// 当前用户 // /// </summary> // protected new UserModel User // { // get => _user ?? (_user = _userCache.Current);//从缓存中取 // set => _user = value; // } public override void OnActionExecuting(ActionExecutingContext filterContext) { base.OnActionExecuting(filterContext); if (filterContext.ActionDescriptor is ControllerActionDescriptor controllerActionDescriptor) { var isDefined = controllerActionDescriptor.MethodInfo.GetCustomAttributes(true) .Any(a => a.GetType() == typeof(AllowAnonymousAttribute)); if (isDefined) { return; } } var token = Request.Headers["token"]; if (string.IsNullOrEmpty(token)) { filterContext.Result = new CustomHttpStatusCodeResult(200, 401, "未授权"); return; } var str = TokenManager.ValidateToken(token, out DateTime date); if (!string.IsNullOrEmpty(str) || date > DateTime.Now) { //当token过期时间小于五小时,更新token并重新返回新的token if (date.AddHours(-5) > DateTime.Now) return; var nToken = TokenManager.GenerateToken(str); Response.Headers["token"] = nToken; Response.Headers["Access-Control-Expose-Headers"] = "token"; return; } filterContext.Result = new CustomHttpStatusCodeResult(200, 401, "未授权"); } }
添加token测试代码,将之前的测试代码改变下
public class TokenTestController : BaseUserController { [HttpGet] [Route("testtoken")] [AllowAnonymous]//允许所有人访问 public ActionResult TestToken() { var token = TokenManager.GenerateToken("测试token的生成"); Response.Headers["token"] = token; Response.Headers["Access-Control-Expose-Headers"] = "token";//一定要添加这一句 不然前端是取不到token字段的值的!更别提存store了。 return Succeed(token); } //[HttpPost] //[Route("validtoken")] //public ActionResult ValidToken([FromHeader]string token) //{ // var str = TokenManager.ValidateToken(token, out DateTime date); // if (!string.IsNullOrEmpty(str) || date > DateTime.Now) // { // //当token过期时间小于五小时,更新token并重新返回新的token // if (date.AddHours(-5) > DateTime.Now) return Succeed($"Token字符串:{str},过期时间:{date}"); // var nToken = TokenManager.GenerateToken(str); // Response.Headers["token"] = nToken; // token = nToken; // Response.Headers["Access-Control-Expose-Headers"] = "token"; // } // else // { // return Fail(101, "未取得授权信息"); // } // return Succeed($"Token字符串:{str},过期时间:{DateTime.Now.AddHours(3 * 24)}"); //} [HttpPost] [Route("validtoken")] public ActionResult ValidToken() { //业务处理 token已在基类中验证 return Succeed("成功"); } }
然后再允许测试看下效果。发现是不是特别棒~~~~
前端使用:使用Axios来管理执行请求操作。可以完美的使用请求、响应拦截器来处理token等信息。做业务时都无需关心token问题。
部分文字描述参考于:
https://www.jianshu.com/p/576dbf44b2ae
在下一篇中将介绍如何在NetCore中如何使用 MemoryCache 和 Redis 来做缓存不常变动数据,提高响应速度~~
有需要源码的可通过此 GitHub 链接拉取 觉得还可以的给个 start 哦,谢谢!