为一个内部高风险操作平台重构 API 时,我们遇到了一个经典的安全困境。系统原来的认证机制是标准的 JWT Bearer Token。这对于常规的数据读取(Query)是足够的,但对于一些核心的资金划拨、权限变更等 GraphQL 突变(Mutation),这种机制显得异常脆弱。一旦 JWT 在生命周期内被窃取,攻击者就可以无限制地重放这些高风险操作,后果不堪设想。
我们的目标很明确:必须确保每一次高风险操作都由用户本人真实意图发起,并且操作指令本身未被篡改。传统的二次密码确认或 MFA 动态码体验繁琐,且依然存在被中间人攻击的风险。我们需要一个更底层的、基于密码学的解决方案。
初步构想是利用 WebAuthn。通常,WebAuthn 被用于无密码登录,但它的核心能力——利用设备硬件(如 YubiKey、TPM 芯片、Face ID)生成非对称密钥对并进行签名——远不止于此。如果我们将这种签名能力从“登录”扩展到“授权每一次操作”,理论上可以完美解决问题。
最终的技术方案定型为:在前端,使用 Redux 管理认证状态和操作流程;通过 GraphQL Client 包装,对标记为高风险的 Mutation,在发送前动态获取一个来自服务端的挑战(Challenge),调用 WebAuthn API(navigator.credentials.get()
)让用户签名,然后将签名和原始操作数据一同发送到后端的 Fastify 服务器。服务器则使用用户注册时存储的公钥来验证该签名,只有验证通过,才执行真正的业务逻辑。
这个方案本质上是将一次性的登录认证,升级为对每一次关键操作的“微认证”,构建起一套零信任的 API 调用链。
后端基石:Fastify 与 WebAuthn 服务端
首先是后端的搭建。选择 Fastify 是因为它轻量、高性能,插件生态也足以支撑我们的需求。我们将使用 mercurius
来提供 GraphQL 服务,并引入 @simplewebauthn/server
处理 WebAuthn 相关的复杂密码学操作。
项目的核心依赖如下:
// package.json (backend)
{
"dependencies": {
"@simplewebauthn/server": "^8.3.4",
"@simplewebauthn/types": "^8.3.4",
"fastify": "^4.24.3",
"mercurius": "^13.1.0",
"graphql": "^16.8.1"
}
}
我们的数据库模型需要存储用户信息以及他们注册的 WebAuthn 凭证。在真实项目中,你会使用 PostgreSQL 或 MongoDB,但这里为了聚焦逻辑,我们用内存存储模拟。
// src/db.ts
import { AuthenticatorDevice } from '@simplewebauthn/server/script/deps';
interface User {
id: string;
username: string;
authenticators: AuthenticatorDevice[];
}
// 简单的内存数据库
const users: Map<string, User> = new Map();
const challenges: Map<string, string> = new Map();
export const db = {
users,
challenges,
};
接下来是 Fastify 服务器的核心逻辑。我们需要实现 WebAuthn 的注册、登录流程,并提供一个特殊的 GraphQL Query 来为签名操作生成挑战。
// src/server.ts
import Fastify from 'fastify';
import mercurius from 'mercurius';
import { schema }s from './schema';
import { resolvers } from './resolvers';
const app = Fastify({ logger: true });
app.register(mercurius, {
schema,
resolvers,
graphiql: true,
context: (request, reply) => {
// 在真实项目中,这里会从 JWT 或 session 中解析用户信息
// 为简化,我们假设用户ID通过 header 传递
return {
userId: request.headers['x-user-id'] as string | undefined,
};
},
});
// ... WebAuthn 注册和登录的 RESTful 路由 ...
// 这部分是标准实现,不是本文重点,故省略具体代码
// 你需要实现 /register-challenge, /register, /login-challenge, /login 路由
const start = async () => {
try {
await app.listen({ port: 4000 });
} catch (err) {
app.log.error(err);
process.exit(1);
}
};
start();
关键在于 GraphQL Schema 和 Resolvers 的设计。我们需要一个用于执行敏感操作的 Mutation,和一个用于获取签名前置挑战的 Query。
# src/schema.ts
export const schema = `
type Query {
generateOperationChallenge(userId: String!): String!
}
type Mutation {
# 这是一个高风险操作,需要签名
executeCriticalOperation(input: CriticalOperationInput!): OperationResult!
}
input CriticalOperationInput {
payload: String!
# WebAuthn 签名返回的数据
signatureData: SignatureDataInput!
}
input SignatureDataInput {
credentialId: String!
authenticatorData: String!
clientDataJSON: String!
signature: String!
}
type OperationResult {
success: Boolean!
message: String
}
`;
generateOperationChallenge
的 Resolver 负责生成一个唯一的、有时效性的随机字符串。这个挑战必须与用户绑定,并在签名验证后立即失效,以防止重放攻击。
// src/resolvers.ts
import { generateAuthenticationOptions, verifyAuthenticationResponse } from '@simplewebauthn/server';
import { db } from './db';
import type { VerifiedAuthenticationResponse } from '@simplewebauthn/server';
// RP = Relying Party,即我们的应用信息
const rpID = 'localhost';
const rpName = 'Critical Ops Platform';
const origin = `http://${rpID}:3000`; // 前端地址
export const resolvers = {
Query: {
generateOperationChallenge: async (_: any, { userId }: { userId: string }) => {
const user = db.users.get(userId);
if (!user) {
throw new Error('User not found');
}
// 这里的 challenge 是为 "authentication" (get) 操作生成的
// WebAuthn 的 `get` 操作本质就是一个签名质询
const opts = await generateAuthenticationOptions({
rpID,
allowCredentials: user.authenticators.map(auth => ({
id: auth.credentialID,
type: 'public-key',
})),
userVerification: 'required',
});
// 在真实项目中,应使用 Redis 并设置过期时间
db.challenges.set(userId, opts.challenge);
console.log(`Generated challenge for ${userId}: ${opts.challenge}`);
return opts.challenge;
},
},
Mutation: {
executeCriticalOperation: async (_: any, { input }: any, context: any) => {
const { userId } = context;
if (!userId) {
throw new mercurius.ErrorWithProps('Unauthorized: Missing user ID', { code: 'UNAUTHENTICATED' });
}
const user = db.users.get(userId);
if (!user) {
throw new mercurius.ErrorWithProps('User not found', { code: 'NOT_FOUND' });
}
const expectedChallenge = db.challenges.get(userId);
if (!expectedChallenge) {
throw new mercurius.ErrorWithProps('No challenge found. Please request one first.', { code: 'BAD_REQUEST' });
}
// 操作完成后立即删除 challenge,防止重放
db.challenges.delete(userId);
const authenticator = user.authenticators.find(
auth => auth.credentialID.toString('base64url') === input.signatureData.credentialId
);
if (!authenticator) {
throw new mercurius.ErrorWithProps('Authenticator not found for this user', { code: 'NOT_FOUND' });
}
let verification: VerifiedAuthenticationResponse;
try {
verification = await verifyAuthenticationResponse({
response: {
id: input.signatureData.credentialId,
rawId: input.signatureData.credentialId, // 前端传来的 base64url string
response: {
authenticatorData: input.signatureData.authenticatorData,
clientDataJSON: input.signatureData.clientDataJSON,
signature: input.signatureData.signature,
},
type: 'public-key',
},
expectedChallenge,
expectedOrigin: origin,
expectedRPID: rpID,
authenticator,
requireUserVerification: true,
});
} catch (error: any) {
console.error(error);
throw new mercurius.ErrorWithProps(`Signature verification failed: ${error.message}`, { code: 'VERIFICATION_FAILED' });
}
const { verified } = verification;
if (!verified) {
throw new mercurius.ErrorWithProps('Signature could not be verified.', { code: 'VERIFICATION_FAILED' });
}
// 核心安全点:验证通过后才能执行业务逻辑
console.log(`User ${userId} successfully signed for payload: ${input.payload}`);
// --- 在此执行真正的、危险的业务逻辑 ---
// example: await transferFunds(userId, input.payload);
// ------------------------------------
return { success: true, message: 'Operation executed successfully.' };
},
},
};
这里的坑在于,clientDataJSON
中包含了 challenge,verifyAuthenticationResponse
会自动校验。同时,它还会校验签名是否由给定的 authenticator
(包含公钥)所签发,确保了操作的真实来源。
前端协同:Redux 与 GraphQL Client 的复杂流程管理
前端的挑战在于如何优雅地管理这个“请求挑战 -> 用户签名 -> 发送突变”的异步、多步流程。这正是 Redux Toolkit (RTK) 和其异步 thunk 的用武之地。我们将使用 urql
作为 GraphQL Client,因为它轻量且易于扩展。
首先是 Redux store 和一个用于处理关键操作的 slice。
// src/store/operationSlice.ts
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
import { client } from '../graphqlClient'; // urql client instance
import { startAuthentication } from '@simplewebauthn/browser';
import { gql } from 'urql';
// GraphQL 文档
const GenerateChallengeMutation = gql`
query GenerateOperationChallenge($userId: String!) {
generateOperationChallenge(userId: $userId)
}
`;
const ExecuteOperationMutation = gql`
mutation ExecuteCriticalOperation($input: CriticalOperationInput!) {
executeCriticalOperation(input: $input) {
success
message
}
}
`;
// 异步 Thunk
export const performSignedOperation = createAsyncThunk(
'operation/performSigned',
async ({ userId, payload }: { userId: string; payload: string }, { rejectWithValue }) => {
try {
// 步骤 1: 获取 Challenge
const challengeResult = await client.query(GenerateChallengeMutation, { userId }).toPromise();
if (challengeResult.error) {
throw new Error(`Failed to get challenge: ${challengeResult.error.message}`);
}
const challenge = challengeResult.data.generateOperationChallenge;
if (!challenge) {
throw new Error('Server did not return a challenge.');
}
// 步骤 2: 调用 WebAuthn API 进行签名
const authenticationResponse = await startAuthentication({
challenge,
rpId: 'localhost',
userVerification: 'required',
});
// `startAuthentication` 内部调用 navigator.credentials.get()
// 它返回的数据结构需要适配我们的 GraphQL 输入
const signatureData = {
credentialId: authenticationResponse.id,
authenticatorData: Buffer.from(authenticationResponse.response.authenticatorData).toString('base64url'),
clientDataJSON: Buffer.from(authenticationResponse.response.clientDataJSON).toString('base64url'),
signature: Buffer.from(authenticationResponse.response.signature).toString('base64url'),
};
// 步骤 3: 带着签名执行真正的 Mutation
const operationResult = await client.mutation(ExecuteOperationMutation, {
input: {
payload,
signatureData,
},
}).toPromise();
if (operationResult.error) {
// 这里的错误可能来自 GraphQL 层,也可能来自我们的签名验证失败
throw new Error(`Operation failed: ${operationResult.error.message}`);
}
return operationResult.data.executeCriticalOperation;
} catch (error: any) {
console.error('Signed operation process failed:', error);
return rejectWithValue(error.message);
}
}
);
const operationSlice = createSlice({
name: 'operation',
initialState: {
loading: false,
error: null as string | null,
lastResult: null as { success: boolean; message: string } | null,
},
reducers: {},
extraReducers: (builder) => {
builder
.addCase(performSignedOperation.pending, (state) => {
state.loading = true;
state.error = null;
state.lastResult = null;
})
.addCase(performSignedOperation.fulfilled, (state, action) => {
state.loading = false;
state.lastResult = action.payload;
})
.addCase(performSignedOperation.rejected, (state, action) => {
state.loading = false;
state.error = action.payload as string;
});
},
});
export default operationSlice.reducer;
这个 Thunk 完美地封装了整个复杂流程。React 组件只需要 dispatch(performSignedOperation(...))
,然后通过 useSelector
监听 loading
, error
, lastResult
状态来更新 UI 即可。Redux 在这里扮演了复杂工作流协调者的角色,让 UI 组件保持纯粹。
一个简单的 React 组件示例如下:
// src/components/CriticalActionButton.tsx
import React from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { performSignedOperation } from '../store/operationSlice';
import { RootState, AppDispatch } from '../store';
const CriticalActionButton = () => {
const dispatch = useDispatch<AppDispatch>();
const { loading, error, lastResult } = useSelector((state: RootState) => state.operation);
// 假设用户ID从 auth slice 或 context 中获取
const userId = 'user-123';
const handleClick = () => {
const payload = JSON.stringify({ action: 'TRANSFER_FUNDS', amount: 1000, to: 'account-xyz' });
dispatch(performSignedOperation({ userId, payload }));
};
return (
<div>
<button onClick={handleClick} disabled={loading}>
{loading ? 'Processing...' : 'Execute Critical Operation'}
</button>
{error && <p style={{ color: 'red' }}>Error: {error}</p>}
{lastResult && (
<p style={{ color: lastResult.success ? 'green' : 'orange' }}>
Result: {lastResult.message}
</p>
)}
</div>
);
};
流程可视化
整个交互流程可以用 Mermaid 时序图清晰地展示出来,这对于理解各组件间的通信至关重要。
sequenceDiagram participant Client as React Component participant Redux as Redux Thunk participant GQLClient as GraphQL Client participant Server as Fastify/GraphQL Server participant WebAuthn as Browser/OS (WebAuthn API) Client->>Redux: dispatch(performSignedOperation) Redux->>GQLClient: 1. Send query: generateOperationChallenge GQLClient->>Server: POST /graphql (generateOperationChallenge) Server-->>GQLClient: Returns { challenge: "..." } GQLClient-->>Redux: Resolve with challenge Redux->>WebAuthn: 2. Call startAuthentication({ challenge }) WebAuthn-->>Client: Prompt user for biometrics/security key Client-->>WebAuthn: User confirms WebAuthn-->>Redux: Returns signature data Redux->>GQLClient: 3. Send mutation: executeCriticalOperation(payload, signatureData) GQLClient->>Server: POST /graphql (executeCriticalOperation) Server->>Server: Verify signature against stored public key and challenge alt Signature Valid Server->>Server: Execute business logic Server-->>GQLClient: Returns { success: true } else Signature Invalid Server-->>GQLClient: Returns GraphQL error (VERIFICATION_FAILED) end GQLClient-->>Redux: Resolve with operation result or error Redux->>Client: Update component state (loading, result, error)
单元测试思路
对这样复杂的系统,测试至关重要。
- 后端 Resolver 测试: 可以编写单元测试,使用
@simplewebauthn/server
提供的测试向量或自己生成密钥对来模拟签名和验证过程。确保在各种错误情况下(如挑战不匹配、签名错误、用户不存在)都能返回正确的 GraphQL 错误。 - 前端 Thunk 测试: 使用
redux-mock-store
,并 mockurql
client 和@simplewebauthn/browser
的startAuthentication
函数。这可以让你测试 Thunk 是否按照“获取挑战 -> 请求签名 -> 发送突变”的正确顺序执行,以及是否能正确处理每个步骤的成功和失败。
一个常见的错误是在前端对 WebAuthn API 返回的 ArrayBuffer
进行转换时出错。Buffer.from(arrayBuffer).toString('base64url')
是一个关键且容易出错的步骤,必须确保它在所有浏览器环境中都表现一致,或使用可靠的库来处理。
方案的局限性与适用场景
这个方案并非银弹。最显著的代价是增加了交互延迟和用户操作成本。每次关键操作都需要用户进行一次物理或生物特征确认(触摸安全密钥、面部识别等),这对于高频操作是不可接受的。因此,它不适用于像“点赞”或“发送消息”这样的功能。
它的威力体现在低频、高影响力的场景:
- DevOps 平台: 确认一次生产部署。
- 金融后台: 批准一笔大额转账。
- 管理系统: 删除一个组织或更改关键配置。
- 个人中心: 修改密码、删除账户。
未来的一个优化方向或许是探索一种会话级的签名机制。例如,在用户登录后的短时间内,可以缓存一个有时效的、由硬件密钥签名的“授权令牌”,后续操作使用这个令牌进行认证。但这会引入新的复杂性,需要在安全性和便利性之间做出新的权衡,可能会削弱“每一次操作都独立授权”这一核心安全保证。