fix(docreader/auth): harden gRPC TLS/Token rollout from #1359

Follow-up to #1359. Addresses a set of correctness and security gaps in
the initial docreader auth implementation.

- docker-compose: inject GRPC_TLS_*/GRPC_TLS_SERVER_NAME/GRPC_AUTH_TOKEN
  into the WeKnora-app service. Without this the Go client never saw the
  knobs, so enabling token auth on the server broke every RPC.
- client: bind tokenAuth.RequireTransportSecurity() to TLSEnabled so a
  bearer token cannot be sent over an insecure channel once TLS is on.
- server: load_tls_credentials now raises TLSConfigError on misconfig
  (cert/key missing, file unreadable, mTLS without CA); main.py exits 1
  instead of silently downgrading to insecure.
- server: replace endswith("/Check"|"/Watch") health bypass with exact
  match against /grpc.health.v1.Health/{Check,Watch}.
- server: compare tokens with hmac.compare_digest, warn on tokens < 16B.
- server: AuthInterceptor now returns an abort handler matching the
  original RPC kind (unary/stream) and uses context.abort, so streaming
  RPCs surface UNAUTHENTICATED instead of INTERNAL.
- internal/infrastructure/docparser/grpc_parser.go: drop the duplicated
  TLS/tokenAuth block and reuse docreader/client.LoadAuthConfigFromEnv +
  BuildDialOptions. Single source of truth for client-side auth.
- Add GRPC_TLS_SERVER_NAME (client SNI override) and
  GRPC_MTLS_REQUIRE_CLIENT_CERT (server explicit mTLS toggle); document
  the differing CA semantics between client and server in .env*.example.
- Reject half-configured client mTLS (cert XOR key) loudly.
- Fix missing trailing newline in .env.lite.example.

Verified locally: go build ./... and go vet ./... clean; auth.py
fail-fast / token paths smoke-tested.
This commit is contained in:
wizardchen
2026-05-16 21:39:10 +08:00
committed by lyingbug
parent 5a02e22f52
commit a3411899cf
8 changed files with 208 additions and 126 deletions
+9 -4
View File
@@ -439,12 +439,17 @@ DOCREADER_TRANSPORT=grpc
# gRPC TLS 配置(可选)
# GRPC_TLS_ENABLED=false
# GRPC_TLS_CERT=/path/to/server.crt
# GRPC_TLS_KEY=/path/to/server.key
# GRPC_TLS_CA=/path/to/ca.crt
# GRPC_TLS_CERT=/path/to/server.crt # server: 必填;client: mTLS 时填
# GRPC_TLS_KEY=/path/to/server.key # 同上
# GRPC_TLS_CA=/path/to/ca.crt # client: 校验 server 证书用;server: mTLS 时签发客户端证书的 CA
# GRPC_TLS_SERVER_NAME= # 可选;当 DOCREADER_ADDR 用 IP/与证书 CN 不一致时设为证书的 hostname
# GRPC_MTLS_REQUIRE_CLIENT_CERT=false # server 端:true 时强制要求客户端证书(mTLS)
# gRPC 认证 Token(可选,客户端和服务端需配置相同的值)
# GRPC_AUTH_TOKEN=your-secret-token
# 注意:启用 GRPC_AUTH_TOKEN 时强烈建议同时启用 TLS,否则 token 会以明文随
# 每次 RPC 在网络上传输。当 GRPC_TLS_ENABLED=true 时客户端会拒绝在不安全
# 的连接上发送 token。
# GRPC_AUTH_TOKEN=your-secret-token-at-least-16-bytes
# Docreader 中 DOCX 解析的最大页数,默认 0(不限制)
# 设为正整数(如 500)可限制超大 Word 文档的解析开销;超过页数的内容将不会继续解析
+9 -4
View File
@@ -62,9 +62,14 @@ DOCREADER_TRANSPORT=grpc
# gRPC TLS 配置(可选)
# GRPC_TLS_ENABLED=false
# GRPC_TLS_CERT=/path/to/server.crt
# GRPC_TLS_KEY=/path/to/server.key
# GRPC_TLS_CA=/path/to/ca.crt
# GRPC_TLS_CERT=/path/to/server.crt # server: 必填;client: mTLS 时填
# GRPC_TLS_KEY=/path/to/server.key # 同上
# GRPC_TLS_CA=/path/to/ca.crt # client: 校验 server 证书用;server: mTLS 时签发客户端证书的 CA
# GRPC_TLS_SERVER_NAME= # 可选;当 DOCREADER_ADDR 用 IP/与证书 CN 不一致时设为证书的 hostname
# GRPC_MTLS_REQUIRE_CLIENT_CERT=false # server 端:true 时强制要求客户端证书(mTLS)
# gRPC 认证 Token(可选,客户端和服务端需配置相同的值)
# GRPC_AUTH_TOKEN=your-secret-token
# 注意:启用 GRPC_AUTH_TOKEN 时强烈建议同时启用 TLS,否则 token 会以明文随
# 每次 RPC 在网络上传输。当 GRPC_TLS_ENABLED=true 时客户端会拒绝在不安全
# 的连接上发送 token。
# GRPC_AUTH_TOKEN=your-secret-token-at-least-16-bytes
+1
View File
@@ -203,6 +203,7 @@ services:
- GRPC_TLS_CERT=${GRPC_TLS_CERT:-}
- GRPC_TLS_KEY=${GRPC_TLS_KEY:-}
- GRPC_TLS_CA=${GRPC_TLS_CA:-}
- GRPC_TLS_SERVER_NAME=${GRPC_TLS_SERVER_NAME:-}
- GRPC_AUTH_TOKEN=${GRPC_AUTH_TOKEN:-}
healthcheck:
test: ["CMD", "grpc_health_probe", "-addr=localhost:50051"]
+7
View File
@@ -102,6 +102,13 @@ services:
- MILVUS_METRIC_TYPE=${MILVUS_METRIC_TYPE:-IP}
- DOCREADER_ADDR=${DOCREADER_ADDR:-docreader:50051}
- DOCREADER_TRANSPORT=${DOCREADER_TRANSPORT:-grpc}
# docreader gRPC TLS / 认证(客户端侧)
- GRPC_TLS_ENABLED=${GRPC_TLS_ENABLED:-false}
- GRPC_TLS_CERT=${GRPC_TLS_CERT:-}
- GRPC_TLS_KEY=${GRPC_TLS_KEY:-}
- GRPC_TLS_CA=${GRPC_TLS_CA:-}
- GRPC_TLS_SERVER_NAME=${GRPC_TLS_SERVER_NAME:-}
- GRPC_AUTH_TOKEN=${GRPC_AUTH_TOKEN:-}
- WEAVIATE_HOST=${WEAVIATE_HOST:-weaviate:8080}
- WEAVIATE_GRPC_ADDRESS=${WEAVIATE_GRPC_ADDRESS:-weaviate:50051}
- WEAVIATE_SCHEME=${WEAVIATE_SCHEME:-http}
+129 -42
View File
@@ -3,14 +3,21 @@
环境变量配置:
TLS 相关:
GRPC_TLS_ENABLED: 是否启用 TLS(true/false),默认 false
GRPC_TLS_CERT: TLS 证书文件路径
GRPC_TLS_KEY: TLS 私钥文件路径
GRPC_TLS_CA: CA 证书路径(可选,用于 mTLS 双向认证)
GRPC_TLS_CERT: TLS 证书文件路径(GRPC_TLS_ENABLED=true 时必填)
GRPC_TLS_KEY: TLS 私钥文件路径(GRPC_TLS_ENABLED=true 时必填)
GRPC_TLS_CA: CA 证书路径
GRPC_MTLS_REQUIRE_CLIENT_CERT: 设为 true 时启用 mTLS,要求客户端
出示由 GRPC_TLS_CA 签发的证书。未设置时默认按 GRPC_TLS_CA 是否
存在自动判断(保留向后兼容)。
认证相关:
GRPC_AUTH_TOKEN: 认证 Token,如果设置则启用认证
注意:当 GRPC_TLS_ENABLED=true 但任何 TLS 配置项缺失或加载失败时,
本模块会抛出异常以触发 fail-fast,避免静默降级到明文。
"""
import hmac
import logging
import os
from typing import Optional
@@ -20,9 +27,24 @@ import grpc
logger = logging.getLogger(__name__)
class TLSConfigError(RuntimeError):
"""TLS 配置错误,用于 fail-fast。"""
def _env_bool(name: str, default: bool = False) -> bool:
raw = os.getenv(name)
if raw is None:
return default
return raw.strip().lower() in ("true", "1", "yes", "on")
def load_tls_credentials() -> Optional[grpc.ServerCredentials]:
tls_enabled = os.getenv("GRPC_TLS_ENABLED", "false").lower() == "true"
if not tls_enabled:
"""构建 server 端 TLS 凭据。
GRPC_TLS_ENABLED=false 时返回 None;为 true 时如果配置无效会抛出
TLSConfigError,由调用方决定是否终止启动。
"""
if not _env_bool("GRPC_TLS_ENABLED", False):
logger.info("TLS disabled (GRPC_TLS_ENABLED is not 'true')")
return None
@@ -30,38 +52,105 @@ def load_tls_credentials() -> Optional[grpc.ServerCredentials]:
key_path = os.getenv("GRPC_TLS_KEY")
if not cert_path or not key_path:
logger.warning(
"TLS enabled but certificate not configured (GRPC_TLS_CERT or GRPC_TLS_KEY not set)"
raise TLSConfigError(
"GRPC_TLS_ENABLED=true but GRPC_TLS_CERT/GRPC_TLS_KEY not set; "
"refusing to start in plaintext mode"
)
return None
try:
with open(cert_path, "rb") as f:
cert_chain = f.read()
with open(key_path, "rb") as f:
private_key = f.read()
except OSError as e:
raise TLSConfigError(f"failed to read TLS cert/key: {e}") from e
ca_path = os.getenv("GRPC_TLS_CA")
if ca_path:
ca_path = os.getenv("GRPC_TLS_CA")
require_client_auth = _env_bool(
"GRPC_MTLS_REQUIRE_CLIENT_CERT",
default=bool(ca_path),
)
if require_client_auth and not ca_path:
raise TLSConfigError(
"GRPC_MTLS_REQUIRE_CLIENT_CERT=true requires GRPC_TLS_CA to be set"
)
if ca_path:
try:
with open(ca_path, "rb") as f:
ca_cert = f.read()
credentials = grpc.ssl_server_credentials(
[(private_key, cert_chain)],
root_certificates=ca_cert,
require_client_auth=True,
)
except OSError as e:
raise TLSConfigError(f"failed to read CA cert: {e}") from e
credentials = grpc.ssl_server_credentials(
[(private_key, cert_chain)],
root_certificates=ca_cert,
require_client_auth=require_client_auth,
)
if require_client_auth:
logger.info("TLS enabled with mTLS (mutual authentication)")
else:
credentials = grpc.ssl_server_credentials([(private_key, cert_chain)])
logger.info("TLS enabled")
logger.info("TLS enabled with CA configured (client auth optional)")
else:
credentials = grpc.ssl_server_credentials([(private_key, cert_chain)])
logger.info("TLS enabled (1-way)")
return credentials
except FileNotFoundError as e:
logger.error(f"TLS certificate file not found: {e}")
return None
except Exception as e:
logger.error(f"Failed to load TLS credentials: {e}")
return None
return credentials
# gRPC 健康检查标准服务路径,需在鉴权前放行,便于 K8s/Docker 探活。
_HEALTH_METHODS = frozenset(
{
"/grpc.health.v1.Health/Check",
"/grpc.health.v1.Health/Watch",
}
)
def _make_abort_handler(original: Optional[grpc.RpcMethodHandler]) -> grpc.RpcMethodHandler:
"""为给定的原 handler 构造一个匹配 RPC kind 的鉴权失败 handler。
如果原 handler 不可用(理论上不会发生,但作为兜底),返回 unary_unary。
直接返回 None 而不是 set_code 也可,但显式调用 abort 能确保框架按
UNAUTHENTICATED 收尾,且匹配 kind 防止 grpc 触发 INTERNAL。
"""
def _abort(_request, context):
context.abort(
grpc.StatusCode.UNAUTHENTICATED,
"Invalid or missing authentication token",
)
def _abort_stream(_request, context):
context.abort(
grpc.StatusCode.UNAUTHENTICATED,
"Invalid or missing authentication token",
)
return
yield # pragma: no cover - make this a generator
if original is None or original.unary_unary is not None:
return grpc.unary_unary_rpc_method_handler(
_abort,
request_deserializer=getattr(original, "request_deserializer", None),
response_serializer=getattr(original, "response_serializer", None),
)
if original.unary_stream is not None:
return grpc.unary_stream_rpc_method_handler(
_abort_stream,
request_deserializer=original.request_deserializer,
response_serializer=original.response_serializer,
)
if original.stream_unary is not None:
return grpc.stream_unary_rpc_method_handler(
_abort,
request_deserializer=original.request_deserializer,
response_serializer=original.response_serializer,
)
return grpc.stream_stream_rpc_method_handler(
_abort_stream,
request_deserializer=original.request_deserializer,
response_serializer=original.response_serializer,
)
class AuthInterceptor(grpc.ServerInterceptor):
@@ -75,9 +164,14 @@ class AuthInterceptor(grpc.ServerInterceptor):
- value: "Bearer <token>" 或直接 "<token>"
"""
def __init__(self):
self.auth_token = os.getenv("GRPC_AUTH_TOKEN")
def __init__(self) -> None:
token = os.getenv("GRPC_AUTH_TOKEN") or ""
self.auth_token: Optional[bytes] = token.encode("utf-8") if token else None
if self.auth_token:
if len(self.auth_token) < 16:
logger.warning(
"GRPC_AUTH_TOKEN is shorter than 16 bytes; consider a stronger token"
)
logger.info("Token authentication enabled")
else:
logger.warning("Token authentication disabled (GRPC_AUTH_TOKEN not set)")
@@ -87,25 +181,18 @@ class AuthInterceptor(grpc.ServerInterceptor):
return continuation(handler_call_details)
method = handler_call_details.method
if method.endswith("/Check") or method.endswith("/Watch"):
if method in _HEALTH_METHODS:
return continuation(handler_call_details)
metadata = dict(handler_call_details.invocation_metadata or [])
token = metadata.get("authorization", "")
raw = metadata.get("authorization", "") or ""
if raw.startswith("Bearer "):
raw = raw[7:]
token_bytes = raw.encode("utf-8")
if token.startswith("Bearer "):
token = token[7:]
if token != self.auth_token:
logger.warning(f"Authentication failed for method: {method}")
return self._unauthenticated_handler()
if not hmac.compare_digest(token_bytes, self.auth_token):
logger.warning("Authentication failed for method: %s", method)
original = continuation(handler_call_details)
return _make_abort_handler(original)
return continuation(handler_call_details)
def _unauthenticated_handler(self):
def handler(request, context):
context.set_code(grpc.StatusCode.UNAUTHENTICATED)
context.set_details("Invalid or missing authentication token")
return None
return grpc.unary_unary_rpc_method_handler(handler)
+35 -4
View File
@@ -1,3 +1,7 @@
// Package client provides a docreader gRPC client and the shared TLS / token
// authentication helpers used by both the standalone Go SDK in this package
// and the internal docparser wrapper. Keep all auth/TLS construction here so
// the two call sites cannot drift on security defaults.
package client
import (
@@ -12,25 +16,36 @@ import (
"google.golang.org/grpc/credentials/insecure"
)
// AuthConfig holds the docreader gRPC client TLS / token configuration.
type AuthConfig struct {
TLSEnabled bool
CertFile string
KeyFile string
CAFile string
// ServerName overrides the SNI / certificate-host check on the client.
// When empty, the address passed to Dial is used by Go's TLS stack.
ServerName string
AuthToken string
}
// LoadAuthConfigFromEnv reads docreader gRPC auth knobs from the process
// environment. The caller must pass the result to BuildDialOptions to apply
// them to a gRPC connection.
func LoadAuthConfigFromEnv() *AuthConfig {
return &AuthConfig{
TLSEnabled: os.Getenv("GRPC_TLS_ENABLED") == "true",
CertFile: os.Getenv("GRPC_TLS_CERT"),
KeyFile: os.Getenv("GRPC_TLS_KEY"),
CAFile: os.Getenv("GRPC_TLS_CA"),
ServerName: os.Getenv("GRPC_TLS_SERVER_NAME"),
AuthToken: os.Getenv("GRPC_AUTH_TOKEN"),
}
}
// BuildDialOptions returns the gRPC DialOptions that apply the configured
// transport credentials and per-RPC token. Callers should append their own
// per-call options (load balancer, message size, etc.).
func (c *AuthConfig) BuildDialOptions(maxMsgSize int) ([]grpc.DialOption, error) {
opts := []grpc.DialOption{
grpc.WithDefaultServiceConfig(`{"loadBalancingPolicy":"round_robin"}`),
@@ -52,8 +67,14 @@ func (c *AuthConfig) BuildDialOptions(maxMsgSize int) ([]grpc.DialOption, error)
}
if c.AuthToken != "" {
opts = append(opts, grpc.WithPerRPCCredentials(&tokenAuth{token: c.AuthToken}))
Logger.Printf("INFO: Token authentication enabled for gRPC client")
// Only allow per-RPC tokens to ride a secured channel. This mirrors
// gRPC's own oauth2 credentials behaviour and prevents the bearer
// token from leaking on plaintext connections.
opts = append(opts, grpc.WithPerRPCCredentials(&tokenAuth{
token: c.AuthToken,
requireTLSGuard: c.TLSEnabled,
}))
Logger.Printf("INFO: Token authentication enabled for gRPC client (TLS=%v)", c.TLSEnabled)
}
return opts, nil
@@ -62,6 +83,7 @@ func (c *AuthConfig) BuildDialOptions(maxMsgSize int) ([]grpc.DialOption, error)
func (c *AuthConfig) buildTLSCredentials() (credentials.TransportCredentials, error) {
tlsConfig := &tls.Config{
MinVersion: tls.VersionTLS12,
ServerName: c.ServerName,
}
if c.CAFile != "" {
@@ -76,13 +98,18 @@ func (c *AuthConfig) buildTLSCredentials() (credentials.TransportCredentials, er
tlsConfig.RootCAs = certPool
}
if c.CertFile != "" && c.KeyFile != "" {
switch {
case c.CertFile != "" && c.KeyFile != "":
cert, err := tls.LoadX509KeyPair(c.CertFile, c.KeyFile)
if err != nil {
return nil, fmt.Errorf("failed to load client certificate: %w", err)
}
tlsConfig.Certificates = []tls.Certificate{cert}
Logger.Printf("INFO: mTLS enabled (client certificate loaded)")
case c.CertFile != "" || c.KeyFile != "":
return nil, fmt.Errorf(
"GRPC_TLS_CERT and GRPC_TLS_KEY must be set together for mTLS",
)
}
return credentials.NewTLS(tlsConfig), nil
@@ -90,6 +117,10 @@ func (c *AuthConfig) buildTLSCredentials() (credentials.TransportCredentials, er
type tokenAuth struct {
token string
// requireTLSGuard mirrors AuthConfig.TLSEnabled; we expose it via
// RequireTransportSecurity so gRPC will refuse to send the bearer token
// over an insecure connection when the operator has enabled TLS.
requireTLSGuard bool
}
func (t *tokenAuth) GetRequestMetadata(ctx context.Context, uri ...string) (map[string]string, error) {
@@ -99,5 +130,5 @@ func (t *tokenAuth) GetRequestMetadata(ctx context.Context, uri ...string) (map[
}
func (t *tokenAuth) RequireTransportSecurity() bool {
return false
return t.requireTLSGuard
}
+7 -2
View File
@@ -11,7 +11,7 @@ import grpc
from grpc_health.v1 import health_pb2_grpc
from grpc_health.v1.health import HealthServicer
from docreader.auth import AuthInterceptor, load_tls_credentials
from docreader.auth import AuthInterceptor, TLSConfigError, load_tls_credentials
from docreader import config
from docreader.config import CONFIG
from docreader.parser import Parser
@@ -212,7 +212,12 @@ def main():
health_servicer = HealthServicer()
health_pb2_grpc.add_HealthServicer_to_server(health_servicer, server)
tls_credentials = load_tls_credentials()
try:
tls_credentials = load_tls_credentials()
except TLSConfigError as e:
logger.error("Refusing to start: %s", e)
sys.exit(1)
if tls_credentials:
server.add_secure_port(f"[::]:{CONFIG.grpc_port}", tls_credentials)
logger.info("Server starting on port %d with TLS", CONFIG.grpc_port)
@@ -2,20 +2,17 @@ package docparser
import (
"context"
"crypto/tls"
"crypto/x509"
"fmt"
"os"
"strconv"
"sync"
"time"
docclient "github.com/Tencent/WeKnora/docreader/client"
"github.com/Tencent/WeKnora/docreader/proto"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/Tencent/WeKnora/internal/types"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/resolver"
)
@@ -47,30 +44,19 @@ func NewGRPCDocumentReader(addr string) (*GRPCDocumentReader, error) {
}
func (p *GRPCDocumentReader) connect(addr string) error {
maxMsgSize := getMaxMessageSize()
opts := []grpc.DialOption{
grpc.WithDefaultServiceConfig(`{"loadBalancingPolicy":"round_robin"}`),
grpc.WithDefaultCallOptions(
grpc.MaxCallRecvMsgSize(maxMsgSize),
grpc.MaxCallSendMsgSize(maxMsgSize),
),
authConfig := docclient.LoadAuthConfigFromEnv()
opts, err := authConfig.BuildDialOptions(getMaxMessageSize())
if err != nil {
return fmt.Errorf("failed to build docreader dial options: %w", err)
}
if os.Getenv("GRPC_TLS_ENABLED") == "true" {
creds, err := buildTLSCredentials()
if err != nil {
return fmt.Errorf("failed to build TLS credentials: %w", err)
}
opts = append(opts, grpc.WithTransportCredentials(creds))
if authConfig.TLSEnabled {
logger.Infof(context.Background(), "TLS enabled for docreader gRPC client")
} else {
opts = append(opts, grpc.WithTransportCredentials(insecure.NewCredentials()))
}
if authToken := os.Getenv("GRPC_AUTH_TOKEN"); authToken != "" {
opts = append(opts, grpc.WithPerRPCCredentials(&tokenAuth{token: authToken}))
logger.Infof(context.Background(), "Token authentication enabled for docreader gRPC client")
if authConfig.AuthToken != "" {
logger.Infof(context.Background(),
"Token authentication enabled for docreader gRPC client (TLS=%v)",
authConfig.TLSEnabled,
)
}
resolver.SetDefaultScheme("dns")
@@ -192,48 +178,3 @@ func fromProtoReadResponse(resp *proto.ReadResponse) *types.ReadResult {
return result
}
func buildTLSCredentials() (credentials.TransportCredentials, error) {
tlsConfig := &tls.Config{
MinVersion: tls.VersionTLS12,
}
if caFile := os.Getenv("GRPC_TLS_CA"); caFile != "" {
caCert, err := os.ReadFile(caFile)
if err != nil {
return nil, fmt.Errorf("failed to read CA certificate: %w", err)
}
certPool := x509.NewCertPool()
if !certPool.AppendCertsFromPEM(caCert) {
return nil, fmt.Errorf("failed to parse CA certificate")
}
tlsConfig.RootCAs = certPool
}
if certFile := os.Getenv("GRPC_TLS_CERT"); certFile != "" {
if keyFile := os.Getenv("GRPC_TLS_KEY"); keyFile != "" {
cert, err := tls.LoadX509KeyPair(certFile, keyFile)
if err != nil {
return nil, fmt.Errorf("failed to load client certificate: %w", err)
}
tlsConfig.Certificates = []tls.Certificate{cert}
logger.Infof(context.Background(), "mTLS enabled (client certificate loaded)")
}
}
return credentials.NewTLS(tlsConfig), nil
}
type tokenAuth struct {
token string
}
func (t *tokenAuth) GetRequestMetadata(ctx context.Context, uri ...string) (map[string]string, error) {
return map[string]string{
"authorization": "Bearer " + t.token,
}, nil
}
func (t *tokenAuth) RequireTransportSecurity() bool {
return false
}