metadata 与鉴权:令牌透传与上下文传递

metadata 就是 gRPC 的 HTTP/2 头(header):它随每个 RPC 一起发送,服务端在业务逻辑执行前就能读到。鉴权令牌、traceId、租户、灰度标记都放这里,所以它同时是"上下文传递"的入口和最容易被滥用的地方——把大对象塞进 metadata、或者直接信任客户端发来的 x-uid,都是事故的起点。

metadata 的形态

四条规则先记住:

  • 键只能是 [0-9a-z-_.],且统一用小写中划线(写成 Authorization 会被规范化,键里有大写或非法字符则直接报错);
  • grpc- 前缀被框架保留(grpc-timeout、grpc-encoding、grpc-status-details-bin),自定义键不要使用;
  • 值默认按 ASCII 处理,二进制值必须用 -bin 后缀(框架自动 base64 编解码);中文也要走 -bin;
  • 键值对进的是 HTTP/2 首帧,会计入 header list size,所以只放"短、必要"的内容:单次建议 ≤ 2KB,总和 ≤ 8KB。

grpc-go 三个核心 API:

// 客户端:构造带 metadata 的 ctx(覆盖式,适合整条链路固定的值)
md := metadata.Pairs("authorization", "Bearer "+token, "x-request-id", reqID)
ctx = metadata.NewOutgoingContext(ctx, md)

// 客户端:追加(在已有 md 副本上加,不覆盖同名键)——拦截器里推荐用这个
ctx = metadata.AppendToOutgoingContext(ctx, "x-trace-id", traceID)

// 服务端:读取调用方传来的 metadata
incoming, ok := metadata.FromIncomingContext(ctx) // ok=false 表示一个都没有
ids := incoming.Get("x-request-id")               // 返回 []string,同键可多值,必须判空

NewOutgoingContext 是覆盖,AppendToOutgoingContext 是追加;服务端读不到客户端的 outgoing metadata,两者是两套视角。

令牌透传:客户端拦截器统一注入

不要在几百个调用点手写令牌,集中到一个一元/流式客户端拦截器里(见第 08 章):

func authUnaryClient(store TokenStore) grpc.UnaryClientInterceptor {
	return func(ctx context.Context, method string, req, reply any,
		cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error {
		// 每次调用取一次:令牌可能刚被刷新,别缓存在闭包里
		token, err := store.Get(ctx)
		if err != nil {
			return status.Error(codes.Unauthenticated, "no token")
		}
		ctx = metadata.AppendToOutgoingContext(ctx,
			"authorization", "Bearer "+token,
			"x-trace-id", trace.FromContext(ctx),
		)
		return invoker(ctx, method, req, reply, cc, opts...)
	}
}

令牌来自 OAuth2 等标准来源时,用框架能力更省事:实现 credentials.PerRPCCredentials 的 GetRequestMetadata(ctx, uri) 与 RequireTransportSecurity(),再 grpc.WithPerRPCCredentials(cred)。注意 RequireTransportSecurity() 返回 true 时明文连接会直接报错;流式调用只在建流时取一次令牌,长流场景要自己处理令牌过期(重建流)。

服务端校验:把身份写进 ctx

服务端拦截器验令牌,把身份放进带类型键的 ctx,业务代码不再碰 metadata:

type ctxKey struct{} // 未导出类型,避免与其他包的键冲突

type Identity struct {
	UID      int64
	TenantID string
	Device   int32
}

func authUnaryServer() grpc.UnaryServerInterceptor {
	return func(ctx context.Context, req any, info *grpc.UnaryServerInfo,
		handler grpc.UnaryHandler) (any, error) {
		if isPublic(info.FullMethod) { // 健康检查等公开方法白名单
			return handler(ctx, req)
		}
		md, ok := metadata.FromIncomingContext(ctx)
		if !ok {
			return nil, status.Error(codes.Unauthenticated, "missing metadata")
		}
		raw := md.Get("authorization") // []string,必须判空再取 [0]
		if len(raw) == 0 {
			return nil, status.Error(codes.Unauthenticated, "missing token")
		}
		id, err := verify(strings.TrimPrefix(raw[0], "Bearer "))
		if err != nil {
			return nil, status.Error(codes.Unauthenticated, "invalid token")
		}
		// 把身份的 ctx 传给 handler,业务侧才能取到
		return handler(context.WithValue(ctx, ctxKey{}, id), req)
	}
}

业务代码里固定写 id, _ := ctx.Value(ctxKey{}).(*Identity);不要在业务里读 metadata.FromIncomingContext(ctx).Get("x-uid")。流式接口要另挂 grpc.StreamServerInterceptor,两套钩子都挂上,否则流式方法就是无鉴权的后门。

向下游转发:只传白名单

服务端收到的 incoming metadata 不会自动变成下一次出站调用的 outgoing metadata,需要显式构造:

var forwardKeys = []string{"authorization", "x-request-id", "x-trace-id", "x-tenant-id"}

func forward(ctx context.Context) context.Context {
	in, _ := metadata.FromIncomingContext(ctx)
	out := metadata.MD{}
	for _, k := range forwardKeys {
		if vs := in.Get(k); len(vs) > 0 {
			out[k] = vs
		}
	}
	return metadata.NewOutgoingContext(ctx, out)
}

内部身份(如 uid)更稳的做法是走请求体或服务间签名,而不是在 metadata 里传一个字符串。

常用键一览

键方向内容建议
authorization客户端 → 服务端Bearer <token>单值,≤ 4KB,短令牌优先
x-request-id全链路单次请求唯一 ID首跳生成后透传,便于对账
x-trace-id / x-span-id全链路追踪 ID接入链路追踪后由框架注入
x-tenant-id全链路租户/组织由网关注入,禁止客户端自定义
x-uid仅内部内部身份只在可信边界内传,边境外必须剥离
x-idem-key客户端 → 服务端幂等键见第 10 章

常见坑

  • 信任客户端身份字段:网关注入 x-uid、内部服务直接读,客户端伪造一个就能越权。规则是身份只能由最外层鉴权后写入,且进入内网前剥离客户端自带的同名键。
  • metadata 里塞大对象:把用户画像塞进 metadata,首帧超过 header 上限(按 MaxHeaderListSize 被拒,表现为 ResourceExhausted 或连接错误),而且每次调用都重复传。大对象走请求体或缓存。
  • -bin 用错:值不是二进制却加 -bin,或中文直接塞普通键,拿到的是乱码而不是报错,排查成本很高。
  • 建了新 ctx 但没往下传:用 context.WithValue 拿到新 ctx 后忘了传给 handler,业务侧只能读到空身份,且是"偶发"的。
  • 长流令牌过期:流式调用建流时取一次令牌,之后不刷新;第 07 章的 deadline、第 10 章的重试都会重新走拦截器,令牌必须可刷新。
  • 健康检查被打死:健康检查方法也要令牌会让探针失败并反复重启,鉴权拦截器必须留白名单(第 12 章的限流同理)。

小结:metadata 就是 HTTP/2 头,键小写、值要小、二进制走 -bin;客户端在拦截器里用 metadata.NewOutgoingContext / AppendToOutgoingContext 注入令牌,服务端用 metadata.FromIncomingContext 校验后把身份写进带类型键的 ctx,向下游只转发白名单键;身份绝不信任客户端字段,一元与流式两套拦截器都要挂。

笔记加载中…