美文网首页
JSON WEB TOKEN(JWT)

JSON WEB TOKEN(JWT)

作者: duan777 | 来源:发表于2021-03-01 17:22 被阅读0次

    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

    image
    /**
     * @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已经在黑名单中了,请求会被拒绝。

    image

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

    相关文章

      网友评论

          本文标题:JSON WEB TOKEN(JWT)

          本文链接:https://www.haomeiwen.com/subject/eabafltx.html