--- tags: [gRPC, Metadata, Auth, TLS, Security, JWT, Interceptor] create time: 2026-05-11 16:30 --- # 元数据与鉴权 ## 概述 Metadata 是 gRPC 的请求头(HTTP/2 headers)。你可以在 metadata 里放任何键值对,最经典的用途就是传递认证 token、租户 ID、追踪 ID。本篇讲清楚 metadata 的读写机制和几种常见的鉴权策略。 ## 正文 ### Metadata 基础 服务端读取 metadata: ```go 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: ```go 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(推荐前缀) | 用途 | > |------|----------------|------| > | 认证令牌 | `authorization` | Bearer token、API Key | > | 链路追踪 | `x-correlation-id` / `x-trace-id` | 跨服务请求关联 | > | 租户隔离 | `x-tenant-id` | SaaS 多租户识别 | > | 国际化 | `accept-language` | 响应语言偏好 | > | 重试标识 | `x-retry-count` | 服务端限流或降级决策 | > | 自定义超时 | `grpc-timeout` | gRPC 原生支持的超时格式(如 `1S`, `500MS`) | > | 二进制数据 | `x-session-data-bin` | 需要 `-bin` 后缀 + base64 编码 | #### Response Metadata 除了请求 metadata,gRPC 还支持在响应中附加 metadata——比如返回会话信息、速率限制等: ```go 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: ```go hdr, _ := resp.Header() // 响应头 map[string][]string trl := resp.Trailer() // 仅包含 SetTrailer 设置的 key ``` ### 鉴权请求全链路 ```mermaid 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 重复: ```go 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 的最佳实践,避免与其他库产生键名冲突: ```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`,鉴权逻辑几乎一致,但需要同时处理请求和响应流: ```go 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: ```go 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 支持完整的证书体系: ```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 之类的公共接口应该放行: ```go 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-日志与链路追踪]]