Skip to content

JWT(JSON Web Token)

目前最流行的跨域身份验证解决方案。JWT 解决了 session 不支持分布式架构,无法支持横向扩展的问题,服务器不保存任何会话数据,只需要保存密钥即可。

JWT 组成

  • header

    固定的内容,alg 表示算法,typ 表示类型。

    javascript
    {
      alg: 'HS256', 
      typ: 'JWT' 
    }
  • payload

    • iss 签发人

    • exp 过期时间

    • sub 主题

    • aud 受众

    • nbf 生效时间

    • iat 签发时间

    • jti 编号

  • Signature

使用方式

  • 请求头,Bearer 表示 token 类型,是一个约定,可以省略。

    Authorization: Bearer <token>

  • url

    http://www.foo.com?token=bar

    有些场合 token 可能会放到 url 中,base64 中 + / = 这三个符号在 url 中有特殊含义,+ 替换成 -,/ 替换成 _,结尾的 = 被省略,这就是 base64URL 编码,所以解决办法就是对 payload 进行编码时使用 base64URL,而不是使用 base64,如果你使用 base64,需要替换所有+ -> -,/ -> _,移除结尾的=,解码时先把-替换回+,_替换回/,根据需要补全结尾的=,使长度为4的倍数,再用标准base64解码

  • 如果是 POST 请求也可以放到请求体中。

流程

  • 如果登录成功,基于 JWT 的算法创建一个 Token 并返回给客户端,客户端通过 localStorage 存储。

  • 之后的请求头中把 Token 带上,服务器根据 header 和 payload 重新签名判断是否被 修改,如果没有被修改再解析 token 得到 payload,和 payload 中的 expirationTime 对比 判断是否过期,过期让用户重新登录或者再给一个新的 token。

实现

js
const { createHmac, timingSafeEqual } = require('crypto')

const toBase64URL = s => Buffer.from(JSON.stringify(s)).toString('base64url')

const sign = (base64URL, secret) =>
  createHmac('sha256', secret).update(base64URL).digest('base64url')

const jwtEncoder = (payload, secret, header = { typ: 'JWT', alg: 'HS256' }) => {
  const base64URL = `${toBase64URL(header)}.${toBase64URL(payload)}`
  const signature = sign(base64URL, secret)

  return `${base64URL}.${signature}`
}

const jwtDecoder = (token, secret) => {
  const segments = token.split('.')

  if (segments.length !== 3) {
    return null
  }

  const [header, payload, signature] = segments
  const _signature = sign(`${header}.${payload}`, secret)
  const signBuffer = Buffer.from(signature)
  const _signBuffer = Buffer.from(_signature)

  if (
    signBuffer.length === _signBuffer.length &&
    // 避免 timing attack
    timingSafeEqual(signBuffer, _signBuffer)
  ) {
    const _payload = JSON.parse(Buffer.from(payload, 'base64url').toString())

    return _payload.exp > Date.now() ? _payload : null
  }

  return null
}

module.exports = {
  jwtEncoder,
  jwtDecoder
}
js
// A month
const time = 1000 * 60 * 60 * 24 * 30
const secret = 'yourSecret'

const token = jwtEncoder(
  { foo: 'foo', bar: 'bar', exp: Date.now() + time },
  secret
)
console.log(token)

const res = jwtDecoder(token, secret)
console.log(res)

双 token

一、初始登录阶段

  1. 用户提交登录凭证:用户在客户端输入用户名、密码等身份验证信息,通过HTTPS协议发送至服务端认证接口。
  2. 服务端验证身份:服务端接收请求后,对用户提交的凭证进行校验,包括账号密码匹配度、账号状态(是否禁用、锁定)等。
  3. 生成双Token:验证通过后,服务端生成两类Token:
    • Access Token:采用JWT格式,包含用户ID、角色权限、过期时间(通常设置为15-30分钟)等基础信息,通过签名算法加密后生成。
      • Refresh Token:生成随机字符串或加密令牌,有效期设置为7-30天,仅关联用户ID,不包含敏感权限信息。
  4. 返回Token至客户端:服务端将Access Token直接返回至客户端,同时将Refresh Token存储在服务端Redis或数据库中,并通过HttpOnly、Secure属性的Cookie返回给客户端,禁止前端JS读取,防范XSS攻击。
  5. 客户端存储Token:客户端将Access Token存储在内存或LocalStorage中,用于后续接口请求;Refresh Token由浏览器自动管理在Cookie中,无需前端手动操作。

二、正常资源访问阶段

  1. 客户端发起请求:客户端每次调用受保护的API接口时,自动在请求头的Authorization字段中携带Bearer ${Access Token}
  2. 服务端验证Access Token:服务端接收请求后,首先解析并验证Access Token的有效性,包括签名是否合法、是否过期、权限是否匹配等。
  3. 返回资源或错误信息:
    • 验证通过:服务端处理业务逻辑,返回接口数据及200状态码。
    • 验证失败:若Access Token过期、签名无效或权限不足,服务端返回401 Unauthorized状态码,并在响应头中携带Refresh-Token-Required标识,提示客户端需要刷新Token。

三、Token自动刷新阶段

  1. 客户端触发刷新逻辑:客户端捕获到401状态码及Refresh-Token-Required标识后,自动触发Token刷新流程,避免用户感知。
  2. 客户端发送刷新请求:客户端向服务端刷新接口发送POST请求,浏览器自动携带存储在Cookie中的Refresh Token,无需前端手动添加。
  3. 服务端验证Refresh Token:服务端从Redis或数据库中查询该Refresh Token的记录,验证其是否存在、是否过期、是否与用户ID匹配,同时检查是否超过绝对会话上限(如设置30天,即使Refresh Token未过期,也需强制重新登录)。
  4. 刷新成功处理:
    • 服务端生成新的Access Token和Refresh Token,新Refresh Token覆盖原记录存储在服务端,并更新Cookie中的Refresh Token。
    • 服务端将新的Access Token返回至客户端,同时在响应头中携带新的Token过期时间。
    • 客户端更新本地存储的Access Token,自动重试之前失败的接口请求,将请求头中的Token替换为新的Access Token。
  5. 刷新失败处理:
    • 若Refresh Token过期、无效或超过绝对会话上限,服务端返回401 Unauthorized状态码,并携带Re-Login-Required标识。
    • 客户端清理本地存储的Access Token,跳转到登录页面,提示用户重新登录。

四、用户登出阶段

  1. 客户端发起登出请求:用户在客户端点击登出按钮,客户端向服务端登出接口发送请求。
  2. 服务端清理Token记录:服务端接收到登出请求后,从Redis或数据库中删除该用户对应的Refresh Token记录,使其立即失效。
  3. 客户端清理状态:客户端清空本地存储的Access Token,同时浏览器自动清除Refresh Token对应的Cookie,完成登出流程。

五、异常场景处理

  1. Access Token泄露:由于Access Token有效期短,攻击者仅能在有限时间内访问资源,服务端可通过监控异常请求频率,及时触发Refresh Token失效,强制用户重新登录。
  2. Refresh Token泄露:攻击者无法直接使用Refresh Token访问业务资源,仅能用于刷新Access Token。服务端可通过绑定用户IP、设备指纹等信息,验证Refresh Token使用环境的一致性,发现异常立即失效。
  3. 并发刷新请求:当多个请求同时触发401状态码时,客户端通过全局锁机制(如isRefreshing变量)确保仅发起一次刷新请求,避免重复生成Token。

六、注意事项

access token 和 refresh token 应该使用不同的密钥,防止将 refresh token 当成 access token 使用,导致权限一直不过期,如果硬要使用相同密码,可以在 payload 中通过手动声明 type 来区分不同的类型。