Metadata 和 RPC 自定义认证

3.9 Metadata 和 RPC 自定义认证

3.9.1 Metadata 介绍

在 HTTP/1.1 中,我们常常通过直接操纵 Header 来传递数据,而对于 gRPC 来讲,它基于 HTTP/2 协议,本质上也可是通过 Header 来进行传递,但我们不会直接的去操纵它,而是通过 gRPC 中的 metadata 来进行调用过程中的数据传递和操纵。但需要注意的是,metadata 的使用需要我们所使用的库进行支持,并不能像 HTTP/1.1 那样自行去 Header 去取。

在 gRPC 中,Metadata 实际上就是一个 map 结构,其原型如下:

type MD map[string][]string

是一个字符串与字符串切片的映射结构。

3.9.1.1 创建 metadata

google.golang.org/grpc/metadata 中分别提供了两个方法来创建 metadata,第一种是 metadata.New 方法,如下:

metadata.New(map[string]string{"go": "programming", "tour": "book"})

使用 New 方法所创建的 metadata,将会直接被转换为对应的 MD 结构,参考结果如下:

go:   []string{"programming"}
tour: []string{"book"}

第二种是 metadata.Pairs 方法,如下:

metadata.Pairs(
    "go", "programming",
    "tour", "book",
    "go", "eddycjy",
)

使用 Pairs 方法所创建的 metadata,将会以奇数来配对,并且所有的 Key 都会被默认转为小写,若出现同名的 Key,将会追加到对应 Key 的切片(slice)上,参考结果如下:

go:   []string{"programming", "eddycjy"}
tour: []string{"book"}

3.9.1.2 设置/获取 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})
}

实际上主要是在内部进行了 Key 的区分,以所指定的 Key 来读取相对应的 metadata,以防造成脏读,其在实现逻辑上本质上并没有太大的区别。另外大家可以看到,其对 Key 的设置,是用一个结构体去定义的,这是 Go 语言官方一直在推荐的写法,建议大家也这么写。

3.9.1.3 实际使用场景

在上面我们已经介绍了关键的 metadata 以及其相对的 IncomingContext、OutgoingContext 类别的相关方法,但在实际的使用中,仍然常常会有开发人员用错,然后出现了疑惑,最后无奈只能调试半天,才恍然大悟。

那么我们回过来想,假设我现在有一个 ServiceA 作为服务端,然后有一个 Client 去调用 ServiceA,我想传入我们自定义的 metadata 信息,那我们应该怎么写才合适,流程图如下:

image

在常规情况下,我们在 ServiceA 的服务端,应当使用 metadata.FromIncomingContext 方法进行读取,如下:

func (t *TagServer) GetTagList(ctx context.Context, r *pb.GetTagListRequest) (*pb.GetTagListReply, error) {
	md, _ := metadata.FromIncomingContext(ctx)
	log.Printf("md: %+v", md)
	...
}

而在 Client,我们应当使用 metadata.AppendToOutgoingContext 方法,如下:

func main() {
	ctx := context.Background()
	newCtx := metadata.AppendToOutgoingContext(ctx, "eddycjy", "Go 语言编程之旅")
	
	clientConn, _ := GetClientConn(newCtx, ...)
	defer clientConn.Close()
	tagServiceClient := pb.NewTagServiceClient(clientConn)
	resp, _ := tagServiceClient.GetTagList(newCtx, &pb.GetTagListRequest{Name: "Go"})
	...
}

这里需要注意一点,在新增 metadata 信息时,务必使用 Append 类别的方法,否则如果直接 New 一个全新的 md,将会导致原有的 metadata 信息丢失(除非你确定你希望得到这样的结果)。

3.9.2 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"})
	...
}
...

我们再重新查看抓包工具的结果:

image

显然,我们所传入的 "go": "programming", "tour": "book" 是在 Header 中进行传播的。

3.9.3 对 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 认证进行安全传输。

3.9.3.1 客户端

我们打开先前章节编写的 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 方法进行了注册。

3.9.3.2 服务端

客户端的校验数据已经传过来了,接下来我们需要修改先前的服务端代码,对其进行 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 方法中进行认证检查就可以了。

3.9.4 小结

在本章节中我们介绍了 metadata 的使用和传播机制,通过分析我们可以看到实质上 metadata 在应用传输上做了严格的进出入隔离,也就是在上下文中分隔传入和传出的 metadata。而这项功能是在 grpc v1.3.0 发布的,在当时属于相当严重的安全错误修复,因为我们必须确保服务端不会在无意中将 metadata 从入站 RPC 转发到其出站 RPC,那么对于开发人员来讲,就是在使用 metadata 时,需要多思考一下,到底它应该是出还是入,以此来调用不同的处理方法。

随后我们通过抓包分析了 metadata 是如何具体传输的,并且利用 metadata 实现了自定义认证,以此来支持更多的自定义认证需求。



本图书由 煎鱼 ©2020 版权所有,所有文章采用知识署名-非商业性使用-禁止演绎 4.0 国际进行许可。