对于需要最高安全级别的内部系统——例如核心基础设施控制台或财务数据看板——仅依赖于用户名密码或 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 fsnpm 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 代码直接访问和管理本地文件系统中的证书文件。
在企业环境中,通常有两种解决方案:
- 将客户端证书安装到操作系统的证书存储或硬件令牌中,浏览器在发起请求时会自动使用它。这对用户配置要求高,不易管理。
- 使用一个本地代理。我们的 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-querynpx 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
这个架构的优势是显而易见的:
- 强设备认证: 只有持有有效客户端证书的设备才能与后端建立连接。
- 端到端类型安全: tRPC 确保了前后端之间的数据契约,任何不匹配都会在编译时被发现。
- 前端代码解耦: Svelte 应用本身不需要关心 mTLS 的复杂性,它只是在与一个普通的本地 HTTP 服务通信。
局限性与未来迭代路径
这个方案虽然健壮,但在生产部署时还有几个问题需要考虑。
首先,证书的分发和轮换是最大的挑战。手动生成和分发证书是不可扩展的。一个成熟的系统需要一个自动化的公钥基础设施 (PKI),例如使用 HashiCorp Vault 来动态生成短期证书,并通过设备管理工具(如 JAMF 或 Intune)安全地部署到客户端机器上。
其次,本地代理的部署和生命周期管理。需要确保员工设备上这个代理能正确安装、启动并保持运行。这通常需要编写安装脚本,并将其集成到公司的设备管理流程中。对于非技术用户来说,这是一个潜在的痛点。
最后,这只解决了认证(Authentication),没有解决授权(Authorization)。我们知道是 device-001 在请求,但我们不知道是哪个用户在使用这个设备。通常,mTLS 会与另一层认证机制(如 OIDC 登录流程)结合使用,mTLS 确保设备可信,而 OIDC 确保用户可信。从客户端证书中提取的 CN(device-001)可以在后端与用户身份进行关联,实现更精细的访问控制。