构建高可信 GraphQL API 实现基于 WebAuthn 的突变密码学签名


为一个内部高风险操作平台重构 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)

单元测试思路

对这样复杂的系统,测试至关重要。

  1. 后端 Resolver 测试: 可以编写单元测试,使用 @simplewebauthn/server 提供的测试向量或自己生成密钥对来模拟签名和验证过程。确保在各种错误情况下(如挑战不匹配、签名错误、用户不存在)都能返回正确的 GraphQL 错误。
  2. 前端 Thunk 测试: 使用 redux-mock-store,并 mock urql client 和 @simplewebauthn/browserstartAuthentication 函数。这可以让你测试 Thunk 是否按照“获取挑战 -> 请求签名 -> 发送突变”的正确顺序执行,以及是否能正确处理每个步骤的成功和失败。

一个常见的错误是在前端对 WebAuthn API 返回的 ArrayBuffer 进行转换时出错。Buffer.from(arrayBuffer).toString('base64url') 是一个关键且容易出错的步骤,必须确保它在所有浏览器环境中都表现一致,或使用可靠的库来处理。

方案的局限性与适用场景

这个方案并非银弹。最显著的代价是增加了交互延迟和用户操作成本。每次关键操作都需要用户进行一次物理或生物特征确认(触摸安全密钥、面部识别等),这对于高频操作是不可接受的。因此,它不适用于像“点赞”或“发送消息”这样的功能。

它的威力体现在低频、高影响力的场景:

  • DevOps 平台: 确认一次生产部署。
  • 金融后台: 批准一笔大额转账。
  • 管理系统: 删除一个组织或更改关键配置。
  • 个人中心: 修改密码、删除账户。

未来的一个优化方向或许是探索一种会话级的签名机制。例如,在用户登录后的短时间内,可以缓存一个有时效的、由硬件密钥签名的“授权令牌”,后续操作使用这个令牌进行认证。但这会引入新的复杂性,需要在安全性和便利性之间做出新的权衡,可能会削弱“每一次操作都独立授权”这一核心安全保证。


  目录