feat: implement frontend-backend encrypted communication via AES-256-GCM

参考 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
This commit is contained in:
2026-08-02 09:11:36 +08:00
parent c480772aec
commit d5d04aa96d
24 changed files with 449 additions and 120 deletions
+7
View File
@@ -20,3 +20,10 @@ CMS_REVALIDATE_SECRET=change-me-in-production-min-32-bytes
# CDN 配置(可选) # CDN 配置(可选)
CDN_DOMAIN= 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
@@ -42,6 +42,8 @@ import { POST } from './route';
function createMockRequest(body: Record<string, unknown>): NextRequest { function createMockRequest(body: Record<string, unknown>): NextRequest {
return { return {
headers: new Headers(),
url: 'http://localhost:3000/api/admin/items/item-1/workflow',
json: async () => body, json: async () => body,
} as unknown as NextRequest; } as unknown as NextRequest;
} }
@@ -15,6 +15,7 @@ import {
internalError, internalError,
forbidden, forbidden,
} from '@/lib/api-response'; } from '@/lib/api-response';
import { withCrypto } from '@/lib/api-crypto';
function parseItem(item: { data: string; [key: string]: unknown }) { function parseItem(item: { data: string; [key: string]: unknown }) {
return { ...item, data: JSON.parse(item.data as string) }; return { ...item, data: JSON.parse(item.data as string) };
@@ -32,10 +33,10 @@ const ACTION_PERMISSION: Record<WorkflowAction, 'update' | 'publish'> = {
archive: 'publish', archive: 'publish',
}; };
export async function POST( export const POST = withCrypto(async (
request: NextRequest, request: NextRequest,
{ params }: { params: Promise<{ id: string }> } { params }: { params: Promise<{ id: string }> }
) { ) => {
const { id } = await params; const { id } = await params;
try { try {
@@ -90,4 +91,4 @@ export async function POST(
console.error('Workflow error:', error); console.error('Workflow error:', error);
return internalError(); return internalError();
} }
} });
+1
View File
@@ -46,6 +46,7 @@ function createMockRequest(options: {
json?: () => Promise<Record<string, unknown>>; json?: () => Promise<Record<string, unknown>>;
}): NextRequest { }): NextRequest {
return { return {
headers: new Headers(),
url: options.url || 'http://localhost/api/admin/items', url: options.url || 'http://localhost/api/admin/items',
json: options.json || (async () => ({})), json: options.json || (async () => ({})),
} as unknown as NextRequest; } as unknown as NextRequest;
+9 -8
View File
@@ -7,6 +7,7 @@ import {
validationError, validationError,
internalError, internalError,
} from '@/lib/api-response'; } from '@/lib/api-response';
import { withCrypto } from '@/lib/api-crypto';
function parseItem(item: { data: string; [key: string]: unknown }) { function parseItem(item: { data: string; [key: string]: unknown }) {
return { ...item, data: JSON.parse(item.data as string) }; return { ...item, data: JSON.parse(item.data as string) };
@@ -46,7 +47,7 @@ async function ensureUniqueSlug(
} }
// GET /api/admin/items - 获取内容列表 // GET /api/admin/items - 获取内容列表
export async function GET(request: NextRequest) { export const GET = withCrypto(async (request: NextRequest) => {
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
const modelCode = searchParams.get('modelCode'); const modelCode = searchParams.get('modelCode');
@@ -91,10 +92,10 @@ export async function GET(request: NextRequest) {
console.error('Get items error:', error); console.error('Get items error:', error);
return internalError(); return internalError();
} }
} });
// POST /api/admin/items - 创建内容 // POST /api/admin/items - 创建内容
export async function POST(request: NextRequest) { export const POST = withCrypto(async (request: NextRequest) => {
try { try {
const body = await request.json(); const body = await request.json();
const { modelId, modelCode, title, slug, data, status, sortOrder } = body; 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); console.error('Create item error:', error);
return internalError(); return internalError();
} }
} });
// PUT /api/admin/items/[id] - 更新内容 // 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 { searchParams } = new URL(request.url);
const id = searchParams.get('id'); const id = searchParams.get('id');
if (!id) return validationError('缺少 ID'); if (!id) return validationError('缺少 ID');
@@ -218,10 +219,10 @@ export async function PUT(request: NextRequest) {
console.error('Update item error:', error); console.error('Update item error:', error);
return internalError(); return internalError();
} }
} });
// DELETE /api/admin/items/[id] - 删除内容 // 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 { searchParams } = new URL(request.url);
const id = searchParams.get('id'); const id = searchParams.get('id');
if (!id) return validationError('缺少 ID'); if (!id) return validationError('缺少 ID');
@@ -250,4 +251,4 @@ export async function DELETE(request: NextRequest) {
console.error('Delete item error:', error); console.error('Delete item error:', error);
return internalError(); return internalError();
} }
} });
+1
View File
@@ -28,6 +28,7 @@ function createMockRequest(options: {
formData?: () => Promise<FormData>; formData?: () => Promise<FormData>;
}): NextRequest { }): NextRequest {
return { return {
headers: new Headers(),
url: options.url || 'http://localhost/api/admin/media', url: options.url || 'http://localhost/api/admin/media',
formData: options.formData || (async () => new FormData()), formData: options.formData || (async () => new FormData()),
} as unknown as NextRequest; } as unknown as NextRequest;
+7 -6
View File
@@ -12,12 +12,13 @@ import {
validationError, validationError,
internalError, internalError,
} from '@/lib/api-response'; } from '@/lib/api-response';
import { withCrypto } from '@/lib/api-crypto';
const MODEL_CODE = 'media'; const MODEL_CODE = 'media';
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
// GET /api/admin/media - 获取媒体列表或按 ID 查询 // 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'); const permission = await requirePermission(request, MODEL_CODE, 'read');
if ('response' in permission) return permission.response; if ('response' in permission) return permission.response;
@@ -41,10 +42,10 @@ export async function GET(request: NextRequest) {
console.error('Get media error:', error); console.error('Get media error:', error);
return internalError(); return internalError();
} }
} });
// POST /api/admin/media - 上传媒体文件(支持单文件/多文件) // 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'); const permission = await requirePermission(request, MODEL_CODE, 'create');
if ('response' in permission) return permission.response; if ('response' in permission) return permission.response;
@@ -94,10 +95,10 @@ export async function POST(request: NextRequest) {
console.error('Upload media error:', error); console.error('Upload media error:', error);
return internalError(); return internalError();
} }
} });
// DELETE /api/admin/media?id=xxx - 删除媒体文件 // 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'); const permission = await requirePermission(request, MODEL_CODE, 'delete');
if ('response' in permission) return permission.response; if ('response' in permission) return permission.response;
@@ -115,4 +116,4 @@ export async function DELETE(request: NextRequest) {
} }
return internalError(); return internalError();
} }
} });
+1
View File
@@ -27,6 +27,7 @@ import { GET } from './route';
function createMockRequest(): NextRequest { function createMockRequest(): NextRequest {
return { return {
headers: new Headers(),
url: 'http://localhost/api/admin/models', url: 'http://localhost/api/admin/models',
} as unknown as NextRequest; } as unknown as NextRequest;
} }
+3 -2
View File
@@ -2,9 +2,10 @@ import { NextRequest } from 'next/server';
import { prisma } from '@/lib/db'; import { prisma } from '@/lib/db';
import { requirePermission } from '@/lib/permissions'; import { requirePermission } from '@/lib/permissions';
import { success, internalError } from '@/lib/api-response'; import { success, internalError } from '@/lib/api-response';
import { withCrypto } from '@/lib/api-crypto';
// GET /api/admin/models - 获取所有内容模型 // 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'); const permission = await requirePermission(request, 'content-model', 'read');
if ('response' in permission) return permission.response; if ('response' in permission) return permission.response;
@@ -21,4 +22,4 @@ export async function GET(request: NextRequest) {
console.error('Get models error:', error); console.error('Get models error:', error);
return internalError(); return internalError();
} }
} });
@@ -20,7 +20,7 @@ jest.unmock('./route');
import { GET } from './route'; import { GET } from './route';
function createMockRequest(url: string): NextRequest { function createMockRequest(url: string): NextRequest {
return { url } as unknown as NextRequest; return { headers: new Headers(), url } as unknown as NextRequest;
} }
beforeEach(() => { beforeEach(() => {
+3 -2
View File
@@ -2,9 +2,10 @@ import { NextRequest } from 'next/server';
import { authenticateRequest } from '@/lib/auth'; import { authenticateRequest } from '@/lib/auth';
import { getUserNotifications } from '@/lib/cms/notifications'; import { getUserNotifications } from '@/lib/cms/notifications';
import { success, unauthorized, validationError, internalError } from '@/lib/api-response'; import { success, unauthorized, validationError, internalError } from '@/lib/api-response';
import { withCrypto } from '@/lib/api-crypto';
// GET /api/admin/notifications - 获取当前用户通知列表 // GET /api/admin/notifications - 获取当前用户通知列表
export async function GET(request: NextRequest) { export const GET = withCrypto(async (request: NextRequest) => {
const user = authenticateRequest(request); const user = authenticateRequest(request);
if (!user) return unauthorized(); if (!user) return unauthorized();
@@ -27,4 +28,4 @@ export async function GET(request: NextRequest) {
console.error('Get notifications error:', error); console.error('Get notifications error:', error);
return internalError(); return internalError();
} }
} });
@@ -20,7 +20,7 @@ jest.unmock('./route');
import { GET } from './route'; import { GET } from './route';
function createMockRequest(): NextRequest { function createMockRequest(): NextRequest {
return {} as unknown as NextRequest; return { headers: new Headers() } as unknown as NextRequest;
} }
beforeEach(() => { beforeEach(() => {
@@ -2,9 +2,10 @@ import { NextRequest } from 'next/server';
import { authenticateRequest } from '@/lib/auth'; import { authenticateRequest } from '@/lib/auth';
import { getUnreadCount } from '@/lib/cms/notifications'; import { getUnreadCount } from '@/lib/cms/notifications';
import { success, unauthorized, internalError } from '@/lib/api-response'; import { success, unauthorized, internalError } from '@/lib/api-response';
import { withCrypto } from '@/lib/api-crypto';
// GET /api/admin/notifications/unread-count - 获取当前用户未读通知数量 // GET /api/admin/notifications/unread-count - 获取当前用户未读通知数量
export async function GET(request: NextRequest) { export const GET = withCrypto(async (request: NextRequest) => {
const user = authenticateRequest(request); const user = authenticateRequest(request);
if (!user) return unauthorized(); if (!user) return unauthorized();
@@ -15,4 +16,4 @@ export async function GET(request: NextRequest) {
console.error('Get unread count error:', error); console.error('Get unread count error:', error);
return internalError(); return internalError();
} }
} });
+1
View File
@@ -43,6 +43,7 @@ import { GET, PUT } from './route';
function createMockRequest(body?: Record<string, unknown>): NextRequest { function createMockRequest(body?: Record<string, unknown>): NextRequest {
return { return {
headers: new Headers(),
json: async () => body ?? {}, json: async () => body ?? {},
} as unknown as NextRequest; } as unknown as NextRequest;
} }
+5 -4
View File
@@ -2,12 +2,13 @@ import { NextRequest } from 'next/server';
import { prisma } from '@/lib/db'; import { prisma } from '@/lib/db';
import { authenticateRequest } from '@/lib/auth'; import { authenticateRequest } from '@/lib/auth';
import { success, unauthorized, forbidden, internalError, validationError } from '@/lib/api-response'; 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']); const BUILTIN_ROLES = new Set(['super_admin', 'content_admin', 'content_editor', 'reviewer', 'readonly']);
// GET /api/admin/roles - 获取角色列表及其权限 // GET /api/admin/roles - 获取角色列表及其权限
export async function GET(request: NextRequest) { export const GET = withCrypto(async (request: NextRequest) => {
const user = authenticateRequest(request); const user = authenticateRequest(request);
if (!user) return unauthorized(); if (!user) return unauthorized();
@@ -46,10 +47,10 @@ export async function GET(request: NextRequest) {
console.error('Get roles error:', error); console.error('Get roles error:', error);
return internalError(); return internalError();
} }
} });
// PUT /api/admin/roles/:roleCode - 更新角色权限 // PUT /api/admin/roles/:roleCode - 更新角色权限
export async function PUT(request: NextRequest) { export const PUT = withCrypto(async (request: NextRequest) => {
const user = authenticateRequest(request); const user = authenticateRequest(request);
if (!user) return unauthorized(); if (!user) return unauthorized();
@@ -100,4 +101,4 @@ export async function PUT(request: NextRequest) {
console.error('Update roles error:', error); console.error('Update roles error:', error);
return internalError(); return internalError();
} }
} });
+3 -2
View File
@@ -2,9 +2,10 @@ import { NextRequest } from 'next/server';
import { prisma } from '@/lib/db'; import { prisma } from '@/lib/db';
import { authenticateRequest } from '@/lib/auth'; import { authenticateRequest } from '@/lib/auth';
import { success, unauthorized, internalError } from '@/lib/api-response'; import { success, unauthorized, internalError } from '@/lib/api-response';
import { withCrypto } from '@/lib/api-crypto';
// GET /api/admin/stats - 获取仪表盘统计数据 // GET /api/admin/stats - 获取仪表盘统计数据
export async function GET(request: NextRequest) { export const GET = withCrypto(async (request: NextRequest) => {
const user = authenticateRequest(request); const user = authenticateRequest(request);
if (!user) return unauthorized(); if (!user) return unauthorized();
@@ -79,4 +80,4 @@ export async function GET(request: NextRequest) {
console.error('Get stats error:', error); console.error('Get stats error:', error);
return internalError(); return internalError();
} }
} });
+9 -8
View File
@@ -2,9 +2,10 @@ import { NextRequest } from 'next/server';
import { prisma } from '@/lib/db'; import { prisma } from '@/lib/db';
import { authenticateRequest, hashPassword } from '@/lib/auth'; import { authenticateRequest, hashPassword } from '@/lib/auth';
import { success, unauthorized, forbidden, internalError, validationError } from '@/lib/api-response'; import { success, unauthorized, forbidden, internalError, validationError } from '@/lib/api-response';
import { withCrypto } from '@/lib/api-crypto';
// GET /api/admin/users - 获取用户列表 // GET /api/admin/users - 获取用户列表
export async function GET(request: NextRequest) { export const GET = withCrypto(async (request: NextRequest) => {
const user = authenticateRequest(request); const user = authenticateRequest(request);
if (!user) return unauthorized(); if (!user) return unauthorized();
@@ -77,10 +78,10 @@ export async function GET(request: NextRequest) {
console.error('Get users error:', error); console.error('Get users error:', error);
return internalError(); return internalError();
} }
} });
// POST /api/admin/users - 创建用户 // POST /api/admin/users - 创建用户
export async function POST(request: NextRequest) { export const POST = withCrypto(async (request: NextRequest) => {
const user = authenticateRequest(request); const user = authenticateRequest(request);
if (!user) return unauthorized(); if (!user) return unauthorized();
@@ -153,10 +154,10 @@ export async function POST(request: NextRequest) {
console.error('Create user error:', error); console.error('Create user error:', error);
return internalError(); return internalError();
} }
} });
// PUT /api/admin/users - 更新用户 // PUT /api/admin/users - 更新用户
export async function PUT(request: NextRequest) { export const PUT = withCrypto(async (request: NextRequest) => {
const currentUser = authenticateRequest(request); const currentUser = authenticateRequest(request);
if (!currentUser) return unauthorized(); if (!currentUser) return unauthorized();
@@ -220,10 +221,10 @@ export async function PUT(request: NextRequest) {
console.error('Update user error:', error); console.error('Update user error:', error);
return internalError(); return internalError();
} }
} });
// DELETE /api/admin/users - 删除用户 // DELETE /api/admin/users - 删除用户
export async function DELETE(request: NextRequest) { export const DELETE = withCrypto(async (request: NextRequest) => {
const currentUser = authenticateRequest(request); const currentUser = authenticateRequest(request);
if (!currentUser) return unauthorized(); if (!currentUser) return unauthorized();
@@ -260,4 +261,4 @@ export async function DELETE(request: NextRequest) {
console.error('Delete user error:', error); console.error('Delete user error:', error);
return internalError(); return internalError();
} }
} });
+1
View File
@@ -42,6 +42,7 @@ function createMockRequest(options: {
json?: () => Promise<Record<string, unknown>>; json?: () => Promise<Record<string, unknown>>;
}): NextRequest { }): NextRequest {
return { return {
headers: new Headers(),
url: options.url || 'http://localhost/api/admin/zones', url: options.url || 'http://localhost/api/admin/zones',
json: options.json || (async () => ({})), json: options.json || (async () => ({})),
} as unknown as NextRequest; } as unknown as NextRequest;
+9 -8
View File
@@ -7,9 +7,10 @@ import {
validationError, validationError,
internalError, internalError,
} from '@/lib/api-response'; } from '@/lib/api-response';
import { withCrypto } from '@/lib/api-crypto';
// GET /api/admin/zones - 获取所有内容区域 // 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'); const permission = await requirePermission(request, 'content-zone', 'read');
if ('response' in permission) return permission.response; if ('response' in permission) return permission.response;
@@ -30,10 +31,10 @@ export async function GET(request: NextRequest) {
console.error('Get zones error:', error); console.error('Get zones error:', error);
return internalError(); return internalError();
} }
} });
// POST /api/admin/zones - 创建/更新内容区域 // 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'); const permission = await requirePermission(request, 'content-zone', 'update');
if ('response' in permission) return permission.response; if ('response' in permission) return permission.response;
@@ -95,15 +96,15 @@ export async function POST(request: NextRequest) {
console.error('Save zone error:', error); console.error('Save zone error:', error);
return internalError(); return internalError();
} }
} });
// PUT /api/admin/zones - 更新区域设置 // PUT /api/admin/zones - 更新区域设置
export async function PUT(request: NextRequest) { export const PUT = withCrypto(async (request: NextRequest) => {
return POST(request); return POST(request);
} });
// DELETE /api/admin/zones/[id] - 删除内容区域 // 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'); const permission = await requirePermission(request, 'content-zone', 'delete');
if ('response' in permission) return permission.response; if ('response' in permission) return permission.response;
@@ -132,4 +133,4 @@ export async function DELETE(request: NextRequest) {
console.error('Delete zone error:', error); console.error('Delete zone error:', error);
return internalError(); return internalError();
} }
} });
+40 -17
View File
@@ -8,13 +8,19 @@ class AdminApiClient {
return localStorage.getItem('novalon_admin_token'); return localStorage.getItem('novalon_admin_token');
} }
/**
* 检查前端加密是否可用(NEXT_PUBLIC_ENCRYPTION_SECRET 已配置)
*/
private isEncryptionAvailable(): boolean {
return !!process.env.NEXT_PUBLIC_ENCRYPTION_SECRET;
}
async request<T>( async request<T>(
path: string, path: string,
options: RequestInit & { encrypt?: boolean } = {}, options: RequestInit = {},
): Promise<T> { ): Promise<T> {
const token = this.getToken(); const token = this.getToken();
// 当前服务端 API 未实现请求体解密,默认不加密;如需加密,调用方显式传入 encrypt: true。 const shouldEncrypt = this.isEncryptionAvailable();
const shouldEncrypt = options.encrypt === true && token;
const headers: Record<string, string> = { const headers: Record<string, string> = {
...(options.headers as Record<string, string>), ...(options.headers as Record<string, string>),
@@ -26,19 +32,24 @@ class AdminApiClient {
let body = options.body; let body = options.body;
// 自动加密请求体(有 body 且加密可用时)
if (shouldEncrypt && body && token) { if (shouldEncrypt && body && token) {
try { try {
const parsed = JSON.parse(body as string); const parsed = typeof body === 'string' ? JSON.parse(body) : body;
const encrypted = encrypt(parsed, token); const jsonStr = typeof parsed === 'string' ? parsed : JSON.stringify(parsed);
const encrypted = await encrypt(jsonStr);
headers['Content-Type'] = 'application/json'; headers['Content-Type'] = 'application/json';
headers['X-Encrypted'] = '1'; headers['X-Encrypted'] = 'true';
body = JSON.stringify({ data: encrypted }); body = JSON.stringify({ data: encrypted });
} catch { } catch {
// 如果不是 JSON 或加密失败,使用原始 body // 加密失败时降级为明文传输
if (!headers['Content-Type']) { if (!headers['Content-Type']) {
headers['Content-Type'] = 'application/json'; headers['Content-Type'] = 'application/json';
} }
} }
} else if (shouldEncrypt && token) {
// 无 body 的请求(GET/DELETE)也要标记加密,让后端加密响应
headers['X-Encrypted'] = 'true';
} else if (body && !headers['Content-Type']) { } else if (body && !headers['Content-Type']) {
headers['Content-Type'] = 'application/json'; headers['Content-Type'] = 'application/json';
} }
@@ -58,22 +69,34 @@ class AdminApiClient {
throw new Error('未授权'); 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) { if (!res.ok) {
const data = await res.json().catch(() => ({})); const data = await res.json().catch(() => ({}));
throw new Error(data.error || data.message || `请求失败 (${res.status})`); throw new Error(data.error || data.message || `请求失败 (${res.status})`);
} }
const data = await res.json(); const data = await res.json();
// 解密响应
if (shouldEncrypt && token && data.encrypted) {
try {
return decrypt<T>(data.encrypted, token);
} catch {
return data as T;
}
}
return data as T; return data as T;
} }
+170
View File
@@ -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<T extends Record<string, unknown> = Record<string, unknown>>(
handler: (
request: NextRequest,
context: CryptoContext & T,
) => Promise<NextResponse>,
): (request: NextRequest, routeContext?: T) => Promise<NextResponse> {
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<NextResponse> {
// 跳过非 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<T = unknown>(
request: NextRequest,
): Promise<T | null> {
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' } },
);
}
+88
View File
@@ -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;
}
+78 -54
View File
@@ -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<CryptoKey> {
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<CryptoKey> | null = null;
function getKey(): Promise<CryptoKey> {
if (!cachedKey) {
cachedKey = deriveKey(getSecret());
}
return cachedKey;
} }
/** /**
* 加密数据 * 加密明文,返回 Base64 编码的密文(IV 前置)
*/ */
export function encrypt(data: unknown, token: string): string { export async function encrypt(plaintext: string): Promise<string> {
const key = deriveKey(token); const key = await getKey();
const iv = crypto.randomBytes(IV_LENGTH); const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH));
const cipher = crypto.createCipheriv(ALGORITHM, key, iv); const encoder = new TextEncoder();
const ciphertext = await crypto.subtle.encrypt(
const json = JSON.stringify(data); { name: ALGORITHM, iv, tagLength: 128 },
const encrypted = Buffer.concat([ key,
cipher.update(json, 'utf8'), encoder.encode(plaintext),
cipher.final(), );
]);
const authTag = cipher.getAuthTag();
// 格式: iv + authTag + encrypted (全部 base64) const combined = new Uint8Array(IV_LENGTH + ciphertext.byteLength);
const combined = Buffer.concat([iv, authTag, encrypted]); combined.set(iv, 0);
return combined.toString('base64'); combined.set(new Uint8Array(ciphertext), IV_LENGTH);
return btoa(Array.from(combined, (b) => String.fromCodePoint(b)).join(''));
} }
/** /**
* 解密数据 * 解密 Base64 编码的密文(IV 前置),返回明文字符串
*/ */
export function decrypt<T = unknown>(encryptedData: string, token: string): T { export async function decrypt(encryptedBase64: string): Promise<string> {
const key = deriveKey(token); const key = await getKey();
const combined = Buffer.from(encryptedData, 'base64'); 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 iv = combined.slice(0, IV_LENGTH);
const authTag = combined.subarray(IV_LENGTH, IV_LENGTH + AUTH_TAG_LENGTH); const ciphertext = combined.slice(IV_LENGTH);
const encrypted = combined.subarray(IV_LENGTH + AUTH_TAG_LENGTH);
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv); const decrypted = await crypto.subtle.decrypt(
decipher.setAuthTag(authTag); { name: ALGORITHM, iv, tagLength: 128 },
key,
ciphertext,
);
const decrypted = Buffer.concat([ return new TextDecoder().decode(decrypted);
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);
} }
+2 -2
View File
@@ -1,4 +1,4 @@
import { NextRequest } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/db'; import { prisma } from '@/lib/db';
import { authenticateRequest, type JwtPayload } from '@/lib/auth'; import { authenticateRequest, type JwtPayload } from '@/lib/auth';
import { unauthorized, forbidden } from '@/lib/api-response'; import { unauthorized, forbidden } from '@/lib/api-response';
@@ -61,7 +61,7 @@ export interface PermissionSuccess {
} }
export interface PermissionFailure { export interface PermissionFailure {
response: Response; response: NextResponse;
} }
/** /**