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 实现了自定义认证,以此来支持更多的自定义认证需求。
参考