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,向下游只转发白名单键;身份绝不信任客户端字段,一元与流式两套拦截器都要挂。