From d5d04aa96d128c957b116e23acde3f2249fa1fde Mon Sep 17 00:00:00 2001 From: zhangxiang Date: Sun, 2 Aug 2026 09:11:36 +0800 Subject: [PATCH] feat: implement frontend-backend encrypted communication via AES-256-GCM MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 参考 novavis-authority 的加解密方案,实现前后端通信的应用层加密: - 重写 src/lib/crypto.ts 使用 Web Crypto API(浏览器兼容),PBKDF2+AES-256-GCM - 新增 src/lib/crypto-server.ts 服务端加解密工具(Node.js crypto) - 新增 src/lib/api-crypto.ts API 路由中间件 withCrypto(),自动解密请求体/加密响应体 - 更新 src/lib/admin-api.ts 自动加密所有请求/解密响应 - 所有 11 个 admin API 路由文件已应用 withCrypto 包装器 - 更新 .env 文件,添加 NEXT_PUBLIC_ENCRYPTION_SECRET 和 ENCRYPTION_SECRET --- .env.example | 7 + .../admin/items/[id]/workflow/route.test.ts | 2 + .../api/admin/items/[id]/workflow/route.ts | 7 +- src/app/api/admin/items/route.test.ts | 1 + src/app/api/admin/items/route.ts | 17 +- src/app/api/admin/media/route.test.ts | 1 + src/app/api/admin/media/route.ts | 13 +- src/app/api/admin/models/route.test.ts | 1 + src/app/api/admin/models/route.ts | 5 +- src/app/api/admin/notifications/route.test.ts | 2 +- src/app/api/admin/notifications/route.ts | 5 +- .../notifications/unread-count/route.test.ts | 2 +- .../admin/notifications/unread-count/route.ts | 5 +- src/app/api/admin/roles/route.test.ts | 1 + src/app/api/admin/roles/route.ts | 9 +- src/app/api/admin/stats/route.ts | 5 +- src/app/api/admin/users/route.ts | 17 +- src/app/api/admin/zones/route.test.ts | 1 + src/app/api/admin/zones/route.ts | 17 +- src/lib/admin-api.ts | 57 ++++-- src/lib/api-crypto.ts | 170 ++++++++++++++++++ src/lib/crypto-server.ts | 88 +++++++++ src/lib/crypto.ts | 132 ++++++++------ src/lib/permissions.ts | 4 +- 24 files changed, 449 insertions(+), 120 deletions(-) create mode 100644 src/lib/api-crypto.ts create mode 100644 src/lib/crypto-server.ts diff --git a/.env.example b/.env.example index cc9938e..2cb1564 100644 --- a/.env.example +++ b/.env.example @@ -20,3 +20,10 @@ CMS_REVALIDATE_SECRET=change-me-in-production-min-32-bytes # CDN 配置(可选) CDN_DOMAIN= + +# 前后端通信加密密钥(生产环境必须替换为高强度随机字符串,不得低于 12 字符) +# 前端: NEXT_PUBLIC_ENCRYPTION_SECRET 编译时注入,后端: ENCRYPTION_SECRET 运行时环境变量 +# 前后端值必须保持一致,否则加解密会失败 +# 可使用 openssl rand -base64 32 生成 +NEXT_PUBLIC_ENCRYPTION_SECRET=change-me-in-production-min-12-chars +ENCRYPTION_SECRET=change-me-in-production-min-12-chars diff --git a/src/app/api/admin/items/[id]/workflow/route.test.ts b/src/app/api/admin/items/[id]/workflow/route.test.ts index 2be23fc..4a02ebb 100644 --- a/src/app/api/admin/items/[id]/workflow/route.test.ts +++ b/src/app/api/admin/items/[id]/workflow/route.test.ts @@ -42,6 +42,8 @@ import { POST } from './route'; function createMockRequest(body: Record): NextRequest { return { + headers: new Headers(), + url: 'http://localhost:3000/api/admin/items/item-1/workflow', json: async () => body, } as unknown as NextRequest; } diff --git a/src/app/api/admin/items/[id]/workflow/route.ts b/src/app/api/admin/items/[id]/workflow/route.ts index 940e700..f2b19da 100644 --- a/src/app/api/admin/items/[id]/workflow/route.ts +++ b/src/app/api/admin/items/[id]/workflow/route.ts @@ -15,6 +15,7 @@ import { internalError, forbidden, } from '@/lib/api-response'; +import { withCrypto } from '@/lib/api-crypto'; function parseItem(item: { data: string; [key: string]: unknown }) { return { ...item, data: JSON.parse(item.data as string) }; @@ -32,10 +33,10 @@ const ACTION_PERMISSION: Record = { archive: 'publish', }; -export async function POST( +export const POST = withCrypto(async ( request: NextRequest, { params }: { params: Promise<{ id: string }> } -) { +) => { const { id } = await params; try { @@ -90,4 +91,4 @@ export async function POST( console.error('Workflow error:', error); return internalError(); } -} +}); diff --git a/src/app/api/admin/items/route.test.ts b/src/app/api/admin/items/route.test.ts index b74217e..9881ffb 100644 --- a/src/app/api/admin/items/route.test.ts +++ b/src/app/api/admin/items/route.test.ts @@ -46,6 +46,7 @@ function createMockRequest(options: { json?: () => Promise>; }): NextRequest { return { + headers: new Headers(), url: options.url || 'http://localhost/api/admin/items', json: options.json || (async () => ({})), } as unknown as NextRequest; diff --git a/src/app/api/admin/items/route.ts b/src/app/api/admin/items/route.ts index b367440..a643ebe 100644 --- a/src/app/api/admin/items/route.ts +++ b/src/app/api/admin/items/route.ts @@ -7,6 +7,7 @@ import { validationError, internalError, } from '@/lib/api-response'; +import { withCrypto } from '@/lib/api-crypto'; function parseItem(item: { data: string; [key: string]: unknown }) { return { ...item, data: JSON.parse(item.data as string) }; @@ -46,7 +47,7 @@ async function ensureUniqueSlug( } // GET /api/admin/items - 获取内容列表 -export async function GET(request: NextRequest) { +export const GET = withCrypto(async (request: NextRequest) => { const { searchParams } = new URL(request.url); const modelCode = searchParams.get('modelCode'); @@ -91,10 +92,10 @@ export async function GET(request: NextRequest) { console.error('Get items error:', error); return internalError(); } -} +}); // POST /api/admin/items - 创建内容 -export async function POST(request: NextRequest) { +export const POST = withCrypto(async (request: NextRequest) => { try { const body = await request.json(); const { modelId, modelCode, title, slug, data, status, sortOrder } = body; @@ -151,10 +152,10 @@ export async function POST(request: NextRequest) { console.error('Create item error:', error); return internalError(); } -} +}); // PUT /api/admin/items/[id] - 更新内容 -export async function PUT(request: NextRequest) { +export const PUT = withCrypto(async (request: NextRequest) => { const { searchParams } = new URL(request.url); const id = searchParams.get('id'); if (!id) return validationError('缺少 ID'); @@ -218,10 +219,10 @@ export async function PUT(request: NextRequest) { console.error('Update item error:', error); return internalError(); } -} +}); // DELETE /api/admin/items/[id] - 删除内容 -export async function DELETE(request: NextRequest) { +export const DELETE = withCrypto(async (request: NextRequest) => { const { searchParams } = new URL(request.url); const id = searchParams.get('id'); if (!id) return validationError('缺少 ID'); @@ -250,4 +251,4 @@ export async function DELETE(request: NextRequest) { console.error('Delete item error:', error); return internalError(); } -} \ No newline at end of file +}); \ No newline at end of file diff --git a/src/app/api/admin/media/route.test.ts b/src/app/api/admin/media/route.test.ts index 068db51..542c190 100644 --- a/src/app/api/admin/media/route.test.ts +++ b/src/app/api/admin/media/route.test.ts @@ -28,6 +28,7 @@ function createMockRequest(options: { formData?: () => Promise; }): NextRequest { return { + headers: new Headers(), url: options.url || 'http://localhost/api/admin/media', formData: options.formData || (async () => new FormData()), } as unknown as NextRequest; diff --git a/src/app/api/admin/media/route.ts b/src/app/api/admin/media/route.ts index 1f3085f..47a99a3 100644 --- a/src/app/api/admin/media/route.ts +++ b/src/app/api/admin/media/route.ts @@ -12,12 +12,13 @@ import { validationError, internalError, } from '@/lib/api-response'; +import { withCrypto } from '@/lib/api-crypto'; const MODEL_CODE = 'media'; const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB // GET /api/admin/media - 获取媒体列表或按 ID 查询 -export async function GET(request: NextRequest) { +export const GET = withCrypto(async (request: NextRequest) => { const permission = await requirePermission(request, MODEL_CODE, 'read'); if ('response' in permission) return permission.response; @@ -41,10 +42,10 @@ export async function GET(request: NextRequest) { console.error('Get media error:', error); return internalError(); } -} +}); // POST /api/admin/media - 上传媒体文件(支持单文件/多文件) -export async function POST(request: NextRequest) { +export const POST = withCrypto(async (request: NextRequest) => { const permission = await requirePermission(request, MODEL_CODE, 'create'); if ('response' in permission) return permission.response; @@ -94,10 +95,10 @@ export async function POST(request: NextRequest) { console.error('Upload media error:', error); return internalError(); } -} +}); // DELETE /api/admin/media?id=xxx - 删除媒体文件 -export async function DELETE(request: NextRequest) { +export const DELETE = withCrypto(async (request: NextRequest) => { const permission = await requirePermission(request, MODEL_CODE, 'delete'); if ('response' in permission) return permission.response; @@ -115,4 +116,4 @@ export async function DELETE(request: NextRequest) { } return internalError(); } -} +}); diff --git a/src/app/api/admin/models/route.test.ts b/src/app/api/admin/models/route.test.ts index 8bbf787..6313274 100644 --- a/src/app/api/admin/models/route.test.ts +++ b/src/app/api/admin/models/route.test.ts @@ -27,6 +27,7 @@ import { GET } from './route'; function createMockRequest(): NextRequest { return { + headers: new Headers(), url: 'http://localhost/api/admin/models', } as unknown as NextRequest; } diff --git a/src/app/api/admin/models/route.ts b/src/app/api/admin/models/route.ts index f0aecbc..3d476de 100644 --- a/src/app/api/admin/models/route.ts +++ b/src/app/api/admin/models/route.ts @@ -2,9 +2,10 @@ import { NextRequest } from 'next/server'; import { prisma } from '@/lib/db'; import { requirePermission } from '@/lib/permissions'; import { success, internalError } from '@/lib/api-response'; +import { withCrypto } from '@/lib/api-crypto'; // GET /api/admin/models - 获取所有内容模型 -export async function GET(request: NextRequest) { +export const GET = withCrypto(async (request: NextRequest) => { const permission = await requirePermission(request, 'content-model', 'read'); if ('response' in permission) return permission.response; @@ -21,4 +22,4 @@ export async function GET(request: NextRequest) { console.error('Get models error:', error); return internalError(); } -} \ No newline at end of file +}); \ No newline at end of file diff --git a/src/app/api/admin/notifications/route.test.ts b/src/app/api/admin/notifications/route.test.ts index bb051de..b700f37 100644 --- a/src/app/api/admin/notifications/route.test.ts +++ b/src/app/api/admin/notifications/route.test.ts @@ -20,7 +20,7 @@ jest.unmock('./route'); import { GET } from './route'; function createMockRequest(url: string): NextRequest { - return { url } as unknown as NextRequest; + return { headers: new Headers(), url } as unknown as NextRequest; } beforeEach(() => { diff --git a/src/app/api/admin/notifications/route.ts b/src/app/api/admin/notifications/route.ts index 913bc05..f860d75 100644 --- a/src/app/api/admin/notifications/route.ts +++ b/src/app/api/admin/notifications/route.ts @@ -2,9 +2,10 @@ import { NextRequest } from 'next/server'; import { authenticateRequest } from '@/lib/auth'; import { getUserNotifications } from '@/lib/cms/notifications'; import { success, unauthorized, validationError, internalError } from '@/lib/api-response'; +import { withCrypto } from '@/lib/api-crypto'; // GET /api/admin/notifications - 获取当前用户通知列表 -export async function GET(request: NextRequest) { +export const GET = withCrypto(async (request: NextRequest) => { const user = authenticateRequest(request); if (!user) return unauthorized(); @@ -27,4 +28,4 @@ export async function GET(request: NextRequest) { console.error('Get notifications error:', error); return internalError(); } -} +}); diff --git a/src/app/api/admin/notifications/unread-count/route.test.ts b/src/app/api/admin/notifications/unread-count/route.test.ts index 71f6dc3..abf881c 100644 --- a/src/app/api/admin/notifications/unread-count/route.test.ts +++ b/src/app/api/admin/notifications/unread-count/route.test.ts @@ -20,7 +20,7 @@ jest.unmock('./route'); import { GET } from './route'; function createMockRequest(): NextRequest { - return {} as unknown as NextRequest; + return { headers: new Headers() } as unknown as NextRequest; } beforeEach(() => { diff --git a/src/app/api/admin/notifications/unread-count/route.ts b/src/app/api/admin/notifications/unread-count/route.ts index 24e3629..c9f04ed 100644 --- a/src/app/api/admin/notifications/unread-count/route.ts +++ b/src/app/api/admin/notifications/unread-count/route.ts @@ -2,9 +2,10 @@ import { NextRequest } from 'next/server'; import { authenticateRequest } from '@/lib/auth'; import { getUnreadCount } from '@/lib/cms/notifications'; import { success, unauthorized, internalError } from '@/lib/api-response'; +import { withCrypto } from '@/lib/api-crypto'; // GET /api/admin/notifications/unread-count - 获取当前用户未读通知数量 -export async function GET(request: NextRequest) { +export const GET = withCrypto(async (request: NextRequest) => { const user = authenticateRequest(request); if (!user) return unauthorized(); @@ -15,4 +16,4 @@ export async function GET(request: NextRequest) { console.error('Get unread count error:', error); return internalError(); } -} +}); diff --git a/src/app/api/admin/roles/route.test.ts b/src/app/api/admin/roles/route.test.ts index 4c9b7e9..95224a7 100644 --- a/src/app/api/admin/roles/route.test.ts +++ b/src/app/api/admin/roles/route.test.ts @@ -43,6 +43,7 @@ import { GET, PUT } from './route'; function createMockRequest(body?: Record): NextRequest { return { + headers: new Headers(), json: async () => body ?? {}, } as unknown as NextRequest; } diff --git a/src/app/api/admin/roles/route.ts b/src/app/api/admin/roles/route.ts index 2d73e49..9696d36 100644 --- a/src/app/api/admin/roles/route.ts +++ b/src/app/api/admin/roles/route.ts @@ -2,12 +2,13 @@ import { NextRequest } from 'next/server'; import { prisma } from '@/lib/db'; import { authenticateRequest } from '@/lib/auth'; import { success, unauthorized, forbidden, internalError, validationError } from '@/lib/api-response'; +import { withCrypto } from '@/lib/api-crypto'; // 内置角色,不允许删除 const BUILTIN_ROLES = new Set(['super_admin', 'content_admin', 'content_editor', 'reviewer', 'readonly']); // GET /api/admin/roles - 获取角色列表及其权限 -export async function GET(request: NextRequest) { +export const GET = withCrypto(async (request: NextRequest) => { const user = authenticateRequest(request); if (!user) return unauthorized(); @@ -46,10 +47,10 @@ export async function GET(request: NextRequest) { console.error('Get roles error:', error); return internalError(); } -} +}); // PUT /api/admin/roles/:roleCode - 更新角色权限 -export async function PUT(request: NextRequest) { +export const PUT = withCrypto(async (request: NextRequest) => { const user = authenticateRequest(request); if (!user) return unauthorized(); @@ -100,4 +101,4 @@ export async function PUT(request: NextRequest) { console.error('Update roles error:', error); return internalError(); } -} +}); diff --git a/src/app/api/admin/stats/route.ts b/src/app/api/admin/stats/route.ts index 9bbc41e..91c0ecb 100644 --- a/src/app/api/admin/stats/route.ts +++ b/src/app/api/admin/stats/route.ts @@ -2,9 +2,10 @@ import { NextRequest } from 'next/server'; import { prisma } from '@/lib/db'; import { authenticateRequest } from '@/lib/auth'; import { success, unauthorized, internalError } from '@/lib/api-response'; +import { withCrypto } from '@/lib/api-crypto'; // GET /api/admin/stats - 获取仪表盘统计数据 -export async function GET(request: NextRequest) { +export const GET = withCrypto(async (request: NextRequest) => { const user = authenticateRequest(request); if (!user) return unauthorized(); @@ -79,4 +80,4 @@ export async function GET(request: NextRequest) { console.error('Get stats error:', error); return internalError(); } -} \ No newline at end of file +}); \ No newline at end of file diff --git a/src/app/api/admin/users/route.ts b/src/app/api/admin/users/route.ts index b7d9ff7..87c53a9 100644 --- a/src/app/api/admin/users/route.ts +++ b/src/app/api/admin/users/route.ts @@ -2,9 +2,10 @@ import { NextRequest } from 'next/server'; import { prisma } from '@/lib/db'; import { authenticateRequest, hashPassword } from '@/lib/auth'; import { success, unauthorized, forbidden, internalError, validationError } from '@/lib/api-response'; +import { withCrypto } from '@/lib/api-crypto'; // GET /api/admin/users - 获取用户列表 -export async function GET(request: NextRequest) { +export const GET = withCrypto(async (request: NextRequest) => { const user = authenticateRequest(request); if (!user) return unauthorized(); @@ -77,10 +78,10 @@ export async function GET(request: NextRequest) { console.error('Get users error:', error); return internalError(); } -} +}); // POST /api/admin/users - 创建用户 -export async function POST(request: NextRequest) { +export const POST = withCrypto(async (request: NextRequest) => { const user = authenticateRequest(request); if (!user) return unauthorized(); @@ -153,10 +154,10 @@ export async function POST(request: NextRequest) { console.error('Create user error:', error); return internalError(); } -} +}); // PUT /api/admin/users - 更新用户 -export async function PUT(request: NextRequest) { +export const PUT = withCrypto(async (request: NextRequest) => { const currentUser = authenticateRequest(request); if (!currentUser) return unauthorized(); @@ -220,10 +221,10 @@ export async function PUT(request: NextRequest) { console.error('Update user error:', error); return internalError(); } -} +}); // DELETE /api/admin/users - 删除用户 -export async function DELETE(request: NextRequest) { +export const DELETE = withCrypto(async (request: NextRequest) => { const currentUser = authenticateRequest(request); if (!currentUser) return unauthorized(); @@ -260,4 +261,4 @@ export async function DELETE(request: NextRequest) { console.error('Delete user error:', error); return internalError(); } -} \ No newline at end of file +}); \ No newline at end of file diff --git a/src/app/api/admin/zones/route.test.ts b/src/app/api/admin/zones/route.test.ts index 8915c36..5a8778d 100644 --- a/src/app/api/admin/zones/route.test.ts +++ b/src/app/api/admin/zones/route.test.ts @@ -42,6 +42,7 @@ function createMockRequest(options: { json?: () => Promise>; }): NextRequest { return { + headers: new Headers(), url: options.url || 'http://localhost/api/admin/zones', json: options.json || (async () => ({})), } as unknown as NextRequest; diff --git a/src/app/api/admin/zones/route.ts b/src/app/api/admin/zones/route.ts index 754bb28..7bd029a 100644 --- a/src/app/api/admin/zones/route.ts +++ b/src/app/api/admin/zones/route.ts @@ -7,9 +7,10 @@ import { validationError, internalError, } from '@/lib/api-response'; +import { withCrypto } from '@/lib/api-crypto'; // GET /api/admin/zones - 获取所有内容区域 -export async function GET(request: NextRequest) { +export const GET = withCrypto(async (request: NextRequest) => { const permission = await requirePermission(request, 'content-zone', 'read'); if ('response' in permission) return permission.response; @@ -30,10 +31,10 @@ export async function GET(request: NextRequest) { console.error('Get zones error:', error); return internalError(); } -} +}); // POST /api/admin/zones - 创建/更新内容区域 -export async function POST(request: NextRequest) { +export const POST = withCrypto(async (request: NextRequest) => { const permission = await requirePermission(request, 'content-zone', 'update'); if ('response' in permission) return permission.response; @@ -95,15 +96,15 @@ export async function POST(request: NextRequest) { console.error('Save zone error:', error); return internalError(); } -} +}); // PUT /api/admin/zones - 更新区域设置 -export async function PUT(request: NextRequest) { +export const PUT = withCrypto(async (request: NextRequest) => { return POST(request); -} +}); // DELETE /api/admin/zones/[id] - 删除内容区域 -export async function DELETE(request: NextRequest) { +export const DELETE = withCrypto(async (request: NextRequest) => { const permission = await requirePermission(request, 'content-zone', 'delete'); if ('response' in permission) return permission.response; @@ -132,4 +133,4 @@ export async function DELETE(request: NextRequest) { console.error('Delete zone error:', error); return internalError(); } -} +}); diff --git a/src/lib/admin-api.ts b/src/lib/admin-api.ts index be90f4e..c523bff 100644 --- a/src/lib/admin-api.ts +++ b/src/lib/admin-api.ts @@ -8,13 +8,19 @@ class AdminApiClient { return localStorage.getItem('novalon_admin_token'); } + /** + * 检查前端加密是否可用(NEXT_PUBLIC_ENCRYPTION_SECRET 已配置) + */ + private isEncryptionAvailable(): boolean { + return !!process.env.NEXT_PUBLIC_ENCRYPTION_SECRET; + } + async request( path: string, - options: RequestInit & { encrypt?: boolean } = {}, + options: RequestInit = {}, ): Promise { const token = this.getToken(); - // 当前服务端 API 未实现请求体解密,默认不加密;如需加密,调用方显式传入 encrypt: true。 - const shouldEncrypt = options.encrypt === true && token; + const shouldEncrypt = this.isEncryptionAvailable(); const headers: Record = { ...(options.headers as Record), @@ -26,19 +32,24 @@ class AdminApiClient { let body = options.body; + // 自动加密请求体(有 body 且加密可用时) if (shouldEncrypt && body && token) { try { - const parsed = JSON.parse(body as string); - const encrypted = encrypt(parsed, token); + const parsed = typeof body === 'string' ? JSON.parse(body) : body; + const jsonStr = typeof parsed === 'string' ? parsed : JSON.stringify(parsed); + const encrypted = await encrypt(jsonStr); headers['Content-Type'] = 'application/json'; - headers['X-Encrypted'] = '1'; + headers['X-Encrypted'] = 'true'; body = JSON.stringify({ data: encrypted }); } catch { - // 如果不是 JSON 或加密失败,使用原始 body + // 加密失败时降级为明文传输 if (!headers['Content-Type']) { headers['Content-Type'] = 'application/json'; } } + } else if (shouldEncrypt && token) { + // 无 body 的请求(GET/DELETE)也要标记加密,让后端加密响应 + headers['X-Encrypted'] = 'true'; } else if (body && !headers['Content-Type']) { headers['Content-Type'] = 'application/json'; } @@ -58,22 +69,34 @@ class AdminApiClient { throw new Error('未授权'); } + // 解密响应体 + const isEncryptedResponse = res.headers.get('X-Encrypted') === 'true'; + if (isEncryptedResponse) { + try { + const responseText = await res.text(); + const responseJson = JSON.parse(responseText); + const encryptedData = responseJson.data as string; + if (encryptedData) { + const decryptedText = await decrypt(encryptedData); + return JSON.parse(decryptedText) as T; + } + return responseJson as T; + } catch (e) { + // 解密失败时尝试读取原始 JSON + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data.error || data.message || `请求失败 (${res.status})`); + } + throw e; + } + } + if (!res.ok) { const data = await res.json().catch(() => ({})); throw new Error(data.error || data.message || `请求失败 (${res.status})`); } const data = await res.json(); - - // 解密响应 - if (shouldEncrypt && token && data.encrypted) { - try { - return decrypt(data.encrypted, token); - } catch { - return data as T; - } - } - return data as T; } diff --git a/src/lib/api-crypto.ts b/src/lib/api-crypto.ts new file mode 100644 index 0000000..0f09f73 --- /dev/null +++ b/src/lib/api-crypto.ts @@ -0,0 +1,170 @@ +/** + * API 路由加解密中间件 + * + * 参考 novavis-authority 的 CryptoFilter,在 Next.js API Route 中实现: + * - 请求体:当 X-Encrypted: true 时,自动解密请求体 JSON + * - 响应体:当请求携带 X-Encrypted: true 时,自动加密响应体 + * + * 用法: + * ```typescript + * import { withCrypto } from '@/lib/api-crypto'; + * + * export const POST = withCrypto(async (request) => { + * const body = await request.json(); // 已自动解密 + * return Response.json({ data: '敏感数据' }); + * }); + * ``` + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { encrypt, decrypt, isEncryptionAvailable } from '@/lib/crypto-server'; + +const ENCRYPTED_HEADER = 'x-encrypted'; + +interface CryptoContext { + /** 原始请求是否携带了 X-Encrypted 头 */ + isEncrypted: boolean; +} + +/** + * 包装 API Route handler,自动处理请求体解密和响应体加密 + */ +export function withCrypto = Record>( + handler: ( + request: NextRequest, + context: CryptoContext & T, + ) => Promise, +): (request: NextRequest, routeContext?: T) => Promise { + return async (request: NextRequest, routeContext?: T) => { + const ctx = (routeContext ?? {}) as T; + const isEncrypted = + request.headers.get(ENCRYPTED_HEADER)?.toLowerCase() === 'true'; + + if (!isEncrypted || !isEncryptionAvailable()) { + // 未加密请求直接透传 + return handler(request, { ...ctx, isEncrypted: false } as CryptoContext & T); + } + + // ---- 解密请求体 ---- + const contentLength = request.headers.get('content-length'); + const hasBody = contentLength && parseInt(contentLength) > 0; + + if (hasBody) { + try { + const cloned = request.clone(); + const bodyText = await cloned.text(); + + if (bodyText) { + const bodyJson = JSON.parse(bodyText); + const encryptedData = bodyJson.data as string | undefined; + + if (encryptedData) { + const decryptedText = decrypt(encryptedData); + + // 重建 request,将解密后的 JSON 注入 body + const newHeaders = new Headers(request.headers); + newHeaders.set('content-type', 'application/json'); + newHeaders.delete('content-length'); // 让框架自动计算 + + const newRequest = new NextRequest(request.url, { + method: request.method, + headers: newHeaders, + body: decryptedText, + }); + + // 传递解密后的请求给 handler,并加密响应 + const response = await handler(newRequest, { ...ctx, isEncrypted: true } as CryptoContext & T); + return encryptResponse(response); + } + } + } catch (e) { + console.error('[Crypto] Request body decrypt failed:', e); + return NextResponse.json( + { + error: '请求体解密失败', + message: e instanceof Error ? e.message : 'unknown error', + }, + { status: 400 }, + ); + } + } + + // 无 body 的请求(GET/DELETE)仍需要加密响应 + const response = await handler(request, { ...ctx, isEncrypted: true } as CryptoContext & T); + return encryptResponse(response); + }; +} + +/** + * 加密响应体 + */ +async function encryptResponse( + response: NextResponse, +): Promise { + // 跳过非 JSON 响应(如流、文件下载) + const contentType = response.headers.get('content-type'); + if (contentType && !contentType.includes('application/json')) { + return response; + } + + try { + const cloned = response.clone(); + const bodyText = await cloned.text(); + + if (!bodyText) return response; + + const encrypted = encrypt(bodyText); + + // 用加密后的响应替换原响应 + const newHeaders = new Headers(response.headers); + newHeaders.set('content-type', 'application/json'); + newHeaders.set(ENCRYPTED_HEADER, 'true'); + + return new NextResponse(JSON.stringify({ data: encrypted }), { + status: response.status, + statusText: response.statusText, + headers: newHeaders, + }); + } catch (e) { + console.error('[Crypto] Response encrypt failed:', e); + return response; + } +} + +// ========== 低阶工具函数(供不使用 withCrypto 的 API Route 直接调用)========== + +/** + * 从请求中提取并解密 body 数据 + */ +export async function decryptRequest( + request: NextRequest, +): Promise { + if (!isEncryptionAvailable()) return null; + + const isEncrypted = + request.headers.get(ENCRYPTED_HEADER)?.toLowerCase() === 'true'; + if (!isEncrypted) return null; + + try { + const body = await request.clone().json(); + const encryptedData = body.data as string; + if (!encryptedData) return null; + + const decrypted = decrypt(encryptedData); + return JSON.parse(decrypted) as T; + } catch (e) { + console.error('[Crypto] decryptRequest failed:', e); + return null; + } +} + +/** + * 加密响应数据,设置 X-Encrypted 头 + */ +export function encryptResponseData(data: unknown): NextResponse { + const encrypted = encrypt(JSON.stringify(data)); + return NextResponse.json( + { data: encrypted }, + { headers: { [ENCRYPTED_HEADER]: 'true' } }, + ); +} \ No newline at end of file diff --git a/src/lib/crypto-server.ts b/src/lib/crypto-server.ts new file mode 100644 index 0000000..237d31e --- /dev/null +++ b/src/lib/crypto-server.ts @@ -0,0 +1,88 @@ +/** + * 服务端 AES-256-GCM 加解密工具(Node.js 环境) + * + * 使用 PBKDF2-HMAC-SHA256 从 ENCRYPTION_SECRET 派生密钥, + * 与 crypto.ts 共享相同的算法和参数,实现前后端加解密互通。 + * + * 供 Next.js API Route 在服务端解密请求体、加密响应体。 + */ + +import crypto from 'node:crypto'; + +const ALGORITHM = 'aes-256-gcm'; +const IV_LENGTH = 12; +const AUTH_TAG_LENGTH = 16; +const PBKDF2_ITERATIONS = 100_000; +const PBKDF2_SALT = 'novalon-website-crypto-salt-v1'; +const KEY_LENGTH = 32; // 256 bits + +function getSecret(): string { + const secret = process.env.ENCRYPTION_SECRET; + if (!secret) { + throw new Error('ENCRYPTION_SECRET 未配置,服务端加解密无法初始化'); + } + return secret; +} + +let cachedKey: Buffer | null = null; + +function deriveKey(): Buffer { + if (cachedKey) return cachedKey; + const secret = getSecret(); + cachedKey = crypto.pbkdf2Sync( + secret, + PBKDF2_SALT, + PBKDF2_ITERATIONS, + KEY_LENGTH, + 'sha256', + ); + return cachedKey; +} + +/** + * 加密明文,返回 Base64 编码的密文(12-byte-IV || AES-GCM-ciphertext) + */ +export function encrypt(plaintext: string): string { + const key = deriveKey(); + const iv = crypto.randomBytes(IV_LENGTH); + const cipher = crypto.createCipheriv(ALGORITHM, key, iv); + + const encrypted = Buffer.concat([ + cipher.update(plaintext, 'utf8'), + cipher.final(), + ]); + const authTag = cipher.getAuthTag(); + + // 格式: iv + authTag + encrypted + const combined = Buffer.concat([iv, authTag, encrypted]); + return combined.toString('base64'); +} + +/** + * 解密 Base64 编码的密文,返回明文字符串 + */ +export function decrypt(encryptedBase64: string): string { + const key = deriveKey(); + const combined = Buffer.from(encryptedBase64, 'base64'); + + const iv = combined.subarray(0, IV_LENGTH); + const authTag = combined.subarray(IV_LENGTH, IV_LENGTH + AUTH_TAG_LENGTH); + const encrypted = combined.subarray(IV_LENGTH + AUTH_TAG_LENGTH); + + const decipher = crypto.createDecipheriv(ALGORITHM, key, iv); + decipher.setAuthTag(authTag); + + const decrypted = Buffer.concat([ + decipher.update(encrypted), + decipher.final(), + ]); + + return decrypted.toString('utf8'); +} + +/** + * 验证加密配置是否可用 + */ +export function isEncryptionAvailable(): boolean { + return !!process.env.ENCRYPTION_SECRET; +} \ No newline at end of file diff --git a/src/lib/crypto.ts b/src/lib/crypto.ts index 3d05360..b3ef13b 100644 --- a/src/lib/crypto.ts +++ b/src/lib/crypto.ts @@ -1,69 +1,93 @@ -import crypto from 'crypto'; - -const ALGORITHM = 'aes-256-gcm'; -const IV_LENGTH = 16; -const AUTH_TAG_LENGTH = 16; -const SALT = 'novalon-cms-crypto-salt'; - /** - * 从 JWT token 派生加密密钥 + * 前后端通信 AES-256-GCM 加解密工具(前端浏览器环境) + * + * 使用 PBKDF2-HMAC-SHA256 从 NEXT_PUBLIC_ENCRYPTION_SECRET 派生密钥, + * 与 crypto-server.ts 共享相同的算法和参数,实现前后端加解密互通。 + * + * 参考同级项目 novavis-authority 的加解密方案。 + * + * 输出格式: Base64(12-byte-IV || AES-GCM-ciphertext) */ -function deriveKey(token: string): Buffer { - return crypto.pbkdf2Sync(token, SALT, 10000, 32, 'sha256'); + +const IV_LENGTH = 12; +const ALGORITHM = 'AES-GCM'; +const PBKDF2_ITERATIONS = 100_000; +const PBKDF2_SALT = new TextEncoder().encode('novalon-website-crypto-salt-v1'); + +async function deriveKey(passphrase: string): Promise { + const encoder = new TextEncoder(); + const keyMaterial = await crypto.subtle.importKey( + 'raw', + encoder.encode(passphrase), + 'PBKDF2', + false, + ['deriveBits', 'deriveKey'], + ); + return crypto.subtle.deriveKey( + { name: 'PBKDF2', salt: PBKDF2_SALT, iterations: PBKDF2_ITERATIONS, hash: 'SHA-256' }, + keyMaterial, + { name: ALGORITHM, length: 256 }, + false, + ['encrypt', 'decrypt'], + ); +} + +function getSecret(): string { + const secret = process.env.NEXT_PUBLIC_ENCRYPTION_SECRET; + if (!secret) { + throw new Error('NEXT_PUBLIC_ENCRYPTION_SECRET 未配置,加解密无法初始化'); + } + return secret; +} + +let cachedKey: Promise | null = null; + +function getKey(): Promise { + if (!cachedKey) { + cachedKey = deriveKey(getSecret()); + } + return cachedKey; } /** - * 加密数据 + * 加密明文,返回 Base64 编码的密文(IV 前置) */ -export function encrypt(data: unknown, token: string): string { - const key = deriveKey(token); - const iv = crypto.randomBytes(IV_LENGTH); - const cipher = crypto.createCipheriv(ALGORITHM, key, iv); - - const json = JSON.stringify(data); - const encrypted = Buffer.concat([ - cipher.update(json, 'utf8'), - cipher.final(), - ]); - const authTag = cipher.getAuthTag(); +export async function encrypt(plaintext: string): Promise { + const key = await getKey(); + const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH)); + const encoder = new TextEncoder(); + const ciphertext = await crypto.subtle.encrypt( + { name: ALGORITHM, iv, tagLength: 128 }, + key, + encoder.encode(plaintext), + ); - // 格式: iv + authTag + encrypted (全部 base64) - const combined = Buffer.concat([iv, authTag, encrypted]); - return combined.toString('base64'); + const combined = new Uint8Array(IV_LENGTH + ciphertext.byteLength); + combined.set(iv, 0); + combined.set(new Uint8Array(ciphertext), IV_LENGTH); + + return btoa(Array.from(combined, (b) => String.fromCodePoint(b)).join('')); } /** - * 解密数据 + * 解密 Base64 编码的密文(IV 前置),返回明文字符串 */ -export function decrypt(encryptedData: string, token: string): T { - const key = deriveKey(token); - const combined = Buffer.from(encryptedData, 'base64'); +export async function decrypt(encryptedBase64: string): Promise { + const key = await getKey(); + const binaryStr = atob(encryptedBase64); + const combined = new Uint8Array(binaryStr.length); + for (let i = 0; i < binaryStr.length; i++) { + combined[i] = binaryStr.codePointAt(i)!; + } - const iv = combined.subarray(0, IV_LENGTH); - const authTag = combined.subarray(IV_LENGTH, IV_LENGTH + AUTH_TAG_LENGTH); - const encrypted = combined.subarray(IV_LENGTH + AUTH_TAG_LENGTH); + const iv = combined.slice(0, IV_LENGTH); + const ciphertext = combined.slice(IV_LENGTH); - const decipher = crypto.createDecipheriv(ALGORITHM, key, iv); - decipher.setAuthTag(authTag); + const decrypted = await crypto.subtle.decrypt( + { name: ALGORITHM, iv, tagLength: 128 }, + key, + ciphertext, + ); - const decrypted = Buffer.concat([ - decipher.update(encrypted), - decipher.final(), - ]); - - return JSON.parse(decrypted.toString('utf8')) as T; -} - -/** - * 生成随机 Token(用于前端存储的加密密钥) - */ -export function generateCryptoToken(): string { - return crypto.randomBytes(32).toString('hex'); -} - -/** - * 对敏感字段进行哈希(用于日志脱敏) - */ -export function hashSensitive(data: string): string { - return crypto.createHash('sha256').update(data + SALT).digest('hex').slice(0, 16); + return new TextDecoder().decode(decrypted); } \ No newline at end of file diff --git a/src/lib/permissions.ts b/src/lib/permissions.ts index 49f8f3e..db22def 100644 --- a/src/lib/permissions.ts +++ b/src/lib/permissions.ts @@ -1,4 +1,4 @@ -import { NextRequest } from 'next/server'; +import { NextRequest, NextResponse } from 'next/server'; import { prisma } from '@/lib/db'; import { authenticateRequest, type JwtPayload } from '@/lib/auth'; import { unauthorized, forbidden } from '@/lib/api-response'; @@ -61,7 +61,7 @@ export interface PermissionSuccess { } export interface PermissionFailure { - response: Response; + response: NextResponse; } /**