Jade Dungeon

JSON Web Token

用途

JSON Web Token(JWT)是一个非常轻巧的规范。这个规范允许我们使用JWT在用户和 服务器之间传递安全可靠的信息。

让我们来假想一下一个场景。在A用户关注了B用户的时候,系统发邮件给B用户, 并且附有一个链接「点此关注A用户」。链接的地址可以是这样的:

https://your.awesome-app.com/make-friend/?from_user=B&target_user=A

上面的URL主要通过URL来描述这个当然这样做有一个弊端, 那就是要求用户B用户是一定要先登录的。可不可以简化这个流程, 让B用户不用登录就可以完成这个操作。JWT就允许我们做到这点。

JWT的组成

一个JWT实际上就是一个字符串,它由三部分组成,头部、载荷与签名。

载荷(Payload)

我们先将上面的添加好友的操作描述成一个JSON对象。其中添加了一些其他的信息, 帮助今后收到这个JWT的服务器理解这个JWT。

{
    "iss": "John Wu JWT",         // (JWT必填的字段)该JWT的签发者
    "iat": 1441593502,            // (JWT必填的字段)签发时间
    "exp": 1441594722,            // (JWT必填的字段)过期时间
    "aud": "www.example.com",     // (JWT必填的字段)接收该JWT的一方
    "sub": "jrocket@example.com", // (JWT必填的字段)该JWT所面向的用户
    "from_user": "B",             // 应用自己定义的字段
    "target_user": "A"            // 应用自己定义的字段
}

这些定义都可以在标准中找到。

将上面的JSON对象进行base64编码可以得到下面的字符串。 这个字符串我们将它称作JWT的Payload(载荷):

eyJpc3MiOiJKb2huIFd1IEpXVCIsImlhdCI6MTQ0MTU5MzUwMiwiZXhwIjoxNDQxNTk0NzIyLCJhdWQiOiJ3d3cuZXhhbXBsZS5jb20iLCJzdWIiOiJqcm9ja2V0QGV4YW1wbGUuY29tIiwiZnJvbV91c2VyIjoiQiIsInRhcmdldF91c2VyIjoiQSJ9

如果你使用Node.js,可以用Node.js的包base64url来得到这个字符串。

var base64url = require('base64url')
var header = {
    "from_user": "B",
    "target_user": "A"
}
console.log(base64url(JSON.stringify(header)))
// 输出:eyJpc3MiOiJKb2huIFd1IEpXVCIsImlhdCI6MTQ0MTU5MzUwMiwiZXhwIjoxNDQxNTk0NzIyLCJhdWQiOiJ3d3cuZXhhbXBsZS5jb20iLCJzdWIiOiJqcm9ja2V0QGV4YW1wbGUuY29tIiwiZnJvbV91c2VyIjoiQiIsInRhcmdldF91c2VyIjoiQSJ9

头部(Header)

JWT还需要一个头部,头部用于描述关于该JWT的最基本的信息, 例如其类型以及签名所用的算法等。这也可以被表示成一个JSON对象。例如:

{
  "typ": "JWT",
  "alg": "HS256"
}

在这里,我们说明了这是一个JWT,并且我们所用的签名算法(后面会提到)是HS256算法。

对它也要进行Base64编码,之后的字符串就成了JWT的Header(头部)。

eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9

签名(签名)

将上面的两个编码后的字符串都用句号.连接在一起(头部在前),就形成了:

eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJmcm9tX3VzZXIiOiJCIiwidGFyZ2V0X3VzZXIiOiJBIn0

这一部分的过程在node-jws的源码中有体现

最后,我们将上面拼接完的字符串用HS256算法进行加密。在加密的时候, 我们还需要提供一个密钥(secret)。如果我们用mystar作为密钥的话, 那么就可以得到我们加密后的内容:

rSWamyAYwuHCo7IFAgd1oRpSP7nzL7BF5t7ItqpKViM

这一部分又叫做签名。

最后将这一部分签名也拼接在被签名的字符串后面,我们就得到了完整的JWT

eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJmcm9tX3VzZXIiOiJCIiwidGFyZ2V0X3VzZXIiOiJBIn0.rSWamyAYwuHCo7IFAgd1oRpSP7nzL7BF5t7ItqpKViM

于是,我们就可以将邮件中的URL改成

https://your.awesome-app.com/make-friend/?jwt=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJmcm9tX3VzZXIiOiJCIiwidGFyZ2V0X3VzZXIiOiJBIn0.rSWamyAYwuHCo7IFAgd1oRpSP7nzL7BF5t7ItqpKViM

这样就可以安全地完成添加好友的操作了!

信息会暴露?

是的。

所以,在JWT中,不应该在载荷里面加入任何敏感的数据。在上面的例子中, 我们传输的是用户的User ID。这个值实际上不是什么敏感内容,一般情况下被知道也是安全的。

但是像密码这样的内容就不能被放在JWT中了。如果将用户的密码放在了JWT中, 那么怀有恶意的第三方通过Base64解码就能很快地知道你的密码了。 JWT的适用场景

我们可以看到,JWT适合用于向Web应用传递一些非敏感信息。 例如在上面提到的完成加好友的操作,还有诸如下订单的操作等等。

使用JWT实现单点登录

步骤

用户认证

首先,服务器应用(下面简称「应用」) 让用户通过Web表单将自己的用户名和密码发送到服务器的接口。 这一过程一般是一个HTTP POST请求。建议的方式是通过SSL加密的传输(https协议), 从而避免敏感信息被嗅探。

所谓用户认证(Authentication),就是让用户登录, 并且在接下来的一段时间内让用户访问网站时可以使用其账户,而不需要再次登录的机制。

可别把用户认证和用户授权(Authorization)搞混了。 用户授权指的是规定并允许用户使用自己的权限,例如发布帖子、管理站点等。

生成JWT加入Cookie

核对用户名和密码成功后,应用将用户的id作为JWT Payload的一个属性, 将其与头部分别进行Base64编码拼接后签名,形成一个JWT。 这里的JWT就是一个形同lll.zzz.xxx的字符串。

应用将JWT字符串作为该请求Cookie的一部分返回给用户。注意, 在这里必须使用HttpOnly属性来防止Cookie被JavaScript读取, 从而避免跨站脚本攻击(XSS攻击)。

以后用户访问时从JWT取得相关信息

检查JWT的有效性

在Cookie失效或者被删除前,用户每次访问应用,应用都会接受到含有jwt的Cookie。 从而应用就可以将JWT从请求中提取出来。

应用通过一系列任务检查JWT的有效性。例如,检查签名是否正确;检查Token是否过期; 检查Token的接收方是否是自己(可选)。

从JWT中取得用户ID

应用在确认JWT有效之后,JWT进行Base64解码(可能在上一步中已经完成), 然后在Payload中读取用户的id值,也就是user_id属性。

和Session方式存储id的差异

Session方式存储用户id的最大弊病在于要占用大量服务器内存, 对于较大型应用而言可能还要保存许多的状态。一般而言, 大型应用还需要借助一些KV数据库和一系列缓存机制来实现Session的存储。

而JWT方式将用户状态分散到了客户端中,可以明显减轻服务端的内存压力。 除了用户id之外,还可以存储其他的和用户相关的信息,例如该用户是否是管理员、 用户所在的分桶(常见于A/B测试)等。

虽说JWT方式让服务器有一些计算压力(例如加密、编码和解码), 但是这些压力相比磁盘I/O而言或许是半斤八两。具体是否采用, 需要在不同场景下用数据说话。

单点登录

Session方式来存储用户id,一开始用户的Session只会存储在一台服务器上。 对于有多个子域名的站点,每个子域名至少会对应一台不同的服务器,例如:

  • www.taobao.com
  • nv.taobao.com
  • nz.taobao.com
  • login.taobao.com

所以如果要实现在login.taobao.com登录后,在其他的子域名下依然可以取到Session, 这要求我们在多台服务器上同步Session。

使用JWT的方式则没有这个问题的存在,因为用户的状态已经被传送到了客户端。因此, 我们只需要将含有JWT的Cookie的domain设置为顶级域名即可,例如:

Set-Cookie: jwt=lll.zzz.xxx; HttpOnly; max-age=980000; domain=.taobao.com

注意domain必须设置为一个点加顶级域名,即.taobao.com。这样, taobao.com*.taobao.com就都可以接受到这个Cookie,并获取JWT了。