我们团队在GKE上运行着数十个微服务,统一身份认证始终是个绕不开的痛点。最初各个服务自己维护用户体系,后来引入了统一的身份提供商(IdP),但每个服务都去直接对接OIDC协议,代码重复、配置繁杂,安全策略也难以统一。一个服务出现OIDC库的漏洞,就得逐个排查升级。这种架构在团队扩张后,维护成本变得无法接受。
我们的目标很明确:构建一个独立的、轻量级的认证网关服务。它作为唯一的OIDC客户端(Relying Party),处理与IdP的所有交互,完成后为用户建立一个内部会话,下游的业务服务只需验证这个内部会話即可。
技术选型上,Node.js生态是首选,因为它启动快、资源占用低,非常适合做网关这类IO密集型应用。而在众多框架中,我们放弃了Express,选择了Fastify。原因无他,性能、开箱即用的schema验证、以及对TypeScript的优秀支持,都使其成为构建一个严肃的、生产级服务的更优选择。
这篇记录将复盘我们如何使用Fastify、TypeScript和openid-client
库,在GKE环境中从零构建这个认证服务,并重点讨论在容器化环境中处理会话、安全配置以及代码规范的实践。
第一步:奠定坚实的项目基础
一个安全攸关的服务,项目初始化阶段的规范性至关重要。这不仅是关于代码风格,更是关于可维护性和减少低级错误。
我们使用pnpm
作为包管理器,并立即引入TypeScript、ESLint和Prettier。
package.json
核心依赖:
{
"name": "auth-gateway",
"version": "1.0.0",
"main": "dist/server.js",
"scripts": {
"build": "tsc",
"start": "node dist/server.js",
"dev": "ts-node-dev --respawn --transpile-only src/server.ts",
"lint": "eslint . --ext .ts",
"lint:fix": "eslint . --ext .ts --fix"
},
"dependencies": {
"@fastify/cookie": "^9.1.0",
"@fastify/session": "^10.5.0",
"connect-redis": "^7.1.0",
"dotenv": "^16.3.1",
"fastify": "^4.23.2",
"ioredis": "^5.3.2",
"openid-client": "^5.6.1",
"pino-pretty": "^10.2.3"
},
"devDependencies": {
"@types/node": "^20.8.7",
"@typescript-eslint/eslint-plugin": "^6.8.0",
"@typescript-eslint/parser": "^6.8.0",
"eslint": "^8.51.0",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-prettier": "^5.0.1",
"prettier": "^3.0.3",
"ts-node-dev": "^2.0.0",
"typescript": "^5.2.2"
}
}
tsconfig.json
的关键配置:
这里的strict
模式是强制性的。对于认证服务,任何类型不明确的地方都是潜在的安全隐患。
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"rootDir": "./src",
"outDir": "./dist",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true
},
"include": ["src/**/*"]
}
代码规范我们使用ESLint配合Prettier,规则非常严格。例如,禁止使用any
类型,强制所有分支都有返回值等。
第二步:配置管理与OIDC客户端初始化
在GKE这类环境中,配置必须通过环境变量注入,任何硬编码都是不可接受的。我们创建一个专门的配置模块来加载和校验环境变量。
src/config.ts
import 'dotenv/config'; // 用于本地开发加载.env文件
function getEnv(key: string): string {
const value = process.env[key];
if (!value) {
throw new Error(`Missing environment variable: ${key}`);
}
return value;
}
export const config = {
// 服务配置
server: {
host: process.env.HOST || '0.0.0.0',
port: parseInt(process.env.PORT || '8080', 10),
logLevel: process.env.LOG_LEVEL || 'info',
},
// OIDC提供商配置
oidc: {
issuer: getEnv('OIDC_ISSUER_URL'),
clientId: getEnv('OIDC_CLIENT_ID'),
clientSecret: getEnv('OIDC_CLIENT_SECRET'),
redirectUri: getEnv('OIDC_REDIRECT_URI'),
// 通常是 openid profile email
scope: process.env.OIDC_SCOPE || 'openid profile email',
},
// Redis会话存储配置
redis: {
host: getEnv('REDIS_HOST'),
port: parseInt(process.env.REDIS_PORT || '6379', 10),
},
// Cookie安全配置
cookie: {
secret: getEnv('COOKIE_SECRET'),
// 在生产环境中必须为 true
secure: process.env.NODE_ENV === 'production',
// 限制在同站发送
sameSite: 'lax' as const,
},
};
这里的getEnv
函数确保了服务在启动时如果缺少关键配置会立刻失败,而不是在运行时才报错。
接下来,初始化openid-client
。一个常见的错误是在每次请求中都去初始化客户端或重新发现(discover)IdP的元数据,这会带来不必要的网络开销和延迟。正确的做法是在服务启动时初始化一次,并作为单例使用。我们通过Fastify的decorate
机制将其封装成插件。
src/plugins/oidcClient.ts
import { FastifyInstance } from 'fastify';
import fp from 'fastify-plugin';
import { Issuer, Client, custom } from 'openid-client';
import { config } from '../config';
// 扩展FastifyInstance类型,让TypeScript知道oidcClient的存在
declare module 'fastify' {
interface FastifyInstance {
oidcClient: Client;
}
}
async function oidcClientPlugin(fastify: FastifyInstance) {
// 在真实项目中,你可能需要配置自定义的HTTP Agent来处理代理或超时
// custom.setHttpOptionsDefaults({ timeout: 5000 });
try {
const oidcIssuer = await Issuer.discover(config.oidc.issuer);
const client = new oidcIssuer.Client({
client_id: config.oidc.clientId,
client_secret: config.oidc.clientSecret,
redirect_uris: [config.oidc.redirectUri],
response_types: ['code'],
});
// 将初始化好的客户端实例装饰到Fastify实例上
fastify.decorate('oidcClient', client);
fastify.log.info('OIDC client initialized successfully');
} catch (error) {
fastify.log.error({ err: error }, 'Failed to initialize OIDC client');
// 在启动阶段失败,直接退出进程
process.exit(1);
}
}
export default fp(oidcClientPlugin);
第三步:会话管理 - Stateful vs. Stateless 的抉择
在Kubernetes环境中,服务实例是“易逝”的,可能会随时被调度、重启或扩缩容。这使得会话管理策略的选择尤为重要。
Stateless (无状态): 将OIDC的
id_token
或一个自定义的JWT直接返回给前端,前端在后续请求中通过Authorization
头携带。- 优点: 扩展性好,服务端无需存储任何会话信息。
- 缺点: 令牌一旦签发,在过期前无法主动撤销。如果用户登出或权限变更,令牌依然有效。对于安全要求高的场景,这是个大问题。令牌通常较大,每次请求都携带会增加网络开销。
Stateful (有状态): 服务端通过一个随机生成的
session_id
来维护会话,并将此ID存储在客户端的HttpOnly
Cookie中。会话数据(包括OIDC令牌和用户信息)存储在外部存储中,如Redis。- 优点: 可以随时在服务端撤销会话(删除Redis中的记录),安全性更高。Cookie对前端透明,无需前端额外处理。
- 缺点: 引入了对外部存储(Redis)的依赖,架构上增加了一个故障点。
在我们的场景中,强制用户登出和及时响应权限变更的需求是刚性的。因此,我们选择了有状态的方案,使用Redis作为会话存储。@fastify/session
和connect-redis
让这个过程变得非常简单。
src/server.ts
中注册会话插件
import fastify from 'fastify';
import cookie from '@fastify/cookie';
import session from '@fastify/session';
import RedisStore from 'connect-redis';
import Redis from 'ioredis';
// ... 其他 imports
import { config } from './config';
async function buildServer() {
const app = fastify({
logger: {
level: config.server.logLevel,
transport: config.server.logLevel === 'info'
? { target: 'pino-pretty' }
: undefined,
},
});
// 注册cookie插件
await app.register(cookie);
// 初始化Redis客户端
const redisClient = new Redis({
host: config.redis.host,
port: config.redis.port,
});
// 注册session插件
await app.register(session, {
secret: config.cookie.secret,
store: new RedisStore({ client: redisClient }),
cookie: {
secure: config.cookie.secure,
httpOnly: true,
sameSite: config.cookie.sameSite,
maxAge: 24 * 60 * 60 * 1000, // 24 hours
},
});
// ... 注册其他插件和路由
return app;
}
这里的cookie
配置至关重要。secure: true
确保Cookie只在HTTPS下传输,httpOnly: true
防止客户端脚本读取Cookie,sameSite: 'lax'
提供了对CSRF攻击的基础防护。
第四步:实现OIDC核心认证流程
整个流程可以被一个Mermaid图清晰地表示出来:
sequenceDiagram participant User as 用户浏览器 participant App as 业务应用前端 participant Gateway as Fastify认证网关 participant IdP as OIDC身份提供商 participant Redis User->>App: 访问受保护页面 App->>Gateway: 发起请求 (无会话) Gateway->>Gateway: 检查会话, 发现不存在 Note over Gateway: 生成 state, nonce, code_verifier Gateway->>Redis: 存储 state, nonce, code_verifier 到session Gateway-->>User: 302重定向至IdP认证URL (含code_challenge) User->>IdP: 输入凭证进行登录 IdP-->>User: 302重定向至网关的回调URL (含code, state) User->>Gateway: 访问 /auth/callback?code=...&state=... Gateway->>Redis: 获取session中的 state, nonce, code_verifier Note over Gateway: 校验state防止CSRF Gateway->>IdP: 发送code, code_verifier, client credentials IdP->>Gateway: 返回 id_token, access_token, refresh_token Note over Gateway: 校验 id_token (issuer, audience, nonce, etc.) Gateway->>Redis: 将tokens和用户信息存入session Gateway-->>User: 302重定向至最初访问的应用页面 User->>App: 再次访问受保护页面 (携带会话Cookie) App->>Gateway: 发起请求 (有会话) Gateway->>Redis: 验证会话有效 Gateway-->>App: 返回受保护资源
1. 登录路由 (/auth/login
)
此路由负责启动整个OIDC流程。
src/routes/auth.ts
import { FastifyInstance } from 'fastify';
import { generators } from 'openid-client';
export async function authRoutes(fastify: FastifyInstance) {
fastify.get('/login', async (request, reply) => {
// PKCE (Proof Key for Code Exchange) 是现代OIDC流程的强制要求
const code_verifier = generators.codeVerifier();
const code_challenge = generators.codeChallenge(code_verifier);
const nonce = generators.nonce();
const state = generators.state();
// 将验证需要的数据存入会话
request.session.set('oidc', {
code_verifier,
nonce,
state,
// 存储用户登录后应该被重定向到的原始URL
returnTo: request.query.returnTo || '/',
});
const authorizationUrl = fastify.oidcClient.authorizationUrl({
scope: 'openid profile email',
code_challenge,
code_challenge_method: 'S256',
nonce,
state,
});
return reply.redirect(authorizationUrl);
});
}
这里的坑在于state
和nonce
的正确使用。state
用于防止CSRF攻击,确保回调请求是由我们的应用发起的。nonce
用于防止重放攻击,确保收到的id_token
是针对本次登录请求签发的。PKCE则防止了授权码被截获后盗用的风险。
2. 回调路由 (/auth/callback
)
这是最核心也最容易出错的部分。它接收来自IdP的授权码,并将其交换为令牌。
// 继续在 src/routes/auth.ts 中
export async function authRoutes(fastify: FastifyInstance) {
// ... login route
fastify.get('/callback', async (request, reply) => {
const sessionData = request.session.get('oidc');
if (!sessionData) {
// 如果会话中没有OIDC数据,说明流程异常,返回错误
return reply.code(400).send({ error: 'Invalid OIDC session state' });
}
try {
// 使用 openid-client 提供的 params 方法来解析请求参数
const params = fastify.oidcClient.callbackParams(request.raw);
// 执行回调,交换令牌,并进行所有必要的校验
const tokenSet = await fastify.oidcClient.callback(
config.oidc.redirectUri,
params,
{
// 这里的检查是 `openid-client` 自动执行的
code_verifier: sessionData.code_verifier,
state: sessionData.state,
nonce: sessionData.nonce,
}
);
const claims = tokenSet.claims();
// 清理临时的OIDC会话数据
request.session.set('oidc', undefined);
// 存储用户信息和令牌到主会话中
request.session.set('user', {
id: claims.sub,
email: claims.email,
name: claims.name,
});
request.session.set('tokens', {
id_token: tokenSet.id_token,
access_token: tokenSet.access_token,
refresh_token: tokenSet.refresh_token,
expires_at: tokenSet.expires_at,
});
return reply.redirect(sessionData.returnTo || '/');
} catch (err: any) {
request.log.error({ err }, 'OIDC callback error');
return reply.code(500).send({ error: 'Authentication failed', message: err.message });
}
});
}
一个常见的错误是手动校验state
和nonce
。openid-client
库的callback
方法已经内置了这些校验,我们只需要把期望值传给它即可。如果校验失败,它会直接抛出异常,这大大简化了我们的错误处理逻辑。
3. 登出路由 (/auth/logout
)
正确的登出不仅要销毁本地会话,还应该尝试通知IdP,以实现单点登出(Single Sign-Out)。
// 继续在 src/routes/auth.ts 中
export async function authRoutes(fastify: FastifyInstance) {
// ... login and callback routes
fastify.get('/logout', async (request, reply) => {
const idToken = request.session.get('tokens')?.id_token;
// 销毁本地会话
await request.session.destroy();
// 如果IdP支持RP-Initiated Logout,则重定向到登出端点
try {
const endSessionUrl = fastify.oidcClient.endSessionUrl({
id_token_hint: idToken,
// 登出后IdP重定向回来的地址
post_logout_redirect_uri: config.oidc.logoutRedirectUri,
});
return reply.redirect(endSessionUrl);
} catch (err) {
// 如果IdP不支持或者没有配置,则直接重定向到首页
request.log.warn('Could not construct end_session_url. Redirecting to home.');
return reply.redirect('/');
}
});
}
第五步:部署到GKE
最后一步是将服务容器化并部署到GKE。
Dockerfile
(多阶段构建)
# ---- Base Stage ----
FROM node:18-alpine AS base
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN npm i -g pnpm
# ---- Dependencies Stage ----
FROM base AS dependencies
RUN pnpm fetch
RUN pnpm install --prod --frozen-lockfile
# ---- Build Stage ----
FROM base AS build
COPY . .
RUN pnpm install --frozen-lockfile
RUN pnpm build
# ---- Production Stage ----
FROM node:18-alpine AS production
WORKDIR /app
COPY /app/node_modules ./node_modules
COPY /app/dist ./dist
# 使用非root用户运行
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser
EXPOSE 8080
CMD ["node", "dist/server.js"]
多阶段构建可以显著减小最终镜像的体积,并移除构建时依赖,提升安全性。使用非root用户运行是容器安全的最佳实践。
GKE deployment.yaml
关键部分
apiVersion: apps/v1
kind: Deployment
metadata:
name: auth-gateway
spec:
replicas: 2
template:
spec:
containers:
- name: auth-gateway
image: gcr.io/your-project/auth-gateway:latest
ports:
- containerPort: 8080
env:
- name: NODE_ENV
value: "production"
- name: OIDC_ISSUER_URL
value: "https://accounts.google.com" # 示例
- name: OIDC_REDIRECT_URI
value: "https://your-app.com/auth/callback"
# 密钥应该从Kubernetes Secrets中获取
- name: OIDC_CLIENT_ID
valueFrom:
secretKeyRef:
name: oidc-credentials
key: client-id
- name: OIDC_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: oidc-credentials
key: client-secret
- name: COOKIE_SECRET
valueFrom:
secretKeyRef:
name: oidc-credentials
key: cookie-secret
- name: REDIS_HOST
value: "redis-service.default.svc.cluster.local"
# 配置资源限制和存活探针
resources:
requests:
cpu: "100m"
memory: "128Mi"
limits:
cpu: "250m"
memory: "256Mi"
livenessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 15
periodSeconds: 20
readinessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 5
periodSeconds: 10
在生产环境中,绝对不能将密钥直接写入YAML或通过环境变量明文传递。正确的方式是使用Kubernetes Secrets,并通过valueFrom.secretKeyRef
注入到容器中。
局限性与未来迭代
这套方案已经能满足我们当前的核心需求,但它并非完美。
首先,令牌刷新机制尚未实现。当前实现中,一旦会话(和内部存储的令牌)过期,用户必须重新登录。一个改进是在认证钩子中检查access_token
的过期时间,如果即将过期,则使用refresh_token
在后台静默刷新,这对用户体验是透明的。
其次,当前的Redis会话存储是一个单点。虽然在GKE中可以部署高可用的Redis(例如使用Redis Sentinel或Cluster),但这增加了架构的复杂性。对于某些允许一定安全风险以换取更高可用性的场景,可以重新评估无状态JWT方案,并结合短时效令牌和高效的吊销列表(CRL)作为折衷。
最后,服务本身的高可用依赖于GKE的调度和副本机制。对于认证这种关键服务,需要配置更严格的Pod反亲和性规则,确保实例分散在不同的物理节点上,并设置合适的PodDisruptionBudget来保证在节点维护期间服务的可用性。