一个用户发起请求,穿过网关,进入微服务集群,数据在十几个服务之间流转。这整个链路中,每一跳都在问:“你是谁?”
答案在不同场景下截然不同:
- 网关问”这个用户是谁?“(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 有效性
认证职责分工
网关的职责(集中鉴权):
- 验证 JWT 签名和过期时间
- 拒绝无效或过期的 Token
- 提取用户信息并注入到请求 Header(如
X-User-Id: 123456) - 将清洗后的请求转发给内网服务
后端服务的职责(业务鉴权):
- 从 Header 中读取用户信息(
req.headers['x-user-id']) - 根据业务逻辑判断权限(如”用户只能修改自己的订单”)
- 不需要再验证 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,前端有两种处理方式:
- Refresh Token 机制:用长期有效的 Refresh Token 向认证服务换取新的 JWT
- 重新登录:引导用户重新登录获取新 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)。
认证流程:
- 服务 A 启动时,拿着系统分配的
Client ID和Client Secret,向内部授权中心证明身份 - 授权中心核实无误后,发给服务 A 一个内部 Token(标注持有者是 Service A)
- 服务 A 调用服务 B 时,在 HTTP Header 里带上这个 Token
- 服务 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;
}实际发生了什么:
- 你的代码发出
http://service-b:8080请求 - Sidecar 拦截这个请求,发现目标是
service-b - Sidecar 自动升级为 mTLS 连接,与对方 Sidecar 互换证书
- 证书验证通过后,加密传输数据
- 对方 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 为例):
- 制证中心:安装 Istio 时,控制面 Istiod 内置了根证书颁发机构(Root CA)
- 自动申请:服务启动时,K8s 自动在旁边塞一个 Sidecar 代理(Envoy)。代理启动后在内存里生成公钥/私钥对,拿着自己的公钥和 K8s ServiceAccount Token,向内部 CA 发起证书签名请求(CSR)
- 发证:CA 向 K8s 核实身份无误后,用根证书给服务的公钥签字,生成专属 TLS 证书并下发
- 自动续期:内部证书有效期很短(通常 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 Account | GCP Service Account |
|---|---|---|
| 管辖范围 | K8s 集群内部 | 整个 GCP 生态 |
| 底层凭证 | mTLS 证书 / JWT Token | OAuth/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 小时),系统自动续期