JWT是toke的一種形式。主要由header(頭部)、payload(載荷)、signature(簽名)這三部分字符串組成,這三部分使用"."進(jìn)行連接,完整的一條JWT值為${header}.${payload}.${signature},例如下面使用"."進(jìn)行連接的字符串:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyIjoiMTEiLCJpYXQiOjE2MTQzMjU5NzksImV4cCI6MTYxNDMyNTk4MH0.iMjzC_jN3iwSpIyawy3kNRNlL1mBSEiXtOJqhIZmsl8
header
header最開始是一個JSON對象,該JSON包含alg和typ這兩個屬性,對JSON使用base64url(使用base64轉(zhuǎn)碼后再對特殊字符進(jìn)行處理的編碼算法,后面會詳細(xì)介紹)編碼后得到的字符串就是header的值。
{
"alg": "HS256",
"typ": "JWT"
}
- alg:簽名算法類型,生成
JWT中的signature部分時需要使用到,默認(rèn)HS256 - typ:當(dāng)前
token類型
payload
payload跟header一樣,最開始也是一個JSON對象,使用base64url編碼后的字符串就是最終的值。
payload中存放著7個官方定義的屬性,同時我們可以寫入一些額外的信息,例如用戶的信息等。
- iss:簽發(fā)人
- sub:主題
- aud:受眾
- exp:過期時間
- nbf:生效時間
- iat:簽發(fā)時間
- jti:編號
signature
signature會使用header中alg屬性定義的簽名算法,對header和payload合并的字符串進(jìn)行加密,加密過程偽代碼如下:
HMACSHA256(
`${base64UrlEncode(header)}.${base64UrlEncode(payload)}`,
secret
)
加密過后得到的字符串就是signature。
base64url
經(jīng)過base64編碼過后的字符串中會存在+、/、=這三個特殊字符,而JWT有可能通過url query進(jìn)行傳輸,而url query中不能有+、/,url safe base64規(guī)定將+和/分別用-和_進(jìn)行替換,同時=會在url query中產(chǎn)生歧義,因此需要將=刪除,這就是整個編碼過程,代碼如下
/**
* node環(huán)境
* @desc 編碼過程
* @param {any} data 需要編碼的內(nèi)容
* @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;
}
當(dāng)服務(wù)解析JWT內(nèi)容的時候,需要將base64url編碼后的內(nèi)容進(jìn)行解碼操作。首先就是將-和_轉(zhuǎn)成+和/,base64轉(zhuǎn)碼后得到的字符串長度能夠被4整除,并且base64編碼后的內(nèi)容只有最后才會有=,下面我們看下解碼過程:
/**
* node環(huán)境
* @desc 解碼過程
* @param {any} base64UrlData 需要解碼的內(nèi)容
* @return {string} 解碼后的內(nèi)容
*/
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插件可以快速進(jìn)行JWT開發(fā),該插件主要提供了sign和verify兩個函數(shù),分別用來生成和驗證JWT。
這里簡單實現(xiàn)下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進(jìn)行簽名了
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內(nèi)容是否被篡改
* jwt時效校驗,exp和nbf
* @param {string} jwt
* @param {string} secret
*/
const crypto = require('crypto');
function verify(jwt, secret) {
// jwt內(nèi)容是否被篡改
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請求后端敏感服務(wù),來惡意的獲取或修改該用戶的數(shù)據(jù)。
加干擾碼
服務(wù)端在生成JWT第三部分signature時,密鑰的內(nèi)容可以包含客戶端的UA,既HMACSHA256(`${base64UrlEncode(header)}.${base64UrlEncode(payload)}`,`${secret}${UA}`) 。
如果該JWT在另一個客戶端使用的時候,由于UA不同,重新生成的簽名與JWT中的signature不一致,請求無效。
該方案也不能完全避免重放攻擊,如果攻擊者發(fā)現(xiàn)服務(wù)端加密的時候使用了UA字段,那攻擊者在攔截JWT的時候,會一并拿到用戶UA,然后再同時帶上JWT和UA請求服務(wù)端,服務(wù)端就覺得當(dāng)前請求是有效的。
把UA改成IP也是有一樣的問題。
JWT續(xù)簽
服務(wù)端驗證傳入的JWT通過后,生成一個新的JWT,在響應(yīng)請求的時候,將新的JWT返回給客戶端,同時將傳入的JWT加入到黑名單中??蛻舳嗽谑盏巾憫?yīng)后,將新的JWT寫入本地緩存,等到下次請求的時候,將新的JWT帶上一起請求服務(wù)。服務(wù)端驗證的JWT的時候,需要判斷當(dāng)前JWT是否在黑名單中,如果在,就拒絕當(dāng)前請求,反之就接受。如果請求的是登出接口,就不下發(fā)新的JWT。
/**
* @desc JWT續(xù)簽例子
*/
const http = require('http');
const secret = 'test secret';
// 暫時用一個變量來存放黑名單,實際生產(chǎn)中改用redis、mysql等數(shù)據(jù)庫存放
const blacks = [];
http.createServer((req, res) => {
const { authorization } = req.headers;
// 1、驗證傳入的JWT是否可用
const availabel = verify(authorization, secret);
if (!availabel) {
return res.end();
}
// 2、判斷黑名單中是否存在當(dāng)前JWT
if (blacks.includes(authorization)) {
return res.end();
}
// 3、將當(dāng)前JWT放入黑名單
blacks.push(authorization);
// 4、生成新的JWT,并響應(yīng)請求
const newJwt = sign({ userId: '1' }, secret);
res.end(newJwt);
}).listen(3000);
每次請求都刷新JWT會引起下面兩個問題:
- 問題一:每次請求都會將老的
JWT放入黑名單中,隨著時間的推移,黑名單越來越龐大,占用內(nèi)存過多,每次查詢時間過長。 - 問題二:客戶端并行請求接口的時候,這些請求帶的
JWT都是一樣的值,請求進(jìn)入服務(wù)始終有先后順序,先進(jìn)入的請求,服務(wù)端會將當(dāng)前JWT放入黑名單。后進(jìn)入的請求,服務(wù)端在判斷到當(dāng)前JWT在黑名單中,從而拒絕當(dāng)前請求。
問題一解決方案:
JWT中定義exp過期時間,程序設(shè)置定時任務(wù),每過一段時間就去將黑名單中已經(jīng)過期的JWT給刪除。
const http = require('http');
const secret = 'test secret';
// 暫時用一個變量來存放黑名單,實際生產(chǎn)中改用redis、mysql等數(shù)據(jù)庫存放
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、判斷黑名單中是否存在當(dāng)前JWT
if (blacks.includes(authorization)) {
return res.end();
}
// 3、將當(dāng)前JWT放入黑名單
blacks.push(authorization);
// 4、生成新的JWT,并響應(yīng)請求
const newJwt = sign({
userId: '1',
exp: Date.now() + 10 * 60 * 1000, // 10m過期
}, secret);
res.end(newJwt);
}).listen(3000);
問題二解決方案:
給黑名單中的JWT添加一個寬限時間。如果當(dāng)前請求攜帶的JWT已經(jīng)在黑名單了,但是當(dāng)前還沒有超過非給當(dāng)前JWT的寬限時間,那么就正常運行后續(xù)代碼,如果超出就拒絕請求。
const http = require('http');
const secret = 'test secret';
// 暫時直接用一個變量來存放黑名單,實際生產(chǎn)中改用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、判斷黑名單中是否存在當(dāng)前JWT,如果在,判斷當(dāng)前JWT是否處于寬限期內(nèi)
const unavailable = blacks.includes(authorization) && now >= (grace[authorization] || now);
if (unavailable) {
return res.end();
}
// 3、當(dāng)前JWT還沒有加入黑名單時,將當(dāng)前JWT放入黑名單
if (!blacks.includes(authorization)) {
blacks.push(authorization);
grace[authorization] = now + 1 * 60 * 1000; // 1m寬限時間
}
// 4、生成新的JWT,并響應(yīng)請求
const newJwt = sign({ userId: '1' }, secret);
res.end(newJwt);
}).listen(3000);
注意:這個寬限時間是
JWT加入黑名單的時,依據(jù)當(dāng)前時間向后設(shè)置的一個時間節(jié)點,并不是生成JWT的時候加入的。
互斥登錄
使用JWT實現(xiàn)登錄邏輯,要實現(xiàn)服務(wù)端主動登出功能,服務(wù)端需要在下發(fā)JWT前,就將該JWT存放到用戶與JWT對應(yīng)關(guān)系數(shù)據(jù)庫中,等到服務(wù)端要主動注銷該用戶的時候,就將用戶所對應(yīng)的JWT加入到黑名單中。后續(xù),該用戶再請求服務(wù)的時候,傳入的JWT已經(jīng)在黑名單中了,請求會被拒絕。
用戶密碼修改,服務(wù)端主動注銷用戶登錄功能,基本上和互斥登錄差不多。