JSON WEB TOKEN(JWT)

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

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

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 215,294评论 6 497
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,780评论 3 391
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 161,001评论 0 351
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,593评论 1 289
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,687评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,679评论 1 294
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,667评论 3 415
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,426评论 0 270
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,872评论 1 307
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,180评论 2 331
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,346评论 1 345
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,019评论 5 340
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,658评论 3 323
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,268评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,495评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,275评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,207评论 2 352

推荐阅读更多精彩内容