深入浅出 jwt

jwt 的组成

jwt 由以下三个部分组成:

  1. 头部(header)

    用于描述元信息,例如加密算法等:

    1
    2
    3
    4
    {
    typ: 'JWT', // 类型 JWT,固定的
    alg: 'HS256', // 哈希算法,例如 HS256、RS512 等
    }
  1. 载荷(payload)

    这是 jwt 的主体部分,包含三个部分:

    • 标准声明(Registered Claim Names)
    • 公共声明(Public Claim Names)
    • 私有声明(Private Claim Names)

      标准声明有:

    • iss 签发者

    • sub 面向的用户
    • aud 接收方
    • exp 有效期
    • nbf 此时间前不可用
    • iat 颁发时间
    • jti 唯一标识,防止重复使用

      这些声明是建议使用但并不强制要求。

      公共的声明可以添加任何信息,一般添加用户的相关信息或其他业务需要的必要信息,例如 userId 等,但是不要添加敏感信息,因为 base64 是对称解密的,放在载荷里面的可以归类为明文信息。

      私有的声明提供者和消费者所共同定义的声明,同样不应该存放敏感信息。

  2. 签名(signature)

    签名由 header、payload 和密钥计算而来,算法如下:

    1
    signature = sign(base64(header) + '.' + base64(payload), secret)
也就是说把头部和载荷先转成 base64 编码,用 . 拼接起来,然后按照头部指定的算法进行加密。但是这里要注意,要把 base64 编码中的 3 个特殊字符做转换,确保传输过程中是 URL safe 的:

- `+` 转换为中划线 `-`
- `/` 转换为下划线 `_`
- `=` 转换为空字符 `''`

手写 jwt

这里写了一个工具类来进行 jwt 的生成、验证和解码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
const crypto = require('crypto')
const jwt = {

stringToBase64(str) { // 字符串转base64
return Buffer.from(str).toString('base64')
},

base64ToString(str) { // base64还原字符串
return Buffer.from(str, 'base64').toString()
},

stringToSafeBase64(str) { // 字符串转url安全的base64(即替换+/=三个字符)
return this.stringToBase64(str)
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/\=/g, '')
},

safeBase64ToString(str) { // url安全的base64还原字符串
str += new Array(5 - (str.length % 4)).join('=')
const base64 = str.replace(/\-/g, '+').replace(/_/g, '/')
return this.base64ToString(base64)
},

sign(content, secret) { // sha256签名
const str = crypto
.createHmac('sha256', secret)
.update(content)
.digest('base64')
return this.stringToSafeBase64(str)
},

encode(header, payload, secret) { // jwt编码
const headerStr = this.stringToSafeBase64(JSON.stringify(header))
const payloadStr = this.stringToSafeBase64(JSON.stringify(payload))
const signature = this.sign(headerStr + '.' + payloadStr, secret)
return [headerStr, payloadStr, signature].join('.')
},

verify(token, secret) { // jwt验证
const [headerStr, payloadStr, signature] = token.split('.')
const newSignature = this.sign([headerStr, payloadStr].join('.'), secret)
return signature === newSignature
},

decode(token) { // jwt解码
const [headerStr, payloadStr] = token.split('.')
const header = JSON.parse(this.safeBase64ToString(headerStr))
const payload = JSON.parse(this.safeBase64ToString(payloadStr))
return { header, payload }
},
}

使用方法如下:

1
2
3
4
5
6
7
8
9
10
11
const header = {
typ: 'JWT',
alg: 'HS256',
}
const payload = {
iat: 1580601600000, // 颁发时间,例如 2020-02-02
exp: '2d', // 有效期,例如 2 天
uerId: '123456', // 用户id
}
const secret = '123456'
const token = jwt.encode(header, payload, secret)

这样就得到 token 了:

1
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpYXQiOjE1ODA2MDE2MDAwMDAsImV4cCI6IjJkIiwidWVySWQiOiIxMjM0NTYifQ.ZWNVNzA5R0FybnZ2SjZkb1lweklDcUtJTWZYTnF4T2pleHI4RFFUQlBucz0

解码和验证:

1
2
console.log(jwt.decode(token))
console.log(jwt.verify(token, secret))

摘要算法

这里用到了 HS256 摘要算法,摘要算法又称哈希/散列算法,它通过一个函数,把任意长度的数据转换为一个长度固定的数据串。摘要一般用作验证内容的完整性,真实性。最常用的就是 md5 和 sha1,使用 crypto 模块进行 md5 加密非常简单,只有一句话:

1
crypto.createHash('md5').update('helloworld').digest('hex')

这里的 update() 方法可以追加内容字符串,追加后得到的摘要结果和上面得到的结果是一样的,例如:

1
crypto.createHash('md5').update('hello').update('world').digest('hex')

其中 digest 可以接收 latin1hexbase64 作为参数。

如果要计算 sha1,只需要把 md5 改成 sha1 即可。

这两种哈希算法都是不需要密钥的,而 sha256 属于 Hmac 算法,只要密钥发生了变化,即使同样的输入也会得到不同的签名,更加保密,不易破解。

1
crypto.createHmac('sha256', secret).update(content).digest('base64')