JWT(Json Web Token)初探与实践


协议标准:https://tools.ietf.org/html/rfc7519
jwt.io:https://jwt.io
开箱即用:https://jwt.io/#libraries

前言

最近网站后台迎来第三次改版,原来采用的是jquery+bootstrap这样常规的方式,但是随着网站的交互越来越多,信息量越来越大,就非常力不从心了,每次写动态交互都好痛苦。趁着这次机会,决定采用MVVM的新JS框架,最终评估选择vue.js大礼包,没错!正因为如此,前后端实现了完全分离,就不能采用session这样简单的登陆校验机制了,取而代之的是令牌+RESTful的方式进行交互,此时JWT闪亮登场!

什么是JWT?

JWT(Json Web Token)是一个开放标准(RFC 7519),它基于json对象定义了一种紧凑并且自包含的方式进行安全信息传输。由于消息经过了数字签名,所以是可以被校验和信任的。另外JWT可以使用密匙,或者使用RSA的公钥/私钥进行签名。
其中的一些概念:

  • 紧凑:由于其较小的尺寸,JWT可通过URL,POST参数或HTTP标头内发送。 另外,较小的尺寸意味着传输速度很快。
  • 自包含:JWT的数据中可以包含用户的必要信息,避免了多次查询数据库的情况。

为什么使用JWT?

session认证
因为http本身是无状态的协议,所以每一次的请求其实都要校验,session的原理就初次登陆的时候将相关信息保存到服务端,响应一个cookie保存到客户端,这样每次请求都携带cookie,服务器能够实现校验,这会面临3个问题
1、难以实现单点登录,除非不同服务器之间共享session
2、session默认保存在服务端,增加服务器的存储压力
3、API调试麻烦

OAuth 2.0
OAuth 一般用于第三方接入的场景,管理对外的权限,比如什么第三方登录,微信授权,开放平台等,类似这些更加严谨的场景,相对来说也更加安全,但是部署过程复杂,授权流程也是麻烦,感觉是有些小题大做。而JWT更适用于类似RESTful API(微服务)之间的交互。

自建token协议
这种情况当然最灵活,但是除非有雄厚的资金实例,多余的时间和必要的情况,否则没必要重复造轮子呐。
曾经我们还用过简单的办法,登陆之后根据用户信息进行加盐hash,该hash值即为token,然后以(hash,value)的形式存储在缓存或者数据库中,每次请求携带hash,然后读取校验该hash是否存在,否则校验失败。这种方式也不失为一种简单快捷的好办法,但是仅仅只能当做token校验,并且相关数据存储在服务器,每次访问都还需要进行一次查询,增加服务器开销

什么时候使用JWT?

下面是一些JWT有用的场景
1、身份校验
这是最常见的的使用场景,一旦用户完成了登陆校验,后面每一次的请求豆浆携带JWT,从而校验用户是否允许访问路由、服务、资源。更重要的是,通过JWT可以非常容易实现SSO(Single Sign On)单点登录,因为开销很小,这就意味着,在一个主站登陆了,别的站点就都可以轻松使用JWT访问。
2、信息交换
从上文可知,JWT是能够被签名的的,所以在安全信息传输中,是一个不错的方案,例如使用公钥私钥时,你可以确定收件人是谁,另外还可以校验确保内容是否被篡改。这样,就可以在一些类似下单、交易等等重要的场合使用。

JWT的基本结构


JWT由三部分组成,他们中间由.分隔:

  • Header 头部
  • Payload 数据
  • Signature 签名

因此,典型的JWT看起来是这样的
xxxxx.yyyyy.zzzzz

Header

头部主要包含2个部分,token类型和采用的加密算法。

  1. {
  2. "alg": "HS256",
  3. "typ": "JWT"
  4. }

然后用Base64Url进行编码,就成了JWT的第一个部分

Payload

数据部分包含了主要的声明字段以及相应的值,声明主要包括3种类型:reserved , public 和 private

  • Reserved claims: 这些字段是JWT预先定义的,在JWT中并不会强制使用它们,而是推荐使用。
    常用的有:
  1. ississuer): jwt签发者
  2. subsubject): 签发的项目
  3. audaudience): 接收jwt的一方
  4. expexipre): jwt的过期时间,这个过期时间必须要大于签发时间
  5. nbfnot before): 定义在什么时间之前,该jwt是不可用的.
  6. iatissued at): jwt的签发时间
  7. jtijwt token id): jwt的唯一身份标识,主要用来作为一次性token,从而回避重放攻击

需要注意的是,声明名称只有三个字符长度,这是为了让JWT保持紧凑

  • Public claims:公共的声明可以添加任何的信息,一般添加用户的相关信息或其他业务需要的必要信息.
  • Private claims:私有声明是提供者和消费者所共同定义的声明

简单示例如下:

  1. {
  2. "iss": "www",
  3. "iat": 1441593502,
  4. "exp": 1441594722,
  5. "aud": "www.example.com",
  6. "sub": "www@example.com",
  7. "from_user": "B",
  8. "target_user": "A"
  9. }

然后用Base64Url进行编码,就成了JWT的第二个部分

Signature

为了创建签名,你需要先对前面的部分进行Base64的编码,然后加上私匙,对其进行签名。
例如,你想使用HMAC SHA256算法进行前面,那么创建过程如下:

  1. HMACSHA256(
  2. base64UrlEncode(header) + "." +
  3. base64UrlEncode(payload),
  4. secret)

签名的目的是为了校验JWT的携带者信息,并且检验是否有篡改过所携带的JWT信息。
HMAC SHA256算法计算之后的二进制数据默认进行Base64编码,就是JWT的第三个部分了

将他们放在一起

最终的结果是三段Base64字符串,通过.拼接在一起,这样就很容易在HTML和HTTP环境中传输,与基于XML的标准相比,更加紧凑节省资源。

  1. eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ

调试工具:https://jwt.io/#debugger-io

项目实践JWT

后端

项目使用的是基于php的thinkphp5.0框架作为后端提供服务。前端则是vue+element-ui+axios,至于php类库,采用的是php中Star最多的
https://github.com/lcobucci/jwt
后端php通过composer安装之后使用起来非常的简单,新建一个类专门用于校验

  1. use Lcobucci\JWT\Builder;
  2. use Lcobucci\JWT\Parser;
  3. use Lcobucci\JWT\Signer\Hmac\Sha256;
  4. use Lcobucci\JWT\ValidationData;
  5. class Auth
  6. {
  7. const KEY = 'febcbaae13751fa2ds44c2f107afb08d';
  8. const VALID_INFO = [
  9. 'Issuer' => 'http://www.xxxx.com',
  10. 'Audience' => 'http://aaa.xxxx.com',
  11. 'Subject' => 'test',
  12. 'Expire' => 259200
  13. ];
  14. public static function check()
  15. {
  16. $jwt = request()->header('jwt');
  17. $valid = new ValidationData();
  18. $valid->setIssuer(self::VALID_INFO['Issuer']);
  19. $valid->setAudience(self::VALID_INFO['Audience']);
  20. $valid->setSubject(self::VALID_INFO['Subject']);
  21. //校验jwt信息,同时校验签名,否则可以伪造信息
  22. $signer = new Sha256();
  23. if ($jwt->validate($valid) && $jwt->verify($signer, self::KEY)) {
  24. $uinfo = $jwt->getClaim('uinfo');
  25. //取出数据的时候是对象而不是数组
  26. $uinfo->id
  27. //后续的权限校验过程……
  28. }
  29. }
  30. public static function getSignedJWT($userinfo)
  31. {
  32. $signer = new Sha256();
  33. $token = (new Builder())
  34. ->setIssuer(self::VALID_INFO['Issuer'])
  35. ->setAudience(self::VALID_INFO['Audience'])
  36. ->setSubject(self::VALID_INFO['Subject'])
  37. ->setIssuedAt(time())
  38. ->setExpiration(time() + self::VALID_INFO['Expire'])
  39. //可以直接保存数组或对象
  40. ->set('uinfo', $userinfo)
  41. ->sign($signer, self::KEY)
  42. ->getToken()->__toString();
  43. return $token;
  44. }
  45. }

前端

登陆的时候保存JWT到localStorage,退出登录时前端删除保存的JWT即可。

  1. apiLogin.login(this.$data.loginForm).then(res => {
  2. if (res.data.ret === 0) {
  3. this.$local.set('jwt', res.data.jwt)
  4. this.$local.set('menu', res.data.menu)
  5. this.$local.set('rules', res.data.rules)
  6. this.$local.set('username', this.loginForm.username)
  7. this.$local.set('title', res.data.title)
  8. this.$local.set('gpid', res.data.gpid)
  9. this.$router.push('index')
  10. // 原本没有jwt,所以登陆获取之后手动设置一次
  11. this.$http.defaults.headers.common['jwt'] = this.$local.get('jwt')
  12. } else {
  13. this.isLogining = false
  14. this.$message.error(res.data.msg)
  15. }
  16. }).catch(() => {
  17. this.isLogining = false
  18. })

base_api.js

  1. import axios from 'axios'
  2. import { Message } from 'element-ui'
  3. import local from 'store'
  4. // Add a request interceptor
  5. axios.interceptors.request.use(function (config) {
  6. return config
  7. }, function (error) {
  8. Message.error({
  9. showClose: true,
  10. message: '网络异常,请检查您的网络'
  11. })
  12. console.log(error)
  13. // Do something with request error
  14. return Promise.reject(error)
  15. })
  16. // Add a response interceptor
  17. axios.interceptors.response.use(function (response) {
  18. // 授权过期,无授权信息,跳出登陆
  19. if (response.data.ret === 4011 || response.data.ret === 4013) {
  20. window.location.href = '/#/login'
  21. // 删除本地的token令牌
  22. local.remove('jwt')
  23. Message.error({
  24. showClose: true,
  25. message: response.data.msg
  26. })
  27. return
  28. }
  29. if (response.data.ret === 4012) {
  30. // 无权限返回
  31. window.history.back()
  32. Message.error({
  33. showClose: true,
  34. message: response.data.msg
  35. })
  36. return
  37. }
  38. return response
  39. }, function (error) {
  40. Message.error({
  41. showClose: true,
  42. message: '网络异常,请检查您的网络'
  43. })
  44. return Promise.reject(error)
  45. })
  46. const baseUrl = process.env.API_ROOT
  47. axios.defaults.baseURL = baseUrl
  48. // 初始化的时候加载本地储存过的jwt
  49. if (local.get('jwt')) {
  50. axios.defaults.headers.common['jwt'] = local.get('jwt')
  51. }
  52. export const http = axios

关于安全性

Cookie 可以启用 HttpOnly 和 Secure:

  • HttpOnly:禁止浏览器的 JavaScript 环境访问 Cookie,防御针对 Cookie 的 XSS。
  • Secure:Cookie 只在 HTTPS 请求中被传输。

但是为了实现正真意义上的无状态和跨域单点,还是坚持存储在LocalStorage,而目前localStorage存储没有对XSS攻击有任何抵御机制,一旦出现XSS漏洞,那么存储在localStorage里的数据就极易被获取到。
如果一个网站存在XSS漏洞,那么攻击者注入如下代码,就可以获取使用localStorage存储在本地的所有信息。
HTML5本地存储的安全性
所以务必做好过滤安全检查。

总结

1、JWT并不包含权限校验部分,只包含Token校验,所以在Token校验完成之后,权限部分还需自行校验一次。
2、jwt的payload数据部分不要存放敏感信息,此部分是任何人都可以解密查看的,而jwt主要依靠签名校验身份,同时也不建议存放易改动的信息,否则需要token过期或者重新登录才能来获取最新的信息。
3、签名所用的secret私匙一定要保管好!!!
4、务必使用https,否则用户被截获到token,就可以进行伪造攻击。
5、JWT使用的场景中,一般是要跨域的,所以服务端需要做好CORS的策略支持。见这里
6、若需要强制过期JWT,则在用户表新建一个签名时间字段即可,在登陆的时候检查,若JWT保存签名时间小于服务器签名时间,即强制过期

参考

1 2 3 4 5

posted @ 2017-07-26 21:35  leestar54  阅读(1033)  评论(0编辑  收藏  举报