Files
2026-05-24 11:42:38 +08:00

292 lines
10 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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-日志与链路追踪]]