初识JWT

最近一直在整理过去的项目经历,其中有一个使用vue-cli和Element-UI搭建的后台管理单页应用,其中的管理员权限认证用到了JSON Web Token,之前在做博客的后台管理系统的时候(虽然现在基本没用过了),也采用和同样的认证方式,当时只是简单对着文档进行实现,对其原理并没有很深刻的认识,因此决定稍作整理。

<!--more-->

参考:

1. 业务场景

在单页应用中,通过Token实现用户的身份权限认证,大致流程如下

  • 用户首次登陆输入账户密码,后台接口验证通过,则返回一个token值
  • 用户接受到服务器传回的token后,然后保存在本地,
  • 在之后的接口请求中都附带该token值,用于服务器进行用户身份权限校验
  • 如果校验通过,则返回正常数据;如果不通过,返回401错误,跳转到登录界面

其中由几个关键点,下面一一道来

1.1. token

token包含一些关键信息,也可以转码成原始数据,这样后台才能解析token相关的信息。

token生成 需要防止token被修改过伪造,否则token进行的身份认证将毫无意义。

一般会通过密钥对token进行加密,密钥由后台控制,不会传递给前端。

token有效期 token包含过期校验,可以通过保存一个过期时间expries字段,也可以通过token的保存机制(比如cookie,下面会提到)来实现。

一旦token过期,则需要让旧的token失效,然后判断用户是不是仍在使用应用;如果是,则需要返回一个新的token,或者更新旧token的有效期。

1.2. 保存token

获取到token后,需要将其保存在本地,前端本地保存常见的有三种形式(这个好像是一个常见的面试题~)

localStorage

不能跨子域名共享,可以持久化保存,需要手动实现认证过期机制

sessionStorage

不能在子域名共享,也不能跨窗口共享,关闭窗口后就会被清除,如果业务比较敏感可以采用

document.cookie

可以跨子域名共享(将所有子域名的domain都修改成顶级域名即可),可以通过设置过期时间控制token的有效期。

需要注意的是这里只是把cookie作为前端存储机制,对应的cookie字段本身并不参与后台的权限认证逻辑中,这样可以避免CSRF攻击

1.3. 流程实现

通过上面的流程,我们需要实现的主要有三个部分

  • token的生成机制,如果保存基础信息,以及token的加密机制
  • 前端在每次的请求中都需要附带对应的token,这可以通过请求拦截器处理。在项目中使用的是axios.interceptors.request,为请求头的添加一个自定义头部来实现的。
  • 后台需要在每次请求中对token进行解析和判断,这个可以通过使用中间件实现(比如express中的server.use或者Laravel中的middleware),在中间件中对请求头中的token进行解码

总之,这里的认证需要需要前后端的配合来实现

  • 前端负责保存token,并为每次请求附带上对应的token
  • 后端负责生成和解析token,获取token内部信息,判断是否合法和过期,并决定对应操作

2. JSON Web Token

OK,上面大致了解了Token的作用和使用流程,接下来看一看一种实现Token的方式:JSON Web Token(简称JWT)。

2.1. 组成

为了方便理解,从代码实现开始入手,这里用到的jwt-simple这个库。

let jwt = require("jwt-simple");
// 服务端密钥
let jwtSecret = "my_base_secret";

let Util =  {
    encode(params) {
        return jwt.encode(params, jwtSecret);
    },
    verify(token) {
        let data = jwt.decode(token, jwtSecret);
        let { expires } = data;
        return expires > Date.now();
    }
};


// 载荷
let data = {
    uid: 100,
    name: 'shymean',
    expires: new Data().getTime() + 24*60*60*1000
}

let token = Util.encode(data)
// eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1aWQiOjEwMCwibmFtZSI6InNoeW1lYW4ifQ.sbxKmBPjaQcXrfwBTxDkppXUa7ZJVvQ9ppw0Apnd_Jk
console.log(token)

先不用管上面代码的具体含义,打印输出的token,通过两个.连接的三个子字符串组成的一个token。 一个JWT实际上就是一个字符串,它由三部分组成,头部、载荷与签名。

载荷 载荷指的是token保存的原始数据,即上面代码中的data变量,其中保存了一些信息,服务器通过解析JWT,然后可以获取到对应的信息。通过对data进行base64转码,就可以得到对应的子字符串。

let base64url = require('base64url')
console.log(base64url(JSON.stringify(data)))

得到的结果为

eyJ1aWQiOjEwMCwibmFtZSI6InNoeW1lYW4ifQ

发现了什么?没错,就是上面的token中第二部分。base64可以通过解码还原成原始的数据,因此没有加密之说。

头部 头部用于描述jwt的基本信息,比如签名所使用的算法等。头部甚至可以是某种固定的形式,查看jwt-simple的源码可以发现,在其encode方法中

if (!algorithm) {
    algorithm = 'HS256';
}
var header = { typ: 'JWT', alg: algorithm };

然后同样对header进行base64编码,得到的结果为

eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9

就是上面的token中第一部分。同理,对header编码进行还原,可以获取JWT的基本信息。

签名 将头部和载荷通过.连接在一起,然后通过指定的HS256算法进行加密(关于HS256算法,可以移步这里),就可以得到加密后的内容,这个内容被称为签名。

在加密过程中,需要一个密钥,通过密钥和HS256算法,就可以得到加密后的签名。对应的加密可以通过crypto这个包来实现。

let crypto = require('crypto');
let msg = 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1aWQiOjEwMCwibmFtZSI6InNoeW1lYW4ifQ'

let base64str = crypto.createHmac('sha256', jwtSecret).update(msg).digest('base64');
console.log(base64str)

// sbxKmBPjaQcXrfwBTxDkppXUa7ZJVvQ9ppw0Apnd/Jk=

最后将头部、载荷和签名通过.连接起来,就得到了上面的JWT。OK,现在我们理解了JWT的生成过程。

2.2. 理解

可以看见,上面的载荷和头部都是简单的base64转码,只有签名是经过加密的,其中密钥保存在服务器上,不会泄露到客户端。 当服务器接收到JWT后,会根据头部的信息(加密类型和算法),再次对头部和载荷拼接的字符串进行加密,然后比较传回的签名和计算得到的签名,如果不一致,则表示数据已发生改变,token不可信任,否则,解析载荷的内容,获取token保存的信息。这就是为什么需要将头部、载荷和签名一起保存到JWT中的原因。

由于载荷采用的是base64编码,因此不能在载荷中放入敏感信息,比如用户密码等。但是由于签名的存在,在用户认证和权限判定等应用场景下还是可以使用的。

3. 注意事项

这里我一直有一个疑问,如果恶意用户不修改头部和载荷,而是直接盗用了整个token,那么就可以完整地模拟权限用户了。这跟我们如何保存token有很大关系

  • 如果将token保存在本地(localStorage、sessionStorage),则需要注意XSS攻击,这样恶意脚本就可能获取到对应的token值。
  • 如果将JWT保存在cookie中,然后设置为HttpOnly,则需要注意CSRF攻击,但是这样就需要后台去解析每次请求的Cookie字段了,一旦发生了CSRF攻击,则会绕过认证权限。

因此这里就回到了前端的Web安全问题上了:XSS和CSRF。另外为在token中保存过期时间也是一个很有必要的事情,这样可以避免万一发生泄漏导致用户持久被攻击的问题。

过期问题随之而来的另外一个问题是在用户访问过程中token失效的处理措施,即后台更新过期时间策略。

关于JWT的具体使用场景,除了单页面身份认证,目前在项目中接触的并不是很多,有待进一步学习和整理。