Background
本次文章一樣會用以下專案中的程式碼作為說明。
原本因為有過 Gin 做 OAuth2 + JWT 驗證的經驗,同時為了後續功能開發的手動測試便利性,所以先將使用者驗證這塊,放在後期開發。 但是這個想法忽略了一件事,就是在 gRPC(HTTP/2)的世界中,HTTP/1.1 的標頭(Header)會以 Metadata 的方式在 gRPC 中呈現,並且需要實作 Interceptor。
Preface
在閱讀本文前,請先服用以下須知:
- 程式碼用 Golang 實作
- 範例會介紹如何實作
AuthInterceptor
- 在
UserService
中授權自訂 JWT token - 用該授權的 token 訪問
RecordService
- 在
- 其餘的內容則不會是本文的主軸
在講解程式碼以前,會先簡單科普 gRPC 中的 Metadata 概念。
IMPORTANTMetadata 定義
命名規範
根據官網說明,元數據(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 中查看。 從以上程式碼片段中,UserJWTClaims
的 UserId
和 Role
是自己添加的資訊,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 可以存取的服務有users
、records
、records_bank
Token Generation
發送請求
# 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"}'
取得回傳的 JWT token
{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoxLCJyb2xlIjoidXNlciIsImlzcyI6InVzZXJzIiwiYXVkIjpbInVzZXJzIiwicmVjb3JkcyIsInJlY29yZHNfYmFuayJdLCJleHAiOjE3NDMxNTkzMjUsImlhdCI6MTc0MzA3MjkyNX0.4p41Y3msmCSCm0XosFEjb7TZ3lqZXVGFsI9EfbtkL08"}
放進 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 中,分別執行以下事項:
- 確認目前 call 的服務方法(
service_name
)是否有定義在serviceRoles()
中,取得服務方法的存取角色列表 - 確認目前的傳輸上下文中含有 metadata
- 確認 metadata 是否有包含
authorization
,類似於 HTTP/1.1 中的 Header - 取得 JWT token
- 驗證 JWT token
- 確認該 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
- 啟動
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
- 成功取得回傳訊息
{"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"}}}