第九节:JWT简介和以JS+WebApi为例基于JWT的安全校验
一. 简介
1. 背景
传统的基于Session的校验存在诸多问题,比如:Session过期、服务器开销过大、不能分布式部署、不适合前后端分离的项目。 传统的基于Token的校验需要存储Key-Value信息,存在Session或数据库中都有弊端,如果按照一定规律采用对称加密算法生成token,虽然能解决上面问题,但是一旦对称加密算法泄露,很容被反编译;所以在此基础上继续升级,利用userId生成Token,只要保存好秘钥即可,从而引出JWT。
2. 什么是JWT
Json web token 简称:JWT, 是为了在网络应用环境间传递声明而执行的一种基于JSON的开放标准。该token被设计为紧凑且安全的,特别适用于分布式站点的单点登录(SSO)场景。JWT的声明一般被用来在身份提供者和服务提供者间传递被认证的用户身份信息,以便于从资源服务器获取资源,也可以增加一些额外的其它业务逻辑所必须的声明信息,该token也可直接被用于认证,也可被加密。
下面就是一段JWT字符串(后面详细分析)
1 eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJVc2VySWQiOjEyMywiVXNlck5hbWUiOiJhZG1pbiJ9.Qjw1epD5P6p4Yy2yju3-fkq28PddznqRj3ESfALQy_U
3. JWT的优点
(1). JWT是无状态的,不需要服务器端保存会话信息,减轻服务器端的读取压力(存储在客户端上),同时易于扩展、易于分布式部署。
(2). JWT可以跨语言支持。
(3). 便于传输,jwt的构成很简单,字节占用空间少,所以是非常便于传输的。
(4). 自身构成有payload部分,可以存储一下业务逻辑相关的非敏感信息。
特别声明:JWT最大的优势是无状态的,相对传统的Session验证能减轻服务器端的存储压力,安全性更高,但也不是绝对的,比如针对同一个接口,JWT字符串被截取后,且在有效期内,在不篡改JWT字符串的情况下,也是可以模拟请求进行访问的。(随着下面的内容深入体会JWT的核心)
二. JWT深度剖析
1. JWT的长相
下面的一段字符串就是JWT加密后的显示格式,我们仔细看,中间通过两个 “点” 将这段字符串分割成三部分了。
eyJ0eXAxIjoiMTIzNCIsImFsZzIiOiJhZG1pbiIsInR5cCI6IkpXVCIsImFsZyI6IkhTMjU2In0.eyJVc2VySWQiOjEyMywiVXNlck5hbWUiOiJhZG1pbiIsImV4cCI6MTU1MjI4Njc0Ni44Nzc0MDE4fQ.pEgdmFAy73walFonEm2zbxg46Oth3dlT02HR9iVzXa8
上面一段很长的字符串到底是怎么来的呢?就需要了解JWT的构成原理。
2. JWT的构成
JWT由三部分组成,如下图,分别是:Header头部、Payload负载、Signature签名。
(1). 头部(Header)
通常包括两部分,类型(如 “typ”:“JWT”)和加密算法(如“alg”:"HS256"),当然你也可以添加其它自定义的一些参数,然后对这个对象机型base64编码,生成一段字符串,
如“eyJ0eXAxIjoiMTIzNCIsImFsZzIiOiJhZG1pbiIsInR5cCI6IkpXVCIsImFsZyI6IkhTMjU2In0”,我们可以对其进行反编码一下,看一下其庐山真面目。
注:Base64是一种编码,也就是说,它是可以被翻译回原来的样子来的。它并不是一种加密过程。
(2). 负载(Payload)
通常用来存放一些业务需要但不敏感的信息,比如:用户编号(userId)、用户账号(userAccount)、权限等等,该部分也有一些默认的声明,如下图,很多不常用。
- iss: jwt签发者
- sub: jwt所面向的用户
- aud: 接收jwt的一方
- exp: jwt的过期时间,这个过期时间必须要大于签发时间
- nbf: 定义在什么时间之前,该jwt都是不可用的.
- iat: jwt的签发时间
- jti: jwt的唯一身份标识,主要用来作为一次性token,从而回避重放攻击。
最后对该部分组装成的对象进行base64编码,如:“eyJVc2VySWQiOjEyMywiVXNlck5hbWUiOiJhZG1pbiIsImV4cCI6MTU1MjI4Njc0Ni44Nzc0MDE4fQ”,我们可以对其进行反编码看一下庐山真面目,如下图:
注:该部分也是可以解码的,所以不要存储敏感信息。
(3). 签名(Signature)
这个部分需要base64加密后的header和base64加密后的payload使用.
连接组成的字符串,然后通过header中声明的加密方式进行加盐secret
组合加密,然后就构成了jwt的第三部分。
伪代码如下:
1 var encodedString = base64UrlEncode(header) + '.' + base64UrlEncode(payload); 2 var signature = HMACSHA256(encodedString, 'sercret密钥')
说明:密钥存在服务器端,不要泄露,在不知道密钥的情况下,是不能进行解密的,jwt的签发生成也是在服务器端的,secret就是用来进行jwt的签发和jwt的验证,所以,它就是你服务端的私钥,在任何场景都不应该流露出去。一旦客户端得知这个secret, 那就意味着客户端是可以自我签发jwt了。
特别说明:即使payload中的信息被篡改,服务器端通过signature就可以判断出来是非法请求,即校验不能通过。
3. 代码尝鲜
需要通过Nuget装JWT包,新版本的jwt建议.Net 版本4.6起。
1 [HttpGet] 2 public string JiaM() 3 { 4 //设置过期时间(可以不设置,下面表示签名后 20分钟过期) 5 double exp = (DateTime.UtcNow.AddMinutes(20) - new DateTime(1970, 1, 1)).TotalSeconds; 6 var payload = new Dictionary<string, object> 7 { 8 { "UserId", 123 }, 9 { "UserName", "admin" }, 10 {"exp",exp } //该参数也可以不写 11 }; 12 var secret = "GQDstcKsx0NHjPOuXOYg5MbeJ1XT0uFiwDVvVBrk";//不要泄露 13 IJwtAlgorithm algorithm = new HMACSHA256Algorithm(); 14 15 //注意这个是额外的参数,默认参数是 typ 和alg 16 var headers = new Dictionary<string, object> 17 { 18 { "typ1", "1234" }, 19 { "alg2", "admin" } 20 }; 21 22 IJsonSerializer serializer = new JsonNetSerializer(); 23 IBase64UrlEncoder urlEncoder = new JwtBase64UrlEncoder(); 24 IJwtEncoder encoder = new JwtEncoder(algorithm, serializer, urlEncoder); 25 var token = encoder.Encode(headers, payload, secret); 26 return token; 27 } 28 29 [HttpGet] 30 public string JieM(string token) 31 { 32 var secret = "GQDstcKsx0NHjPOuXOYg5MbeJ1XT0uFiwDVvVBrk"; 33 try 34 { 35 IJsonSerializer serializer = new JsonNetSerializer(); 36 IDateTimeProvider provider = new UtcDateTimeProvider(); 37 IJwtValidator validator = new JwtValidator(serializer, provider); 38 IBase64UrlEncoder urlEncoder = new JwtBase64UrlEncoder(); 39 IJwtDecoder decoder = new JwtDecoder(serializer, validator, urlEncoder); 40 var json = decoder.Decode(token, secret, true); 41 return json; 42 } 43 catch (TokenExpiredException) 44 { 45 //过期了自动进入这里 46 return "Token has expired"; 47 } 48 catch (SignatureVerificationException) 49 { 50 //校验未通过自动进入这里 51 return "Token has invalid signature"; 52 } 53 catch (Exception) 54 { 55 //其它错误,自动进入到这里 56 return "other error"; 57 }
上述代码方便通过PostMan进行快速测试,注意解密的方法中的三个catch,token过期,会自动进入到TokenExpiredException异常中,token验证不通过,会自动进入SignatureVerificationException中。
三. JWT的使用流程
整体流程,大致如下图:
1. 客户端(前端或App端)通过一个Http请求把用户名和密码传到登录接口,建议采用Https的模式,避免信息被嗅探。
2. 服务器端校验登录的接口验证用户名和密码通过后,把一些业务逻辑需要的信息如:userId、userAccount放到Payload中,进而生成一个xxx.yyy.zzz形式的JWT字符串返回给客户端。
3. 客户端获取到JWT的字符串,可以存放到LocalStorage中,注意退出的登录的时候删除该值。
4. 登录成功,每次请求其它接口的时候都在表头带着该jwt字符串,建议放入HTTP Header中的Authorization位。(解决XSS和XSRF问题) 或者自己命名比如:“auth”,进行该字符串的传递。
5. 服务器端要写一个过滤器,在该过滤器中进行校验jwt的有效性(签名是否正确、是否过期),验证通过进行接口的业务逻辑,验证不通过,返回给客户端。
这里要解决两个问题?
(1). 在WebApi的过滤器中,如果校验通过了,如何将解密后的值传递到action中。(解密两次就有点坑了)
(2). 在WebApi的过滤器中,如果校验不通过,如何返回给客户端,然后客户端针对这种情况,又该如何接受呢。
(实战中揭晓)。
四. 项目实战
一. 整体目标:
通过一个登陆接口和一个获取信息的接口模拟JWT的整套验证逻辑。
二. 详细步骤
1. 封装JWT加密和解密的方法。
需要通过Nuget安装JWT的程序集,JWT的最新版本建议使用.Net 4.6 起。
1 public class JWTHelp 2 { 3 4 /// <summary> 5 /// JWT加密算法 6 /// </summary> 7 /// <param name="payload">负荷部分,存储使用的信息</param> 8 /// <returns></returns> 9 public static string JWTJiaM(IDictionary<string, object> payload) 10 { 11 //密钥保存好,不要泄露 12 var secret = "GQDstcKsx0NHjPOuXOYg5MbeJ1XT0uFiwDVvVBrk"; 13 IJwtAlgorithm algorithm = new HMACSHA256Algorithm(); 14 IJsonSerializer serializer = new JsonNetSerializer(); 15 IBase64UrlEncoder urlEncoder = new JwtBase64UrlEncoder(); 16 IJwtEncoder encoder = new JwtEncoder(algorithm, serializer, urlEncoder); 17 var token = encoder.Encode(payload, secret); 18 return token; 19 } 20 21 /// <summary> 22 /// JWT解密算法 23 /// </summary> 24 /// <param name="token"></param> 25 /// <returns></returns> 26 public static string JWTJieM(string token) 27 { 28 //密钥保存好,不要泄露 29 var secret = "GQDstcKsx0NHjPOuXOYg5MbeJ1XT0uFiwDVvVBrk"; 30 try 31 { 32 IJsonSerializer serializer = new JsonNetSerializer(); 33 IDateTimeProvider provider = new UtcDateTimeProvider(); 34 IJwtValidator validator = new JwtValidator(serializer, provider); 35 IBase64UrlEncoder urlEncoder = new JwtBase64UrlEncoder(); 36 IJwtDecoder decoder = new JwtDecoder(serializer, validator, urlEncoder); 37 var json = decoder.Decode(token, secret, true); 38 //校验通过,返回解密后的字符串 39 return json; 40 } 41 catch (TokenExpiredException) 42 { 43 //表示过期 44 return "expired"; 45 } 46 catch (SignatureVerificationException) 47 { 48 //表示验证不通过 49 return "invalid"; 50 } 51 catch (Exception ex) 52 { 53 return "error"; 54 } 55 } 56 57 58 }
2. 模拟登陆接口
在登录接口中,模拟数据库校验,即账号和密码为admin和12345,即校验通过,然后把账号和userId(实际应该到数据库中查),这里也可以设置一下过期时间,比如20分钟,一同存放到PayLoad中,然后生成JWT字符串,返回给客户端。
/// <summary> /// 模拟登陆 /// </summary> /// <param name="userAccount"></param> /// <param name="pwd"></param> /// <returns></returns> [HttpGet] public string Login1(string userAccount, string pwd) { try { //这里模拟数据操作,只要是admin和123456就验证通过 if (userAccount == "admin" && pwd == "123456") { //1. 进行业务处理(这里模拟获取userId) string userId = "0806"; //过期时间(可以不设置,下面表示签名后 20分钟过期) double exp = (DateTime.UtcNow.AddMinutes(20) - new DateTime(1970, 1, 1)).TotalSeconds; //进行组装 var payload = new Dictionary<string, object> { {"userId", userId }, {"userAccount", userAccount }, {"exp",exp } }; //2. 进行JWT签名 var token = JWTHelp.JWTJiaM(payload); var result = new { result = "ok", token = token }; return JsonConvert.SerializeObject(result); } else { var result = new { result = "error", token = "" }; return JsonConvert.SerializeObject(result); } } catch (Exception) { var result = new { result = "error", token = "" }; return JsonConvert.SerializeObject(result); } }
3. 客户端调用登录接口
这里只是单纯为了测试,使用的get请求,实际项目中建议post请求,且配置Https,请求成功后,把jwt字符串存放到localStorage中。
1 //1.登录 2 $('#j_jwtLogin').on('click', function () { 3 $.get("/api/Seventh/Login1", { userAccount: "admin", pwd: "123456" }, function (data) { 4 var jsonData = JSON.parse(data); 5 if (jsonData.result == "ok") { 6 console.log(jsonData.token); 7 //存放到本地缓存中 8 window.localStorage.setItem("token", jsonData.token); 9 alert("登录成功,ticket=" + jsonData.token); 10 } else { 11 alert("登录失败"); 12 } 13 }); 14 });
运行结果:
4. 服务器端过滤器
代码中分享了两种获取header中信息的方式,获取到“auth”后,进行校验,校验不通过的话,通过状态码401返回给客户端,校验通过的话,则使用 actionContext.RequestContext.RouteData.Values.Add("auth", result); 进行解密值的存储,方便后续action的直接获取。
1 /// <summary> 2 /// 验证JWT算法的过滤器 3 /// </summary> 4 public class JWTCheck : AuthorizeAttribute 5 { 6 public override void OnAuthorization(HttpActionContext actionContext) 7 { 8 //获取表头Header中值的几种方式 9 //方式一: 10 //{ 11 // var authHeader2 = from t in actionContext.Request.Headers 12 // where t.Key == "auth" 13 // select t.Value.FirstOrDefault(); 14 // var token2 = authHeader2.FirstOrDefault(); 15 //} 16 17 //方式二: 18 IEnumerable<string> auths; 19 if (!actionContext.Request.Headers.TryGetValues("auth", out auths)) 20 { 21 //HttpContext.Current.Response.Write("报文头中的auth为空"); 22 //返回状态码验证未通过,并返回原因(前端进行401状态码的捕获),注意:这句话并不能截断该过滤器,还会继续往下走,要借助if-else,如果想直接截断,需要加 return; 23 actionContext.Response = actionContext.Request.CreateErrorResponse(HttpStatusCode.Unauthorized, new HttpError("报文头中的auth为空")); 24 } 25 else 26 { 27 var token = auths.FirstOrDefault(); 28 if (token != null) 29 { 30 if (!string.IsNullOrEmpty(token)) 31 { 32 var result = JWTHelp.JWTJieM(token); 33 if (result == "expired") 34 { 35 //返回状态码验证未通过,并返回原因(前端进行401状态码的捕获) 36 actionContext.Response = actionContext.Request.CreateErrorResponse(HttpStatusCode.Unauthorized, new HttpError("expired")); 37 } 38 else if (result == "invalid") 39 { 40 //返回状态码验证未通过,并返回原因(前端进行401状态码的捕获) 41 actionContext.Response = actionContext.Request.CreateErrorResponse(HttpStatusCode.Unauthorized, new HttpError("invalid")); 42 } 43 else if (result == "error") 44 { 45 //返回状态码验证未通过,并返回原因(前端进行401状态码的捕获) 46 actionContext.Response = actionContext.Request.CreateErrorResponse(HttpStatusCode.Unauthorized, new HttpError("error")); 47 } 48 else 49 { 50 //表示校验通过,用于向控制器中传值 51 actionContext.RequestContext.RouteData.Values.Add("auth", result); 52 } 53 } 54 } 55 else 56 { 57 //返回状态码验证未通过,并返回原因(前端进行401状态码的捕获) 58 actionContext.Response = actionContext.Request.CreateErrorResponse(HttpStatusCode.Unauthorized, new HttpError("token 空")); 59 } 60 } 61 62 } 63 }
5.服务器端获取信息的的方法
将上说过滤器以特性的形式作用在该方法中,然后通过 RequestContext.RouteData.Values["auth"] 获取到解密后的值,进而进行其它业务处理。
1 /// <summary> 2 /// 加密后的获取信息 3 /// </summary> 4 /// <returns></returns> 5 [JWTCheck] 6 [HttpGet] 7 public string GetInfor() 8 { 9 var userData = JsonConvert.DeserializeObject<userData>(RequestContext.RouteData.Values["auth"].ToString()); ; 10 if (userData == null) 11 { 12 var result = new { Message = "error", data = "" }; 13 return JsonConvert.SerializeObject(result); 14 } 15 else 16 { 17 var data = new { userId = userData.userId, userAccount = userData.userAccount }; 18 var result = new { Message = "ok", data =data }; 19 return JsonConvert.SerializeObject(result); 20 } 21 }
6. 客户端调用获取信息的方法
前端获取到localStorage中token值,采用自定义header的方式以“auth”进行传递调用服务器端的方法,由于服务器的验证token不正确的时候,是以状态码的形式返回,所以这里要采用error方法,通过xhr.status==401进行判断,凡是进入到这个401中,均是token验证没有通过,具体是什么原因,可以通过xhr.responseText获取详细的值进行判断。
1 //2.获取信息 2 $('#j_jwtGetInfor').on('click', function () { 3 //从本地缓存中读取token值 4 var token = window.localStorage.getItem("token"); 5 $.ajax({ 6 url: "/api/Seventh/GetInfor", 7 type: "Get", 8 data: {}, 9 datatype: "json", 10 //设置header的方式1 11 headers: { "auth": token}, 12 //设置header的方式2 13 //beforeSend: function (xhr) { 14 // xhr.setRequestHeader("auth", token) 15 //}, 16 success: function (data) { 17 console.log(data); 18 var jsonData = JSON.parse(data); 19 if (jsonData.Message == "ok") { 20 var myData = jsonData.data; 21 console.log("获取成功"); 22 console.log(myData.userId); 23 console.log(myData.userAccount); 24 } else { 25 console.log("获取失败"); 26 } 27 }, 28 //当安全校验未通过的时候进入这里 29 error: function (xhr) { 30 if (xhr.status == 401) { 31 console.log(xhr.responseText); 32 var jsonData = JSON.parse(xhr.responseText); 33 console.log("授权失败,原因为:" + jsonData.Message); 34 } 35 } 36 }); 37 });
运行结果:
其他的如token过期只需要改一下电脑时间即可以测试,token不正确改一下获取到的jwt字符串可以测试,这里不再进行 了。
!
- 作 者 : Yaopengfei(姚鹏飞)
- 博客地址 : http://www.cnblogs.com/yaopengfei/
- 声 明1 : 本人才疏学浅,用郭德纲的话说“我是一个小学生”,如有错误,欢迎讨论,请勿谩骂^_^。
- 声 明2 : 原创博客请在转载时保留原文链接或在文章开头加上本人博客地址,否则保留追究法律责任的权利。