深入浅出 jwt

859 阅读3分钟

jwt 的组成

jwt 由以下三个部分组成:

  1. 头部(header)

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

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

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

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

    标准声明有:

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

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

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

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

  3. 签名(signature)

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

    signature = sign(base64(header) + '.' + base64(payload), secret)
    

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

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

手写 jwt

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

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 }
  }
}

使用方法如下:

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 了:

eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpYXQiOjE1ODA2MDE2MDAwMDAsImV4cCI6IjJkIiwidWVySWQiOiIxMjM0NTYifQ.ZWNVNzA5R0FybnZ2SjZkb1lweklDcUtJTWZYTnF4T2pleHI4RFFUQlBucz0

解码和验证:

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

摘要算法

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

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

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

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

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

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

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

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