Metadata 和 RPC 自定義認證
Metadata 和 RPC 自定義認證
一、Metadata 介紹
在 HTTP/1.1 中,我們常常通過直接操縱 Header 來傳遞資料,而對於 gRPC 來講,它基於 HTTP/2 協議,本質上也可是通過 Header 來進行傳遞,但我們不會直接的去操縱它,而是通過 gRPC 中的 metadata 來進行呼叫過程中的資料傳遞和操縱。但需要注意的是,metadata 的使用需要我們所使用的庫進行支援,並不能像 HTTP/1.1 那樣自行去 Header 去取。
在 gRPC 中,Metadata 實際上就是一個 map 結構,其原型如下:
type MD map[string][]string
是一個字串與字串切片的對映結構。
二、建立 metadata
在 google.golang.org/grpc/metadata
中分別提供了兩個方法來建立 metadata,第一種是 metadata.New
方法,如下:
md := metadata.New(map[string]string{"go": "programming", "tour": "book"})
fmt.Printf("%#v, \n%T \n ", md, md)
使用 New 方法所建立的 metadata,將會直接被轉換為對應的 MD 結構,參考結果如下:
metadata.MD{"go":[]string{"programming"}, "tour":[]string{"book"}}, metadata.MD
第二種是 metadata.Pairs
方法,如下:
mdPairs := metadata.Pairs(
"go", "programming",
"tour", "book",
"go", "eddycjy",
)
fmt.Printf("%#v, \n%T \n ", mdPairs, mdPairs)
使用 Pairs 方法所建立的 metadata,將會以奇數來配對,並且所有的 Key 都會被預設轉為小寫,若出現同名的 Key,將會追加到對應 Key 的切片(slice)上,參考結果如下:
metadata.MD{"go":[]string{"programming", "eddycjy"}, "tour":[]string{"book"}}, metadata.M
三、設定/獲取 metadata
ctx := context.Background()
md := metadata.New(map[string]string{"go": "programming", "tour": "book"})
newCtx1 := metadata.NewIncomingContext(ctx, md)
newCtx2 := metadata.NewOutgoingContext(ctx, md)
在 gRPC 中對於 metadata 進行了區別,分為了傳入和傳出用的 metadata,這是為了防止 metadata 從入站 RPC 轉發到其出站 RPC 的情況(詳見 issues #1148),針對此提供了兩種方法來分別進行設定,如下:
- NewIncomingContext:建立一個附加了所傳入的 md 新上下文,僅供自身的 gRPC 服務端內部使用。
- NewOutgoingContext:建立一個附加了傳出 md 的新上下文,可供外部的 gRPC 客戶端、服務端使用。
因此相對的在 metadata 的獲取上,也區分了兩種方法,分別是 FromIncomingContext 和 NewOutgoingContext,與設定的方法所相對應的含義,如下:
md1, _ := metadata.FromIncomingContext(ctx)
md2, _ := metadata.FromOutgoingContext(ctx)
那麼總的來說,這兩種方法在實現上有沒有什麼區別呢,我們可以一起深入看看:
type mdIncomingKey struct{}
type mdOutgoingKey struct{}
func NewIncomingContext(ctx context.Context, md MD) context.Context {
return context.WithValue(ctx, mdIncomingKey{}, md)
}
func NewOutgoingContext(ctx context.Context, md MD) context.Context {
return context.WithValue(ctx, mdOutgoingKey{}, rawMD{md: md})
}
func FromIncomingContext(ctx context.Context) (MD, bool) {
md, ok := ctx.Value(mdIncomingKey{}).(MD)
if !ok {
return nil, false
}
out := MD{}
for k, v := range md {
// We need to manually convert all keys to lower case, because MD is a
// map, and there's no guarantee that the MD attached to the context is
// created using our helper functions.
key := strings.ToLower(k)
out[key] = v
}
return out, true
}
func FromOutgoingContextRaw(ctx context.Context) (MD, [][]string, bool) {
raw, ok := ctx.Value(mdOutgoingKey{}).(rawMD)
if !ok {
return nil, nil, false
}
return raw.md, raw.added, true
}
實際上主要是在內部進行了 Key 的區分,以所指定的 Key 來讀取相對應的 metadata,以防造成髒讀,其在實現邏輯上本質上並沒有太大的區別。另外大家可以看到,其對 Key 的設定,是用一個結構體去定義的,這是 Go 語言官方一直在推薦的寫法,建議大家也這麼寫。
四、實際使用場景
在上面我們已經介紹了關鍵的 metadata 以及其相對的 IncomingContext、OutgoingContext 類別的相關方法,但在實際的使用中,仍然常常會有開發人員用錯,然後出現了疑惑,最後無奈只能除錯半天,才恍然大悟。
那麼我們回過來想,假設我現在有一個 ServiceA 作為服務端,然後有一個 Client 去呼叫 ServiceA,我想傳入我們自定義的 metadata 資訊,那我們應該怎麼寫才合適,流程圖如下:
syntax = "proto3";// 協議為proto3
//option go_package = "path;name";
//path 表示生成的go檔案的存放地址,會自動生成目錄的。
//name 表示生成的go檔案所屬的包名
option go_package = "./;proto";
package proto;
// protoc -I ./ --go_out=plugins=grpc:.\16metadata\proto\ .\16metadata\proto\simple.proto
// 定義我們的服務(可定義多個服務,每個服務可定義多個介面)
service Simple{
rpc Route (SimpleRequest) returns (SimpleResponse){};
}
// 定義傳送請求資訊
message SimpleRequest{
// 定義傳送的引數,採用駝峰命名方式,小寫加下劃線,如:student_name
// 引數型別 引數名 標識號(不可重複)
string data = 1;
}
// 定義響應資訊
message SimpleResponse{
// 定義接收的引數
// 引數型別 引數名 標識號(不可重複)
int32 code = 1;
string value = 2;
}
在常規情況下,我們在 ServiceA 的服務端,應當使用 metadata.FromIncomingContext
服務端內部使用 方法進行讀取,如下:
package main
import (
"context"
"fmt"
pb "go-grpc-example/16metadata/proto"
"log"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/metadata"
)
/*
@author RandySun
@create 2022-05-15-15:24
*/
const (
// Address 監聽地址
Address string = ":8001"
// NetWork 網路通訊協議
NetWork string = "tcp"
)
func main() {
// 連線伺服器
conn, err := grpc.Dial(Address, grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
log.Fatalf("net.Connect connect: %v", err)
}
defer conn.Close()
// 建立gRpc連線
grpcClient := pb.NewSimpleClient(conn)
// 建立傳送結構體
req := pb.SimpleRequest{
Data: "grpc",
}
ctx := context.Background()
//// 追加自定義欄位
//newCtx := metadata.AppendToOutgoingContext(ctx, "token", "RandySun")
md := metadata.New(map[string]string{"go": "programming", "tour": "book"})
newCtx := metadata.NewOutgoingContext(ctx, md)
// 呼叫 Route 方法 同時傳入context.Context, 在有需要時可以讓我們改變RPC的行為,比如超時/取消一個正在執行的RPC
var header, trailer metadata.MD
res, err := grpcClient.Route(newCtx, &req, grpc.Header(&header), grpc.Trailer(&trailer))
if err != nil {
log.Fatalf("Call Route err:%v", err)
}
fmt.Println("timestamp from header:\n", header, trailer)
if t, ok := header["timestamp"]; ok {
fmt.Printf("timestamp from header:\n")
for i, e := range t {
fmt.Printf(" %d. %s\n", i, e)
}
} else {
log.Fatal("timestamp expected but doesn't exist in header")
}
if l, ok := header["location"]; ok {
fmt.Printf("location from header:\n")
for i, e := range l {
fmt.Printf(" %d. %s\n", i, e)
}
} else {
log.Fatal("location expected but doesn't exist in header")
}
fmt.Printf("response:\n")
if t, ok := trailer["timestamp"]; ok {
fmt.Printf("timestamp from trailer:\n")
for i, e := range t {
fmt.Printf(" %d. %s\n", i, e)
}
} else {
log.Fatal("timestamp expected but doesn't exist in trailer")
}
// 列印返回直
log.Println("服務的返回響應data:", res)
}
而在 Client,我們應當使用 metadata.AppendToOutgoingContext
方法追加metadata ,如下:
package main
import (
"context"
pb "go-grpc-example/16metadata/proto"
"log"
"net"
"time"
"google.golang.org/grpc"
"google.golang.org/grpc/metadata"
)
/*
@author RandySun
@create 2022-05-15-15:24
*/
// SimpleService 定義我們的服務
type SimpleService struct {
}
const (
timestampFormat = time.StampNano
streamingCount = 10
)
// Route 實現Route方法
func (s *SimpleService) Route(ctx context.Context, req *pb.SimpleRequest) (*pb.SimpleResponse, error) {
md, _ := metadata.FromIncomingContext(ctx)
log.Printf("md: %+v", md)
res := pb.SimpleResponse{
Code: 200,
Value: "hello " + req.Data,
}
trailer := metadata.Pairs("timestamp", time.Now().Format(timestampFormat))
grpc.SetTrailer(ctx, trailer)
header := metadata.New(map[string]string{"location": "MTV", "timestamp": time.Now().Format(timestampFormat)})
grpc.SendHeader(ctx, header)
return &res, nil
}
const (
// Address 監聽地址
Address string = ":8001"
// NetWork 網路通訊協議
NetWork string = "tcp"
)
func main() {
// 監聽本地埠
listener, err := net.Listen(NetWork, Address)
if err != nil {
log.Fatalf("net.Listen err: %V", err)
}
log.Println(Address, "net.Listing...")
// 建立grpc服務例項
grpcServer := grpc.NewServer()
// 在grpc伺服器註冊我們的服務
pb.RegisterSimpleServer(grpcServer, &SimpleService{})
err = grpcServer.Serve(listener)
if err != nil {
log.Fatalf("grpcService.Serve err:%v", err)
}
log.Println("grpcService.Serve run succ")
}
這裡需要注意一點,在新增 metadata 資訊時,務必使用 Append 類別的方法,否則如果直接 New 一個全新的 md,將會導致原有的 metadata 資訊丟失(除非你確定你希望得到這樣的結果)。
go run service.go
2022/04/09 22:37:11 :8001 net.Listing...
2022/04/09 22:38:04 md: map[:authority:[localhost:8001] content-type:[application/grpc] token:[RandySun] user-agent:[grpc-go/1.45.0]]
go run \client.go
code:200 value:"hello grpc"
2022/04/09 22:38:04 服務的返回響應data: code:200 value:"hello grpc"
五、Metadata 是如何傳遞的
在上小節中,我們已經知道 metadata 其實是儲存在 context 之中的,那麼 context 中的資料又是承載在哪裡呢,我們繼續對前面的 gRPC 呼叫例子進行調整,將已經傳入 metadata 的 context 設定到對應的 RPC 方法呼叫上,程式碼如下:
func main() {
ctx := context.Background()
md := metadata.New(map[string]string{"go": "programming", "tour": "book"})
newCtx := metadata.NewOutgoingContext(ctx, md)
clientConn, err := GetClientConn(newCtx, "localhost:8004", nil)
if err != nil {
log.Fatalf("err: %v", err)
}
defer clientConn.Close()
tagServiceClient := pb.NewTagServiceClient(clientConn)
resp, err := tagServiceClient.GetTagList(newCtx, &pb.GetTagListRequest{Name: "Go"})
...
}
...
我們再重新檢視抓包工具的結果:
顯然,我們所傳入的 "go": "programming", "tour": "book"
是在 Header 中進行傳播的。
六、對 RPC 方法做自定義認證
在實際需求中,我們有時候會需要對某些模組的 RPC 方法做特殊認證或校驗,這時候我們可以利用 gRPC 所提供的 Token 介面,如下:
type PerRPCCredentials interface {
GetRequestMetadata(ctx context.Context, uri ...string) (map[string]string, error)
RequireTransportSecurity() bool
}
在 gRPC 中所提供的 PerRPCCredentials,是 gRPC 預設提供用於自定義認證 Token 的介面,它的作用是將所需的安全認證資訊新增到每個 RPC 方法的上下文中。其包含兩個介面方法,如下:
- GetRequestMetadata:獲取當前請求認證所需的元資料(metadata)。
- RequireTransportSecurity:是否需要基於 TLS 認證進行安全傳輸。
客戶端
我們開啟先前章節編寫的 gRPC 呼叫的程式碼(也就是 gRPC 客戶端的角色),那麼在客戶端的重點在於實現 type PerRPCCredentials interface
所需的介面方法,程式碼如下:
type Auth struct {
AppKey string
AppSecret string
}
func (a *Auth) GetRequestMetadata(ctx context.Context, uri ...string) (map[string]string, error) {
return map[string]string{"app_key": a.AppKey, "app_secret": a.AppSecret}, nil
}
func (a *Auth) RequireTransportSecurity() bool {
return false
}
func main() {
auth := Auth{
AppKey: "go-programming-tour-book",
AppSecret: "eddycjy",
}
ctx := context.Background()
opts := []grpc.DialOption{grpc.WithPerRPCCredentials(&auth)}
clientConn, err := GetClientConn(ctx, "localhost:8004", opts)
if err != nil {
log.Fatalf("err: %v", err)
}
defer clientConn.Close()
...
}
...
在上述程式碼中,我們聲明瞭 Auth 結構體,並實現了所需的兩個介面方法,最後在 DialOption
配置中呼叫 grpc.WithPerRPCCredentials
方法進行了註冊。
服務端
客戶端的校驗資料已經傳過來了,接下來我們需要修改先前的服務端程式碼,對其進行 Token 校驗,如下:
type TagServer struct {
auth *Auth
}
type Auth struct {}
func (a *Auth) GetAppKey() string {
return "go-programming-tour-book"
}
func (a *Auth) GetAppSecret() string {
return "eddycjy"
}
func (a *Auth) Check(ctx context.Context) error {
md, _ := metadata.FromIncomingContext(ctx)
var appKey, appSecret string
if value, ok := md["app_key"]; ok {
appKey = value[0]
}
if value, ok := md["app_secret"]; ok {
appSecret = value[0]
}
if appKey != a.GetAppKey() || appSecret != a.GetAppSecret() {
return errcode.TogRPCError(errcode.Unauthorized)
}
return nil
}
func NewTagServer() *TagServer {
return &TagServer{}
}
func (t *TagServer) GetTagList(ctx context.Context, r *pb.GetTagListRequest) (*pb.GetTagListReply, error) {
if err := t.auth.Check(ctx); err != nil {
return nil, err
}
...
}
上述程式碼實際就是呼叫 metadata.FromIncomingContext
從上下文中獲取 metadata,再在不同的 RPC 方法中進行認證檢查就可以了。
七、小結
在本章節中我們介紹了 metadata 的使用和傳播機制,通過分析我們可以看到實質上 metadata 在應用傳輸上做了嚴格的進出入隔離,也就是在上下文中分隔傳入和傳出的 metadata。而這項功能是在 grpc v1.3.0 釋出的,在當時屬於相當嚴重的安全錯誤修復,因為我們必須確保服務端不會在無意中將 metadata 從入站 RPC 轉發到其出站 RPC,那麼對於開發人員來講,就是在使用 metadata 時,需要多思考一下,到底它應該是出還是入,以此來呼叫不同的處理方法。
隨後我們通過抓包分析了 metadata 是如何具體傳輸的,並且利用 metadata 實現了自定義認證,以此來支援更多的自定義認證需求。
參考