一. 身分驗證(Authentication)
要想區分來自不同用戶的請求的話,服務端需要根據客戶端請求確認其用戶身分,即身分驗證
在人機交互中,身分驗證意味著要求用戶登入才能訪問某些資訊。而為了確認用戶身分,用戶必須提供只有用戶和服務器知道的資訊(即身分驗證因子),比如用戶名/密碼
Web 環境下,身分驗證方案分為 2 類:
-
基於 Session 的驗證
-
基於 Token 的驗證
基於 Session 的方案中,登入成功後,服務端將用戶的身分資訊儲存在 Session 裡,並將 Session ID 透過 Cookie 傳遞給客戶端。後續的數據請求都會帶上 Cookie,服務端根據 Cookie 中攜帶的 Session ID 來辨別用戶身分
而在基於 Token 的方案中,服務端根據用戶身分資訊生成 Token,發放給客戶端。客戶端收好 Token,並在之後的數據請求中帶上 Token,服務端接到請求後校驗並解析 Token 得出用戶身分,過程如下:
[caption id="attachment_1957" align="alignnone" width="810"]
token based login[/caption]
P.S. 用戶名/密碼屬於知識因子,另外還有佔有因子和遺傳因子:
-
知識因子:用戶登入時必須知道的東西都是知識因子,比如用戶名、密碼等
-
佔有因子:用戶登入時必須具備的東西,比如密碼令牌、ID 卡等
-
遺傳因子:個人的生物特徵,比如指紋、虹膜、人臉等
P.S. Authentication(驗證)與 Authorization(授權)不同,前者驗證身分,後者驗證權限
二. Token
身分驗證中的 Token 就像身分證,由服務端簽發/驗證,並且在有效期內都具有合法性,認「證」(Token)不認「人」(用戶)
Session 方案中用戶身分資訊(以 Session 紀錄形式)儲存在服務端。而 Token 方案中(以 Token 形式)儲存在客戶端,服務端僅驗證 Token 合法性。這種區別在單點登入(SSO,Single Sign On)的場景最為明顯:
-
基於 Session 的 SSO:考慮如何同步 Session 和共享 Cookie。比如登入成功後把回應 Cookie 的 domain 設置為通配兄弟應用域名的形式,並且所有應用都從身分驗證服務同步 Session
-
基於 Token 的 SSO:考慮如何共享 Token。比如進入兄弟應用時透過 URL 帶上 Token
Token 相當於加密過的 Session 紀錄,含有用戶 ID 等身分資訊,以及 Token 簽發時間,有效期等用於 Token 合法性驗證的元資訊,例如:
{
// 身分資訊
user_id: 9527,
// Token元資訊
issued_at: '2012年3月5号12点整',
expiration_time: '1天'
}
// 加密后
895u3485y3748%^HGdsbafjhb
任何帶有該 Token 的請求,都會被服務端認為是來自用戶 9527 的消息,直到一天之後該 Token 過期失效,服務端不再認可其代表的用戶身分
Token 形式多種多樣,其中,JSON Web Token 是一種比較受歡迎的 Token 規範
三. JSON Web Token
JSON Web Token (JWT) is a compact, URL-safe means of representing claims to be transferred between two parties.
簡言之,一種通信規範(簡稱 JWT),用來安全地表示要在雙方之間傳遞的聲明,能夠透過 URL 傳輸
P.S. 聲明可以是任意的消息,比如用戶身分驗證場景中的「我是用戶 XXX」,好友申請中的「用戶 A 添加用戶 B 為好友」
Token 格式
JWT 中的 Token 分為 3 部分:Header、Payload 與 Signature,例如:
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJqb2UiLCJleHAiOjEzMDA4MTkzODAsImh0dHA6Ly9leGFtcGxlLmNvbS9pc19yb290Ijp0cnVlLCJvdGhlckZpZWxkIjoiZXRjLiJ9.BkrYadYHS66ZrONzFoA91gBJK0iE-S2ZYOWCpRhgFJQ
兩個 . 字元隔開三部分,即:
Header.Payload.Signature
含義上,Header 表示 Token 相關的基本元資訊,如 Token 類型、加密方式(算法)等,具體如下(alg 是必填的,其餘都可選):
-
typ:Token type -
cty:Content type -
alg:Message authentication code algorithm
Payload 表示 Token 攜帶的數據及其它 Token 元資訊,規範定義的標準欄位如下:
-
iss:Issuer,簽發方 -
sub:Subject,Token 資訊主題(Sub identifies the party that this JWT carries information about) -
aud:Audience,接收方 -
exp:Expiration Time,過期時間 -
nbf:Not (valid) Before,生效時間 -
iat:Issued at,生成時間 -
jti:JWT ID,唯一標識
這些欄位都是可選的,Payload 只要是合法 JSON 即可
生成
Token 的三部分分別為:
Base64编码的Header.Base64编码的Payload.对前两部分按指定算法加密的结果
例如,對於
// JOSE Header
const header = JSON.stringify({"typ":"JWT", "alg":"HS256"});
// JWT Claims Set
const claims = JSON.stringify({
"iss":"joe", "exp":1300819380, "http://example.com/is_root":true
});
對 JOSE Header 和 JWT Claims Set 分別進行 Base64 編碼得到 JWT Token 中的 Header 與 Payload 部分:
const tokenHeader = Buffer.from(header).toString('base64');
const tokenPayload = Buffer.from(header).toString('base64');
接著把 Header 與 Payload 用 . 字元連接起來,並透過 HMAC SHA-256 算法(Header 中 alg 欄位指定的加密算法)加密,得到 Signature 部分:
// https://www.npmjs.com/package/jwa
const jwa = require('jwa');
const hmac = jwa('HS256');
const toSign = `${tokenHeader}.${tokenPayload}`;
const tokenSignature = hmac.sign(toSign, 'mySecret');
最後把 Signature 也用 . 字元連接在最後:
const token = `${toSign}.${tokenSignature}`;
得到結果:
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJqb2UiLCJleHAiOjEzMDA4MTkzODAsImh0dHA6Ly9leGFtcGxlLmNvbS9pc19yb290Ijp0cnVlLCJvdGhlckZpZWxkIjoiZXRjLiJ9.BkrYadYHS66ZrONzFoA91gBJK0iE-S2ZYOWCpRhgFJQ
P.S. 透過 JWT.IO 可以解析驗證該 Token
P.S. 注意,Base64 編碼的 Header 與 Payload 需要去掉末尾等號(padding trailing equals)
傳輸
服務端生成 Token 之後,放在響應體裡傳遞到客戶端
客戶端收到之後,將 Token 存放到 LocalStorage/SessionStorage 中,之後請求數據時,將 Token 塞到請求頭的 Authentication 欄位裡帶到服務端:
Authorization: Bearer <jwt_token>
服務端收到數據請求後,從 Authorization 欄位取出 Token,並校驗其合法性,進一步解析 Token 內容,獲知用戶身分
驗證
校驗 Token 合法性需要確認幾件事情:
-
Token 有沒有過期
-
是不是自己簽發的
從 Payload 部分解析(直接 Base64 解碼)出 iat、nbf 和 exp 三個時間相關的欄位,檢查是否滿足以下關係:
iat签发时间 <= nbf生效时间 < 当前时间 < exp过期时间
接著取出 Token 的前兩部分(Header.Payload),再計算一次簽名(Signature),看計算結果是否一致
解析
確認 Token 合法之後,只需要簡單地對 Payload 進行 Base64 解碼,即可得知 Token 攜帶的數據,例如:
const token = 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJqb2UiLCJleHAiOjEzMDA4MTkzODAsImh0dHA6Ly9leGFtcGxlLmNvbS9pc19yb290Ijp0cnVlLCJvdGhlckZpZWxkIjoiZXRjLiJ9.BkrYadYHS66ZrONzFoA91gBJK0iE-S2ZYOWCpRhgFJQ';
const [_, rawPayload,] = token.split('.');
const payload = JSON.parse(new Buffer(rawPayload, 'base64').toString());
// { iss: 'joe',
// exp: 1300819380,
// 'http://example.com/is_root': true,
// otherField: 'etc.' }
四. 登入
Session 方案中,Cookie 機制讓登入變得很簡單(客戶端幾乎無感知),將用戶名和密碼 Post 過去,返回 200,之後就是已登入用戶了
而在 Token 方案中,不一定將 Token 寫入 Cookie,比如 SSO 場景下可能直接透過 URL 回傳給應用。因此,登入之後的身分憑證對客戶端而言是有感知的,客戶端需要接收並管理 Token:
-
儲存 Token
-
請求數據時帶上 Token
-
跳轉時將 Token 共享給兄弟應用
-
用戶注銷後刪掉 Token
同樣地,請求數據時也不一定透過 Cookie 攜帶 Token,而是透過請求頭的 Authentication 欄位
五. 數據操作
發送數據請求時,將 Token 以 Bearer <jwt_token> 的格式填入 Authorization 欄位即可:
Authorization: Bearer <jwt_token>
P.S. Bearer(持有者認證) 也叫 Token 認證,類似於我們所熟知的 Basic(基本認證)和 Digest(摘要認證),也是一種基於 HTTP 的認證方式
服務端接到請求會從該欄位中取出 Token,並進行校驗,校驗通過之後將期望的數據或操作結果響應發回客戶端
六. 注銷
在基於 Session 的身分驗證中,注銷操作就是刪掉 Session 中對應的紀錄。因為在 Session 模式下,用戶的登入狀態只以服務端 Session 紀錄為準,所以只要讓服務端忘記這段感情,之後就不認得客戶端拋來的媚眼了
而 Token 驗證則不同,Token 攜帶著完整的狀態資訊,服務端的角色更像是負責簽發 Token 的認證中心(CA,Certificate Authority),發出去的 Token 在自動過期之前都是合法的,服務端僅透過驗證 Token 無法區分合法 Token 與已經作廢的(合法 Token)
那麼,有辦法能讓 Token 立即作廢嗎?
其實,HTTPS 依賴的 SSL 證書也存在這個問題,所以有一份 Certificate revocation list(證書吊銷列表):
In cryptography, a certificate revocation list (or CRL) is "a list of digital certificates that have been revoked by the issuing certificate authority (CA) before their scheduled expiration date and should no longer be trusted".
CRL 即證書黑名單,用來紀錄需要立即作廢的合法證書,CA 驗證證書之前先檢查黑名單,以此區分出已經作廢的合法 Token
例如:
// 1.维护黑名单
const tokenBlackLists = [];
function invalidateToken(token) {
if (!isTokenInvalidated(token)) {
tokenBlackLists.push(token);
}
}
function removeInalidatedToken(token) {
if (isTokenInvalidated(token)) {
tokenBlackLists.splice(tokenBlackLists.indexOf(token), 1);
}
}
function isTokenInvalidated(token) {
return tokenBlackLists.includes(token);
}
// 2.注销时加黑
router.get('/logout', ensureAuthenticated, (req, res, next) => {
// 废掉token
invalidateToken(res.locals.token);
res.status(200).json({
status: 'success'
});
});
// 3.过期时去黑
// 4.操作时验证是否已黑
decodeToken(token, (err, payload) => {
if (err) {
// remove expired token from blacklist
removeInalidatedToken(token);
return res.status(401).json({
status: 'Token has expired'
});
} else {
// check invalidated token
if (isTokenInvalidated(token)) {
return res.status(401).json({
status: 'Token has been invalidated'
});
}
//...
}
});
P.S. 上例中,黑名單只放在記憶體中,服務重啟時會丟失,比較完備的實現應該是加黑/去黑(即過期)時落庫,驗證時走記憶體快取,重啟時讀庫加載
除黑名單外,還有一些常見策略,如:
-
刪掉客戶端 Token:把發出去的 Token 幹掉,Token 消失了,登入狀態也就不存在了。但服務端仍然認為 Token 合法,不安全
-
用過期時間很短的 Token,經常輪轉:過期時間足夠短的話,自動過期就相當於立即過期。但太短又喪失了保持狀態的優勢
-
Token 帶上注銷時間:把注銷時間也像密碼一樣存庫、校驗,像改密碼一樣讓 Token 立即作廢。但需要多存/取、校驗一個欄位,性能相關
必要的話,這 4 種策略可以多管齊下,比如無論使用哪種策略,客戶端 Token 都是理應刪掉的
P.S. 關於如何立即作廢 JWT 的更多討論,見:
七. FAQ
-
JWT 的 Payload 安全嗎?
不安全,僅經 Base64 編碼過,相當於明文傳輸,因此不要攜帶敏感數據
-
用戶輸入的密碼需要在客戶端加密嗎?
不需要加密,直接明文傳,客戶端密碼安全由 SSL 保證
-
服務端收到密碼應該如何加密?
一般做法是 Hash 加鹽(Adding Salt to Hashing),具體見 Adding Salt to Hashing: A Better Way to Store Passwords
暫無評論,快來發表你的看法吧