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
@@ -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();
}
}
});
+1
View File
@@ -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;
+9 -8
View File
@@ -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();
}
}
});
+1
View File
@@ -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;
+7 -6
View File
@@ -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();
}
}
});
+1
View File
@@ -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;
}
+3 -2
View File
@@ -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(() => {
+3 -2
View File
@@ -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();
}
}
});
+1
View File
@@ -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;
}
+5 -4
View File
@@ -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();
}
}
});
+3 -2
View File
@@ -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();
}
}
});
+9 -8
View File
@@ -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();
}
}
});
+1
View File
@@ -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;
+9 -8
View File
@@ -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();
}
}
});