寫給 go 開發者的 gRPC 教程 - 用戶認證


gRPC 的用戶認證

用戶認證,簡單來說就是驗證請求的用戶身份,避免破壞者僞造身份獲取他人數據隱私。比如當訪問微博網站時,微博服務端通過用戶認證來識別你的身份,並返回正確的主頁數據

用戶認證有很多方式。例如 HTTP 中使用的 cookie、session、oauth、jwt 等等。gRPC 框架並不限制用戶認證的方式,而是提供了開放的能力來支持各種各樣的用戶認證

gRPC 的用戶認證可以用兩句話總結

要實現在每一次調用注入用戶憑證的能力,我們需要實現credentials.PerRPCCredentials接口,並且在客戶端創建鏈接的時候指定grpc.WithPerRPCCredentials(credentials.PerRPCCredentials)

type PerRPCCredentials interface {
 // GetRequestMetadata 獲取當前請求的metadata
 GetRequestMetadata(ctx context.Context, uri ...string) (map[string]string, error)
 // RequireTransportSecurity 是否使用安全的傳輸協議
 RequireTransportSecurity() bool
}

要驗證每一個客戶端的請求,我們需要用到前幾期提到的攔截器:寫給 go 開發者的 gRPC 教程 - 攔截器

gRPC 四種攔截器一覽

下面我們來介紹在 gRPC 中使用比較常見的兩種認證方式:Basic AuthenticationJWT

Basic Authentication

Basic Authentication是最簡單的認證方式

使用Basic Authentication時,客戶端攜帶一個Authorization header 頭,值爲Basic + 空格 + base64編碼的用戶名:密碼

例如一個用戶名和密碼都是 admin,那麼 header 頭如下

Authorization: Basic YWRtaW46YWRtaW4=

通常並不推薦使用Basic Authentication,gRPC 也沒有內置組件支持,但在 gRPC 中很容易做到。

客戶端代碼

我們定義一個結構體BasicAuthentication並讓它實現credentials.PerRPCCredentials接口,就可以把用戶憑證添加到客戶端的上下文中

type PerRPCCredentials interface {
 // GetRequestMetadata 獲取當前請求的metadata
 GetRequestMetadata(ctx context.Context, uri ...string) (map[string]string, error)
 // RequireTransportSecurity 是否使用安全的傳輸協議
 RequireTransportSecurity() bool
}
var _ credentials.PerRPCCredentials = BasicAuthentication{}

type BasicAuthentication struct {
 password string
 username string
}

func (b BasicAuthentication) GetRequestMetadata(ctx context.Context, uri ...string) (map[string]string, error) {
 auth := b.username + ":" + b.password
 enc := base64.StdEncoding.EncodeToString([]byte(auth))

 return map[string]string{
  "authorization""Basic " + enc,
 }, nil
}

func (b BasicAuthentication) RequireTransportSecurity() bool {
 return true
}

在創建連接時使用grpc.WithPerRPCCredentials(auth)設置每一次請求的用戶憑證

func main() {
 ctx, cancel := context.WithTimeout(context.Background(), time.Second)
 defer cancel()

 auth := BasicAuthentication{
  username: "admin",
  password: "admin",
 }

 creds, err := credentials.NewClientTLSFromFile("./x509/rootCa.crt""www.example.com")
 if err != nil {
  panic(err)
 }

 conn, err := grpc.Dial("localhost:8009",
  grpc.WithTransportCredentials(creds),
  grpc.WithPerRPCCredentials(auth))
 if err != nil {
  panic(err)
 }
 defer conn.Close()

 client := pb.NewOrderManagementClient(conn)

 // Get Order
 retrievedOrder, err := client.GetOrder(ctx, &wrapperspb.StringValue{Value: "101"})
 if err != nil {
  panic(err)
 }

 log.Print("GetOrder Response -> : ", retrievedOrder)
}

⚠️ 注意

type PerRPCCredentials interface {
 // GetRequestMetadata 獲取當前請求的metadata
 GetRequestMetadata(ctx context.Context, uri ...string) (map[string]string, error)
 // RequireTransportSecurity 是否使用安全的傳輸協議
 RequireTransportSecurity() bool
}

RequireTransportSecurity()代表是否使用安全的傳輸協議。如果設置了true,則必須通過grpc.WithTransportCredentials()設置合理的傳輸層加密方式,否則會導致建立連接時失敗

gRPC 官方庫裏有個insecure.NewCredentials(),這段函數含義爲禁用傳輸層安全協議,因此grpc.WithTransportCredentials(insecure.NewCredentials())是無效的,依舊會導致建立連接時失敗

auth := BasicAuthentication{
    username: "admin",
    password: "admin",
}

conn, err := grpc.Dial("localhost:8009",
                       grpc.WithTransportCredentials(insecure.NewCredentials()),
                       grpc.WithPerRPCCredentials(auth))
if err != nil {
    panic(err)
}
$ go run basic-authentication/client/main.go
panic: grpc: the credentials require transport level security (use grpc.WithTransportCredentials() to set)

服務端代碼

服務端使用攔截器來驗證請求是否合法

對於不合法的 token 返回codes.Unauthenticated

如果 token 合法,在ensureValidBasicCredentials中調用handler來繼續請求的處理

var (
 errMissingMetadata = status.Errorf(codes.InvalidArgument, "missing metadata")
 errInvalidToken    = status.Errorf(codes.Unauthenticated, "invalid credentials")
)

func ensureValidBasicCredentials(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (resp interface{}, err error) {
 md, ok := metadata.FromIncomingContext(ctx)
 if !ok {
  return nil, errMissingMetadata
 }

 authorization := md["authorization"]

 if len(authorization) < 1 {
  return nil, errInvalidToken
 }

 token := strings.TrimPrefix(authorization[0]"Basic ")
 if token != base64.StdEncoding.EncodeToString([]byte("admin:admin")) {
  return nil, errInvalidToken
 }

 return handler(ctx, req)
}

func main() {
 l, err := net.Listen("tcp"":8009")
 if err != nil {
  panic(err)
 }

 creds, err := credentials.NewServerTLSFromFile("./x509/server.crt""./x509/server.key")
 s := grpc.NewServer(
  grpc.UnaryInterceptor(ensureValidBasicCredentials),
  grpc.Creds(creds),
 )

 pb.RegisterOrderManagementServer(s, &server{})

 if err := s.Serve(l); err != nil {
  panic(err)
 }
}

JWT

關於 jwt 的介紹參考:JSON Web Token 入門教程

這裏簡述如下

gRPC 提供的 jwt

整個 gRPC 或者說谷歌 golang 生態提供的 jwt 包很混亂

如果不想了解細節直接看結論:不能用golang.org/x/oauth2實現我們常規意義上的 jwt 功能,雖然這個包裏有各式各樣的含有 jwt 字樣的函數

下面是詳細梳理

golang.org/x/oauth2 包含常見平臺如谷歌,亞馬遜等 oauth2 的認證功能

⚠️ golang.org/x/oauth2/google

這個包專門提供谷歌 api 的認證功能,它使用了谷歌的serviceaccount的 json 文件作爲用戶憑證

但這個包不是 oauth2 的標準流程,而是創建 jwt 作爲 oauth2 的 access token。它作爲一種優化的認證方式被部分谷歌的服務支持。這部分說明可以參考:https://developers.google.com/identity/protocols/oauth2/service-account#jwt-auth

⚠️ golang.org/x/oauth2/jwt

這是一個標準的 jwt oauth2.0 的流程,它的交互可以參考谷歌文檔,它能支持所有 two-legged oauth2.0 的服務,不僅限於谷歌的服務

但它不是我們常規認爲的 jwt,而是使用 jwt 作爲 oauth2.0 的一環

⚠️ google.golang.org/grpc/credentials/oauth

它使用 golang.org/x/oauth2/google 來實現 gRPC 的credentials.PerRPCCredentials,也就意味着它主要用作訪問谷歌服務的認證

import (
 "context"
 "log"
 "time"

 pb "github.com/liangwt/note/grpc/authentication/ecommerce"
 "google.golang.org/grpc"
 "google.golang.org/grpc/credentials"
 "google.golang.org/grpc/credentials/oauth"
 "google.golang.org/protobuf/types/known/wrapperspb"
)

func main() {
 ctx, cancel := context.WithTimeout(context.Background(), time.Second)
 defer cancel()

 jwtAuth, err := oauth.NewJWTAccessFromFile("./x509/winged-axon-372312-154a8b3aa89d.json")
 if err != nil {
  panic(err)
 }

 creds, err := credentials.NewClientTLSFromFile("./x509/rootCa.crt""www.example.com")
 if err != nil {
  panic(err)
 }

 conn, err := grpc.Dial("localhost:8009",
  grpc.WithTransportCredentials(creds),
  grpc.WithPerRPCCredentials(jwtAuth))
 if err != nil {
  panic(err)
 }
  // ...
}

三個包的關係如下

自定義的 jwt

上文說了 golang.org/x/oauth2 不能用

自定義實現 jwt 可以使用 github.com/golang-jwt/jwt/v4 庫

ps 在發佈這篇文章時,又去看了下這個庫,已經 v5 了......

客戶端代碼

客戶端的 token 應該是由服務端返回的,而不是客戶端自己生成的,這裏只是爲了方便演示

主要邏輯是聲明 claims 然後使用 secret key 進行簽名

package main

import (
 "context"
 "log"
 "time"

 "github.com/golang-jwt/jwt/v4"
 pb "github.com/liangwt/note/grpc/authentication/ecommerce"
 "google.golang.org/grpc"
 "google.golang.org/grpc/credentials"
 "google.golang.org/protobuf/types/known/wrapperspb"
)

var _ credentials.PerRPCCredentials = JwtAuthentication{}

type JwtAuthentication struct {
 Key []byte
}

func (a JwtAuthentication) GetRequestMetadata(ctx context.Context, uri ...string) (map[string]string, error) {
 // Create a new token object, specifying signing method and the claims
 // you would like it to contain.
 token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.RegisteredClaims{
  ID:        "example",
  ExpiresAt: jwt.NewNumericDate(time.Now().Add(-2 * time.Hour)),
 })

 // Sign and get the complete encoded token as a string using the secret
 tokenString, err := token.SignedString(a.Key)
 if err != nil {
  return nil, err
 }

 return map[string]string{
  "authorization""Bearer " + tokenString,
 }, nil
}

func (b JwtAuthentication) RequireTransportSecurity() bool {
 return true
}

func main() {
 ctx, cancel := context.WithTimeout(context.Background(), time.Second)
 defer cancel()

 creds, err := credentials.NewClientTLSFromFile("./x509/rootCa.crt""www.example.com")
 if err != nil {
  panic(err)
 }

 jwtAuth := JwtAuthentication{[]byte("154a8b3aa89d3d4c49826f6dbbbe5542b5a9fbbb")}

 conn, err := grpc.Dial("localhost:8009",
  grpc.WithTransportCredentials(creds),
  grpc.WithPerRPCCredentials(jwtAuth))
 if err != nil {
  panic(err)
 }
 defer conn.Close()

 client := pb.NewOrderManagementClient(conn)

 // Get Order
 retrievedOrder, err := client.GetOrder(ctx, &wrapperspb.StringValue{Value: "101"})
 if err != nil {
  panic(err)
 }

 log.Printf("GetOrder Response -> : %+v\n", retrievedOrder)
}

服務端代碼

服務端代碼使用攔截器,來對 jwt 進行驗證

package main

import (
 "context"
 "fmt"
 "net"
 "strings"

 "github.com/golang-jwt/jwt/v4"
 pb "github.com/liangwt/note/grpc/authentication/ecommerce"
 "google.golang.org/grpc"
 "google.golang.org/grpc/codes"
 "google.golang.org/grpc/credentials"
 "google.golang.org/grpc/metadata"
 "google.golang.org/grpc/status"
)

var (
 errMissingMetadata = status.Errorf(codes.InvalidArgument, "missing metadata")
)

func ensureValidBasicCredentials(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (resp interface{}, err error) {
 md, ok := metadata.FromIncomingContext(ctx)
 if !ok {
  return nil, errMissingMetadata
 }

 tokenString := strings.TrimPrefix(md["authorization"][0]"Bearer ")

 token, err := jwt.ParseWithClaims(tokenString, &jwt.RegisteredClaims{}, func(token *jwt.Token) (interface{}, error) {
  // Don't forget to validate the alg is what you expect:
  if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
   return nil, fmt.Errorf("Unexpected signing method: %v", token.Header["alg"])
  }

  return []byte("154a8b3aa89d3d4c49826f6dbbbe5542b5a9fbbb"), nil
 })

 claims, ok := token.Claims.(*jwt.RegisteredClaims)
 if !ok || !token.Valid {
  return nil, status.Errorf(codes.Unauthenticated, err.Error())
 }

 fmt.Println(claims.ID)

 return handler(ctx, req)
}

func main() {
 l, err := net.Listen("tcp"":8009")
 if err != nil {
  panic(err)
 }

 creds, err := credentials.NewServerTLSFromFile("./x509/server.crt""./x509/server.key")
 s := grpc.NewServer(
  grpc.UnaryInterceptor(ensureValidBasicCredentials),
  grpc.Creds(creds),
 )

 pb.RegisterOrderManagementServer(s, &server{})

 if err := s.Serve(l); err != nil {
  panic(err)
 }
}

總結

🌲 gRPC 可以支持各種各樣的用戶認證

gRPC 客戶端提供在每一次調用注入用戶憑證的能力

我們需要實現credentials.PerRPCCredentials接口,並且在客戶端創建鏈接的時候指定grpc.WithPerRPCCredentials(credentials.PerRPCCredentials)

type PerRPCCredentials interface {
 // GetRequestMetadata 獲取當前請求的metadata
 GetRequestMetadata(ctx context.Context, uri ...string) (map[string]string, error)
 // RequireTransportSecurity 是否使用安全的傳輸協議
 RequireTransportSecurity() bool
}

gRPC 服務端使用攔截器來驗證每一個客戶端的請求

type UnaryServerInterceptor func(ctx context.Context, req interface{}, info *UnaryServerInfo, handler UnaryHandler) (resp interface{}, err error)

s := grpc.NewServer(
    grpc.UnaryInterceptor(UnaryServerInterceptor),
)

利用以上兩個特性,gRPC 可以支持各種各樣的用戶認證

🌲 傳輸層加密

實現grpc.WithPerRPCCredentials()RequireTransportSecurity如果設置了true,則必須設置合理的傳輸層加密方式(grpc.WithTransportCredentials()),否則會導致建立連接時失敗

🌲 gRPC 中的 jwt 認證

自定義實現 jwt 推薦使用 github.com/golang-jwt/jwt/v4 庫,golang.org/x/oauth2/jwt 不是一個我們常規理解的 jwt 庫


參考資料

[1]

JSON Web Token 入門教程: https://www.ruanyifeng.com/blog/2018/07/json_web_token-tutorial.html

[2]

golang.org/x/oauth2: https://pkg.go.dev/golang.org/x/oauth2#section-directories

[3]

golang.org/x/oauth2/google: https://pkg.go.dev/golang.org/x/oauth2@v0.3.0/google

[4]

golang.org/x/oauth2/jwt: https://pkg.go.dev/golang.org/x/oauth2@v0.3.0/jwt

[5]

google.golang.org/grpc/credentials/oauth: https://pkg.go.dev/google.golang.org/grpc@v1.51.0/credentials/oauth

[6]

github.com/golang-jwt/jwt/v4: https://github.com/golang-jwt/jwt

[7]

github.com/golang-jwt/jwt/v4: https://github.com/golang-jwt/jwt

[8]

golang.org/x/oauth2/jwt: https://pkg.go.dev/golang.org/x/oauth2@v0.3.0/jwt

[9]

JSON Web Token 入門教程: https://www.ruanyifeng.com/blog/2018/07/json_web_token-tutorial.html

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/rqPsGx7GxK3EUJvWdYmi6Q