Token:如何降低用戶身份鑒權(quán)的流量壓力?
許多網(wǎng)站在初期階段通常使用 Session 方式來實(shí)現(xiàn)用戶登錄鑒權(quán)。具體而言,當(dāng)用戶成功登錄后,服務(wù)端會(huì)將用戶的相關(guān)信息存儲(chǔ)在 Session 緩存 中,并生成一個(gè)唯一的 session_id,這個(gè) ID 被存儲(chǔ)在用戶的 Cookie 中。之后,用戶每次發(fā)送請(qǐng)求時(shí),都會(huì)攜帶該 session_id,服務(wù)端則通過該 ID 查找到 Session 緩存中的用戶記錄,從而進(jìn)行身份驗(yàn)證和用戶信息的管理。
這種用戶鑒權(quán)方式的優(yōu)勢(shì)在于,所有用戶信息都存儲(chǔ)在服務(wù)端,不會(huì)暴露任何敏感數(shù)據(jù)給客戶端,同時(shí)每個(gè)登錄用戶都有共享的 Session 緩存空間。但是,隨著網(wǎng)站流量的增長(zhǎng),這種設(shè)計(jì)也會(huì)暴露出明顯的缺點(diǎn)——用戶中心的身份鑒權(quán)在高并發(fā)下表現(xiàn)不穩(wěn)定。
具體而言,用戶中心需要維護(hù)大量的 Session 緩存,并且頻繁被各個(gè)業(yè)務(wù)系統(tǒng)訪問。如果緩存出現(xiàn)故障,所有依賴它的子系統(tǒng)將無法進(jìn)行用戶身份確認(rèn),導(dǎo)致服務(wù)中斷。這主要是由于 Session 緩存與各子系統(tǒng)的高耦合。每次請(qǐng)求都至少需要訪問一次緩存,因此緩存的容量和響應(yīng)速度直接影響了全站的 QPS 上限,降低了系統(tǒng)的隔離性,使各子系統(tǒng)之間互相影響。
那么,如何降低用戶中心與各子系統(tǒng)之間的耦合度,從而提高系統(tǒng)性能呢?接下來我們一起來探討。
JWT 登陸和 token 校驗(yàn)
常見方式是采用簽名加密的 token,這是登錄的一個(gè)行業(yè)標(biāo)準(zhǔn),即 JWT(JSON Web Token):
圖片
上圖就是 JWT 的登陸流程,用戶登錄后會(huì)將用戶信息放到一個(gè)加密簽名的 token 中,每次請(qǐng)求都把這個(gè)串放到 header 或 cookie 內(nèi)帶到服務(wù)端,服務(wù)端直接將這個(gè) token 解開即可直接獲取到用戶的信息,無需和用戶中心做任何交互請(qǐng)求。
token 生成代碼如下:
import "github.com/dgrijalva/jwt-go"
//簽名所需混淆密鑰 不要太簡(jiǎn)單 容易被破解
//也可以使用非對(duì)稱加密,這樣可以在客戶端用公鑰驗(yàn)簽
var secretString = []byte("jwt secret string 137 rick")
type TokenPayLoad struct {
UserId uint64 `json:"userId"` //用戶id
NickName string `json:"nickname"` //昵稱
jwt.StandardClaims //私有部分
}
// 生成JWT token
func GenToken(userId uint64, nickname string) (string, error) {
c := TokenPayLoad{
UserId: userId, //uid
NickName: nickname, //昵稱
//這里可以追加一些其他加密的數(shù)據(jù)進(jìn)來
//不要明文放敏感信息,如果需要放,必須再加密
//私有部分
StandardClaims: jwt.StandardClaims{
//兩小時(shí)后失效
ExpiresAt: time.Now().Add(2 * time.Hour).Unix(),
//頒發(fā)者
Issuer: "geekbang",
},
}
//創(chuàng)建簽名 使用hs256
token := jwt.NewWithClaims(jwt.SigningMethodHS256, c)
// 簽名,獲取token結(jié)果
return token.SignedString(secretString)
}
可以看出,這種 Token 內(nèi)部包含了過期時(shí)間,接近過期的 Token 會(huì)在客戶端自動(dòng)與服務(wù)端通信進(jìn)行更新。這樣設(shè)計(jì)可以大大增加惡意截取客戶端 Token 并偽造用戶身份的難度。同時(shí),服務(wù)端還可以實(shí)現(xiàn)與用戶中心的解耦,業(yè)務(wù)服務(wù)端只需解析請(qǐng)求中的 Token 就能獲取用戶信息,而不必每次請(qǐng)求都去訪問用戶中心。Token 的刷新完全可以由客戶端主動(dòng)向用戶中心發(fā)起,而無需業(yè)務(wù)服務(wù)端頻繁請(qǐng)求用戶中心來更換 Token。
那么,JWT(JSON Web Token)是如何保證數(shù)據(jù)不會(huì)被篡改并確保數(shù)據(jù)完整性的呢?接下來我們來看看它的組成。
圖片
JWT token 解密后的數(shù)據(jù)結(jié)構(gòu)如下圖所示:
//header
//加密頭
{
"alg": "HS256", // 加密算法,注意檢測(cè)個(gè)別攻擊會(huì)在這里設(shè)置為none繞過簽名
"typ": "JWT" //協(xié)議類型
}
//PAYLOAD
//負(fù)載部分,存在JWT標(biāo)準(zhǔn)字段及我們自定義的數(shù)據(jù)字段
{
"userid": "9527", //我們放的一些明文信息,如果涉及敏感信息,建議再次加密
"nickname": "Rick.Xu", // 我們放的一些明文信息,如果涉及隱私,建議再次加密
"iss": "geekbang",
"iat": 1516239022, //token發(fā)放時(shí)間
"exp": 1516246222, //token過期時(shí)間
}
//簽名
//簽名用于鑒定上兩段內(nèi)容是否被篡改,如果篡改那么簽名會(huì)發(fā)生變化
//校驗(yàn)時(shí)會(huì)對(duì)不上
JWT 如何驗(yàn)證 token 是否有效,還有 token 是否過期、是否合法,具體方法如下:
func DecodeToken(token string) (*TokenPayLoad, error) {
token, err := jwt.ParseWithClaims(token, &TokenPayLoad{}, func(tk *jwt.Token) (interface{}, error) {
return secret, nil
})
if err != nil {
return nil, err
}
if decodeToken, ok := token.Claims.(*TokenPayLoad); ok && token.Valid {
return decodeToken, nil
}
return nil, errors.New("token wrong")
}
JWT(JSON Web Token)的解碼相對(duì)簡(jiǎn)單,第一部分和第二部分都是通過 Base64 編碼的。解碼這兩部分即可獲取到 payload 中的所有數(shù)據(jù),其中包括用戶昵稱、UID、用戶權(quán)限和 Token 的過期時(shí)間。要驗(yàn)證 Token 是否過期,只需將其中的過期時(shí)間與當(dāng)前時(shí)間進(jìn)行對(duì)比,即可確認(rèn) Token 是否有效。而驗(yàn)證 Token 的合法性則通過 簽名驗(yàn)證來完成。任何對(duì)信息的修改都無法通過簽名驗(yàn)證。如果 Token 通過了簽名驗(yàn)證,就表明它沒有被篡改過,是一個(gè)合法的 Token,可以直接使用。
這個(gè)過程如下圖所示:
圖片
通過 Token 方式,可以顯著減輕用戶中心的壓力,不再需要頻繁訪問用戶信息接口。各業(yè)務(wù)服務(wù)端只需解碼并驗(yàn)證 Token 的合法性,即可直接獲取用戶信息。然而,這種方式也存在一些缺點(diǎn)。比如,當(dāng)用戶被拉黑后,客戶端通常要等到 Token 過期才會(huì)自動(dòng)登出,這會(huì)導(dǎo)致管理上的一定延遲。
如果希望實(shí)現(xiàn)實(shí)時(shí)管理,可以在服務(wù)端暫存新生成的 Token,并在每次用戶請(qǐng)求時(shí)與緩存中的 Token 進(jìn)行對(duì)比。不過,這樣的操作會(huì)影響系統(tǒng)性能,因此少數(shù)公司會(huì)采用這種方式。為了提高 JWT 系統(tǒng)的安全性,Token 通常設(shè)置較短的過期時(shí)間,通常為十五分鐘左右。Token 過期后,客戶端會(huì)自動(dòng)向服務(wù)端請(qǐng)求更新。
token 的更換和離線
那么如何對(duì) JWT 的 token 進(jìn)行更換和離線驗(yàn)簽?zāi)兀烤唧w的服務(wù)端換簽很簡(jiǎn)單,只要客戶端檢測(cè)到當(dāng)前的 token 快過期了,就主動(dòng)請(qǐng)求用戶中心更換 token 接口,重新生成一個(gè)離當(dāng)前還有十五分鐘超時(shí)的 token。但是期間如果超過十五分鐘還沒換到,就會(huì)導(dǎo)致客戶端登錄失敗。為了減少這類問題,同時(shí)保證客戶端長(zhǎng)時(shí)間離線仍能正常工作,行業(yè)內(nèi)普遍使用雙 token 方式,具體你可以看看后面的流程圖:
圖片
在這個(gè)方案中,使用了兩種 Token:
- Refresh Token:用于更換 Access Token,有效期為 30 天。
- Access Token:用于存儲(chǔ)當(dāng)前用戶信息和權(quán)限信息,每隔 15 分鐘進(jìn)行一次更換。
當(dāng)客戶端嘗試請(qǐng)求用戶中心進(jìn)行 Token 更換但失敗,且客戶端處于離線狀態(tài)時(shí),只要本地的 Refresh Token 未過期,系統(tǒng)仍然能夠正常運(yùn)作。客戶端可以持續(xù)使用 Access Token,直到 Refresh Token 到期,此時(shí)系統(tǒng)會(huì)提示用戶重新登錄。通過這種方式,即便用戶中心出現(xiàn)故障,業(yè)務(wù)系統(tǒng)也可以正常運(yùn)轉(zhuǎn)一段時(shí)間,提升了系統(tǒng)的健壯性和用戶體驗(yàn)。
用戶中心檢測(cè)更換 token 的實(shí)現(xiàn)如下:
//如果還有五分鐘token要過期,那么換token
if decodeToken.StandardClaims.ExpiresAt < TimestampNow() - 300 {
//請(qǐng)求下用戶中心,問問這個(gè)人禁登陸沒
//....略具體
//重新發(fā)放token
token, err := GenToken(.....)
if err != nil {
return nil, err
}
//更新返回cookie中token
resp.setCookie("xxxx", token)
}
安全建議
在使用 JWT 方案時(shí),除了代碼注釋中提到的內(nèi)容外,還有一些關(guān)鍵注意事項(xiàng)值得留意:
- 確保通訊安全:使用 HTTPS 協(xié)議傳輸數(shù)據(jù),以降低 Token 被攔截的風(fēng)險(xiǎn)。
- 限制 Token 的更換頻率:要控制 Token 的更換次數(shù),并定期刷新 Token。例如,限制用戶的 Access Token 每天只能更換 50 次,如果超出次數(shù)則要求用戶重新登錄,同時(shí)每 15 分鐘更換一次 Token。這樣可以減少 Token 被盜后的潛在影響。
- 安全存儲(chǔ) Web Token:對(duì)于 Web 用戶,當(dāng) Token 存儲(chǔ)在 Cookie 中時(shí),建議設(shè)置
HttpOnly
和SameSite=Strict
標(biāo)記,以防止 Cookie 被惡意腳本竊取。