1252 words
6 minutes
Metadata in gRPC
2025-03-27

Background#

本次文章一樣會用以下專案中的程式碼作為說明。

nu1lspaxe
/
finance-manager-v2
Waiting for api.github.com...
00K
0K
0K
Waiting...

原本因為有過 Gin 做 OAuth2 + JWT 驗證的經驗,同時為了後續功能開發的手動測試便利性,所以先將使用者驗證這塊,放在後期開發。 但是這個想法忽略了一件事,就是在 gRPC(HTTP/2)的世界中,HTTP/1.1 的標頭(Header)會以 Metadata 的方式在 gRPC 中呈現,並且需要實作 Interceptor。

Preface#

在閱讀本文前,請先服用以下須知:

  • 程式碼用 Golang 實作
  • 範例會介紹如何實作 AuthInterceptor
    • UserService 中授權自訂 JWT token
    • 用該授權的 token 訪問 RecordService
  • 其餘的內容則不會是本文的主軸

在講解程式碼以前,會先簡單科普 gRPC 中的 Metadata 概念。

IMPORTANT

Metadata 定義#

命名規範

根據官網說明,元數據(metadata)通常以鍵值 {key:value} 方式呈現。

  • 鍵(key)通常是字串,值(value)可以是字串,也可以是二進位(binary),如果是二進位,則它的 key 會以 -bin 結尾
  • 鍵值命名可使用 ASCII 字元、數字和特殊字元 _.-,但不能以 grpc- 開頭(保留字)

用途

通常元數據的用途包含:

  • 身份驗證(Authentication)
  • 追蹤(Tracing)
  • 自訂標頭(Custom headers)
  • 內部用途(Internal usages)

Custom JWT Claims#

users/utils/jwt.go#

import "github.com/golang-jwt/jwt/v5"

type UserJWTClaims struct {
	UserId int64  `json:"user_id"`
	Role   string `json:"role"`
	jwt.RegisteredClaims
}

func (j *JWTManager) Generate(userId int64, issueTime time.Time) (string, error) {
	claims := UserJWTClaims{
		UserId: userId,
		Role:   "user",
		RegisteredClaims: jwt.RegisteredClaims{
			IssuedAt:  jwt.NewNumericDate(issueTime),
			ExpiresAt: jwt.NewNumericDate(issueTime.Add(j.tokenDuration)),
			Issuer:    "users",
			Audience:  jwt.ClaimStrings{"users", "records", "records_bank"},
		},
	}

	token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)

	tokenString, err := token.SignedString(j.secretKey)
	if err != nil {
		return "", err
	}

	return tokenString, nil
}

這裡著重在 JWT token 生成的 Claims 內容,Verify 的程式碼可以到 GitHub 中查看。 從以上程式碼片段中,UserJWTClaimsUserIdRole 是自己添加的資訊,jwt.RegisteredClaims 中包含以下資訊:

type RegisteredClaims struct {
	// the `iss` (Issuer) claim. See https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.1
	Issuer string `json:"iss,omitempty"`

	// the `sub` (Subject) claim. See https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.2
	Subject string `json:"sub,omitempty"`

	// the `aud` (Audience) claim. See https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.3
	Audience ClaimStrings `json:"aud,omitempty"`

	// the `exp` (Expiration Time) claim. See https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.4
	ExpiresAt *NumericDate `json:"exp,omitempty"`

	// the `nbf` (Not Before) claim. See https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.5
	NotBefore *NumericDate `json:"nbf,omitempty"`

	// the `iat` (Issued At) claim. See https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.6
	IssuedAt *NumericDate `json:"iat,omitempty"`

	// the `jti` (JWT ID) claim. See https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.7
	ID string `json:"jti,omitempty"`
}

所以根據這些欄位,這邊可以記住的是

  • Role 給定 user,即只能存取 user 服務,不能存取 admin 服務
  • Audience 放入 jwt.ClaimStrings{"users", "records", "records_bank"},即稍後這組 token 可以存取的服務有 usersrecordsrecords_bank

Token Generation#

  1. 發送請求

    # cd finance-manager-v2/users
    # go run cmd/main.go
    #
    # (Open another terminal, then nativate to finance-manager-v2/users)
    curl -X POST https://localhost:8443/v1/users/login \
         --cacert certs/cert.pem \
         -d '{"email": "test1@example.com", "password": "SECpassword123"}'
  2. 取得回傳的 JWT token

    {"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoxLCJyb2xlIjoidXNlciIsImlzcyI6InVzZXJzIiwiYXVkIjpbInVzZXJzIiwicmVjb3JkcyIsInJlY29yZHNfYmFuayJdLCJleHAiOjE3NDMxNTkzMjUsImlhdCI6MTc0MzA3MjkyNX0.4p41Y3msmCSCm0XosFEjb7TZ3lqZXVGFsI9EfbtkL08"}
  3. 放進 JWT Decoder 中解析,得到資訊:

    {
      "user_id": 1,
      "role": "user",
      "iss": "users",
      "aud": ["users", "records", "records_bank"],
      "exp": 1743159325,
      "iat": 1743072925
    }

Authentication Interceptor 實作#

users/server/grpc_server.go#

Role Binding#

servicePath 的組成 /<proto_package>.<proto_service>/<proto_package><proto_service> 分別寫在 proto spec (users/proto/users.proto) 當中。定義 service_path + service_name 對應值 {<role_1>, <role_2>...} 的存取角色。

func serviceRoles() map[string][]string {
	const servicePath = "/users.UserService/"

	return map[string][]string{
		servicePath + "GetUser":           {"admin", "user"},
		servicePath + "GetAllUsers":       {"admin"},
		servicePath + "UpdateUser":        {"admin", "user"},
		servicePath + "DeleteUser":        {"admin", "user"},
		servicePath + "AddUserAccount":    {"admin", "user"},
		servicePath + "GetUserAccounts":   {"admin", "user"},
		servicePath + "DeleteUserAccount": {"admin", "user"},
		servicePath + "Logout":            {"admin", "user"},
	}
}

Authorization#

在下面的 authorize func 中,分別執行以下事項:

  1. 確認目前 call 的服務方法(service_name)是否有定義在 serviceRoles() 中,取得服務方法的存取角色列表
  2. 確認目前的傳輸上下文中含有 metadata
  3. 確認 metadata 是否有包含 authorization,類似於 HTTP/1.1 中的 Header
  4. 取得 JWT token
  5. 驗證 JWT token
  6. 確認該 token 有這個服務方法的存取權限
func (a *AuthInterceptor) authorize(ctx context.Context, method string) error {
	accessibleRoles, ok := a.serviceRoles[method]
	if !ok {
		return nil
	}

	md, ok := metadata.FromIncomingContext(ctx)
	if !ok {
		return status.Errorf(codes.Unauthenticated, "metadata is not provided")
	}

	values := md["authorization"]
	if len(values) == 0 {
		return status.Errorf(codes.Unauthenticated, "authorization token is not provided")
	}

	accessToken := values[0]
	claims, err := a.jwtManager.Verify(accessToken)
	if err != nil {
		return status.Errorf(codes.Unauthenticated, "access token is invalid: %v", err)
	}

	for _, role := range accessibleRoles {
		if role == claims.Role {
			return nil
		}
	}

	return status.Error(codes.PermissionDenied, "no permission to access this RPC")
}

套用 Interceptor#

func (a *AuthInterceptor) Unary() grpc.UnaryServerInterceptor {
	return func(
		ctx context.Context,
		req interface{},
		info *grpc.UnaryServerInfo,
		handler grpc.UnaryHandler,
	) (interface{}, error) {
		err := a.authorize(ctx, info.FullMethod)
		if err != nil {
			return nil, err
		}

		return handler(ctx, req)
	}
}

func (a *AuthInterceptor) Stream() grpc.StreamServerInterceptor {
	return func(
		srv interface{},
		stream grpc.ServerStream,
		info *grpc.StreamServerInfo,
		handler grpc.StreamHandler,
	) error {
		err := a.authorize(stream.Context(), info.FullMethod)
		if err != nil {
			return err
		}

		return handler(srv, stream)
	}
}

func NewGrpcServer(tlsConfig *tls.Config, logger *zap.Logger, jwtManager *utils.JWTManager) *GrpcServer {
	creds := credentials.NewTLS(tlsConfig)
	authInterceptor := NewAuthInterceptor(jwtManager)

	server := grpc.NewServer(
		grpc.ChainUnaryInterceptor(
			authInterceptor.Unary(),
		),
		grpc.ChainStreamInterceptor(
			authInterceptor.Stream(),
		),
		grpc.Creds(creds),
		grpc.ConnectionTimeout(utils.TIMEOUT),
	)

	reflection.Register(server)
	return &GrpcServer{
		server: server,
		logger: logger,
	}
}

records/utils/jwt.go#

Token Verify#

與生成 JWT token 的 UserService 不同,RecordService 這裡強調 token 的驗證,所以這裡放上 Verify 的程式碼片段。

在解析完得到 JWTClaims 之後,需要檢查 Audience 列表中是否有包含此服務的 proto_package

func (j *JWTManager) Verify(tokenString string) (*UserJWTClaims, error) {
	token, err := jwt.ParseWithClaims(
		tokenString,
		&UserJWTClaims{},
		func(token *jwt.Token) (interface{}, error) {
			_, ok := token.Method.(*jwt.SigningMethodHMAC)
			if !ok {
				return nil, NewRecordError(ErrTokenAlg)
			}
			return j.secretKey, nil

		}, jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()}),
	)

	if err != nil {
		return nil, err
	}

	claims, ok := token.Claims.(*UserJWTClaims)
	if !ok {
		return nil, NewRecordError(ErrTokenInvalid)
	}

	audienceValid := false
	for _, aud := range claims.Audience {
		if aud == "records" {
			audienceValid = true
			break
		}
	}
	if !audienceValid {
		return nil, NewRecordError(ErrTokenInvalid)
	}

	if claims.ExpiresAt.Time.Unix() < time.Now().Unix() {
		return nil, NewRecordError(ErrTokenInvalid)
	}

	return claims, nil
}

Access Service#

  1. 啟動 RecordService,攜帶從 UserService 登入取得的 JWT token 並發送請求
    # cd finance-manager-v2/records
    # go run cmd/main.go
    #
    # (Open another terminal, then nativate to finance-manager-v2/records)
    curl -X GET https://localhost:8444/v1/records/user\?user_id\=1 \
         -H "authorization: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoxLCJyb2xlIjoidXNlciIsImlzcyI6InVzZXJzIiwiYXVkIjpbInVzZXJzIiwicmVjb3JkcyIsInJlY29yZHNfYmFuayJdLCJleHAiOjE3NDMxNTkzMjUsImlhdCI6MTc0MzA3MjkyNX0.4p41Y3msmCSCm0XosFEjb7TZ3lqZXVGFsI9EfbtkL08" \
         --cacert certs/cert.pem
  2. 成功取得回傳訊息
    {"result":{"record":{"id":"1","userId":"1","amount":45.67,"transactionDate":"1677628800","recordType":"INCOME","detail":"","createdAt":"1742832795","updatedAt":"1742832795"}}}
    {"result":{"record":{"id":"2","userId":"1","amount":30,"transactionDate":"1742860800","recordType":"INCOME","detail":"","createdAt":"1742835020","updatedAt":"1742835020"}}}
    {"result":{"record":{"id":"3","userId":"1","amount":45.67,"transactionDate":"1677628800","recordType":"INCOME","detail":"","createdAt":"1743010174","updatedAt":"1743010174"}}}
Metadata in gRPC
https://nu1lspaxe.github.io/posts/20250327/
Author
nu1lspaxe
Published at
2025-03-27