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:
@@ -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
|
||||
|
||||
@@ -42,6 +42,8 @@ import { POST } from './route';
|
||||
|
||||
function createMockRequest(body: Record<string, unknown>): NextRequest {
|
||||
return {
|
||||
headers: new Headers(),
|
||||
url: 'http://localhost:3000/api/admin/items/item-1/workflow',
|
||||
json: async () => body,
|
||||
} as unknown as NextRequest;
|
||||
}
|
||||
|
||||
@@ -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<WorkflowAction, 'update' | 'publish'> = {
|
||||
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();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -46,6 +46,7 @@ function createMockRequest(options: {
|
||||
json?: () => Promise<Record<string, unknown>>;
|
||||
}): NextRequest {
|
||||
return {
|
||||
headers: new Headers(),
|
||||
url: options.url || 'http://localhost/api/admin/items',
|
||||
json: options.json || (async () => ({})),
|
||||
} as unknown as NextRequest;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -28,6 +28,7 @@ function createMockRequest(options: {
|
||||
formData?: () => Promise<FormData>;
|
||||
}): NextRequest {
|
||||
return {
|
||||
headers: new Headers(),
|
||||
url: options.url || 'http://localhost/api/admin/media',
|
||||
formData: options.formData || (async () => new FormData()),
|
||||
} as unknown as NextRequest;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -43,6 +43,7 @@ import { GET, PUT } from './route';
|
||||
|
||||
function createMockRequest(body?: Record<string, unknown>): NextRequest {
|
||||
return {
|
||||
headers: new Headers(),
|
||||
json: async () => body ?? {},
|
||||
} as unknown as NextRequest;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -42,6 +42,7 @@ function createMockRequest(options: {
|
||||
json?: () => Promise<Record<string, unknown>>;
|
||||
}): NextRequest {
|
||||
return {
|
||||
headers: new Headers(),
|
||||
url: options.url || 'http://localhost/api/admin/zones',
|
||||
json: options.json || (async () => ({})),
|
||||
} as unknown as NextRequest;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
+40
-17
@@ -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<T>(
|
||||
path: string,
|
||||
options: RequestInit & { encrypt?: boolean } = {},
|
||||
options: RequestInit = {},
|
||||
): Promise<T> {
|
||||
const token = this.getToken();
|
||||
// 当前服务端 API 未实现请求体解密,默认不加密;如需加密,调用方显式传入 encrypt: true。
|
||||
const shouldEncrypt = options.encrypt === true && token;
|
||||
const shouldEncrypt = this.isEncryptionAvailable();
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
...(options.headers as Record<string, string>),
|
||||
@@ -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<T>(data.encrypted, token);
|
||||
} catch {
|
||||
return data as T;
|
||||
}
|
||||
}
|
||||
|
||||
return data as T;
|
||||
}
|
||||
|
||||
|
||||
@@ -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' } },
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
+77
-53
@@ -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 {
|
||||
const key = deriveKey(token);
|
||||
const iv = crypto.randomBytes(IV_LENGTH);
|
||||
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
|
||||
export async function encrypt(plaintext: string): Promise<string> {
|
||||
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),
|
||||
);
|
||||
|
||||
const json = JSON.stringify(data);
|
||||
const encrypted = Buffer.concat([
|
||||
cipher.update(json, 'utf8'),
|
||||
cipher.final(),
|
||||
]);
|
||||
const authTag = cipher.getAuthTag();
|
||||
const combined = new Uint8Array(IV_LENGTH + ciphertext.byteLength);
|
||||
combined.set(iv, 0);
|
||||
combined.set(new Uint8Array(ciphertext), IV_LENGTH);
|
||||
|
||||
// 格式: iv + authTag + encrypted (全部 base64)
|
||||
const combined = Buffer.concat([iv, authTag, encrypted]);
|
||||
return combined.toString('base64');
|
||||
return btoa(Array.from(combined, (b) => String.fromCodePoint(b)).join(''));
|
||||
}
|
||||
|
||||
/**
|
||||
* 解密数据
|
||||
* 解密 Base64 编码的密文(IV 前置),返回明文字符串
|
||||
*/
|
||||
export function decrypt<T = unknown>(encryptedData: string, token: string): T {
|
||||
const key = deriveKey(token);
|
||||
const combined = Buffer.from(encryptedData, '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 JSON.parse(decrypted.toString('utf8')) as T;
|
||||
export async function decrypt(encryptedBase64: string): Promise<string> {
|
||||
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)!;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成随机 Token(用于前端存储的加密密钥)
|
||||
*/
|
||||
export function generateCryptoToken(): string {
|
||||
return crypto.randomBytes(32).toString('hex');
|
||||
}
|
||||
const iv = combined.slice(0, IV_LENGTH);
|
||||
const ciphertext = combined.slice(IV_LENGTH);
|
||||
|
||||
/**
|
||||
* 对敏感字段进行哈希(用于日志脱敏)
|
||||
*/
|
||||
export function hashSensitive(data: string): string {
|
||||
return crypto.createHash('sha256').update(data + SALT).digest('hex').slice(0, 16);
|
||||
const decrypted = await crypto.subtle.decrypt(
|
||||
{ name: ALGORITHM, iv, tagLength: 128 },
|
||||
key,
|
||||
ciphertext,
|
||||
);
|
||||
|
||||
return new TextDecoder().decode(decrypted);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user