JSON WEB TOKEN(JWT)

1,232 阅读3分钟

JWTtoke的一种形式。主要由header(头部)payload(载荷)signature(签名)这三部分字符串组成,这三部分使用"."进行连接,完整的一条JWT值为${header}.${payload}.${signature},例如下面使用"."进行连接的字符串: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyIjoiMTEiLCJpYXQiOjE2MTQzMjU5NzksImV4cCI6MTYxNDMyNTk4MH0.iMjzC_jN3iwSpIyawy3kNRNlL1mBSEiXtOJqhIZmsl8

header

header最开始是一个JSON对象,该JSON包含algtyp这两个属性,对JSON使用base64url(使用base64转码后再对特殊字符进行处理的编码算法,后面会详细介绍)编码后得到的字符串就是header的值。

{
  "alg": "HS256",
  "typ": "JWT"
}
  • alg:签名算法类型,生成JWT中的signature部分时需要使用到,默认HS256
  • typ:当前token类型

payload

payloadheader一样,最开始也是一个JSON对象,使用base64url编码后的字符串就是最终的值。

payload中存放着7个官方定义的属性,同时我们可以写入一些额外的信息,例如用户的信息等。

  • iss:签发人
  • sub:主题
  • aud:受众
  • exp:过期时间
  • nbf:生效时间
  • iat:签发时间
  • jti:编号

signature

signature会使用headeralg属性定义的签名算法,对headerpayload合并的字符串进行加密,加密过程伪代码如下:

HMACSHA256(
  `${base64UrlEncode(header)}.${base64UrlEncode(payload)}`,
  secret
)

加密过后得到的字符串就是signature

base64url

经过base64编码过后的字符串中会存在+、/、=这三个特殊字符,而JWT有可能通过url query进行传输,而url query中不能有+、/url safe base64规定将+/分别用-_进行替换,同时=会在url query中产生歧义,因此需要将=删除,这就是整个编码过程,代码如下

/**
 * node环境
 * @desc 编码过程
 * @param {any} data 需要编码的内容
 * @return {string} 编码后的值
 */
function base64UrlEncode(data) {
  const str = JSON.stringify(data);
  const base64Data = Buffer.from(str).toString('base64');
  // + -> -
  // / -> _
  // = -> 
  const base64UrlData = base64Data.replace(/\+/g, '-').replace(/\//g, '_').replace(/\=/g, '');

  return base64UrlData;
}

当服务解析JWT内容的时候,需要将base64url编码后的内容进行解码操作。首先就是将-_转成+/base64转码后得到的字符串长度能够被4整除,并且base64编码后的内容只有最后才会有=,下面我们看下解码过程:

/**
 * node环境
 * @desc 解码过程
 * @param {any} base64UrlData 需要解码的内容
 * @return {string} 解码后的内容
 */
function base64UrlDecode(base64UrlData) {
  // - -> +
  // _ -> /
  // 使用=补充
  const base64LackData = base64UrlData.replace(/\-/g, '+').replace(/\_/g, '/');
  const num = 4 - base64LackData.length % 4;
  const base64Data = `${base64LackData}${'===='.slice(0, num)}`
  const str = Buffer.from(base64Data, 'base64').toString();
  let data;

  try {
    data = JSON.parse(str);
  } catch(err) {
    data = str;
  }

  return data;
}

JWT使用

node中使用jsonwebtoken插件可以快速进行JWT开发,该插件主要提供了signverify两个函数,分别用来生成和验证JWT

这里简单实现下JWT的生成和校验功能:

/**
 * @desc JWT生成
 * base64UrlEncode(jwt header)
 * base64UrlEncode(jwt payload)
 * HMACSHA256(`${base64UrlEncode(header)}.${base64UrlEncode(payload)}`, secret)
 * @param {json} payload
 * @param {string} secret
 * @param {json} options
 */
const crypto = require('crypto');

function sign(payload, secret) {
  const header = {
    alg: 'HS256', // 这里只是走下流程,就直接用HS256进行签名了
    typ: 'JWT',
  };
  const base64Header = base64UrlEncode(header);
  const base64Payload = base64UrlEncode(payload);
  const waitCryptoStr = `${base64Header}.${base64Payload}`;

  const signature = crypto.createHmac('sha256', secret)
                    .update(waitCryptoStr)
                    .digest('hex');

  return `${base64Header}.${base64Payload}.${signature}`;
}
/**
 * @desc JWT校验
 * jwt内容是否被篡改
 * jwt时效校验,exp和nbf
 * @param {string} jwt
 * @param {string} secret
 */
const crypto = require('crypto');

function verify(jwt, secret) {
  // jwt内容是否被篡改
  const [base64Header, base64Payload, oldSinature] = jwt.split('.');
  const newSinature = crypto.createHmac('sha256', secret)
                            .update(`${base64Header}.${base64Payload}`)
                            .digest('hex');
  if (newSinature !== oldSinature) return false;

  const now = Date.now();
  const { nbf = now, exp = now + 1 } = base64UrlDecode(base64Payload);
  // jwt时效校验,大于等于生效时间,小于过期时间
  return now >= nbf && now < exp;
}

重放攻击

攻击者通过拦截请求拿到用户的JWT,然后使用该JWT请求后端敏感服务,来恶意的获取或修改该用户的数据。

加干扰码

服务端在生成JWT第三部分signature时,密钥的内容可以包含客户端的UA,既HMACSHA256(`${base64UrlEncode(header)}.${base64UrlEncode(payload)}`,`${secret}${UA}`)

如果该JWT在另一个客户端使用的时候,由于UA不同,重新生成的签名与JWT中的signature不一致,请求无效。

该方案也不能完全避免重放攻击,如果攻击者发现服务端加密的时候使用了UA字段,那攻击者在拦截JWT的时候,会一并拿到用户UA,然后再同时带上JWTUA请求服务端,服务端就觉得当前请求是有效的。

UA改成IP也是有一样的问题。

JWT续签

服务端验证传入的JWT通过后,生成一个新的JWT,在响应请求的时候,将新的JWT返回给客户端,同时将传入的JWT加入到黑名单中。客户端在收到响应后,将新的JWT写入本地缓存,等到下次请求的时候,将新的JWT带上一起请求服务。服务端验证的JWT的时候,需要判断当前JWT是否在黑名单中,如果在,就拒绝当前请求,反之就接受。如果请求的是登出接口,就不下发新的JWT

/**
 * @desc JWT续签例子
 */
const http = require('http');
const secret = 'test secret';

// 暂时用一个变量来存放黑名单,实际生产中改用redis、mysql等数据库存放
const blacks = [];

http.createServer((req, res) => {
  const { authorization } = req.headers;

  // 1、验证传入的JWT是否可用
  const availabel = verify(authorization, secret);
  if (!availabel) {
    return res.end();
  }

  // 2、判断黑名单中是否存在当前JWT
  if (blacks.includes(authorization)) {
    return res.end();
  }

  // 3、将当前JWT放入黑名单
  blacks.push(authorization);

  // 4、生成新的JWT,并响应请求
  const newJwt = sign({ userId: '1' }, secret);
  res.end(newJwt);
}).listen(3000);

每次请求都刷新JWT会引起下面两个问题:

  • 问题一:每次请求都会将老的JWT放入黑名单中,随着时间的推移,黑名单越来越庞大,占用内存过多,每次查询时间过长。
  • 问题二:客户端并行请求接口的时候,这些请求带的JWT都是一样的值,请求进入服务始终有先后顺序,先进入的请求,服务端会将当前JWT放入黑名单。后进入的请求,服务端在判断到当前JWT在黑名单中,从而拒绝当前请求。

问题一解决方案:
JWT中定义exp过期时间,程序设置定时任务,每过一段时间就去将黑名单中已经过期的JWT给删除。

const http = require('http');
const secret = 'test secret';

// 暂时用一个变量来存放黑名单,实际生产中改用redis、mysql等数据库存放
const blacks = [];

function cleanBlack() {
  setTimeout(() => {
    blacks = blacks.filter(balck => verify(balck));
    cleanBlack();
  }, 10 * 60 * 1000); // 10m清理一次黑名单
}
cleanBlack();

http.createServer((req, res) => {
  const { authorization } = req.headers;

  // 1、验证传入的JWT是否可用
  const availabel = verify(authorization, secret);
  if (!availabel) {
    return res.end();
  }

  // 2、判断黑名单中是否存在当前JWT
  if (blacks.includes(authorization)) {
    return res.end();
  }

  // 3、将当前JWT放入黑名单
  blacks.push(authorization);

  // 4、生成新的JWT,并响应请求
  const newJwt = sign({
    userId: '1',
    exp: Date.now() + 10 * 60 * 1000, // 10m过期
  }, secret);
  res.end(newJwt);
}).listen(3000);

问题二解决方案:
给黑名单中的JWT添加一个宽限时间。如果当前请求携带的JWT已经在黑名单了,但是当前还没有超过非给当前JWT的宽限时间,那么就正常运行后续代码,如果超出就拒绝请求。

const http = require('http');
const secret = 'test secret';

// 暂时直接用一个变量来存放黑名单,实际生产中改用redis或者mysql存放
const blacks = [];
const grace = {};

http.createServer((req, res) => {
  const { authorization } = req.headers;
  const now = Date.now();

  // 1、验证传入的JWT是否可用
  const availabel = verify(authorization, secret);
  if (!availabel) {
    return res.end();
  }

  // 2、判断黑名单中是否存在当前JWT,如果在,判断当前JWT是否处于宽限期内
  const unavailable = blacks.includes(authorization) && now >= (grace[authorization] || now);
  if (unavailable) {
    return res.end();
  }

  // 3、当前JWT还没有加入黑名单时,将当前JWT放入黑名单
  if (!blacks.includes(authorization)) {
    blacks.push(authorization);
    grace[authorization] = now + 1 * 60 * 1000; // 1m宽限时间
  }

  // 4、生成新的JWT,并响应请求
  const newJwt = sign({ userId: '1' }, secret);
  res.end(newJwt);
}).listen(3000);

注意:这个宽限时间是JWT加入黑名单的时,依据当前时间向后设置的一个时间节点,并不是生成JWT的时候加入的。

互斥登录

使用JWT实现登录逻辑,要实现服务端主动登出功能,服务端需要在下发JWT前,就将该JWT存放到用户与JWT对应关系数据库中,等到服务端要主动注销该用户的时候,就将用户所对应的JWT加入到黑名单中。后续,该用户再请求服务的时候,传入的JWT已经在黑名单中了,请求会被拒绝。

用户密码修改,服务端主动注销用户登录功能,基本上和互斥登录差不多。