This commit is contained in:
2026-05-24 11:42:38 +08:00
commit 30d312ac35
521 changed files with 146481 additions and 0 deletions
@@ -0,0 +1,291 @@
---
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-日志与链路追踪]]