构建支持 SSG 的纵深防御 API Fastify 与 Vitest 的安全实践


技术挑战的定义:静态性能与动态安全的矛盾体

项目的核心诉求是为一套内容驱动的静态站点生成(SSG)架构提供数据支持。SSG 的优势在于极致的加载性能、简单的 CDN 部署和优秀的 SEO 基础。但在真实业务中,纯粹的静态内容远远不够,我们需要处理用户登录、权限校验、个性化内容展示等动态需求。这就引入了一个核心矛盾:如何在一个以“静态”为基石的架构上,安全、高效地注入“动态”能力?

传统的服务器端渲染(SSR)方案将安全逻辑和页面渲染耦合在同一服务中,安全模型相对简单。但在 SSG 架构中,前端应用被编译成静态文件分发到 CDN,所有动态数据交互都必须通过一个独立的、暴露在公网的 API 服务。这个 API 服务的安全性和健壮性,直接决定了整个系统的生死。

我们的任务,就是设计并实现这样一个 API 服务。它不仅要性能卓越,以匹配 SSG 的速度优势,更要构建一套纵深防御体系,确保在完全前后端分离的模式下,数据依然安全可控。

架构决策:为何选择 SSG + Decoupled API 而非 SSR

在项目初期,我们评估了两种主流方案。

方案 A: 传统一体化 SSR (Server-Side Rendering)

  • 优势:
    • 安全模型成熟:认证、授权逻辑都封装在服务端,客户端不直接处理敏感 Token。
    • 开发体验统一:前后端代码可以在一个项目中紧密集成。
    • 对 SEO 友好:服务端直接输出完整 HTML。
  • 劣势:
    • 服务器成本高:每个请求都需要服务端计算和渲染,无法充分利用 CDN。
    • TTFB (Time to First Byte) 较长:服务端处理逻辑增加了首字节时间。
    • 架构耦合:前后端技术栈、部署节奏紧密绑定,不利于团队协作和独立扩展。

方案 B: SSG + Decoupled API (我们选择的方案)

  • 优势:
    • 极致性能:静态内容从最近的 CDN 节点提供,加载速度无与伦比。
    • 高可用与低成本:静态文件托管成本极低,且天然具备高可用性。API 服务可以独立扩缩容。
    • 技术栈解耦:前端团队可以自由选择技术栈(React, Vue, Svelte),后端 API 也可以独立演进。
  • 劣势:
    • 安全复杂性提升:认证状态需要客户端管理(如 JWT),API 必须处理所有安全校验,攻击面完全暴露。
    • 数据获取逻辑后置:页面加载后需要通过客户端请求获取动态数据,可能导致内容闪烁或需要额外的骨架屏处理。

决策理由:

对于我们的内容平台而言,大部分页面是公开的、可静态化的。只有少部分涉及用户个人中心、设置等页面需要动态数据。为了最大化性能优势和降低基础设施成本,我们选择了方案 B。这个决策意味着我们将架构的复杂性从“渲染”转移到了“安全”,我们必须投入精力构建一个坚不可摧的 API 服务。技术选型上,Node.js 生态中的 Fastify 因其极致的性能和强大的插件体系,成为我们的首选。而测试框架,我们选择了现代化的 Vitest,它与 TypeScript 的无缝集成和闪电般的速度,能让我们高效地编写覆盖安全逻辑的测试。

核心实现:Fastify API 的纵深防御体系

我们的防御体系并非单一环节,而是由多个层次构成,请求需要依次穿过这些关卡才能到达业务逻辑。

graph TD
    A[客户端请求] --> B{1. 速率限制与基础防护};
    B --> C{2. 输入模式验证};
    C --> D{3. 认证与授权};
    D --> E[业务逻辑处理];
    E --> F[响应];

    subgraph "Fastify Hooks & Plugins"
        B
        C
        D
    end

1. 基础设施与项目初始化

我们使用 pnpm 作为包管理器,项目结构如下:

/project
|-- /src
|   |-- /modules
|   |   |-- /auth
|   |   |   |-- auth.controller.ts
|   |   |   |-- auth.schema.ts
|   |   |   |-- auth.service.ts
|   |   |-- /content
|   |   |   |-- content.controller.ts
|   |   |   |-- content.schema.ts
|   |-- /plugins
|   |   |-- auth.ts
|   |   |-- security.ts
|   |-- /utils
|   |   |-- jwt.ts
|   |-- app.ts
|   |-- server.ts
|-- test
|   |-- /integration
|   |   |-- auth.test.ts
|   |   |-- content.test.ts
|-- .env
|-- package.json
|-- tsconfig.json
|-- vitest.config.ts

2. 防御层一:速率限制与安全头

这是最外层的防护,用于抵御基础的 DoS 攻击和常见的 Web 漏洞。我们通过 fastify-rate-limitfastify-helmet 实现。

src/plugins/security.ts:

import { FastifyInstance, FastifyPluginOptions } from 'fastify';
import fp from 'fastify-plugin';
import helmet from '@fastify/helmet';
import rateLimit from '@fastify/rate-limit';

// fastify-plugin is used to declare that this plugin
// will not be encapsulated, and its effects are global.
export default fp(async function (fastify: FastifyInstance, options: FastifyPluginOptions) {
  // Helmet adds various HTTP headers to make your app more secure.
  // Content-Security-Policy is disabled because it's highly specific to the
  // frontend application and better managed there or via a reverse proxy.
  fastify.register(helmet, {
    contentSecurityPolicy: false,
  });

  // Rate limiting to prevent brute-force attacks.
  // In a real production environment, this configuration should be
  // more sophisticated, perhaps based on IP or user ID, and stored in Redis.
  await fastify.register(rateLimit, {
    max: 100, // max requests per window
    timeWindow: '1 minute',
    // Example of a custom key generator
    // keyGenerator: (req) => req.headers['x-real-ip'] || req.ip,
  });

  fastify.log.info('Security plugins (Helmet, RateLimit) registered.');
});

app.ts 中注册这个插件,就能为所有路由提供基础保护。

3. 防御层二:严格的 API 输入模式验证

Fastify 的核心优势之一是基于 JSON Schema 的高效验证。这不仅是为了保证数据格式正确,更是重要的安全措施。它可以有效防止 NoSQL 注入、原型链污染等基于数据结构的攻击。我们为每个路由都定义严格的 schema。

src/modules/auth/auth.schema.ts:

import { z } from 'zod';
import { buildJsonSchemas } from 'fastify-zod';

// Zod provides a much better developer experience for defining schemas.
const loginSchema = z.object({
  email: z.string().email('Invalid email format'),
  password: z.string().min(8, 'Password must be at least 8 characters long'),
});

// Type inference for our application code
export type LoginInput = z.infer<typeof loginSchema>;

// Build JSON Schemas that Fastify can understand
export const { schemas: authSchemas, $ref } = buildJsonSchemas({
  loginSchema,
}, { $id: 'AuthSchemas' });

app.ts 中添加这些 schema:

// src/app.ts
// ...
import { authSchemas } from './modules/auth/auth.schema';

// ...
async function build() {
  const app = Fastify({ logger: { level: 'info' } });
  
  // Add all schemas to the fastify instance
  for (const schema of [...authSchemas]) {
    app.addSchema(schema);
  }

  // Register plugins and routes...
  return app;
}

在路由中使用它:
src/modules/auth/auth.controller.ts:

import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
import { $ref } from './auth.schema';
import { LoginInput } from './auth.schema';
import { verifyUserPassword } from './auth.service';
import { signJwt } from '../../utils/jwt';

async function authRoutes(server: FastifyInstance) {
  server.post(
    '/login',
    {
      schema: {
        body: $ref('loginSchema'),
        response: {
          200: {
            type: 'object',
            properties: {
              accessToken: { type: 'string' },
            },
          },
        },
      },
    },
    async (request: FastifyRequest<{ Body: LoginInput }>, reply: FastifyReply) => {
      const { email, password } = request.body;

      // In a real app, you'd fetch the user from a database
      const user = await verifyUserPassword({ email, password });
      
      if (!user) {
        return reply.code(401).send({ message: 'Invalid email or password' });
      }

      // Generate a JWT
      const accessToken = signJwt({ id: user.id, email: user.email, roles: user.roles });

      return { accessToken };
    }
  );
}

export default authRoutes;

任何不符合 loginSchema 的请求都会被 Fastify 在进入 handler 之前以 400 Bad Request 拒绝,这极大地减少了业务代码中需要处理的边界情况。

4. 防御层三:基于 JWT 的认证与授权

这是保护私有数据的核心。我们使用 JSON Web Tokens (JWT) 来管理用户会话。

src/utils/jwt.ts:

import jwt from 'jsonwebtoken';

// In a real project, these should be stored securely in environment variables.
const JWT_SECRET = process.env.JWT_SECRET || 'a-very-secret-key-that-is-long-and-random';
const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || '15m';

export interface UserPayload {
  id: number;
  email: string;
  roles: string[];
}

export function signJwt(payload: UserPayload): string {
  // Never put sensitive information in the payload that isn't necessary.
  return jwt.sign(payload, JWT_SECRET, {
    expiresIn: JWT_EXPIRES_IN,
  });
}

export function verifyJwt(token: string): UserPayload | null {
  try {
    const decoded = jwt.verify(token, JWT_SECRET) as UserPayload;
    return decoded;
  } catch (error) {
    // This will catch expired tokens, malformed tokens, etc.
    return null;
  }
}

为了保护需要认证的路由,我们创建一个 preHandler hook。这个 hook 会在路由处理器执行前运行,检查 JWT 的有效性。

src/plugins/auth.ts:

import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import fp from 'fastify-plugin';
import { verifyJwt, UserPayload } from '../utils/jwt';

// Using module augmentation to add a 'user' property to the FastifyRequest interface
declare module 'fastify' {
  interface FastifyRequest {
    user: UserPayload | null;
  }
}

async function authPlugin(fastify: FastifyInstance) {
  // Decorate request with a 'user' property
  fastify.decorate('user', null);

  // Hook to populate req.user from JWT
  fastify.addHook('onRequest', async (request: FastifyRequest, reply: FastifyReply) => {
    const authHeader = request.headers.authorization;
    if (authHeader && authHeader.startsWith('Bearer ')) {
      const token = authHeader.substring(7);
      const decoded = verifyJwt(token);
      request.user = decoded;
    }
  });

  // A decorator to centralize authentication logic
  fastify.decorate('authenticate', async (request: FastifyRequest, reply: FastifyReply) => {
    if (!request.user) {
      return reply.code(401).send({ message: 'Authentication required' });
    }
  });

  // A decorator for role-based access control (RBAC)
  fastify.decorate('authorize', (allowedRoles: string[]) => {
    return async (request: FastifyRequest, reply: FastifyReply) => {
      // This should run after 'authenticate'
      if (!request.user || !allowedRoles.some(role => request.user.roles.includes(role))) {
        return reply.code(403).send({ message: 'Forbidden: Insufficient permissions' });
      }
    };
  });
}

export default fp(authPlugin);

现在,保护一个路由变得非常声明式:
src/modules/content/content.controller.ts:

import { FastifyInstance, FastifyRequest } from 'fastify';

// This is a type declaration extending FastifyInstance
// to include our custom decorators.
interface AppInstance extends FastifyInstance {
  authenticate: (req: FastifyRequest, reply: any) => Promise<void>;
  authorize: (roles: string[]) => (req: FastifyRequest, reply: any) => Promise<void>;
}

async function contentRoutes(server: AppInstance) {
  // Route only accessible to authenticated users
  server.get('/me', {
    preHandler: [server.authenticate],
  }, async (request) => {
    return request.user;
  });

  // Route only accessible to users with the 'admin' role
  server.get('/admin/dashboard', {
    preHandler: [server.authenticate, server.authorize(['admin'])],
  }, async (request) => {
    return {
      message: `Welcome, admin ${request.user?.email}! Here is the secret dashboard data.`,
    };
  });
}

export default contentRoutes;

这种分层的、基于 hook 和 decorator 的设计,让安全逻辑与业务逻辑清晰分离,极大地提高了代码的可维护性。

测试驱动安全:使用 Vitest 验证防御体系

没有测试的安全实现是不可信的。我们必须编写测试用例,主动模拟攻击者,验证每一层防御是否都按预期工作。

vitest.config.ts:

import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    globals: true,
    environment: 'node',
    // We need to setup and teardown our server for integration tests
    // globalSetup: './test/setup.ts',
    include: ['test/**/*.test.ts'],
  },
});

test/integration/content.test.ts:

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { FastifyInstance } from 'fastify';
import { build } from '../../src/app';
import { signJwt } from '../../src/utils/jwt';

describe('Content Routes Security', () => {
  let app: FastifyInstance;

  beforeAll(async () => {
    // Build a test instance of our app
    app = await build();
    await app.ready();
  });

  afterAll(async () => {
    await app.close();
  });

  describe('GET /me', () => {
    it('should return 401 Unauthorized if no token is provided', async () => {
      const response = await app.inject({
        method: 'GET',
        url: '/api/content/me',
      });
      expect(response.statusCode).toBe(401);
      expect(JSON.parse(response.payload).message).toBe('Authentication required');
    });

    it('should return 401 Unauthorized if token is invalid or expired', async () => {
      const response = await app.inject({
        method: 'GET',
        url: '/api/content/me',
        headers: {
          authorization: 'Bearer invalidtoken123',
        },
      });
      expect(response.statusCode).toBe(401);
    });

    it('should return user info if a valid token is provided', async () => {
      const userPayload = { id: 1, email: '[email protected]', roles: ['user'] };
      const token = signJwt(userPayload);

      const response = await app.inject({
        method: 'GET',
        url: '/api/content/me',
        headers: {
          authorization: `Bearer ${token}`,
        },
      });

      expect(response.statusCode).toBe(200);
      const payload = JSON.parse(response.payload);
      expect(payload.id).toBe(userPayload.id);
      expect(payload.email).toBe(userPayload.email);
    });
  });

  describe('GET /admin/dashboard', () => {
    it('should return 403 Forbidden for a user without admin role', async () => {
      // User with 'user' role, but not 'admin'
      const userPayload = { id: 2, email: '[email protected]', roles: ['user'] };
      const token = signJwt(userPayload);

      const response = await app.inject({
        method: 'GET',
        url: '/api/content/admin/dashboard',
        headers: {
          authorization: `Bearer ${token}`,
        },
      });
      expect(response.statusCode).toBe(403);
      expect(JSON.parse(response.payload).message).toContain('Insufficient permissions');
    });

    it('should return 200 OK for a user with admin role', async () => {
      // User with 'admin' role
      const adminPayload = { id: 3, email: '[email protected]', roles: ['user', 'admin'] };
      const token = signJwt(adminPayload);

      const response = await app.inject({
        method: 'GET',
        url: '/api/content/admin/dashboard',
        headers: {
          authorization: `Bearer ${token}`,
        },
      });
      expect(response.statusCode).toBe(200);
      expect(JSON.parse(response.payload).message).toContain('Welcome, admin');
    });
  });
});

这些集成测试直接调用 API 端点,精确模拟了各种场景:无凭证访问、无效凭证访问、权限不足访问和正常访问。当这些测试通过时,我们对安全逻辑的信心就有了坚实的基础。将这些测试集成到 CI/CD 流水线中,就实现了“安全左移”——在代码合并到主分支之前,就完成了对核心安全策略的自动化验证。

架构的局限性与未来迭代方向

当前这套架构为 SSG 应用提供了一个相当稳固的安全数据层,但它并非完美无瑕。

首先,JWT 的存储和刷新机制是一个需要谨慎处理的问题。将 JWT 存储在 localStorage 中存在 XSS 风险。更安全的做法是使用 httpOnlysecure 的 Cookie 来存储 Refresh Token,并用生命周期极短的 Access Token 进行 API 调用,这会增加客户端的复杂度。

其次,当前的速率限制是全局的,颗粒度较粗。在生产环境中,应该基于用户 ID 或 IP 地址实现更精细的限制,并将计数器存储在 Redis 等外部存储中,以支持多实例部署。

最后,随着业务复杂度的增加,角色权限管理(RBAC)可能会演变为更复杂的属性访问控制(ABAC)。届时,简单的角色数组检查将不再适用,可能需要引入类似 Casbin 这样的权限管理库,或者对接一个外部的身份与访问管理(IAM)服务。

未来的迭代路径可以探索使用 OIDC 协议对接第三方身份提供商(如 Auth0, Okta),将用户认证的重任委托给更专业的服务,使我们的 API 更专注于核心业务逻辑的授权与服务。


  目录