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
已经在黑名单中了,请求会被拒绝。
用户密码修改,服务端主动注销用户登录功能,基本上和互斥登录差不多。
网友评论