1. Token-based Authentication
在這種驗證機制中,用戶第一次登錄需要POST自己的用戶名和密碼,在服務器端檢驗用戶名和密碼正確之后,就可以簽署一個令牌,並將其返回給客戶端
在此之后,客戶端就可以用這個access_token來訪問服務器上的資源,服務器只會驗證該令牌是否有效
同時,access_token有一定的生命周期,在這個周期內,客戶端都可以通過這個token來訪問服務器的資源
2. JWT
JWT -- JSON Web Token
2.1 JWT簡介
JWT是一個base64編碼的字符串,主要由三部分組成:
- header
- payload
- verify signature
其中header和payload是base64編碼的,而沒有加密,這意味着我們可以編碼或者解碼任意的payload,但是最后的藍色部分,也就是JWT簽名,保證了只有服務器有私鑰來簽署這個token
JWT提供了很多簽名算法,可以分為以下幾類:
- 對稱秘鑰加密算法:適用於共享秘鑰的場景,本地,典型的算法有:HS256、HS384、HS512
- 非對稱加密算法:私鑰對token簽名,公鑰驗證token,可以提供第三方服務,典型的算法有:RS256、PS256、ES256等
JWT的問題是什么?
(1)不安全的加密算法
JWT給開發者提供了很多的加密算法選擇,其中就包括了已知的易受攻擊的算法
(2)在header中包含了簽名算法的種類
攻擊者只需要將header中的alg字段設置為none就可以繞過簽名驗證過程
在知道服務器使用非對稱加密算法的情況下,修改alg為一個對稱加密算法
2.2 在golang中實現JWT
首先我們定義一個token maker的接口,在之后會使用PASETO和JWT來實現這個接口
Maker接口包括了兩個方法,分別是創建token和驗證token:
type Maker interface {
// CreateToken 創建一個token
CreateToken(username string, duration time.Duration) (string, error)
// VerifyToken 驗證token
VerifyToken(token string) (*Payload, error)
}
現在定義token的payload結構體,其中應該包含一些我們需要的字段,一般意義上就是用戶名、創建時間、過期時間、tokenID這幾個信息:
type Payload struct {
ID uuid.UUID `json:"id" `
Username string `json:"username" `
IssuedAt time.Time `json:"issued_at" `
ExpiredAt time.Time `json:"expired_at" `
}
然后對外提供一個創建payload的函數:
func NewPayload(username string, duration time.Duration) (*Payload, error) {
tokenID, err := uuid.NewRandom()
if err != nil {
return nil, err
}
payload := &Payload{
ID: tokenID,
Username: username,
IssuedAt: time.Now(),
ExpiredAt: time.Now().Add(duration),
}
return payload, nil
}
現在我們就可以開始實現JWT token的代碼了,其需要實現Maker接口定義的兩個方法
func (maker *JWTMaker) CreateToken(username string, duration time.Duration) (string, error) {
payload, err := NewPayload(username, duration)
if err != nil {
return "", err
}
jwtToken := jwt.NewWithClaims(jwt.SigningMethodHS256, payload)
return jwtToken.SignedString([]byte(maker.secretKey))
}
值得注意的是,在jwt.NewWithClaims()方法中,我們傳入payload時會報錯,仔細看提示會發現jwt需要我們定義的payload結構體提供一個驗證功能,就是一個 func(payload *Payload) Valid() error 簽名的函數
我們就可以做一個簡單的過期時間驗證:
func (payload *Payload) Valid() error {
if time.Now().After(payload.ExpiredAt) {
return ErrExpiredToken
}
return nil
}
同樣,我們再去實現驗證token的方法:
func (maker *JWTMaker) VerifyToken(token string) (*Payload, error) {
keyFunc := func(token *jwt.Token) (interface{}, error) {
_, ok := token.Method.(*jwt.SigningMethodHMAC)
if !ok {
return nil, ErrInvalidToken
}
return []byte(maker.secretKey), nil
}
jwtToken, err := jwt.ParseWithClaims(token, &Payload{}, keyFunc)
if err != nil {
verr, ok := err.(*jwt.ValidationError)
if ok && errors.Is(verr.Inner, ErrExpiredToken) {
return nil, ErrExpiredToken
}
return nil, ErrInvalidToken
}
payload, ok := jwtToken.Claims.(*Payload)
if !ok {
return nil, ErrInvalidToken
}
return payload, nil
}
在 jwtToken, err := jwt.ParseWithClaims(token, &Payload{}, keyFunc) 中
keyFunc需要我們自己實現,其作用是驗證header中的簽名算法是否合法,防止一些瑣碎的攻擊
同樣err在jwt包內部是被隱藏的,對於驗證失敗的令牌有兩種情況:令牌過期或者令牌不合法
所以我們需要做一次類型斷言,找出具體的錯誤來做返回
3. PASETO
PASETO -- Platform-Agnostic SEcurity TOkens
3.1 PASETO簡介
每一個版本的PASETO都包含了強大的加密套件,選擇對應的加密算法只需要選擇PASETO版本即可
最多只能有兩個版本同時處於活躍狀態
相比於JWT,PASETO所做的改變在於:
- 不會向用戶開放所有的加密算法
- header中不再含有alg字段,也不會有none算法
- payload使用加密算法,而不是簡單的編碼
PASETO的令牌結構:
3.2 在golang中實現PASETO
PASETO的實現要比JWT簡單一些,我們同樣還是使用對稱加密算法來實現,首先是創建token的方法:
func (maker *PasetoMaker) CreateToken(username string, duration time.Duration) (string, error) {
payload, err := NewPayload(username, duration)
if err != nil {
return "", err
}
return maker.paseto.Encrypt(maker.symmetricKey, payload, nil)
}
然后是驗證token:
func (maker *PasetoMaker) VerifyToken(token string) (*Payload, error) {
payload := &Payload{}
if err := maker.paseto.Decrypt(token, maker.symmetricKey, payload, nil); err != nil {
return nil, ErrInvalidToken
}
if err := payload.Valid(); err != nil {
return nil, err
}
return payload, nil
}
至此我們就完成了PASETO對稱加密的token
4. 實現token驗證中間件
首先客戶端需要提供登錄信息,包括了用戶名和密碼。然后服務器創建一個token返回給客戶端,用於之后的身份驗證
const (
authorizationHeaderKey = "authorization"
authorizationTypeBearer = "bearer"
authorizationPayloadKey = "authorization_payload"
)
func authMiddleware(tokenMaker Maker) gin.HandlerFunc {
return func(ctx *gin.Context) {
authorizationHeader := ctx.GetHeader(authorizationHeaderKey)
if len(authorizationHeader) == 0 {
err := errors.New("authorization header is not provide")
ctx.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": err})
return
}
fields := strings.Fields(authorizationHeader)
if len(fields) < 2 {
err := errors.New("invalid authorization header format")
ctx.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": err})
return
}
authorizationType := strings.ToLower(fields[0])
if authorizationType != authorizationTypeBearer {
err := fmt.Errorf("unsupported authorization type %s", authorizationType)
ctx.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": err})
return
}
accessToken := fields[1]
payload, err := tokenMaker.VerifyToken(accessToken)
if err != nil {
ctx.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": err})
return
}
ctx.Set(authorizationPayloadKey, payload)
ctx.Next()
}
}
然后我們可以將需要授權的api做一個路由組,使用這個中間件
同時我們在授權階段可以簡單的使用一個ctx.MustGet()方法來取得token中的payload,里面包含有用戶名的驗證信息,這樣就可以保證用戶只可以訪問自己的相關內容