iT邦幫忙

2025 iThome 鐵人賽

DAY 10
0
Software Development

Go Clean Architecture API 開發全攻略系列 第 10

身份驗證詳解 (二):JWT (JSON Web Token) 的生成與解析

  • 分享至 

  • xImage
  •  

在上一篇我們解決了「如何驗證密碼」的問題,現在要解決下一個問題:「驗證成功後,如何讓使用者保持登入狀態?」答案就是使用 Token。

本文將深入探討現代無狀態 API 認證的基石——JWT (JSON Web Token),並實作一個完整的 Token 生成與驗證服務。

About JWT

JWT(JSON Web Token)是一種基於 JSON 的開放標準(RFC 7519),用於在各方之間以緊湊且自包含的方式安全地傳輸資訊。它通常用於身份驗證和資訊交換。

更多關於 JWT 的介紹可以參考 JWT 官方網站

可以在 這裡 找到各個 Go 的 JWT 函式庫,並包含其支援哪些演算法

JWT 的結構剖析

JWT 本質上是一個由三個部分組成的、用 . 分隔的長字串,例如 xxxxx.yyyyy.zzzzz。它緊湊且 URL 安全,非常適合在 HTTP Header 中傳輸。

  1. Header (頭部):一個 Base64Url 編碼的 JSON。它描述了 JWT 的元數據,通常包含 alg(簽名演算法,如 HS256)和 typ(類型,固定為 JWT)。
  2. Payload (負載):另一個 Base64Url 編碼的 JSON。這是 JWT 的核心,存放了我們想傳遞的資訊,這些資訊被稱為「聲明(Claims)」。
  3. Signature (簽名):這是 JWT 的安全屏障。

⚠️ 極其重要:JWT 是**被簽名(Signed)的,而不是被加密(Encrypted)**的。因此,絕對不要在 JWT 的 Payload 中存放任何敏感資訊!

一個更安全的簽名演算法,例如 ES512(橢圓曲線數位簽章算法),會比 HS256 更安全,因為它使用一對公私鑰來簽名和驗證。

本文會以 ES512 為例來實作。

產生 ECDSA 公私鑰

在開始實作之前,我們需要一對 ECDSA 公私鑰來簽名和驗證 JWT。
可以使用 OpenSSL 來生成
底下這段把生成的私鑰直接存到 .env 檔案中:

openssl ecparam -genkey -name secp521r1 -noout | tee -a | awk '{printf "%s""\\n",$$0}' | rev | cut -c3- | rev | awk '{printf "\nTOKEN_SECRET=\"%s\"\n",$$0}' >> .env

這會在 .env 檔案中新增一行 TOKEN_SECRET,其值為生成的私鑰。

實作:TokenService

現在,讓我們動手來實作一個專門處理 Token 的服務。

  1. 使用 github.com/pascaldekloe/jwt 函式庫:挑選這一個 JWT Library,因為它簡潔且易於使用。
go get github.com/pascaldekloe/jwt
  1. 建立服務檔案:在 internal/service/token/ 目錄下建立 token.go
type Service struct {
	privateKey *ecdsa.PrivateKey
}

func NewService(key []byte) (*Service, error) {
	block, _ := pem.Decode(key)

	if block == nil {
		return nil, fmt.Errorf("failed to decode PEM block")
	}

	privatekey, err := x509.ParseECPrivateKey(block.Bytes)

	if err != nil {
		return nil, fmt.Errorf("failed to parse EC private key: %w", err)
	}

	return &Service{privateKey: privatekey}, nil
}
  1. 定義 Token 類型:在 token_type.go 中定義不同的 Token 類型。
/internal/service/token/token_type.go

type tokenType string

const (
  tokenTypeAccessToken tokenType = "access"
)
  1. 建立輔助 function:
// signToken 使用私鑰對 Claims 進行簽名,並返回簽名後的 JWT 字串
func (s *Service) signToken(claims jwt.Claims) (string, error) {
	signed, err := claims.ECDSASign("ES512", s.privateKey)
	if err != nil {
		return "", err
	}

	return string(signed), nil
}

// checkToken 使用公鑰驗證 JWT 字串的簽名,並返回解析後的 Claims
func (s *Service) checkToken(token string) (*jwt.Claims, error) {
	return jwt.ECDSACheck([]byte(token), &s.privateKey.PublicKey)
}

// 使用固定的結構來生成不同類型的 Token
func (s *Service) generateToken(tokenType tokenType, userID int, expire time.Duration) (string, error) {
	var claims jwt.Claims
	random := uuid.New()

	now := time.Now().Round(time.Second)
	expires := now.Add(expire).Round(time.Second)

	claims.Issued = jwt.NewNumericTime(now)
	claims.Expires = jwt.NewNumericTime(expires)
	claims.Set = map[string]any{
		"uid":  random.String(),
		"id":   userID,
		"type": tokenType,
	}

	return s.signToken(claims)
}

// 驗證 Token 的有效性,並返回 Token 中的使用者 ID
func (s *Service) validateToken(tokenType tokenType, token string) (int, error) {
	claims, err := s.checkToken(token)
	if err != nil {
		return 0, fmt.Errorf("invalid token: %w", err)
	}

	if claims.Expires.Time().Before(time.Now()) {
		return 0, fmt.Errorf("token expired")
	}

	if claims.Set["type"] != string(tokenType) {
		return 0, fmt.Errorf("invalid token type")
	}

	userID, ok := claims.Set["id"].(float64)
	if !ok {
		return 0, fmt.Errorf("invalid user ID in token")
	}

	return int(userID), nil
}
  1. 生成 Access Token:實作 GenerateAccessToken 方法。
/internal/service/token/token_func.go

func (s *Service) GenerateAccessToken(userID int) (string, error) {
	return s.generateToken(tokenTypeAccessToken, userID, time.Hour*24)
}
  1. 驗證 Access Token:實作 ValidateAccessToken 方法。
/internal/service/token/token_func.go

func (s *Service) ValidateAccessToken(token string) (int, error) {
	return s.validateToken(tokenTypeAccessToken, token)
}

dependency injection

internal/application/service.go

func NewService(app *Application) (*Service, error) {
	passwordService := password.NewService()
	tokenService, err := token.NewService([]byte(app.Config.Token.Secret))
	if err != nil {
		return nil, err
	}

	return &Service{
		Password: passwordService,
		Token:    tokenService,
	}, nil
}

總結

現在,我們擁有了一個可以安全地簽發和驗證「通行證」的服務。
在下一篇文章中,我們將實作 Database 連線。

以上程式碼的完整內容可以到 Github 觀看


上一篇
身份驗證詳解 (一):安全的密碼雜湊 (Hashing) 與處理
下一篇
資料庫整合 (一):使用 GORM 連接 MySQL
系列文
Go Clean Architecture API 開發全攻略19
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言