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

10 KiB
Raw Permalink Blame History

tags, create time
tags create time
gRPC
Metadata
Auth
TLS
Security
JWT
Interceptor
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(推荐前缀) 用途
认证令牌 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——比如返回会话信息、速率限制等:

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-日志与链路追踪