JSON WEB TOKEN(JWT)

JWTtoke的一種形式。主要由header(頭部)payload(載荷)、signature(簽名)這三部分字符串組成,這三部分使用"."進(jìn)行連接,完整的一條JWT值為${header}.${payload}.${signature},例如下面使用"."進(jìn)行連接的字符串:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyIjoiMTEiLCJpYXQiOjE2MTQzMjU5NzksImV4cCI6MTYxNDMyNTk4MH0.iMjzC_jN3iwSpIyawy3kNRNlL1mBSEiXtOJqhIZmsl8

header

header最開始是一個JSON對象,該JSON包含algtyp這兩個屬性,對JSON使用base64url(使用base64轉(zhuǎn)碼后再對特殊字符進(jìn)行處理的編碼算法,后面會詳細(xì)介紹)編碼后得到的字符串就是header的值。

{
  "alg": "HS256",
  "typ": "JWT"
}
  • alg:簽名算法類型,生成JWT中的signature部分時需要使用到,默認(rèn)HS256
  • typ:當(dāng)前token類型

payload

payloadheader一樣,最開始也是一個JSON對象,使用base64url編碼后的字符串就是最終的值。

payload中存放著7個官方定義的屬性,同時我們可以寫入一些額外的信息,例如用戶的信息等。

  • iss:簽發(fā)人
  • sub:主題
  • aud:受眾
  • exp:過期時間
  • nbf:生效時間
  • iat:簽發(fā)時間
  • jti:編號

signature

signature會使用headeralg屬性定義的簽名算法,對headerpayload合并的字符串進(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ā),該插件主要提供了signverify兩個函數(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,然后再同時帶上JWTUA請求服務(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。

image
/**
 * @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)在黑名單中了,請求會被拒絕。

image

用戶密碼修改,服務(wù)端主動注銷用戶登錄功能,基本上和互斥登錄差不多。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容