构建基于 mTLS 双向认证的 tRPC 全栈类型安全应用


对于需要最高安全级别的内部系统——例如核心基础设施控制台或财务数据看板——仅依赖于用户名密码或 JWT 是不够的。这些凭证一旦泄露,攻击者就能从任何设备发起访问。我们需要的是一种更强的身份验证形式,能够将访问权限绑定到特定的、受信任的设备上。这正是 mTLS (Mutual TLS) 的用武之地。

问题在于,在典型的 Web 应用中实现 mTLS 并非易事,尤其是在一个现代化的、端到端类型安全的技术栈中。浏览器本身对客户端证书的编程控制能力非常有限,这给我们的前端 Svelte 应用与后端 tRPC 服务之间的通信带来了挑战。

这次的目标就是从零开始,构建一个完整的、端到端类型安全且通过 mTLS 强制进行设备认证的内部应用。我们将直面浏览器限制,设计一个在生产环境中可行的架构,并确保从数据库到 UI 的每一层都享有 tRPC 带来的类型安全优势。

第一步:证书体系的奠基

mTLS 的核心是证书。我们需要一个证书颁发机构 (CA) 来签署服务端和客户端的证书,形成一个信任链。在真实项目中,这会由公司的 PKI 基础设施或 Vault 等工具管理。为了演示,我们使用 openssl 手动创建这个体系。

首先,生成 CA 的私钥和根证书。

# 生成 CA 私钥
openssl genrsa -out ca.key 4096

# 生成 CA 根证书 (有效期10年)
openssl req -new -x509 -days 3650 -key ca.key -out ca.crt -subj "/C=CN/ST=Beijing/L=Beijing/O=MyOrg/OU=DevOps/CN=MyInternalCA"

接下来,用这个 CA 来签署我们的 tRPC 服务端证书。

# 1. 生成服务端私钥
openssl genrsa -out server.key 4096

# 2. 生成证书签名请求 (CSR)
# 这里的 CN (Common Name) 必须是你的服务域名,对于本地开发,使用 localhost
openssl req -new -key server.key -out server.csr -subj "/C=CN/ST=Beijing/L=Beijing/O=MyOrg/OU=Backend/CN=localhost"

# 3. 使用 CA 签署服务端证书 (有效期1年)
openssl x509 -req -in server.csr -CA ca.crt -CAkey ca.key -CAcreateserial -out server.crt -days 365 -sha256

最后,为我们的客户端设备生成一个唯一的证书。每个需要访问系统的设备都应该有自己独立的证书,以便于单独吊销。

# 1. 生成客户端私钥
openssl genrsa -out client.key 4096

# 2. 生成客户端 CSR
# CN 可以用来标识客户端,例如设备ID或用户ID
openssl req -new -key client.key -out client.csr -subj "/C=CN/ST=Beijing/L=Beijing/O=MyOrg/OU=ClientDevice/CN=device-001"

# 3. 使用 CA 签署客户端证书
openssl x509 -req -in client.csr -CA ca.crt -CAkey ca.key -CAcreateserial -out client.crt -days 365 -sha256

现在我们拥有了信任链的核心文件:ca.crt, server.crt, server.key, client.crt, client.key。这是后续所有工作的基础。

第二步:构建强制 mTLS 的 tRPC 后端

我们的后端不仅要提供 API,更要在应用层代码执行之前,于 TLS 握手阶段就拒绝掉任何没有提供有效客户端证书的请求。

我们将使用 Node.js 内置的 https 模块来创建服务器,并在此基础上集成 tRPC。

项目结构:

/trpc-mtls-backend
|-- certs/
|   |-- ca.crt
|   |-- server.crt
|   |-- server.key
|-- src/
|   |-- index.ts
|   |-- router.ts
|-- package.json
|-- tsconfig.json

首先安装依赖:
npm install @trpc/server express cors fs
npm install -D typescript ts-node-dev @types/express @types/cors @types/node

src/router.ts - 定义 tRPC 路由

import { initTRPC } from '@trpc/server';
import { z } from 'zod';

const t = initTRPC.create();

// 模拟一个受保护的数据源
const protectedData = {
  id: 'proj_1a2b3c',
  name: 'Project Phoenix',
  status: 'Active',
  budget: 1500000,
  lastUpdated: new Date().toISOString(),
};

export const appRouter = t.router({
  // 一个公开的健康检查接口,不需要 mTLS 也可以访问(如果服务器配置允许)
  // 但在我们的严格配置下,它仍然会被 mTLS 阻挡
  healthcheck: t.procedure.query(() => {
    return { status: 'ok' };
  }),

  // 核心的受保护 процедура
  getProjectDetails: t.procedure
    .input(z.object({ projectId: z.string().startsWith('proj_') }))
    .query(({ input }) => {
      // 在真实项目中,这里会从数据库查询
      // 这里的逻辑只有在 mTLS 握手成功后才会执行
      console.log(`[tRPC] Received request for project: ${input.projectId}`);
      if (input.projectId === protectedData.id) {
        return protectedData;
      }
      return null;
    }),
});

export type AppRouter = typeof appRouter;

src/index.ts - 创建并配置 mTLS 服务器

这是整个后端的关键。我们使用 https 模块创建一个服务器,并传入证书配置。

import https from 'https';
import fs from 'fs';
import path from 'path';
import express from 'express';
import cors from 'cors';
import * as trpcExpress from '@trpc/server/adapters/express';
import { appRouter } from './router';

const PORT = 4000;

const app = express();
app.use(cors()); // 在生产中应配置更严格的 CORS 策略

// 创建 tRPC Express 中间件
const createContext = ({
  req,
  res,
}: trpcExpress.CreateExpressContextOptions) => {
  // 从 req.socket.getPeerCertificate() 可以获取客户端证书信息
  // 这对于在应用层进行更细粒度的授权非常有用
  const clientCert = (req.socket as any).getPeerCertificate();
  console.log('Client certificate subject:', clientCert.subject?.CN);
  return { clientCert };
};

app.use(
  '/trpc',
  trpcExpress.createExpressMiddleware({
    router: appRouter,
    createContext,
  })
);

// --- mTLS 服务器配置 ---
// 这里的配置是核心,它强制执行 mTLS
const httpsOptions = {
  // 服务端证书和私钥
  key: fs.readFileSync(path.join(__dirname, '..', 'certs', 'server.key')),
  cert: fs.readFileSync(path.join(__dirname, '..', 'certs', 'server.crt')),
  
  // 用于验证客户端证书的 CA
  ca: fs.readFileSync(path.join(__dirname, '..', 'certs', 'ca.crt')),
  
  // requestCert: true - 服务器必须请求客户端证书
  requestCert: true,
  
  // rejectUnauthorized: true - 如果客户端没有提供由我们的 CA 签署的有效证书,
  // TLS 握手将直接失败。连接会立即被终止,请求根本不会到达 Express 或 tRPC。
  // 这是一个非常重要的安全设置。
  rejectUnauthorized: true, 
};

const server = https.createServer(httpsOptions, app);

server.listen(PORT, () => {
  console.log(`[Server] Secure tRPC server listening on https://localhost:${PORT}`);
});

// 错误处理,例如捕获 TLS 握手错误
server.on('tlsClientError', (err, tlsSocket) => {
    console.error(`[Server] TLS Client Error: ${err.message}`);
    // 在这里可以看到未经授权的连接尝试
    const remoteAddress = `${tlsSocket.remoteAddress}:${tlsSocket.remotePort}`;
    console.error(`[Server] Error from: ${remoteAddress}`);
});

现在启动后端服务 ts-node-dev src/index.ts。如果你此时用浏览器或 curl 直接访问 https://localhost:4000/trpc/healthcheck,请求会立刻失败。curl 会报 “alert certificate required”,浏览器会显示 TLS 错误。这证明我们的 mTLS 防护已经生效。

第三步:前端 Svelte 应用与本地代理

现在面临最大的挑战:如何让运行在浏览器中的 Svelte 应用携带客户端证书去请求后端?答案是:它不能直接做到。浏览器的安全模型不允许 JavaScript 代码直接访问和管理本地文件系统中的证书文件。

在企业环境中,通常有两种解决方案:

  1. 将客户端证书安装到操作系统的证书存储或硬件令牌中,浏览器在发起请求时会自动使用它。这对用户配置要求高,不易管理。
  2. 使用一个本地代理。我们的 Svelte 应用将所有 API 请求发送到一个本地运行的、不要求 mTLS 的 HTTP 代理。这个代理持有客户端证书,并将请求以 mTLS 的方式安全地转发给真正的后端服务。

我们将采用第二种方案,因为它对前端代码是透明的,并且更容易通过脚本进行部署和管理。

创建本地 mTLS 代理

这个代理是一个简单的 Node.js 脚本,它使用 http-proxy-middleware 来处理转发,并用 axios 或 Node 的 https agent 来附加客户端证书。

// local-mtls-proxy.js
const express = require('express');
const { createProxyMiddleware } = require('http-proxy-middleware');
const https = require('https');
const fs = require('fs');
const path = require('path');

const PROXY_PORT = 3001; // Svelte 应用将请求此端口
const TARGET_URL = 'https://localhost:4000'; // 真正的 tRPC 后端

const app = express();

// 配置一个 HTTPS Agent 来携带客户端证书
const mTLSAgent = new https.Agent({
    key: fs.readFileSync(path.join(__dirname, 'certs', 'client.key')),
    cert: fs.readFileSync(path.join(__dirname, 'certs', 'client.crt')),
    // 这个 CA 用于验证服务端的证书,防止中间人攻击
    ca: fs.readFileSync(path.join(__dirname, 'certs', 'ca.crt')),
    rejectUnauthorized: true, // 确保服务端证书是可信的
});

const proxyOptions = {
    target: TARGET_URL,
    changeOrigin: true, // 需要这个来正确设置 Host header
    secure: true, // 我们正在代理到一个 HTTPS 目标
    agent: mTLSAgent, // 使用我们定制的 mTLS agent
    logLevel: 'debug',
    onError: (err, req, res) => {
        console.error('Proxy Error:', err);
        res.writeHead(500, {
            'Content-Type': 'text/plain',
        });
        res.end('Something went wrong. And we are reporting a custom error message.');
    },
};

app.use('/', createProxyMiddleware(proxyOptions));

app.listen(PROXY_PORT, () => {
    console.log(`[Proxy] Local mTLS proxy running on http://localhost:${PROXY_PORT}`);
    console.log(`[Proxy] Forwarding requests to ${TARGET_URL}`);
});

在运行 Svelte 应用前,需要先将客户端证书 (client.key, client.crt) 和 CA 证书 (ca.crt) 复制到代理脚本能访问的目录,然后启动它:node local-mtls-proxy.js

构建 Svelte 前端

现在,我们可以像往常一样构建 SvelteKit 应用了,唯一的区别是 tRPC 客户端将指向本地代理 http://localhost:3001

项目结构:

/svelte-mtls-ui
|-- src/
|   |-- lib/
|   |   |-- trpc.ts
|   |-- routes/
|       |-- +page.svelte
...

安装依赖:
npm install @trpc/client @tanstack/svelte-query
npx svelte-add@latest shadcn-ui (并按需选择组件,如 Button, Card)

src/lib/trpc.ts - 配置 tRPC 客户端

import { createTRPCProxyClient, httpBatchLink } from '@trpc/client';
import type { AppRouter } from '../../../trpc-mtls-backend/src/router'; // 关键:直接从后端导入类型
import { QueryClient } from '@tanstack/svelte-query';

export const queryClient = new QueryClient();

export const trpc = createTRPCProxyClient<AppRouter>({
  links: [
    httpBatchLink({
      // 请求将发送到本地代理,而不是直接发送到 mTLS 后端
      url: 'http://localhost:3001/trpc', 
    }),
  ],
});

这里的类型导入 import type { AppRouter } from ... 是 tRPC 的核心优势。它创建了一个从后端到前端的类型安全契约。如果后端 appRouter 的定义发生变化,TypeScript 会在前端代码中立刻报错。

src/routes/+page.svelte - 构建 UI 并调用 API

我们使用 Shadcn UI 来快速构建一个简洁的界面,并用 @tanstack/svelte-query 来管理数据获取状态。

<script lang="ts">
  import { onMount } from 'svelte';
  import { trpc } from '$lib/trpc';
  import { createQuery } from '@tanstack/svelte-query';

  import { Button } from '$lib/components/ui/button';
  import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '$lib/components/ui/card';
  import { Alert, AlertDescription, AlertTitle } from '$lib/components/ui/alert';
  import { Terminal } from 'lucide-svelte';

  const projectId = 'proj_1a2b3c';

  // 使用 svelte-query 来包装 tRPC 调用
  const projectDetailsQuery = createQuery({
    queryKey: ['projectDetails', projectId],
    queryFn: () => trpc.getProjectDetails.query({ projectId }),
    enabled: false, // 初始不执行
  });

  function fetchData() {
    // 手动触发查询
    $projectDetailsQuery.refetch();
  }
</script>

<div class="container mx-auto p-8 max-w-2xl">
  <Card>
    <CardHeader>
      <CardTitle>Secure Internal Dashboard</CardTitle>
      <CardDescription>
        This application communicates with a backend service protected by mandatory mTLS.
      </CardDescription>
    </CardHeader>
    <CardContent class="space-y-4">
      <Button on:click={fetchData} disabled={$projectDetailsQuery.isFetching}>
        {#if $projectDetailsQuery.isFetching}
          Fetching...
        {:else}
          Fetch Project Details
        {/if}
      </Button>

      {#if $projectDetailsQuery.isError}
        <Alert variant="destructive">
          <Terminal class="h-4 w-4" />
          <AlertTitle>Error Fetching Data</AlertTitle>
          <AlertDescription>
            Failed to connect to the backend. Is the local mTLS proxy running?
            <pre class="mt-2 text-xs bg-gray-800 p-2 rounded">{$projectDetailsQuery.error.message}</pre>
          </AlertDescription>
        </Alert>
      {/if}

      {#if $projectDetailsQuery.isSuccess && $projectDetailsQuery.data}
        {@const data = $projectDetailsQuery.data}
        <Card class="bg-secondary">
            <CardHeader>
                <CardTitle>{data.name}</CardTitle>
                <CardDescription>ID: {data.id}</CardDescription>
            </CardHeader>
            <CardContent>
                <p><strong>Status:</strong> {data.status}</p>
                <p><strong>Budget:</strong> ${data.budget.toLocaleString()}</p>
                <p><strong>Last Updated:</strong> {new Date(data.lastUpdated).toLocaleString()}</p>
            </CardContent>
        </Card>
      {/if}
    </CardContent>
  </Card>
</div>

现在,确保后端服务和本地代理都在运行,然后启动 SvelteKit 应用。点击 “Fetch Project Details” 按钮,数据将成功加载。如果停掉本地代理,或者尝试用错误的客户端证书启动代理,前端会立刻显示错误。这验证了我们的端到端安全链路。

架构回顾与权衡

我们已经成功构建了一个完整的系统。让我们用一个图来清晰地展示数据流和安全边界。

graph TD
    subgraph Client Machine
        A[Browser: Svelte/Shadcn UI] --> B{Local mTLS Proxy};
    end

    subgraph Secure Network
        C[tRPC Backend on Node.js];
    end
    
    B -- "HTTPS with Client Cert (mTLS)" --> C;
    A -- "Plain HTTP (localhost)" --> B;

    style C fill:#f9f,stroke:#333,stroke-width:2px
    style B fill:#bbf,stroke:#333,stroke-width:2px

这个架构的优势是显而易见的:

  1. 强设备认证: 只有持有有效客户端证书的设备才能与后端建立连接。
  2. 端到端类型安全: tRPC 确保了前后端之间的数据契约,任何不匹配都会在编译时被发现。
  3. 前端代码解耦: Svelte 应用本身不需要关心 mTLS 的复杂性,它只是在与一个普通的本地 HTTP 服务通信。

局限性与未来迭代路径

这个方案虽然健壮,但在生产部署时还有几个问题需要考虑。

首先,证书的分发和轮换是最大的挑战。手动生成和分发证书是不可扩展的。一个成熟的系统需要一个自动化的公钥基础设施 (PKI),例如使用 HashiCorp Vault 来动态生成短期证书,并通过设备管理工具(如 JAMF 或 Intune)安全地部署到客户端机器上。

其次,本地代理的部署和生命周期管理。需要确保员工设备上这个代理能正确安装、启动并保持运行。这通常需要编写安装脚本,并将其集成到公司的设备管理流程中。对于非技术用户来说,这是一个潜在的痛点。

最后,这只解决了认证(Authentication),没有解决授权(Authorization)。我们知道是 device-001 在请求,但我们不知道是哪个用户在使用这个设备。通常,mTLS 会与另一层认证机制(如 OIDC 登录流程)结合使用,mTLS 确保设备可信,而 OIDC 确保用户可信。从客户端证书中提取的 CNdevice-001)可以在后端与用户身份进行关联,实现更精细的访问控制。


  目录