一个用户发起请求,穿过网关,进入微服务集群,数据在十几个服务之间流转。这整个链路中,每一跳都在问:“你是谁?”

答案在不同场景下截然不同:

  • 网关问”这个用户是谁?“(JWT 认证)
  • 内网服务问”调用我的是哪个服务?“(Service Account 认证)
  • 数据库问”这个连接来自哪个应用?“(IAM 认证)
┌─────────────────────────────────────────────────────────┐
│                      API Gateway                         │
│              (验证用户 JWT,注入 x-user-id)              │
└─────────────────────────────────────────────────────────┘
                           │
                           ▼
┌─────────────────────────────────────────────────────────┐
│                    服务网格 / 内部网络                      │
│  ┌─────────┐      ┌─────────┐      ┌─────────┐          │
│  │订单服务   │─────▶│库存服务   │─────▶│支付服务   │          │
│  │(Order)  │ mTLS  │(Stock)  │ mTLS  │(Pay)    │          │
│  └─────────┘      └─────────┘      └─────────┘          │
│       │                  │                  │            │
│       └──────────────────┴──────────────────┘            │
│                    服务间调用                              │
│              (验证服务身份 + 可选用户身份透传)              │
└─────────────────────────────────────────────────────────┘

本文串联起 JWT、Service Account、mTLS 和 IAM,解释认证在不同层次如何工作。

JWT:守住外部流量的第一道门

假设你开了一家公司,用户从外网访问你的服务。如何证明”这个请求确实来自合法用户,不是黑客伪造的”?

这就需要 JWT (JSON Web Token)

JWT 工作原理

JWT 本质上是一个加密签名的 JSON 字符串,长这样:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiIxMjM0NTYiLCJleHAiOjE3MDk5OTk5OTl9.abc123xyz

它由三部分组成(用 . 分隔):

  • Header:声明加密算法(如 HS256)
  • Payload:存放用户信息(userId、过期时间等)
  • Signature:用私钥对前两部分签名,防止篡改

JWT Payload 示例(解码后的 JSON):

{
  "userId": "123456",
  "username": "alice",
  "roles": ["user", "admin"],
  "iat": 1709900000,
  "exp": 1709999999
}

关键字段:

  • userId:用户唯一标识
  • roles:用户角色(用于权限判断)
  • iat (issued at):签发时间
  • exp (expiration):过期时间

JWT 认证流程

sequenceDiagram
    participant User as 用户
    participant Gateway as 网关 (API Gateway)
    participant AuthServer as 认证服务
    participant Backend as 后端服务

    User->>AuthServer: 1. 登录(账号密码)
    AuthServer-->>User: 2. 返回 JWT Token
    User->>Gateway: 3. 请求业务 API(携带 JWT)
    Gateway->>Gateway: 4. 验证 JWT 签名与过期时间
    Gateway->>Backend: 5. 转发请求(附带用户信息)
    Backend-->>User: 6. 返回数据

关键点

  • JWT 自包含:后端服务拿到 Token 后,用公钥验签即可确认真伪,无需查数据库
  • 网关集中校验:所有对外流量在网关层统一拦截,验证 JWT 有效性

认证职责分工

网关的职责(集中鉴权):

  1. 验证 JWT 签名和过期时间
  2. 拒绝无效或过期的 Token
  3. 提取用户信息并注入到请求 Header(如 X-User-Id: 123456
  4. 将清洗后的请求转发给内网服务

后端服务的职责(业务鉴权):

  1. 从 Header 中读取用户信息(req.headers['x-user-id']
  2. 根据业务逻辑判断权限(如”用户只能修改自己的订单”)
  3. 不需要再验证 Token(网关已验证过)

为什么要分工?

  • 性能:网关一次验证,后端服务直接复用,避免重复解密
  • 安全:内网服务不暴露公钥,降低密钥泄露风险
  • 职责单一:网关负责”这是合法用户”,服务负责”这个用户能做什么”

Node.js 验证 JWT 示例

// 网关中间件:验证 JWT
function authMiddleware(req, res, next) {
  const token = req.headers.authorization?.split(' ')[1];
  if (!token) return res.status(401).json({ error: 'No token provided' });
  
  try {
    const decoded = jwt.verify(token, process.env.JWT_PUBLIC_KEY);
    req.user = decoded; // { userId: '123456', roles: ['user', 'admin'], exp: ... }
    
    // 注入用户信息到 Header(转发给后端服务)
    req.headers['x-user-id'] = decoded.userId;
    req.headers['x-user-roles'] = decoded.roles.join(',');
    next();
  } catch (err) {
    if (err.name === 'TokenExpiredError') {
      return res.status(401).json({ error: 'Token expired' });
    }
    return res.status(403).json({ error: 'Invalid token' });
  }
}

JWT 过期处理

网关拒绝过期 Token 后返回 401,前端有两种处理方式:

  1. Refresh Token 机制:用长期有效的 Refresh Token 向认证服务换取新的 JWT
  2. 重新登录:引导用户重新登录获取新 Token
// 前端处理 Token 过期
axios.interceptors.response.use(
  response => response,
  async error => {
    if (error.response?.status === 401 && error.response.data.error === 'Token expired') {
      const newToken = await refreshAccessToken(); // 用 Refresh Token 换新 JWT
      error.config.headers.Authorization = `Bearer ${newToken}`;
      return axios.request(error.config); // 重试原请求
    }
    return Promise.reject(error);
  }
);

后端服务如何识别用户

// 后端服务代码:从 Header 读取用户信息
app.put('/orders/:orderId', async (req, res) => {
  const userId = req.headers['x-user-id']; // 网关注入的用户 ID
  const orderId = req.params.orderId;
  
  // 业务鉴权:只能修改自己的订单
  const order = await db.query('SELECT * FROM orders WHERE id = $1', [orderId]);
  if (order.userId !== userId) {
    return res.status(403).json({ error: 'Forbidden: not your order' });
  }
  
  // 执行修改逻辑
  await updateOrder(orderId, req.body);
  res.json({ success: true });
});

内网服务认证:Service Account 登场

网关守住了外部流量,但内网呢?

服务 A 调用服务 B 时,如何证明”我是服务 A,不是黑客渗透进来的伪装者”?这就需要 Service Account

通俗类比:公司大楼的安全系统

  • 网关 + JWT(对外):一楼大堂的保安,外部访客带着访客证(JWT)进门,保安检查没过期、没造假,就放人进去
  • Service Account(对内):公司内部员工的专属工作牌。员工 A(服务 A)要去档案室(服务 B)拿资料,直接刷自己的工作牌。档案室门禁认出”这是员工 A 的工作牌”,且”员工 A 有权限进档案室”,门就开了

机制一:基于内部 Token 流转

类似 JWT,但颁发对象从”用户”变成了”服务”。多用于 OAuth2 客户端凭证模式(Client Credentials)。

认证流程

  1. 服务 A 启动时,拿着系统分配的 Client IDClient Secret,向内部授权中心证明身份
  2. 授权中心核实无误后,发给服务 A 一个内部 Token(标注持有者是 Service A)
  3. 服务 A 调用服务 B 时,在 HTTP Header 里带上这个 Token
  4. 服务 B 收到请求,用授权中心的公钥验证 Token 真伪,确认是服务 A 后放行

Node.js 实现示例

// 服务 A:获取并使用内部 Token 调用服务 B
class ServiceAClient {
  async getToken() {
    if (this.token && Date.now() < this.tokenExpiry) {
      return this.token; // 复用未过期的 Token
    }
    
    // 向授权中心申请 Token
    const response = await axios.post('http://auth-server/token', {
      client_id: process.env.CLIENT_ID,
      client_secret: process.env.CLIENT_SECRET,
      grant_type: 'client_credentials'
    });
    
    this.token = response.data.access_token;
    this.tokenExpiry = Date.now() + response.data.expires_in * 1000;
    return this.token;
  }
  
  async callServiceB() {
    const token = await this.getToken();
    return axios.get('http://service-b/api/data', {
      headers: { Authorization: `Bearer ${token}` }
    });
  }
}
 
// 服务 B:验证内部 Token
function verifyServiceToken(req, res, next) {
  const token = req.headers.authorization?.split(' ')[1];
  try {
    const decoded = jwt.verify(token, process.env.AUTH_SERVER_PUBLIC_KEY);
    req.caller = decoded.client_id; // "service-a"
    next();
  } catch (err) {
    return res.status(403).json({ error: 'Unauthorized service' });
  }
}

用户身份在内网透传

服务 B 除了验证”调用者是服务 A”,有时还需要知道”最初的用户是谁”。

// 服务 A:调用服务 B 时透传用户信息
async function callServiceB(userId) {
  const serviceToken = await this.getToken(); // 服务身份
  return axios.get('http://service-b/api/data', {
    headers: { 
      'Authorization': `Bearer ${serviceToken}`,
      'X-User-Id': userId  // 透传用户身份
    }
  });
}
 
// 服务 B:同时验证服务身份和用户身份
function verifyAndExtractUser(req, res, next) {
  const token = req.headers.authorization?.split(' ')[1];
  const decoded = jwt.verify(token, process.env.AUTH_SERVER_PUBLIC_KEY);
  req.caller = decoded.client_id; // "service-a"
  req.userId = req.headers['x-user-id']; // 原始用户
  next();
}

机制二:基于 mTLS 双向证书认证

如果你在用 Kubernetes + Service Mesh(Istio/Linkerd),大概率用的是这种方式。特点是业务代码完全无感知,一切在网络底层自动完成。

Sidecar 模式:你的贴身保镖

在讲 mTLS 前,先理解 Sidecar(边车) 这个核心概念。Sidecar 是一个与业务容器并排运行的代理容器(通常是 Envoy),就像摩托车的边车一样紧贴主车但独立运行。它拦截所有进出业务容器的网络流量,自动处理认证、加密、监控、限流等。最大优势是业务代码零侵入——不用改一行代码就能获得 mTLS、可观测性等能力,且所有服务的网络策略在 Sidecar 层统一实施,无论你用 Node.js、Java 还是 Go。

在 K8s 中的实际形态

apiVersion: v1
kind: Pod
spec:
  containers:
  - name: app  # 你的业务代码
    image: my-nodejs-app:v1
    ports:
    - containerPort: 3000
    
  - name: envoy-sidecar  # Istio 自动注入的代理
    image: envoy:v1.29
    # 拦截所有进出 app 容器的网络流量

业务代码的视角

// 你的 Node.js 代码只管发普通 HTTP 请求
async function fetchDataFromServiceB() {
  // 你以为这是明文 HTTP,实际上 Sidecar 已加密成 mTLS
  const response = await axios.get('http://service-b:8080/api/data');
  return response.data;
}

实际发生了什么

  1. 你的代码发出 http://service-b:8080 请求
  2. Sidecar 拦截这个请求,发现目标是 service-b
  3. Sidecar 自动升级为 mTLS 连接,与对方 Sidecar 互换证书
  4. 证书验证通过后,加密传输数据
  5. 对方 Sidecar 解密后,把普通 HTTP 请求转给服务 B 的业务代码

mTLS 握手与证书校验详细流程

sequenceDiagram
    autonumber
    
    box LightGray K8s 控制面 (基础设施)
        participant K8sAPI as K8s API Server
        participant MeshCA as 内部 CA (如 Istiod)
    end
    
    box LightBlue 客户端 Pod A (Service A)
        participant AppA as 业务代码 A
        participant EnvoyA as 代理 (Sidecar)
    end
    
    box LightGreen 服务端 Pod B (Service B)
        participant EnvoyB as 代理 (Sidecar)
        participant AppB as 业务代码 B
    end

    rect rgb(245, 245, 245)
    Note over K8sAPI, AppB: 阶段一:自动发证 (Pod 启动时瞬间完成,全自动)
    EnvoyA->>MeshCA: 提交证书申请 (CSR) + 自带的 K8s Service Account Token
    MeshCA->>K8sAPI: 核实这个 Token 确实是 Service A 的身份吗?
    K8sAPI-->>MeshCA: Token 有效,是本集群的 Service A
    MeshCA-->>EnvoyA: 签发专属 TLS 证书 (内含 Service A 身份标识)
    Note over MeshCA, EnvoyB: (Envoy B 启动时,也会走同样的流程拿到 Service B 的证书)
    end

    rect rgb(235, 245, 255)
    Note over K8sAPI, AppB: 阶段二:业务调用 (对业务代码完全透明)
    AppA->>EnvoyA: 发起普通的明文 HTTP 请求 (例如 GET /data)
    Note over AppA, EnvoyA: 业务代码 A 以为自己直接连了 Service B
    
    EnvoyA->>EnvoyB: 拦截请求,发起 mTLS 握手
    Note over EnvoyA, EnvoyB: 互相交换并校验对方的证书。身份确认无误!
    
    EnvoyA->>EnvoyB: 发送加密后的请求数据 (全链路加密)
    EnvoyB->>AppB: 解密数据,把原本的明文 HTTP 请求转交给业务 B
    Note over EnvoyB, AppB: 业务代码 B 以为是 Service A 直接发来的普通请求
    
    AppB-->>EnvoyB: 返回普通 HTTP 响应
    EnvoyB-->>EnvoyA: 加密并传回响应数据
    EnvoyA-->>AppA: 解密并把普通 HTTP 响应交给业务 A
    end

核心原理

  • 业务代码(App A 和 B):完全是个”傻白甜”,只管发 HTTP 请求和响应,不知道 mTLS 是什么
  • 基础设施(K8s API 和 CA):扮演”公安局”,审查资格并定期颁发短期证书
  • 代理(Envoy Sidecar):扮演”贴身保镖”,办证、拦截请求、互查身份、加密/解密,全程包揽

证书如何自动生成?

在 Service Mesh 中,证书完全自动生成、自动分发、自动轮换,你不需要手动敲 openssl 命令。

自动化流程(以 K8s + Istio 为例)

  1. 制证中心:安装 Istio 时,控制面 Istiod 内置了根证书颁发机构(Root CA)
  2. 自动申请:服务启动时,K8s 自动在旁边塞一个 Sidecar 代理(Envoy)。代理启动后在内存里生成公钥/私钥对,拿着自己的公钥和 K8s ServiceAccount Token,向内部 CA 发起证书签名请求(CSR)
  3. 发证:CA 向 K8s 核实身份无误后,用根证书给服务的公钥签字,生成专属 TLS 证书并下发
  4. 自动续期:内部证书有效期很短(通常 12 小时或 1 小时),代理会在过期前自动重新申请新证书

作为开发者,你只需要做声明式配置

# 1. 声明身份
apiVersion: v1
kind: ServiceAccount
metadata:
  name: service-a-account
  namespace: my-biz
 
---
# 2. 绑定服务
apiVersion: apps/v1
kind: Deployment
spec:
  template:
    spec:
      serviceAccountName: service-a-account  # 绑定身份
      containers:
      - name: my-service-a
        image: my-image

剩下的生成私钥、申请证书、分发、续签、网络握手校验,全由系统包揽。

K8s Service Account vs GCP Service Account

很多开发者对各平台的 “Service Account” 感到混淆。它们在概念和目的上一样(都是给机器/服务发”身份证”),但在底层实现和管辖范围上完全不同。

核心差异

特性K8s Service AccountGCP Service Account
管辖范围K8s 集群内部整个 GCP 生态
底层凭证mTLS 证书 / JWT TokenOAuth/OIDC Token
认证位置Sidecar 代理(网络层)云网关 + SDK(应用层)
开发体验YAML 配置云控制台 + gcloud 命令

K8s 内部调用:基于服务名

在 K8s 中,服务 A 调服务 B,代码里直接请求短域名(Service Name)

// Node.js 代码示例(在 K8s Pod 中运行)
async function callServiceB() {
  // 你只需要用服务名,K8s DNS 自动解析
  const response = await axios.get('http://service-b:8080/api/data');
  return response.data;
}
// 如果开启了 mTLS(Service Mesh),Sidecar 自动处理证书认证

底层发生了什么

  • K8s 内置的 CoreDNS 把 service-b 解析成集群内部虚拟 IP
  • Sidecar 代理(Envoy)拦截请求,查清楚 service-b 对应的 Service Account
  • 自动发起 mTLS 握手(互亮证书),最后把数据发过去

GCP Cloud Run 调用:基于完整域名

Cloud Run 是 Serverless 服务,不像 K8s 那样在封闭内网里靠短名字互相叫。它在公网,所以直接通过 域名+token 方式访问

GCP 的”魔法”:你没传 Token 为什么能连数据库?

在 GCP 上,你的 Cloud Run 服务连外网数据库,两边绑定了同一个 Service Account,代码里没写任何获取和传递 Token 的逻辑,为什么能成功?

这是因为 GCP 提供了两大”隐形助手”:Metadata Server(元数据服务器)ADC(Application Default Credentials)

sequenceDiagram
    autonumber

    box LightYellow Cloud Run 运行环境 (你的服务)
        participant App as 你的业务代码
        participant SDK as Google SDK / DB Proxy<br/>(如 Cloud SQL Auth Proxy)
        participant Meta as 本地 Metadata Server<br/>(GCP 自动注入)
    end

    box LightBlue Google Cloud 基础设施
        participant IAM as GCP IAM 认证中心
    end

    box LightGreen 目标环境
        participant DB as 数据库服务<br/>(开启 IAM 认证)
    end

    rect rgb(245, 245, 245)
    Note over App, DB: 阶段一:自动获取身份凭证 (ADC 机制)
    App->>SDK: 请求连接数据库 (未提供账号密码)
    Note over App, SDK: 业务代码只管连,没传 Token
    
    SDK->>Meta: "我没拿到密码,请给我当前环境的默认 Token"
    Meta->>IAM: 请求生成该 Service Account 的临时 Token
    IAM-->>Meta: 返回一个短期的 OAuth/OIDC Token
    Meta-->>SDK: 返回 Token
    Note over SDK, Meta: 此时,SDK 手里已经拿到了"临时通行证"
    end

    rect rgb(235, 245, 255)
    Note over App, DB: 阶段二:携带凭证建立连接
    SDK->>DB: 发起连接请求 (偷偷将 Token 作为密码/凭证带上)
    DB->>IAM: 校验这个 Token 是伪造的吗?有权限连我吗?
    IAM-->>DB: Token 真实有效,且该 Service Account 有访问权限
    DB-->>SDK: 认证通过,连接建立!
    SDK-->>App: 数据库连接成功,可以开始查询了
    
    App->>DB: 执行 SQL (SELECT * FROM table)
    DB-->>App: 返回数据结果
    end

核心原理

  • Metadata Server:运行在 169.254.169.254(GCP 的魔法 IP)。容器启动时,GCP 已告诉它”这台机器绑定了 Service Account A,谁来找你要 Token,你就给谁”
  • 官方 SDK / Proxy:如 Cloud SQL Auth Proxy 或支持 IAM 登录的数据库驱动,内置寻找凭证的逻辑。发现你没提供账号密码,自动向 Metadata Server 请求 Token
  • 自动注入并鉴权:Metadata Server 向 IAM 中心申请临时 Token(有效期 1 小时),返回给 SDK。SDK 把 Token 自动塞到网络请求里(或作为数据库的临时登录密码)发给数据库

Node.js 连接 Cloud SQL (PostgreSQL) 示例

const { Connector } = require('@google-cloud/cloud-sql-connector');
const { Pool } = require('pg');
 
async function connectToDatabase() {
  const connector = new Connector();
  
  const clientOpts = await connector.getOptions({
    instanceConnectionName: 'my-project:us-central1:my-postgres-db',
    authType: 'IAM'
  });
  
  const pool = new Pool({
    ...clientOpts,
    user: 'my-service-account@my-project.iam',
    database: 'my_database'
    // 注意:没有 password 字段!
  });
  
  const result = await pool.query('SELECT * FROM users');
  return result.rows;
}

结论:并非没有 Token 传递,而是 GCP 的底层环境和官方 SDK 帮你把 “申请 Token → 携带 Token” 的脏活累活全给干了。

GCP 配置:Cloud Run 连接 Cloud SQL

要让 Cloud Run 通过 Service Account 连接数据库,需要三步配置:

1. 创建 Service Account 并授权

# 创建 Service Account
gcloud iam service-accounts create my-app-sa \
  --display-name "My App Service Account"
 
# 授予数据库访问权限
gcloud projects add-iam-policy-binding my-project \
  --member="serviceAccount:my-app-sa@my-project.iam.gserviceaccount.com" \
  --role="roles/cloudsql.client"

2. 在 Cloud SQL (PostgreSQL) 中添加 IAM 用户

# 创建 IAM 用户
gcloud sql users create my-app-sa@my-project.iam \
  --instance=my-postgres-db \
  --type=CLOUD_IAM_SERVICE_ACCOUNT
 
# 或在 PostgreSQL 中手动创建
# CREATE ROLE "my-app-sa@my-project" WITH LOGIN;
# GRANT ALL PRIVILEGES ON DATABASE my_database TO "my-app-sa@my-project";

3. 部署 Cloud Run 时绑定 Service Account

gcloud run deploy my-service \
  --image gcr.io/my-project/my-image \
  --service-account my-app-sa@my-project.iam.gserviceaccount.com \
  --add-cloudsql-instances my-project:us-central1:my-postgres-db

完成!你的 Cloud Run 服务现在可以通过 IAM 认证连接 PostgreSQL 数据库,无需密码。

服务间访问控制:只接受特定服务的请求

在微服务架构中,经常需要限制服务 B 只能被服务 A 调用,拒绝其他服务或外部流量。

K8s 中的实现:使用 AuthorizationPolicy

# 使用 Istio AuthorizationPolicy 限制访问
apiVersion: security.istio.io/v1
kind: AuthorizationPolicy
metadata:
  name: service-b-only-from-a
  namespace: my-namespace
spec:
  selector:
    matchLabels:
      app: service-b
  action: ALLOW
  rules:
  - from:
    - source:
        principals:
        - "cluster.local/ns/my-namespace/sa/service-a-account"
    to:
    - operation:
        methods: ["GET", "POST"]

效果

  • 只有使用 service-a-account 这个 Service Account 的 Pod 可以调用 service-b
  • 其他服务尝试调用会收到 403 Forbidden
  • 完全在网络层强制执行,业务代码无需改动

GCP Cloud Run 中的实现:使用 IAM Policy

方式 1:通过 gcloud 命令配置

# 为 service-b 配置 IAM 策略,只允许 service-a 的 SA 调用
gcloud run services add-iam-policy-binding service-b \
  --member="serviceAccount:service-a-sa@my-project.iam.gserviceaccount.com" \
  --role="roles/run.invoker" \
  --region=us-central1
 
# 移除默认的 allUsers 权限(如果之前是公开访问)
gcloud run services remove-iam-policy-binding service-b \
  --member="allUsers" \
  --role="roles/run.invoker" \
  --region=us-central1

方式 2:在代码中验证调用者身份

const { OAuth2Client } = require('google-auth-library');
 
const ALLOWED_SERVICE_ACCOUNT = 'service-a-sa@my-project.iam.gserviceaccount.com';
 
async function verifyCallerIdentity(req, res, next) {
  const authHeader = req.headers.authorization;
  if (!authHeader?.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'No token provided' });
  }
  
  const token = authHeader.split(' ')[1];
  
  try {
    const client = new OAuth2Client();
    const ticket = await client.verifyIdToken({
      idToken: token,
      audience: process.env.SERVICE_URL
    });
    
    const callerEmail = ticket.getPayload().email;
    
    if (callerEmail !== ALLOWED_SERVICE_ACCOUNT) {
      return res.status(403).json({ 
        error: `Forbidden: only ${ALLOWED_SERVICE_ACCOUNT} can access` 
      });
    }
    
    req.caller = callerEmail;
    next();
  } catch (err) {
    return res.status(403).json({ error: 'Invalid token' });
  }
}
 
// 使用中间件保护 API
app.get('/api/data', verifyCallerIdentity, (req, res) => {
  res.json({ message: 'Success', caller: req.caller });
});

对比

  • IAM Policy:在 GCP 网关层拦截,连请求都进不来,性能最佳
  • 代码验证:灵活度高,可以实现更复杂的逻辑(如按时间段、请求频率限制)

总结

安全要点

  • JWT 的 Payload 是 Base64 可解码,不要存敏感信息(如密码)
  • 内网服务必须确保 x-user-id 等 Header 来自网关,不能直接信任外部请求
  • mTLS 证书有效期短(1-12 小时),系统自动续期