JWT
是 toke
的一种模式。次要由 header(头部)
、payload(载荷)
、signature(签名)
这三局部字符串组成,这三局部应用 ”.” 进行连贯,残缺的一条 JWT
值为${header}.${payload}.${signature}
,例如上面应用 ”.” 进行连贯的字符串:eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyIjoiMTEiLCJpYXQiOjE2MTQzMjU5NzksImV4cCI6MTYxNDMyNTk4MH0.iMjzC_jN3iwSpIyawy3kNRNlL1mBSEiXtOJqhIZmsl8
header
header
最开始是一个 JSON
对象,该 JSON
蕴含 alg
和typ
这两个属性,对 JSON
应用 base64url
(应用base64
转码后再对特殊字符进行解决的编码算法,前面会具体介绍)编码后失去的字符串就是 header
的值。
{
"alg": "HS256",
"typ": "JWT"
}
- alg:签名算法类型,生成
JWT
中的signature
局部时须要应用到,默认HS256
- typ:以后
token
类型
payload
payload
跟 header
一样,最开始也是一个 JSON
对象,应用 base64url
编码后的字符串就是最终的值。
payload
中寄存着 7 个官网定义的属性,同时咱们能够写入一些额定的信息,例如用户的信息等。
- iss:签发人
- sub:主题
- aud:受众
- exp:过期工夫
- nbf:失效工夫
- iat:签发工夫
- jti:编号
signature
signature
会应用 header
中alg
属性定义的签名算法,对 header
和payload
合并的字符串进行加密,加密过程伪代码如下:
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
开发,该插件次要提供了 sign
和verify
两个函数,别离用来生成和验证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
,而后再同时带上JWT
和UA
申请服务端,服务端就感觉以后申请是无效的。
把 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
曾经在黑名单中了,申请会被回绝。
用户明码批改,服务端被动登记用户登录性能,基本上和互斥登录差不多。