10 KiB
tags, create time
| tags | create time | |||||||
|---|---|---|---|---|---|---|---|---|
|
2026-05-11 16:30 |
元数据与鉴权
概述
Metadata 是 gRPC 的请求头(HTTP/2 headers)。你可以在 metadata 里放任何键值对,最经典的用途就是传递认证 token、租户 ID、追踪 ID。本篇讲清楚 metadata 的读写机制和几种常见的鉴权策略。
正文
Metadata 基础
服务端读取 metadata:
func (s *server) GetUser(ctx context.Context, req *pb.GetUserRequest) (*pb.User, error) {
md, ok := metadata.FromIncomingContext(ctx)
if !ok {
return nil, status.Error(codes.Unauthenticated, "no metadata")
}
tokens := md.Get("authorization")
token := ""
if len(tokens) > 0 {
token = tokens[0]
}
// validate token...
_ = token
return &pb.User{}, nil
}
客户端发送 metadata:
ctx := metadata.AppendToOutgoingContext(ctx, "authorization", "Bearer "+token)
ctx = metadata.AppendToOutgoingContext(ctx, "x-correlation-id", "abc123")
resp, err := client.GetUser(ctx, &pb.GetUserRequest{Id: "123"})
[!example] Metadata 典型使用场景
场景 Key(推荐前缀) 用途 认证令牌 authorizationBearer token、API Key 链路追踪 x-correlation-id/x-trace-id跨服务请求关联 租户隔离 x-tenant-idSaaS 多租户识别 国际化 accept-language响应语言偏好 重试标识 x-retry-count服务端限流或降级决策 自定义超时 grpc-timeoutgRPC 原生支持的超时格式(如 1S,500MS)二进制数据 x-session-data-bin需要 -bin后缀 + base64 编码
Response Metadata
除了请求 metadata,gRPC 还支持在响应中附加 metadata——比如返回会话信息、速率限制等:
func (s *server) GetUser(ctx context.Context, req *pb.GetUserRequest) (*pb.User, error) {
// ... 业务逻辑 ...
// 设置响应头(客户端可通过 resp.Header() 读取)
err := grpc.SetHeader(ctx, metadata.Pairs(
"x-rate-limit", "100",
"x-response-time", "42ms",
))
if err != nil {
return nil, err
}
return &pb.User{Name: "Alice"}, nil
}
// trailer 仅在流结束时发送,适合放汇总信息:
grpc.SetTrailer(ctx, metadata.Pairs("x-total-cost", "0.003"))
客户端读取 Response Header / Trailer:
hdr, _ := resp.Header() // 响应头 map[string][]string
trl := resp.Trailer() // 仅包含 SetTrailer 设置的 key
鉴权请求全链路
flowchart TD
A["客户端发送请求\n(metadata + credentials)"] --> B["gRPC 框架层\n合并 Per-RPC Credentials"]
B --> C["Server Interceptor Chain\n按注册逆序执行"]
C --> D{Auth Interceptor}
D -- "无 token" --> E["返回 Unauthenticated\n401"]
D -- "token 无效" --> E
D -- "token 有效" --> F["解析 claims\n注入 Context"]
F --> G{"白名单路由检查"}
G -- "公共接口" --> H["直接放行 → Handler"]
G -- "受保护接口" --> I["权限校验\nPermissionDenied?"]
I -- "无权限" --> J["返回 PermissionDenied\n403"]
I -- "有权限" --> H
H --> K["Handler 业务逻辑\n返回响应"]
style D fill:#f9d,stroke:#936
style E fill:#fcc,stroke:#c66
style J fill:#fcc,stroke:#c66
style F fill:#ddf,stroke:#669
[!question 思考一下] 为什么图中拦截器是按注册逆序执行的?这跟 HTTP 中间件的洋葱模型是一个道理——后注册的包裹在最外层,先执行的拦截器拿到的是完整请求上下文,适合做日志记录;先注册的在内层,靠近业务逻辑,适合做精细校验。理解这个顺序有助于调试多拦截器叠加时的行为。
Metadata 规范速查表
| 维度 | 规则 |
|---|---|
| 编码 | 必须 ASCII(非 ASCII 需要 base64 编码后加 -bin 后缀) |
| 大小写 | gRPC Go 统一转小写处理 |
| 大小限制 | Default: 8KB max received metadata size |
| Binary fields | Key 必须以 -bin 结尾,值是 base64 bytes |
自定义鉴权 Interceptor
将鉴权逻辑抽成 interceptor 的好处是全局生效、无需每个 handler 重复:
func AuthInterceptor(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
md, ok := metadata.FromIncomingContext(ctx)
if !ok {
return nil, status.Error(codes.Unauthenticated, "missing metadata")
}
auths := md.Get("authorization")
if len(auths) == 0 {
return nil, status.Error(codes.Unauthenticated, "missing auth")
}
token := strings.TrimPrefix(auths[0], "Bearer ")
claims, err := jwt.Validate(token)
if err != nil {
return nil, status.Error(codes.Unauthenticated, "invalid token")
}
ctx = context.WithValue(ctx, claimsKey{}, claims)
return handler(ctx, req)
}
这里把解析后的 claims 注入到 context,下游 handler 可以直接使用,避免了在每个接口中重复解析 JWT。
Context Key 防冲突
用自定义类型作为 context key 是 Go 的最佳实践,避免与其他库产生键名冲突:
// claimsKey 是 unexported type —— context.WithValue 的 key 应当不可导出
type claimsKey struct{}
// 读取时通过包级辅助函数统一取值
func GetClaims(ctx context.Context) *jwt.Claims {
v := ctx.Value(claimsKey{})
if v == nil {
return nil
}
return v.(*jwt.Claims)
}
[!tip] Token 过期 vs 无权访问 Token 无效或过期时返回
codes.Unauthenticated(401);Token 有效但权限不足时返回codes.PermissionDenied(403)。语义不同,前端处理方式也不同——前者应跳转到登录页,后者可能显示"无权限"弹窗。
Server-Side Streaming 拦截器
Server Stream(serverStreaming)的拦截器签名为 grpc.StreamServerInterceptor,鉴权逻辑几乎一致,但需要同时处理请求和响应流:
func StreamAuthInterceptor(srv interface{}, ss grpc.ServerStream,
*grpc.StreamServerInfo, grpc.StreamHandler,
) error {
md, ok := metadata.FromIncomingContext(ss.Context())
if !ok {
return status.Error(codes.Unauthenticated, "missing metadata")
}
auths := md.Get("authorization")
if len(auths) == 0 || !strings.HasPrefix(auths[0], "Bearer ") {
return status.Error(codes.Unauthenticated, "missing auth token")
}
// Token 校验通过后放行到业务 handler
return handler(srv, ss)
}
// 注册方式:
server := grpc.NewServer(
grpc.ChainStreamInterceptor(StreamAuthInterceptor, LoggingStreamInterceptor),
)
[!tip] Unary vs Stream 的选择
- Unary Interceptor 处理单请求单响应(绝大多数 RPC 方法)
- Stream Interceptor 处理 Server/Client/Bidirectional Stream
grpc.ChainUnaryInterceptor(...)和grpc.ChainStreamInterceptor(...)分别串联多个 interceptor- 注意:Chain 对 unary 和 stream 是分开配置的,不能混用
[!question 思考一下] Bidirectional Stream(双向流)场景下,token 只需要在连接建立时校验一次即可——因为 metadata 是在
Dial/NewStream阶段发送的,一旦握手成功整个流的生命周期内不再重新认证。这和 WebSocket 的鉴权模型是一致的。
Per-RPC Credentials
当客户端需要在每次调用前动态获取 token(例如自动刷新 accessToken),可以用 Per-RPC Credentials:
type bearerCreds struct {
token string
}
func (c bearerCreds) RequireTransportSecurity() bool { return true }
func (c bearerCreds) GetPerRPCCredentials() (metadata.MD, error) {
return metadata.Pairs("authorization", "Bearer "+c.token), nil
}
// 使用:每个 call 可以传入不同的 credential
resp, _ := client.GetUser(
ctx,
&pb.GetUserRequest{},
grpc.Creds(bearerCreds{token: getFreshToken()}),
)
优势是每个 call 可以用不同的 credential,非常适合 token 刷新场景。缺点是需要为每个 call 单独传 option,不如 interceptor 全局生效方便。两者结合使用时,interceptor 放在外层做校验,Per-RPC credentials 负责提供最新 token。
TLS / mTLS
生产环境必须启用 TLS,gRPC Go 支持完整的证书体系:
cert, _ := tls.LoadX509KeyPair("server.crt", "server.key")
ca, _ := os.ReadFile("ca.crt")
pool := x509.NewCertPool()
pool.AppendCertsFromPEM(ca)
creds := credentials.NewTLS(&tls.Config{
ClientAuth: tls.RequireAndVerifyClientCert, // mTLS 模式
Certificates: []tls.Certificate{cert},
ClientCAs: pool,
})
server := grpc.NewServer(grpc.Creds(creds))
Certificate 级别的选择:
tls.NoClientCert:仅服务端验证(常规 TLS)tls.VerifyClientCertIfGiven:客户端有证书就验证,没有也行tls.RequireAnyClientCert:必须有证书但不校验内容tls.RequireAndVerifyClientCert:mTLS,严格双向校验
白名单路由
不是所有接口都需要鉴权——health check、ping 之类的公共接口应该放行:
func ServiceAuthInterceptor(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
allowedPublic := map[string]bool{
"/health.v1.Health/Check": true,
"/status.v1.Status/Ping": true,
}
if allowedPublic[info.FullMethod] {
return handler(ctx, req)
}
// 走正常鉴权逻辑...
return handler(ctx, req)
}
最佳实践 Checklist
- 公共接口排除鉴权 — health check、登录等接口不应走 auth interceptor
- 区分 Unauthenticated (401) 和 PermissionDenied (403) — token 问题用前者,权限问题用后者
- Context Key 使用 unexported type — 避免与其他库产生键名冲突
- metadata key 统一小写 — gRPC Go 转小写存储,硬编码时保持一致
- 禁止
WithInsecure— production 必须 TLS,开发环境可用credentials.NewTLS(nil) - 敏感数据不放 metadata — metadata 最终会落入 HTTP/2 headers,可能被代理或日志记录
- binary field 加
-bin后缀 — 非 ASCII 值必须 base64 编码 +-bin命名约定 - 控制 metadata 大小 — 默认限制 8KB,超限会被拒绝
- Interceptor Chain 分层设计 — 外层做认证授权(auth),内层做日志追踪(logging)
关联笔记
- hhs/gRPC/5. 中间件与拦截器/14-Unary 与 Stream 拦截器
- hhs/gRPC/5. 中间件与拦截器/16-日志与链路追踪