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