收紧许可证激活与初始化安全

This commit is contained in:
WuYongJie 2026-05-25 16:10:18 +08:00
parent 45d1ad3adf
commit 1a8087b406
25 changed files with 2392 additions and 396 deletions

View File

@ -5,6 +5,8 @@ NODE_ENV=development
APP_PUBLIC_URL=https://your-domain.example.com APP_PUBLIC_URL=https://your-domain.example.com
NEXTAUTH_URL=https://your-domain.example.com NEXTAUTH_URL=https://your-domain.example.com
NEXTAUTH_SECRET=replace-with-strong-secret NEXTAUTH_SECRET=replace-with-strong-secret
LICENSE_SERVER_URL=https://your-license-server.example.com
LICENSE_PROJECT_CODE=flnxi-sucai
# Database settings # Database settings
DATABASE_URL=postgresql://username:password@host:5432/database DATABASE_URL=postgresql://username:password@host:5432/database
@ -41,6 +43,17 @@ PUBLIC_DOMAINS=portal.example.com
ALLOWED_ORIGINS=https://portal.example.com ALLOWED_ORIGINS=https://portal.example.com
IMAGE_REMOTE_HOSTS=s3.example.com IMAGE_REMOTE_HOSTS=s3.example.com
# License system security (REQUIRED for license validation)
# RSA public key for license signature verification (PEM format, replace \n with \\n)
LICENSE_PUBLIC_KEY=
# Local encryption key for secure storage (minimum 32 characters)
LOCAL_ENCRYPTION_KEY=
# Optional: one-time token for manual POST /api/system/init before any admin exists
SYSTEM_INIT_TOKEN=
# Optional: explicit initial admin identity; password omitted means generate a one-time password during initialization
INIT_ADMIN_EMAIL=admin@local.flnxi
INIT_ADMIN_PASSWORD=
# AI model (optional) # AI model (optional)
GEMINI_API_KEY= GEMINI_API_KEY=
GEMINI_API_BASE_URL=https://api.huandutech.com/v1 GEMINI_API_BASE_URL=https://api.huandutech.com/v1

View File

@ -278,10 +278,12 @@ RUN if [ -f "./node_modules/canvas/package.json" ] && grep -q "canvas-placeholde
rm -rf ./node_modules/canvas; \ rm -rf ./node_modules/canvas; \
fi fi
# 创建上传目录并设置权限 # 创建上传目录和数据目录并设置权限
RUN mkdir -p /app/uploads && \ # - uploads: 用户上传的文件
chown -R nextjs:nodejs /app/uploads && \ # - data: 许可证实例 ID 等持久化数据
chmod -R 755 /app/uploads RUN mkdir -p /app/uploads /app/data && \
chown -R nextjs:nodejs /app/uploads /app/data && \
chmod -R 755 /app/uploads /app/data
# 复制启动脚本 # 复制启动脚本
COPY --from=builder --chown=nextjs:nodejs /app/scripts/docker-entrypoint.sh ./docker-entrypoint.sh COPY --from=builder --chown=nextjs:nodejs /app/scripts/docker-entrypoint.sh ./docker-entrypoint.sh

View File

@ -0,0 +1,88 @@
import { NextRequest } from 'next/server'
import { POST } from '../route'
jest.mock('@/lib/prisma', () => ({
prisma: {
systemSettings: {
findFirst: jest.fn(),
},
invitation: {
findUnique: jest.fn(),
},
user: {
findFirst: jest.fn(),
},
$transaction: jest.fn(),
},
}))
jest.mock('@/lib/license', () => ({
canCreateUser: jest.fn(),
}))
jest.mock('bcryptjs', () => ({
hash: jest.fn(async (value: string) => `hashed:${value}`),
}))
const { prisma } = require('@/lib/prisma')
const { canCreateUser } = require('@/lib/license')
const bcrypt = require('bcryptjs')
function createRequest(body: Record<string, unknown>) {
return new NextRequest('http://localhost/api/auth/register', {
method: 'POST',
body: JSON.stringify({
email: 'user@example.com',
username: 'user',
department: '默认部门',
password: 'Flnxi-2026-Safe',
...body,
}),
})
}
describe('POST /api/auth/register', () => {
beforeEach(() => {
jest.resetAllMocks()
canCreateUser.mockResolvedValue({ allowed: true })
prisma.systemSettings.findFirst.mockResolvedValue({ enableRegistration: true })
prisma.user.findFirst.mockResolvedValue(null)
prisma.$transaction.mockImplementation(async (callback: any) => callback({
user: {
count: jest.fn().mockResolvedValue(1),
findFirst: jest.fn().mockResolvedValue(null),
create: jest.fn().mockResolvedValue({
id: 'user-1',
email: 'user@example.com',
username: 'user',
department: '默认部门',
role: 'USER',
createdAt: new Date('2026-05-25T00:00:00.000Z'),
}),
},
invitation: {
update: jest.fn(),
},
}))
})
test('注册时拒绝弱密码', async () => {
const response = await POST(createRequest({ password: '123456' }))
const data = await response.json()
expect(response.status).toBe(400)
expect(data.error).toContain('至少')
expect(prisma.user.findFirst).not.toHaveBeenCalled()
expect(bcrypt.hash).not.toHaveBeenCalled()
expect(prisma.$transaction).not.toHaveBeenCalled()
})
test('强密码注册仍可成功', async () => {
const response = await POST(createRequest({ password: 'Flnxi-2026-Safe' }))
const data = await response.json()
expect(response.status).toBe(201)
expect(data.message).toBe('注册成功')
expect(bcrypt.hash).toHaveBeenCalledWith('Flnxi-2026-Safe', 10)
})
})

View File

@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma' import { prisma } from '@/lib/prisma'
import bcrypt from 'bcryptjs' import bcrypt from 'bcryptjs'
import { canCreateUser } from '@/lib/license' import { canCreateUser } from '@/lib/license'
import { validatePasswordStrength } from '@/lib/password-policy'
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
try { try {
@ -33,6 +34,14 @@ export async function POST(req: NextRequest) {
) )
} }
const passwordValidation = validatePasswordStrength(password)
if (!passwordValidation.valid) {
return NextResponse.json(
{ error: passwordValidation.error || '密码不符合安全要求' },
{ status: 400 }
)
}
// 如果注册功能关闭,检查是否有有效的邀请码 // 如果注册功能关闭,检查是否有有效的邀请码
if (settings && !settings.enableRegistration) { if (settings && !settings.enableRegistration) {
if (!inviteCode) { if (!inviteCode) {

View File

@ -0,0 +1,289 @@
import { NextRequest } from 'next/server';
import { POST } from '../route';
import { verifyLicenseSignature } from '@/lib/license/crypto-service';
import { saveLicenseLocally, initializeSystem, startHeartbeat } from '@/lib/license/license-service';
jest.mock('@/lib/license/fingerprint-service', () => ({
generateFingerprint: jest.fn().mockResolvedValue({
machineId: 'machine-1',
hostname: 'nas-1',
macAddress: 'AA:BB:CC:DD:EE:FF',
instanceId: 'instance-1',
}),
}));
jest.mock('@/lib/license/server-config', () => ({
buildLicenseServerLicenseApiUrl: jest.fn(() => 'https://license.example.com/api/license/flnxi/register-free'),
}));
jest.mock('@/lib/license/crypto-service', () => ({
verifyLicenseSignature: jest.fn(),
}));
jest.mock('@/lib/license/license-service', () => ({
saveLicenseLocally: jest.fn(),
initializeSystem: jest.fn(),
startHeartbeat: jest.fn(),
}));
const mockVerifyLicenseSignature = verifyLicenseSignature as jest.MockedFunction<typeof verifyLicenseSignature>;
const mockSaveLicenseLocally = saveLicenseLocally as jest.MockedFunction<typeof saveLicenseLocally>;
const mockInitializeSystem = initializeSystem as jest.MockedFunction<typeof initializeSystem>;
const mockStartHeartbeat = startHeartbeat as jest.MockedFunction<typeof startHeartbeat>;
const signedFreeLicense = {
data: {
type: 'FREE',
limits: { maxUsers: 10, maxMaterials: 100 },
features: {
customTemplates: false,
systemSettings: false,
departmentIsolation: false,
apiAccess: false,
intranetPenetration: false,
},
expiresAt: null,
issuedAt: '2026-05-24T00:00:00.000Z',
},
signature: 'signed-free-license',
issuedAt: '2026-05-24T00:00:00.000Z',
};
function createMockRequest(options: {
url?: string;
headers?: Record<string, string>;
body?: Record<string, unknown>;
} = {}): NextRequest {
const url = options.url || 'http://localhost:3000/api/license/activate-free';
const headers = new Headers(options.headers || {});
return new NextRequest(url, {
method: 'POST',
headers,
body: JSON.stringify({
customerName: '测试公司',
contactInfo: '13800000000',
...options.body,
}),
});
}
function mockRegisterFreeSuccess(overrides: Record<string, unknown> = {}) {
(global.fetch as jest.Mock).mockResolvedValueOnce({
ok: true,
json: async () => ({
success: true,
license: signedFreeLicense,
data: {
activationCode: 'FLNXI-FREE-TEST-1234-5678-ABCD',
isExisting: false,
},
...overrides,
}),
});
}
describe('POST /api/license/activate-free', () => {
beforeEach(() => {
jest.clearAllMocks();
global.fetch = jest.fn();
mockVerifyLicenseSignature.mockReturnValue(true);
mockInitializeSystem.mockResolvedValue({
admin: {
created: false,
},
} as any);
});
it('保存许可证服务端返回的签名免费许可证,不再写空签名', async () => {
mockRegisterFreeSuccess();
const response = await POST(createMockRequest());
const data = await response.json();
expect(response.status).toBe(200);
expect(data.success).toBe(true);
expect(mockSaveLicenseLocally).toHaveBeenCalledWith(
'FLNXI-FREE-TEST-1234-5678-ABCD',
signedFreeLicense
);
expect(mockSaveLicenseLocally.mock.calls[0][1].signature).toBe('signed-free-license');
expect(mockVerifyLicenseSignature).toHaveBeenCalledWith(
signedFreeLicense.data,
signedFreeLicense.signature
);
expect(mockStartHeartbeat).toHaveBeenCalled();
});
it('许可证服务没有返回签名许可证时拒绝激活,避免保存不可读取的本地许可证', async () => {
mockRegisterFreeSuccess({ license: undefined });
const response = await POST(createMockRequest());
const data = await response.json();
expect(response.status).toBe(502);
expect(data.success).toBe(false);
expect(data.error).toContain('许可证服务返回数据异常');
expect(mockSaveLicenseLocally).not.toHaveBeenCalled();
expect(mockInitializeSystem).not.toHaveBeenCalled();
expect(mockStartHeartbeat).not.toHaveBeenCalled();
});
it('许可证签名验证失败时拒绝激活', async () => {
mockRegisterFreeSuccess();
mockVerifyLicenseSignature.mockReturnValue(false);
const response = await POST(createMockRequest());
const data = await response.json();
expect(response.status).toBe(400);
expect(data.success).toBe(false);
expect(data.error).toBe('许可证签名验证失败');
expect(mockSaveLicenseLocally).not.toHaveBeenCalled();
expect(mockInitializeSystem).not.toHaveBeenCalled();
expect(mockStartHeartbeat).not.toHaveBeenCalled();
});
it('应该设置 license_activated cookie', async () => {
mockRegisterFreeSuccess();
const response = await POST(createMockRequest());
const setCookieHeader = response.headers.get('set-cookie');
expect(setCookieHeader).toContain('license_activated=true');
expect(setCookieHeader).toContain('HttpOnly');
expect(setCookieHeader).toContain('Path=/');
expect(setCookieHeader).toContain('Max-Age=31536000');
expect(setCookieHeader?.toLowerCase()).toContain('samesite=lax');
});
it('初始管理员密码来自环境变量时不应在激活响应中回显明文', async () => {
mockRegisterFreeSuccess();
mockInitializeSystem.mockResolvedValueOnce({
admin: {
created: true,
email: 'admin@local.flnxi',
username: 'admin',
initialPassword: 'Env-Password-Should-Not-Return',
mustChangePassword: true,
passwordSource: 'environment',
},
} as any);
const response = await POST(createMockRequest());
const data = await response.json();
expect(response.status).toBe(200);
expect(data.initialAdmin).toEqual({
email: 'admin@local.flnxi',
username: 'admin',
password: null,
mustChangePassword: true,
passwordSource: 'environment',
});
});
it('系统生成的一次性初始管理员密码仍只在首次激活响应中返回', async () => {
mockRegisterFreeSuccess();
mockInitializeSystem.mockResolvedValueOnce({
admin: {
created: true,
email: 'admin@local.flnxi',
username: 'admin',
initialPassword: 'Generated-Password-123',
mustChangePassword: true,
passwordSource: 'generated',
},
} as any);
const response = await POST(createMockRequest());
const data = await response.json();
expect(response.status).toBe(200);
expect(data.initialAdmin.password).toBe('Generated-Password-123');
expect(data.initialAdmin.passwordSource).toBe('generated');
});
it('免费版激活日志不应输出完整激活码', async () => {
const consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(() => undefined);
mockRegisterFreeSuccess();
const response = await POST(createMockRequest());
const data = await response.json();
const logOutput = JSON.stringify(consoleLogSpy.mock.calls);
expect(response.status).toBe(200);
expect(data.success).toBe(true);
expect(logOutput).toContain('FLNX****ABCD');
expect(logOutput).not.toContain('FLNXI-FREE-TEST-1234-5678-ABCD');
consoleLogSpy.mockRestore();
});
it('HTTP 请求时 cookie 不带 Secure,HTTPS 或代理 HTTPS 时带 Secure', async () => {
mockRegisterFreeSuccess();
const httpResponse = await POST(createMockRequest({
url: 'http://localhost:3000/api/license/activate-free',
}));
expect(httpResponse.headers.get('set-cookie')).not.toContain('Secure');
mockRegisterFreeSuccess();
const httpsResponse = await POST(createMockRequest({
url: 'https://example.com/api/license/activate-free',
}));
expect(httpsResponse.headers.get('set-cookie')).toContain('Secure');
mockRegisterFreeSuccess();
const forwardedHttpsResponse = await POST(createMockRequest({
url: 'http://localhost:3000/api/license/activate-free',
headers: { 'x-forwarded-proto': 'https' },
}));
expect(forwardedHttpsResponse.headers.get('set-cookie')).toContain('Secure');
});
it('许可证服务器返回失败时应该返回 400', async () => {
(global.fetch as jest.Mock).mockResolvedValueOnce({
ok: true,
json: async () => ({
success: false,
error: '试用已过期',
}),
});
const response = await POST(createMockRequest());
const data = await response.json();
expect(response.status).toBe(400);
expect(data.success).toBe(false);
expect(data.error).toBe('试用已过期');
expect(mockSaveLicenseLocally).not.toHaveBeenCalled();
});
it('许可证服务器网络失败时应该返回 400', async () => {
(global.fetch as jest.Mock).mockResolvedValueOnce({
ok: false,
status: 503,
statusText: 'Service Unavailable',
json: async () => ({ error: '许可证服务不可用' }),
});
const response = await POST(createMockRequest());
const data = await response.json();
expect(response.status).toBe(400);
expect(data.success).toBe(false);
expect(data.error).toBe('许可证服务不可用');
expect(mockSaveLicenseLocally).not.toHaveBeenCalled();
});
it('缺少客户名称或联系方式时返回 400', async () => {
const missingNameResponse = await POST(createMockRequest({ body: { customerName: '' } }));
expect(missingNameResponse.status).toBe(400);
const missingContactResponse = await POST(createMockRequest({ body: { contactInfo: '' } }));
expect(missingContactResponse.status).toBe(400);
expect(global.fetch).not.toHaveBeenCalled();
expect(mockSaveLicenseLocally).not.toHaveBeenCalled();
});
});

View File

@ -1,29 +1,170 @@
import { NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { activateFreeTrial } from '@/lib/license'; import { generateFingerprint } from '@/lib/license/fingerprint-service';
import { verifyLicenseSignature } from '@/lib/license/crypto-service';
import { saveLicenseLocally, initializeSystem, startHeartbeat } from '@/lib/license/license-service';
import { buildLicenseServerLicenseApiUrl } from '@/lib/license/server-config';
export async function POST() { function maskSecret(value: unknown): string {
if (typeof value !== 'string' || !value) return '****';
if (value.length <= 8) return '****';
return `${value.slice(0, 4)}****${value.slice(-4)}`;
}
function sanitizeLicenseServerLogPayload(payload: unknown) {
if (!payload || typeof payload !== 'object') return payload;
const raw = payload as Record<string, any>;
return {
...raw,
data: raw.data
? {
...raw.data,
activationCode: raw.data.activationCode ? maskSecret(raw.data.activationCode) : raw.data.activationCode,
}
: raw.data,
license: raw.license
? {
data: raw.license.data ? '[present]' : raw.license.data,
signature: raw.license.signature ? '[redacted]' : raw.license.signature,
}
: raw.license,
};
}
export async function POST(request: NextRequest) {
try { try {
const result = await activateFreeTrial(); const body = await request.json().catch(() => ({}));
const customerName = body.customerName?.trim();
const contactInfo = body.contactInfo?.trim();
if (result.success) { // 验证必填字段
const response = NextResponse.json({ success: true }); if (!customerName || customerName.length === 0) {
// 设置许可证激活 cookie(用于 middleware 快速检查)
response.cookies.set('license_activated', 'true', {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 365 * 24 * 60 * 60, // 1年
path: '/',
});
return response;
} else {
return NextResponse.json( return NextResponse.json(
{ success: false, error: result.error }, { success: false, error: '客户名称不能为空' },
{ status: 400 } { status: 400 }
); );
} }
if (!contactInfo || contactInfo.length === 0) {
return NextResponse.json(
{ success: false, error: '联系方式不能为空' },
{ status: 400 }
);
}
// ✅ 安全改进:在后端生成硬件指纹(不暴露给客户端)
const fingerprint = await generateFingerprint();
// 确保指纹有效
if (!fingerprint.machineId || !fingerprint.instanceId) {
console.error('[License] Fingerprint验证失败:', fingerprint);
return NextResponse.json(
{ success: false, error: '系统信息获取失败,请重试' },
{ status: 500 }
);
}
// 确保macAddress不是无效值
if (fingerprint.macAddress === '00:00:00:00:00:00') {
console.warn('[License] MAC地址无效,使用备用值');
fingerprint.macAddress = 'unknown';
}
// ✅ 安全改进:代理请求到许可证服务器(隐藏服务器地址)
const response = await fetch(buildLicenseServerLicenseApiUrl('register-free'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
fingerprint,
customerName,
contactInfo,
timestamp: Date.now(),
}),
});
if (!response.ok) {
let errorMessage = '激活失败,请检查网络连接';
try {
const errorData = await response.json();
errorMessage = errorData.error || errorMessage;
console.error('[License] 免费版注册失败:', sanitizeLicenseServerLogPayload(errorData));
} catch {
errorMessage = response.statusText || errorMessage;
console.error('[License] 免费版注册失败:', response.status, response.statusText);
}
return NextResponse.json(
{ success: false, error: errorMessage },
{ status: 400 }
);
}
const data = await response.json();
if (!data.success) {
console.error('[License] 免费版注册失败:', sanitizeLicenseServerLogPayload(data));
return NextResponse.json(
{ success: false, error: data.error || '激活失败,请检查网络连接' },
{ status: 400 }
);
}
const activationCode = data.data?.activationCode;
const signedLicense = data.license;
if (!activationCode || !signedLicense?.data || !signedLicense?.signature) {
console.error('[License] 免费版注册返回数据不完整:', sanitizeLicenseServerLogPayload(data));
return NextResponse.json(
{ success: false, error: '许可证服务返回数据异常,请稍后重试' },
{ status: 502 }
);
}
// ✅ 免费版也必须使用许可证服务端签名,避免保存不可验证的本地许可证
if (!verifyLicenseSignature(signedLicense.data, signedLicense.signature)) {
return NextResponse.json(
{ success: false, error: '许可证签名验证失败' },
{ status: 400 }
);
}
console.log('[License] 免费版激活码:', maskSecret(activationCode), data.data.isExisting ? '(已存在)' : '(新注册)');
// ✅ 在后端保存许可证
await saveLicenseLocally(activationCode, signedLicense);
// 初始化系统(创建默认管理员、部门、店铺等)
const initResult = await initializeSystem();
// 启动心跳
startHeartbeat();
const result = NextResponse.json({
success: true,
initialAdmin: initResult.admin?.created
? {
email: initResult.admin.email,
username: initResult.admin.username,
password: initResult.admin.passwordSource === 'generated' ? initResult.admin.initialPassword || null : null,
mustChangePassword: initResult.admin.mustChangePassword,
passwordSource: initResult.admin.passwordSource || null,
}
: null,
});
// 自动检测协议:根据请求的协议决定 cookie 的 secure 属性
const isHttps = request.headers.get('x-forwarded-proto') === 'https'
|| request.url.startsWith('https://');
// 设置许可证激活 cookie(用于 middleware 快速检查)
result.cookies.set('license_activated', 'true', {
httpOnly: true,
secure: isHttps,
sameSite: 'lax',
maxAge: 365 * 24 * 60 * 60, // 1年
path: '/',
});
return result;
} catch (error) { } catch (error) {
console.error('免费试用激活失败:', error); console.error('免费试用激活失败:', error);
return NextResponse.json( return NextResponse.json(

View File

@ -1,191 +1,206 @@
/** import { NextRequest } from 'next/server'
* 许可证激活 API 路由测试 import { POST } from '../route'
* 测试 app/api/license/activate/route.ts import { verifyLicenseSignature } from '@/lib/license/crypto-service'
*/ import { saveLicenseLocally, initializeSystem, startHeartbeat } from '@/lib/license/license-service'
import { NextRequest } from 'next/server';
import { POST } from '../route';
// Mock lib/license 模块 jest.mock('@/lib/license/fingerprint-service', () => ({
jest.mock('@/lib/license', () => ({ generateFingerprint: jest.fn().mockResolvedValue({
activate: jest.fn(), machineId: 'machine-1',
})); hostname: 'nas-1',
macAddress: 'AA:BB:CC:DD:EE:FF',
instanceId: 'instance-1',
}),
}))
import { activate } from '@/lib/license'; jest.mock('@/lib/license/server-config', () => ({
buildLicenseServerLicenseApiUrl: jest.fn(() => 'https://license.example.com/api/license/flnxi/activate'),
}))
const mockActivate = activate as jest.MockedFunction<typeof activate>; jest.mock('@/lib/license/crypto-service', () => ({
verifyLicenseSignature: jest.fn(),
}))
jest.mock('@/lib/license/license-service', () => ({
saveLicenseLocally: jest.fn(),
initializeSystem: jest.fn(),
startHeartbeat: jest.fn(),
}))
const mockVerifyLicenseSignature = verifyLicenseSignature as jest.MockedFunction<typeof verifyLicenseSignature>
const mockSaveLicenseLocally = saveLicenseLocally as jest.MockedFunction<typeof saveLicenseLocally>
const mockInitializeSystem = initializeSystem as jest.MockedFunction<typeof initializeSystem>
const mockStartHeartbeat = startHeartbeat as jest.MockedFunction<typeof startHeartbeat>
const signedLicense = {
data: {
type: 'PRO',
limits: { maxUsers: 100, maxMaterials: 10000 },
features: {
customTemplates: true,
systemSettings: true,
departmentIsolation: true,
apiAccess: true,
intranetPenetration: true,
},
expiresAt: null,
issuedAt: '2026-05-24T00:00:00.000Z',
},
signature: 'signed-license',
issuedAt: '2026-05-24T00:00:00.000Z',
}
function createMockRequest(options: {
url?: string
headers?: Record<string, string>
body?: Record<string, unknown>
} = {}) {
const url = options.url || 'http://localhost:3000/api/license/activate'
const headers = new Headers(options.headers || {})
return new NextRequest(url, {
method: 'POST',
headers,
body: JSON.stringify({
licenseKey: 'ABCD-1234-EFGH-5678',
...options.body,
}),
})
}
function mockActivateSuccess(overrides: Record<string, unknown> = {}) {
;(global.fetch as jest.Mock).mockResolvedValueOnce({
ok: true,
json: async () => ({
success: true,
license: signedLicense,
...overrides,
}),
})
}
describe('POST /api/license/activate', () => { describe('POST /api/license/activate', () => {
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks()
}); global.fetch = jest.fn()
mockVerifyLicenseSignature.mockReturnValue(true)
mockInitializeSystem.mockResolvedValue({
admin: {
created: false,
},
} as any)
})
describe('请求验证', () => { test('缺少激活码时应返回 400', async () => {
it('缺少 licenseKey 时应返回 400 错误', async () => { const response = await POST(createMockRequest({ body: { licenseKey: '' } }))
const request = new NextRequest('http://localhost/api/license/activate', { const data = await response.json()
method: 'POST',
body: JSON.stringify({}),
headers: { 'Content-Type': 'application/json' },
});
const response = await POST(request); expect(response.status).toBe(400)
const data = await response.json(); expect(data.success).toBe(false)
expect(data.error).toBe('请输入激活码')
expect(global.fetch).not.toHaveBeenCalled()
})
expect(response.status).toBe(400); test('激活成功时保存签名许可证并设置 cookie', async () => {
expect(data.success).toBe(false); mockActivateSuccess()
expect(data.error).toBe('请输入许可证密钥');
});
it('licenseKey 为空字符串时应返回 400 错误', async () => { const response = await POST(createMockRequest())
const request = new NextRequest('http://localhost/api/license/activate', { const data = await response.json()
method: 'POST', const setCookieHeader = response.headers.get('set-cookie')
body: JSON.stringify({ licenseKey: '' }),
headers: { 'Content-Type': 'application/json' },
});
const response = await POST(request); expect(response.status).toBe(200)
const data = await response.json(); expect(data.success).toBe(true)
expect(mockSaveLicenseLocally).toHaveBeenCalledWith('ABCD-1234-EFGH-5678', signedLicense)
expect(mockVerifyLicenseSignature).toHaveBeenCalledWith(signedLicense.data, signedLicense.signature)
expect(mockStartHeartbeat).toHaveBeenCalled()
expect(setCookieHeader).toContain('license_activated=true')
expect(setCookieHeader).toContain('HttpOnly')
expect(setCookieHeader).toContain('Max-Age=31536000')
})
expect(response.status).toBe(400); test('环境变量来源的初始管理员密码不应在激活响应中回显', async () => {
expect(data.success).toBe(false); mockActivateSuccess()
}); mockInitializeSystem.mockResolvedValueOnce({
}); admin: {
created: true,
email: 'admin@local.flnxi',
username: 'admin',
initialPassword: 'Env-Password-Should-Not-Return',
mustChangePassword: true,
passwordSource: 'environment',
},
} as any)
describe('激活成功', () => { const response = await POST(createMockRequest())
it('激活成功时应返回 success: true', async () => { const data = await response.json()
mockActivate.mockResolvedValueOnce({ success: true });
const request = new NextRequest('http://localhost/api/license/activate', { expect(response.status).toBe(200)
method: 'POST', expect(data.initialAdmin).toEqual({
body: JSON.stringify({ licenseKey: 'ABCD-1234-EFGH-5678' }), email: 'admin@local.flnxi',
headers: { 'Content-Type': 'application/json' }, username: 'admin',
}); password: null,
mustChangePassword: true,
passwordSource: 'environment',
})
})
const response = await POST(request); test('系统生成的一次性初始管理员密码仍只在首次激活响应中返回', async () => {
const data = await response.json(); mockActivateSuccess()
mockInitializeSystem.mockResolvedValueOnce({
admin: {
created: true,
email: 'admin@local.flnxi',
username: 'admin',
initialPassword: 'Generated-Password-123',
mustChangePassword: true,
passwordSource: 'generated',
},
} as any)
expect(response.status).toBe(200); const response = await POST(createMockRequest())
expect(data.success).toBe(true); const data = await response.json()
expect(mockActivate).toHaveBeenCalledWith('ABCD-1234-EFGH-5678');
});
it('激活成功时应设置 license_activated cookie', async () => { expect(response.status).toBe(200)
mockActivate.mockResolvedValueOnce({ success: true }); expect(data.initialAdmin.password).toBe('Generated-Password-123')
expect(data.initialAdmin.passwordSource).toBe('generated')
})
const request = new NextRequest('http://localhost/api/license/activate', { test('许可证服务器拒绝或签名验证失败时不保存本地许可证', async () => {
method: 'POST', mockActivateSuccess({
body: JSON.stringify({ licenseKey: 'ABCD-1234-EFGH-5678' }), success: false,
headers: { 'Content-Type': 'application/json' }, error: '许可证无效',
}); license: undefined,
})
const response = await POST(request); const rejectedResponse = await POST(createMockRequest())
const setCookieHeader = response.headers.get('set-cookie'); const rejectedData = await rejectedResponse.json()
expect(setCookieHeader).toBeTruthy(); expect(rejectedResponse.status).toBe(400)
expect(setCookieHeader).toContain('license_activated=true'); expect(rejectedData.error).toBe('许可证无效')
expect(setCookieHeader).toContain('HttpOnly'); expect(mockSaveLicenseLocally).not.toHaveBeenCalled()
expect(setCookieHeader).toContain('Path=/');
// SameSite 值可能是 Lax 或 lax(大小写不敏感)
expect(setCookieHeader?.toLowerCase()).toContain('samesite=lax');
});
it('cookie 应设置 1 年有效期', async () => { mockActivateSuccess()
mockActivate.mockResolvedValueOnce({ success: true }); mockVerifyLicenseSignature.mockReturnValueOnce(false)
const request = new NextRequest('http://localhost/api/license/activate', { const invalidSignatureResponse = await POST(createMockRequest())
method: 'POST', const invalidSignatureData = await invalidSignatureResponse.json()
body: JSON.stringify({ licenseKey: 'ABCD-1234-EFGH-5678' }),
headers: { 'Content-Type': 'application/json' },
});
const response = await POST(request); expect(invalidSignatureResponse.status).toBe(400)
const setCookieHeader = response.headers.get('set-cookie'); expect(invalidSignatureData.error).toBe('许可证签名验证失败')
expect(mockSaveLicenseLocally).not.toHaveBeenCalled()
})
// 1年 = 365 * 24 * 60 * 60 = 31536000 秒 test('HTTPS 或代理 HTTPS 时 cookie 带 Secure', async () => {
expect(setCookieHeader).toContain('Max-Age=31536000'); mockActivateSuccess()
}); const httpsResponse = await POST(createMockRequest({
}); url: 'https://example.com/api/license/activate',
}))
expect(httpsResponse.headers.get('set-cookie')).toContain('Secure')
describe('激活失败', () => { mockActivateSuccess()
it('许可证无效时应返回 400 错误', async () => { const forwardedHttpsResponse = await POST(createMockRequest({
mockActivate.mockResolvedValueOnce({ success: false, error: '许可证无效' }); url: 'http://localhost:3000/api/license/activate',
headers: { 'x-forwarded-proto': 'https' },
const request = new NextRequest('http://localhost/api/license/activate', { }))
method: 'POST', expect(forwardedHttpsResponse.headers.get('set-cookie')).toContain('Secure')
body: JSON.stringify({ licenseKey: 'INVALID-KEY-1234' }), })
headers: { 'Content-Type': 'application/json' }, })
});
const response = await POST(request);
const data = await response.json();
expect(response.status).toBe(400);
expect(data.success).toBe(false);
expect(data.error).toBe('许可证无效');
});
it('许可证已被使用时应返回相应错误', async () => {
mockActivate.mockResolvedValueOnce({ success: false, error: '许可证已被其他设备激活' });
const request = new NextRequest('http://localhost/api/license/activate', {
method: 'POST',
body: JSON.stringify({ licenseKey: 'USED-KEY-1234-5678' }),
headers: { 'Content-Type': 'application/json' },
});
const response = await POST(request);
const data = await response.json();
expect(response.status).toBe(400);
expect(data.success).toBe(false);
expect(data.error).toBe('许可证已被其他设备激活');
});
it('激活失败时不应设置 cookie', async () => {
mockActivate.mockResolvedValueOnce({ success: false, error: '许可证无效' });
const request = new NextRequest('http://localhost/api/license/activate', {
method: 'POST',
body: JSON.stringify({ licenseKey: 'INVALID-KEY-1234' }),
headers: { 'Content-Type': 'application/json' },
});
const response = await POST(request);
const setCookieHeader = response.headers.get('set-cookie');
expect(setCookieHeader).toBeNull();
});
});
describe('服务器错误处理', () => {
it('activate 函数抛出异常时应返回 500 错误', async () => {
mockActivate.mockRejectedValueOnce(new Error('数据库连接失败'));
const request = new NextRequest('http://localhost/api/license/activate', {
method: 'POST',
body: JSON.stringify({ licenseKey: 'ABCD-1234-EFGH-5678' }),
headers: { 'Content-Type': 'application/json' },
});
const response = await POST(request);
const data = await response.json();
expect(response.status).toBe(500);
expect(data.success).toBe(false);
expect(data.error).toBe('服务器错误');
});
it('JSON 解析错误时应返回 500 错误', async () => {
const request = new NextRequest('http://localhost/api/license/activate', {
method: 'POST',
body: 'invalid json',
headers: { 'Content-Type': 'application/json' },
});
const response = await POST(request);
const data = await response.json();
expect(response.status).toBe(500);
expect(data.success).toBe(false);
});
});
});

View File

@ -1,38 +1,92 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { activate } from '@/lib/license'; import { generateFingerprint } from '@/lib/license/fingerprint-service';
import { verifyLicenseSignature } from '@/lib/license/crypto-service';
import { saveLicenseLocally, initializeSystem, startHeartbeat } from '@/lib/license/license-service';
import { buildLicenseServerLicenseApiUrl } from '@/lib/license/server-config';
import type { ActivateResponse } from '@/lib/license/types';
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
const { licenseKey } = await request.json(); const body = await request.json();
// 支持两种参数名:activationCode(新)和 licenseKey(旧)
const licenseKey = body.activationCode || body.licenseKey;
if (!licenseKey) { if (!licenseKey) {
return NextResponse.json( return NextResponse.json(
{ success: false, error: '请输入许可证密钥' }, { success: false, error: '请输入激活码' },
{ status: 400 } { status: 400 }
); );
} }
const result = await activate(licenseKey); // ✅ 安全改进:在后端生成硬件指纹(不暴露给客户端)
const fingerprint = await generateFingerprint();
if (result.success) { // ✅ 安全改进:代理请求到许可证服务器(隐藏服务器地址)
const response = NextResponse.json({ success: true }); const response = await fetch(buildLicenseServerLicenseApiUrl('activate'), {
method: 'POST',
// 设置许可证激活 cookie(用于 middleware 快速检查) headers: { 'Content-Type': 'application/json' },
response.cookies.set('license_activated', 'true', { body: JSON.stringify({
httpOnly: true, licenseKey,
secure: process.env.NODE_ENV === 'production', fingerprint,
sameSite: 'lax', timestamp: Date.now(),
maxAge: 365 * 24 * 60 * 60, // 1年 }),
path: '/', });
});
const data: ActivateResponse = await response.json();
return response;
} else { if (!data.success || !data.license) {
return NextResponse.json( return NextResponse.json(
{ success: false, error: result.error }, { success: false, error: data.error || '激活失败' },
{ status: 400 } { status: 400 }
); );
} }
// ✅ 在后端验证签名
if (!verifyLicenseSignature(data.license.data, data.license.signature)) {
return NextResponse.json(
{ success: false, error: '许可证签名验证失败' },
{ status: 400 }
);
}
// ✅ 在后端保存许可证
await saveLicenseLocally(licenseKey, data.license);
// 初始化系统(创建默认管理员、部门、店铺等)
const initResult = await initializeSystem();
// 启动心跳
startHeartbeat();
const result = NextResponse.json({
success: true,
initialAdmin: initResult.admin?.created
? {
email: initResult.admin.email,
username: initResult.admin.username,
password: initResult.admin.passwordSource === 'generated' ? initResult.admin.initialPassword || null : null,
mustChangePassword: initResult.admin.mustChangePassword,
passwordSource: initResult.admin.passwordSource || null,
}
: null,
});
// 自动检测协议:根据请求的协议决定 cookie 的 secure 属性
// - HTTP 请求:secure = false(内网部署)
// - HTTPS 请求:secure = true(Pro 版外网穿透)
const isHttps = request.headers.get('x-forwarded-proto') === 'https'
|| request.url.startsWith('https://');
// 设置许可证激活 cookie(用于 middleware 快速检查)
result.cookies.set('license_activated', 'true', {
httpOnly: true,
secure: isHttps,
sameSite: 'lax',
maxAge: 365 * 24 * 60 * 60, // 1年
path: '/',
});
return result;
} catch (error) { } catch (error) {
console.error('激活失败:', error); console.error('激活失败:', error);
return NextResponse.json( return NextResponse.json(

View File

@ -0,0 +1,54 @@
import { NextRequest } from 'next/server'
jest.mock('@/lib/license/license-service', () => ({
getLocalLicense: jest.fn(),
}))
const { getLocalLicense } = require('@/lib/license/license-service')
import { GET } from '../route'
describe('GET /api/license/check', () => {
beforeEach(() => {
jest.clearAllMocks()
})
test('本地许可证通过验签时应恢复激活状态并写入 cookie', async () => {
getLocalLicense.mockResolvedValue({
instanceId: 'test-instance-id',
licenseKey: 'license-key',
licenseData: 'encrypted-license',
lastVerified: new Date().toISOString(),
cachedConfig: {
type: 'FREE',
limits: { maxUsers: 10, maxMaterials: 100 },
features: {
customTemplates: false,
systemSettings: false,
departmentIsolation: false,
apiAccess: false,
intranetPenetration: false,
},
expiresAt: null,
},
})
const response = await GET(new NextRequest('https://localhost/api/license/check'))
const data = await response.json()
expect(response.status).toBe(200)
expect(data.activated).toBe(true)
expect(response.cookies.get('license_activated')?.value).toBe('true')
})
test('本地许可证为空或验签失败时应返回未激活', async () => {
getLocalLicense.mockResolvedValue(null)
const response = await GET(new NextRequest('http://localhost/api/license/check'))
const data = await response.json()
expect(response.status).toBe(200)
expect(data.activated).toBe(false)
expect(response.cookies.get('license_activated')).toBeUndefined()
})
})

View File

@ -0,0 +1,39 @@
import { NextRequest, NextResponse } from 'next/server';
import { getLocalLicense } from '@/lib/license/license-service';
/**
* 检查数据库中是否有已激活的许可证
* 用于恢复 cookie(当 cookie 丢失但数据库中仍有许可证时)
*/
export async function GET(request: NextRequest) {
try {
const license = await getLocalLicense();
if (!license) {
return NextResponse.json({ activated: false });
}
const response = NextResponse.json({ activated: true });
// 自动检测协议:根据请求的协议决定 cookie 的 secure 属性
const isHttps = request.headers.get('x-forwarded-proto') === 'https'
|| request.url.startsWith('https://');
// 恢复 cookie
response.cookies.set('license_activated', 'true', {
httpOnly: true,
secure: isHttps,
sameSite: 'lax',
maxAge: 365 * 24 * 60 * 60, // 1年
path: '/',
});
return response;
} catch (error) {
console.error('检查许可证状态失败:', error);
return NextResponse.json(
{ activated: false, error: '服务器错误' },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,65 @@
import { NextRequest, NextResponse } from 'next/server';
import { generateFingerprint } from '@/lib/license/fingerprint-service';
import { getLocalLicense, updateLocalLicense, clearLocalLicense } from '@/lib/license/license-service';
import { buildLicenseServerLicenseApiUrl } from '@/lib/license/server-config';
import { prisma } from '@/lib/prisma';
import type { HeartbeatResponse } from '@/lib/license/types';
export async function POST(request: NextRequest) {
try {
const localLicense = await getLocalLicense();
if (!localLicense) {
return NextResponse.json({ acknowledged: false, error: '未找到许可证' });
}
// ✅ 安全改进:在后端生成硬件指纹(不暴露给客户端)
const fingerprint = await generateFingerprint();
// 获取使用统计
const [userCount, materialCount] = await Promise.all([
prisma.user.count(),
prisma.material.count(),
]);
// ✅ 安全改进:代理请求到许可证服务器(隐藏服务器地址)
const response = await fetch(buildLicenseServerLicenseApiUrl('heartbeat'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
instanceId: fingerprint.instanceId,
fingerprint,
usage: { userCount, materialCount },
timestamp: Date.now(),
}),
});
// 检查是否被吊销
if (response.status === 403 || response.status === 404) {
console.log('[License] 心跳检测到许可证已被吊销');
await clearLocalLicense();
return NextResponse.json({
acknowledged: false,
revoked: true,
error: '许可证已被吊销'
});
}
const data: HeartbeatResponse = await response.json();
if (data.acknowledged && data.license) {
// ✅ 在后端更新本地缓存
await updateLocalLicense(data.license);
}
return NextResponse.json({
acknowledged: data.acknowledged || false
});
} catch (error) {
console.error('心跳发送失败:', error);
return NextResponse.json({
acknowledged: false,
error: '网络错误'
});
}
}

View File

@ -0,0 +1,87 @@
import { NextRequest, NextResponse } from 'next/server';
import { generateFingerprint } from '@/lib/license/fingerprint-service';
import { updateLocalLicense, getLocalLicense, clearLocalLicense } from '@/lib/license/license-service';
import { verifyLicenseSignature } from '@/lib/license/crypto-service';
import { buildLicenseServerLicenseApiUrl } from '@/lib/license/server-config';
import type { VerifyResponse } from '@/lib/license/types';
// 宽限期(7天)
const GRACE_PERIOD_DAYS = 7;
const GRACE_PERIOD_MS = GRACE_PERIOD_DAYS * 24 * 60 * 60 * 1000;
function isWithinGracePeriod(lastVerified: Date | string | null): boolean {
if (!lastVerified) return false;
const lastVerifiedDate = typeof lastVerified === 'string' ? new Date(lastVerified) : lastVerified;
const now = new Date();
return (now.getTime() - lastVerifiedDate.getTime()) < GRACE_PERIOD_MS;
}
export async function POST(request: NextRequest) {
try {
// 先检查本地缓存
const localLicense = await getLocalLicense();
if (!localLicense) {
return NextResponse.json({ valid: false, error: '未找到许可证' });
}
// ✅ 安全改进:在后端生成硬件指纹(不暴露给客户端)
const fingerprint = await generateFingerprint();
// ✅ 安全改进:代理请求到许可证服务器(隐藏服务器地址)
const response = await fetch(buildLicenseServerLicenseApiUrl('verify'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
instanceId: fingerprint.instanceId,
fingerprint,
timestamp: Date.now(),
}),
});
const data: VerifyResponse = await response.json();
if (!data.valid || !data.license) {
// 检查是否是被吊销(禁用/删除/过期)
const isRevoked = response.status === 403 || response.status === 404;
if (isRevoked) {
// 许可证被吊销,清除本地缓存
console.log('[License] 许可证已被吊销:', data.error);
await clearLocalLicense();
return NextResponse.json({
valid: false,
error: data.error || '许可证已被吊销',
revoked: true
});
}
// 其他错误(如网络问题),检查宽限期
if (isWithinGracePeriod(localLicense.lastVerified)) {
return NextResponse.json({ valid: true });
}
return NextResponse.json({ valid: false, error: data.error || '验证失败' });
}
// ✅ 在后端验证签名
if (!verifyLicenseSignature(data.license.data, data.license.signature)) {
return NextResponse.json({ valid: false, error: '许可证签名验证失败' });
}
// ✅ 在后端更新本地缓存
await updateLocalLicense(data.license);
return NextResponse.json({ valid: true });
} catch (error) {
console.error('验证失败:', error);
// 网络错误时检查宽限期
const localLicense = await getLocalLicense();
if (localLicense && isWithinGracePeriod(localLicense.lastVerified)) {
return NextResponse.json({ valid: true });
}
return NextResponse.json({ valid: false, error: '网络错误' });
}
}

View File

@ -0,0 +1,109 @@
import { NextRequest } from 'next/server'
import { POST } from '../route'
jest.mock('@/lib/auth', () => ({
authOptions: {},
}))
jest.mock('next-auth', () => ({
getServerSession: jest.fn(),
}))
jest.mock('@/lib/prisma', () => ({
prisma: {
user: {
findFirst: jest.fn(),
},
},
}))
jest.mock('@/lib/system-integrity', () => ({
BUILTIN_TEMPLATE_KEYS: ['3dmax-material'],
}))
jest.mock('@/lib/system-init', () => ({
getSystemInitToken: jest.fn(() => 'init-token'),
initializeSystem: jest.fn(),
}))
const { getServerSession } = require('next-auth')
const { prisma } = require('@/lib/prisma')
const { initializeSystem } = require('@/lib/system-init')
describe('POST /api/system/init', () => {
beforeEach(() => {
jest.resetAllMocks()
getServerSession.mockResolvedValue({
user: {
id: 'admin-1',
role: 'ADMIN',
isSuperAdmin: true,
},
})
prisma.user.findFirst.mockResolvedValue({ id: 'admin-1' })
})
test('环境变量来源的初始管理员密码不应在初始化响应中回显', async () => {
initializeSystem.mockResolvedValue({
alreadyInitialized: false,
initialized: true,
admin: {
email: 'admin@local.flnxi',
username: 'admin',
created: true,
upgraded: false,
mustChangePassword: true,
initialPassword: 'Env-Password-Should-Not-Return',
passwordSource: 'environment',
},
defaultDepartment: '默认部门',
defaultCategory: '默认店铺',
defaultScene: '默认场景',
templates: {
total: 1,
created: 0,
createdTemplates: [],
errors: [],
},
})
const response = await POST(new NextRequest('http://localhost/api/system/init', { method: 'POST' }))
const data = await response.json()
expect(response.status).toBe(200)
expect(data.data.admin.initialPassword).toBeNull()
expect(data.data.admin.passwordSource).toBe('environment')
})
test('系统生成的一次性管理员密码仍可在首次初始化响应中返回', async () => {
initializeSystem.mockResolvedValue({
alreadyInitialized: false,
initialized: true,
admin: {
email: 'admin@local.flnxi',
username: 'admin',
created: true,
upgraded: false,
mustChangePassword: true,
initialPassword: 'Generated-Password-123',
passwordSource: 'generated',
},
defaultDepartment: '默认部门',
defaultCategory: '默认店铺',
defaultScene: '默认场景',
templates: {
total: 1,
created: 0,
createdTemplates: [],
errors: [],
},
})
const response = await POST(new NextRequest('http://localhost/api/system/init', { method: 'POST' }))
const data = await response.json()
expect(response.status).toBe(200)
expect(data.data.admin.initialPassword).toBe('Generated-Password-123')
expect(data.data.admin.passwordSource).toBe('generated')
})
})

View File

@ -3,6 +3,8 @@
* 测试内置模板配置和系统初始化功能 * 测试内置模板配置和系统初始化功能
*/ */
export {}
// 内置模板配置(从 route.ts 复制,用于测试验证) // 内置模板配置(从 route.ts 复制,用于测试验证)
const BUILTIN_TEMPLATE_KEYS = [ const BUILTIN_TEMPLATE_KEYS = [
'3dmax-material', '3dmax-material',

View File

@ -1,138 +1,88 @@
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import type { Session } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma' import { prisma } from '@/lib/prisma'
import bcrypt from 'bcryptjs' import { BUILTIN_TEMPLATE_KEYS } from '@/lib/system-integrity'
import { import { getSystemInitToken, initializeSystem } from '@/lib/system-init'
BUILTIN_TEMPLATE_KEYS,
ensureBuiltinTemplates,
} from '@/lib/system-integrity'
// 系统初始化:创建默认部门、店铺、管理员账户、内置模板 function isAdminSession(session: Session | null) {
export async function POST(_req: NextRequest) { const user = session?.user
return Boolean(user && (user.role === 'ADMIN' || user.isSuperAdmin))
}
export async function POST(req: NextRequest) {
try { try {
// 从环境变量读取初始管理员配置,如果没有则使用默认值(向后兼容) const session = await getServerSession(authOptions)
const initAdminEmail = process.env.INIT_ADMIN_EMAIL || 'admin@flnxi.com' const existingSuperAdmin = await prisma.user.findFirst({
const initAdminPassword = process.env.INIT_ADMIN_PASSWORD || '88888888' where: { isSuperAdmin: true },
const initAdminUsername = initAdminEmail.split('@')[0] // 从邮箱提取用户名 select: { id: true },
// 使用事务确保所有数据都创建成功或已存在
const result = await prisma.$transaction(async (tx) => {
// 1. 确保默认管理员账户存在
// 优先查找环境变量配置的邮箱,如果没有则查找旧的 'admin'
let admin = await tx.user.findFirst({
where: {
OR: [
{ email: initAdminEmail },
{ email: 'admin' } // 向后兼容旧配置
]
}
})
let adminCreated = false
if (!admin) {
// 检查是否已有任何用户(避免在已有用户的情况下创建默认管理员)
const userCount = await tx.user.count()
if (userCount === 0) {
// 只有在没有任何用户时才创建初始管理员
const hashedPassword = await bcrypt.hash(initAdminPassword, 10)
admin = await tx.user.create({
data: {
email: initAdminEmail,
username: initAdminUsername,
password: hashedPassword,
role: 'ADMIN',
isSuperAdmin: true, // 初始管理员设为超级管理员
isActive: true,
canDownload: true,
department: '系统管理'
}
})
adminCreated = true
} else {
// 如果已有用户,返回第一个管理员用户(如果有)
admin = await tx.user.findFirst({
where: { role: 'ADMIN' }
}) || await tx.user.findFirst()
}
}
const ensureDepartment = async () => {
const existing = await tx.department.findFirst({ where: { name: '默认部门' } })
if (existing) return { entity: existing, created: false }
const created = await tx.department.create({ data: { name: '默认部门' } })
return { entity: created, created: true }
}
const ensureCategory = async () => {
const existing = await tx.category.findFirst({ where: { name: '默认店铺' } })
if (existing) return { entity: existing, created: false }
const created = await tx.category.create({ data: { name: '默认店铺' } })
return { entity: created, created: true }
}
const ensureScene = async () => {
const existing = await tx.scene.findFirst({ where: { name: '默认场景' } })
if (existing) return { entity: existing, created: false }
const created = await tx.scene.create({ data: { name: '默认场景' } })
return { entity: created, created: true }
}
const departmentResult = await ensureDepartment()
const categoryResult = await ensureCategory()
const sceneResult = await ensureScene()
// 注意:内置模板在事务外创建,因为 ensureBuiltinTemplates 有自己的事务处理
// 这里只返回占位结果,实际创建在事务后执行
const templatesPlaceholder = {
total: BUILTIN_TEMPLATE_KEYS.length,
created: 0,
createdTemplates: [] as string[],
}
return {
admin,
defaultDepartment: departmentResult.entity,
defaultCategory: categoryResult.entity,
defaultScene: sceneResult.entity,
templates: templatesPlaceholder,
createdFlags: {
admin: adminCreated,
department: departmentResult.created,
category: categoryResult.created,
scene: sceneResult.created,
}
}
}) })
// 在事务外创建内置模板(使用统一的模块) let initToken = req.headers.get('x-system-init-token')?.trim() || ''
const templatesResult = await ensureBuiltinTemplates() if (!initToken) {
const body = await req.json().catch(() => ({}))
if (typeof body?.initToken === 'string') {
initToken = body.initToken.trim()
}
}
const alreadyInitialized = if (!isAdminSession(session)) {
!result.createdFlags.admin && if (existingSuperAdmin) {
!result.createdFlags.department && return NextResponse.json(
!result.createdFlags.category && {
!result.createdFlags.scene && success: false,
templatesResult.created.length === 0 error: '系统已初始化,后续初始化操作需要管理员登录',
},
{ status: 403 }
)
}
const configuredInitToken = getSystemInitToken()
if (!configuredInitToken) {
return NextResponse.json(
{
success: false,
error: '未配置 SYSTEM_INIT_TOKEN,禁止公开初始化',
},
{ status: 503 }
)
}
if (!initToken || initToken !== configuredInitToken) {
return NextResponse.json(
{
success: false,
error: '初始化令牌无效',
},
{ status: 401 }
)
}
}
const result = await initializeSystem()
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
message: alreadyInitialized ? '系统已初始化' : '系统初始化成功', message: result.alreadyInitialized ? '系统已初始化' : '系统初始化成功',
initialized: true, initialized: result.initialized,
data: { data: {
admin: result.admin ? { admin: result.admin
email: result.admin.email, ? {
username: result.admin.username, email: result.admin.email,
password: result.createdFlags.admin ? '(已使用配置的密码)' : '(未创建新账号)' username: result.admin.username,
} : null, created: result.admin.created,
defaultDepartment: result.defaultDepartment.name, upgraded: result.admin.upgraded,
defaultCategory: result.defaultCategory.name, mustChangePassword: result.admin.mustChangePassword,
defaultScene: result.defaultScene.name, initialPassword: result.admin.passwordSource === 'generated' ? result.admin.initialPassword || null : null,
templates: { passwordSource: result.admin.passwordSource || null,
total: BUILTIN_TEMPLATE_KEYS.length, }
created: templatesResult.created.length, : null,
createdTemplates: templatesResult.created, defaultDepartment: result.defaultDepartment,
errors: templatesResult.errors, defaultCategory: result.defaultCategory,
} defaultScene: result.defaultScene,
} templates: result.templates,
},
}) })
} catch (error: any) { } catch (error: any) {
console.error('系统初始化失败:', error) console.error('系统初始化失败:', error)
@ -140,39 +90,31 @@ export async function POST(_req: NextRequest) {
{ {
success: false, success: false,
error: '系统初始化失败', error: '系统初始化失败',
details: error.message details: error.message,
}, },
{ status: 500 } { status: 500 }
) )
} }
} }
// 检查系统是否已初始化
export async function GET() { export async function GET() {
try { try {
// 优先查找环境变量配置的管理员,如果没有则查找旧的 'admin'
const initAdminEmail = process.env.INIT_ADMIN_EMAIL || 'admin@flnxi.com'
const admin = await prisma.user.findFirst({ const admin = await prisma.user.findFirst({
where: { where: {
OR: [ OR: [{ isSuperAdmin: true }, { role: 'ADMIN' }],
{ email: initAdminEmail }, },
{ email: 'admin' }, // 向后兼容
{ role: 'ADMIN' } // 或者查找任何管理员账号
]
}
}) })
const hasDepartment = await prisma.department.findFirst() const hasDepartment = await prisma.department.findFirst()
const hasCategory = await prisma.category.findFirst() const hasCategory = await prisma.category.findFirst()
const hasScene = await prisma.scene.findFirst() const hasScene = await prisma.scene.findFirst()
// 检查内置模板是否完整
const builtinTemplateCount = await prisma.uploadTemplate.count({ const builtinTemplateCount = await prisma.uploadTemplate.count({
where: { where: {
id: { id: {
in: BUILTIN_TEMPLATE_KEYS.map(key => `builtin-${key}`) in: BUILTIN_TEMPLATE_KEYS.map((key) => `builtin-${key}`),
} },
} },
}) })
const hasAllTemplates = builtinTemplateCount === BUILTIN_TEMPLATE_KEYS.length const hasAllTemplates = builtinTemplateCount === BUILTIN_TEMPLATE_KEYS.length
@ -184,17 +126,17 @@ export async function GET() {
hasScene: !!hasScene, hasScene: !!hasScene,
hasAllTemplates, hasAllTemplates,
templateCount: builtinTemplateCount, templateCount: builtinTemplateCount,
expectedTemplateCount: BUILTIN_TEMPLATE_KEYS.length expectedTemplateCount: BUILTIN_TEMPLATE_KEYS.length,
publicInitAvailable: !admin && Boolean(getSystemInitToken()),
}) })
} catch (error: any) { } catch (error: any) {
console.error('检查初始化状态失败:', error) console.error('检查初始化状态失败:', error)
return NextResponse.json( return NextResponse.json(
{ {
initialized: false, initialized: false,
error: error.message error: error.message,
}, },
{ status: 500 } { status: 500 }
) )
} }
} }

View File

@ -0,0 +1,90 @@
import { NextRequest } from 'next/server'
import { POST } from '../route'
jest.mock('@/lib/auth', () => ({
authOptions: {},
}))
jest.mock('next-auth', () => ({
getServerSession: jest.fn(),
}))
jest.mock('@/lib/prisma', () => ({
prisma: {
user: {
findUnique: jest.fn(),
update: jest.fn(),
},
},
}))
jest.mock('bcryptjs', () => ({
compare: jest.fn(),
hash: jest.fn(async (value: string) => `hashed:${value}`),
}))
jest.mock('@/lib/system-init', () => ({
isInitialAdminPasswordChangeRequired: jest.fn(),
clearInitialAdminPasswordRequirement: jest.fn(),
}))
const { getServerSession } = require('next-auth')
const { prisma } = require('@/lib/prisma')
const bcrypt = require('bcryptjs')
const {
isInitialAdminPasswordChangeRequired,
clearInitialAdminPasswordRequirement,
} = require('@/lib/system-init')
function createRequest(body: Record<string, unknown>) {
return new NextRequest('http://localhost/api/user/change-password', {
method: 'POST',
body: JSON.stringify(body),
})
}
describe('POST /api/user/change-password', () => {
beforeEach(() => {
jest.resetAllMocks()
getServerSession.mockResolvedValue({
user: {
email: 'admin@local.flnxi',
},
})
prisma.user.findUnique.mockResolvedValue({
id: 'admin-1',
email: 'admin@local.flnxi',
password: 'old-hash',
})
isInitialAdminPasswordChangeRequired.mockResolvedValue(false)
bcrypt.compare.mockResolvedValue(false)
})
test('拒绝弱新密码且不查询用户密码', async () => {
const response = await POST(createRequest({
oldPassword: 'old-password',
newPassword: '123456',
}))
const data = await response.json()
expect(response.status).toBe(400)
expect(data.error).toContain('至少')
expect(prisma.user.findUnique).not.toHaveBeenCalled()
expect(prisma.user.update).not.toHaveBeenCalled()
})
test('首次登录强制改密时允许省略旧密码,但仍要求强密码', async () => {
isInitialAdminPasswordChangeRequired.mockResolvedValue(true)
const response = await POST(createRequest({
newPassword: 'Flnxi-2026-Safe',
}))
const data = await response.json()
expect(response.status).toBe(200)
expect(data.success).toBe(true)
expect(bcrypt.hash).toHaveBeenCalledWith('Flnxi-2026-Safe', 10)
expect(prisma.user.update).toHaveBeenCalled()
expect(clearInitialAdminPasswordRequirement).toHaveBeenCalledWith('admin-1')
})
})

View File

@ -3,6 +3,11 @@ import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth' import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma' import { prisma } from '@/lib/prisma'
import bcrypt from 'bcryptjs' import bcrypt from 'bcryptjs'
import {
clearInitialAdminPasswordRequirement,
isInitialAdminPasswordChangeRequired,
} from '@/lib/system-init'
import { validatePasswordStrength } from '@/lib/password-policy'
// 修改密码 // 修改密码
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
@ -18,16 +23,17 @@ export async function POST(req: NextRequest) {
const { oldPassword, newPassword } = await req.json() const { oldPassword, newPassword } = await req.json()
if (!oldPassword || !newPassword) { if (!newPassword) {
return NextResponse.json( return NextResponse.json(
{ error: '请提供旧密码和新密码' }, { error: '请提供新密码' },
{ status: 400 } { status: 400 }
) )
} }
if (newPassword.length < 6) { const passwordValidation = validatePasswordStrength(newPassword)
if (!passwordValidation.valid) {
return NextResponse.json( return NextResponse.json(
{ error: '新密码长度至少为6位' }, { error: passwordValidation.error || '新密码不符合安全要求' },
{ status: 400 } { status: 400 }
) )
} }
@ -43,16 +49,26 @@ export async function POST(req: NextRequest) {
) )
} }
// 验证旧密码 const mustChangePassword = await isInitialAdminPasswordChangeRequired(user.id)
const isOldPasswordValid = await bcrypt.compare(oldPassword, user.password)
if (!isOldPasswordValid) { if (!mustChangePassword && !oldPassword) {
return NextResponse.json( return NextResponse.json(
{ error: '旧密码错误' }, { error: '请提供旧密码' },
{ status: 400 } { status: 400 }
) )
} }
if (!mustChangePassword) {
const isOldPasswordValid = await bcrypt.compare(oldPassword, user.password)
if (!isOldPasswordValid) {
return NextResponse.json(
{ error: '旧密码错误' },
{ status: 400 }
)
}
}
// 检查新密码是否与旧密码相同 // 检查新密码是否与旧密码相同
const isSamePassword = await bcrypt.compare(newPassword, user.password) const isSamePassword = await bcrypt.compare(newPassword, user.password)
if (isSamePassword) { if (isSamePassword) {
@ -69,6 +85,10 @@ export async function POST(req: NextRequest) {
data: { password: hashedNewPassword } data: { password: hashedNewPassword }
}) })
if (mustChangePassword) {
await clearInitialAdminPasswordRequirement(user.id)
}
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
message: '密码修改成功' message: '密码修改成功'

View File

@ -2,11 +2,11 @@ import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth' import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth' import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma' import { prisma } from '@/lib/prisma'
import { isInitialAdminPasswordChangeRequired } from '@/lib/system-init'
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
import bcrypt from 'bcryptjs'
// 检查用户是否使用默认密码 // 兼容旧接口命名:当前检查的是“系统初始化后是否仍要求首次改密”
export async function GET(req: NextRequest) { export async function GET(req: NextRequest) {
try { try {
const session = await getServerSession(authOptions) const session = await getServerSession(authOptions)
@ -29,27 +29,11 @@ export async function GET(req: NextRequest) {
) )
} }
// 只针对初始管理员账号(admin@flnxi.com)检查默认密码 const mustChangePassword = await isInitialAdminPasswordChangeRequired(user.id)
// 从环境变量读取初始管理员邮箱,如果没有则使用默认值
const initAdminEmail = process.env.INIT_ADMIN_EMAIL || 'admin@flnxi.com'
// 只有当前用户是初始管理员账号时才检查默认密码
const isInitialAdmin = user.email === initAdminEmail || user.email === 'admin@flnxi.com'
if (!isInitialAdmin) {
// 非初始管理员账号,不需要检查默认密码
return NextResponse.json({
isDefaultPassword: false,
email: user.email,
username: user.username
})
}
// 检查密码是否为默认密码 88888888
const isDefaultPassword = await bcrypt.compare('88888888', user.password)
return NextResponse.json({ return NextResponse.json({
isDefaultPassword, isDefaultPassword: mustChangePassword,
mustChangePassword,
email: user.email, email: user.email,
username: user.username username: user.username
}) })

View File

@ -0,0 +1,427 @@
/**
* 许可证服务模块测试
* 测试 lib/license/license-service.ts 中的核心功能
*/
// Mock 依赖模块
jest.mock('@/lib/prisma', () => ({
prisma: {
systemConfig: {
findUnique: jest.fn(),
upsert: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
},
user: {
count: jest.fn(),
findFirst: jest.fn(),
create: jest.fn(),
update: jest.fn(),
},
material: {
count: jest.fn(),
},
department: {
findFirst: jest.fn(),
create: jest.fn(),
},
category: {
findFirst: jest.fn(),
create: jest.fn(),
},
scene: {
findFirst: jest.fn(),
create: jest.fn(),
},
$transaction: jest.fn((fn) => fn({
user: {
findFirst: jest.fn(),
create: jest.fn(),
update: jest.fn(),
},
department: {
findFirst: jest.fn(),
create: jest.fn(),
},
category: {
findFirst: jest.fn(),
create: jest.fn(),
},
scene: {
findFirst: jest.fn(),
create: jest.fn(),
},
})),
},
}));
jest.mock('../license/fingerprint-service', () => ({
generateFingerprint: jest.fn(),
getInstanceId: jest.fn(),
}));
jest.mock('../license/crypto-service', () => ({
verifyLicenseSignature: jest.fn(),
encryptLocal: jest.fn((data) => `encrypted:${data}`),
decryptLocal: jest.fn((data) => data.replace('encrypted:', '')),
}));
jest.mock('@/lib/system-integrity', () => ({
ensureBuiltinTemplates: jest.fn(),
}));
// Mock global fetch
const mockFetch = jest.fn();
global.fetch = mockFetch;
import { prisma } from '@/lib/prisma';
import { verifyLicenseSignature } from '../license/crypto-service';
import { LICENSE_CONFIGS } from '../license/types';
const mockVerifyLicenseSignature = verifyLicenseSignature as jest.MockedFunction<typeof verifyLicenseSignature>;
function createSignedLicense(overrides: Record<string, any> = {}) {
const issuedAt = new Date().toISOString();
return {
data: {
type: 'FREE',
limits: { maxUsers: 10, maxMaterials: 100 },
features: {
customTemplates: false,
systemSettings: false,
departmentIsolation: false,
apiAccess: false,
intranetPenetration: false,
},
expiresAt: null,
issuedAt,
...overrides,
},
signature: 'valid-signature',
issuedAt,
};
}
function createStoredLocalLicense(overrides: Record<string, any> = {}) {
const signedLicense = overrides.signedLicense || createSignedLicense(overrides.signedLicenseData || {});
return {
instanceId: 'test-instance-id',
licenseKey: 'FLNXI-FREE-TEST-1234-5678-ABCD',
licenseData: `encrypted:${JSON.stringify(signedLicense)}`,
lastVerified: new Date().toISOString(),
cachedConfig: {
type: 'FREE',
limits: { maxUsers: 10, maxMaterials: 100 },
features: {
customTemplates: false,
systemSettings: false,
departmentIsolation: false,
apiAccess: false,
intranetPenetration: false,
},
expiresAt: null,
},
signedLicense: undefined,
signedLicenseData: undefined,
...overrides,
};
}
describe('license-service', () => {
beforeEach(() => {
jest.clearAllMocks();
mockFetch.mockReset();
mockVerifyLicenseSignature.mockReturnValue(true);
});
describe('activateFreeTrial', () => {
beforeEach(() => {
(prisma.systemConfig.findUnique as jest.Mock).mockResolvedValue(null);
});
it('应该成功激活免费试用版', async () => {
mockFetch.mockResolvedValueOnce({
json: () => Promise.resolve({
success: true,
}),
});
const { activateFreeTrial } = await import('../license/license-service');
const result = await activateFreeTrial('测试公司');
expect(result.success).toBe(true);
expect(mockFetch).toHaveBeenCalledWith(
'/api/license/activate-free',
expect.objectContaining({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
})
);
});
it('服务器注册失败时应该返回失败(必须联网)', async () => {
// Mock 服务器注册失败
mockFetch.mockRejectedValueOnce(new Error('Network error'));
const { activateFreeTrial } = await import('../license/license-service');
const result = await activateFreeTrial('测试公司');
// 现在必须联网,网络错误应该返回失败
expect(result.success).toBe(false);
expect(result.error).toBe('网络错误,请检查网络连接后重试');
});
it('服务器返回失败时应该返回错误信息', async () => {
// Mock 服务器返回失败
mockFetch.mockResolvedValueOnce({
json: () => Promise.resolve({
success: false,
error: '服务器繁忙,请稍后重试'
}),
});
const { activateFreeTrial } = await import('../license/license-service');
const result = await activateFreeTrial('测试公司');
expect(result.success).toBe(false);
expect(result.error).toBe('服务器繁忙,请稍后重试');
});
it('应该向本地激活接口发送客户信息', async () => {
mockFetch.mockResolvedValueOnce({
json: () => Promise.resolve({
success: true,
}),
});
const { activateFreeTrial } = await import('../license/license-service');
await activateFreeTrial('测试公司', '13800000000');
const fetchCall = mockFetch.mock.calls[0];
if (fetchCall) {
const body = JSON.parse(fetchCall[1].body);
expect(body.customerName).toBe('测试公司');
expect(body.contactInfo).toBe('13800000000');
}
});
});
describe('getLicenseType', () => {
it('未激活时应该返回 FREE', async () => {
(prisma.systemConfig.findUnique as jest.Mock).mockResolvedValue(null);
const { getLicenseType } = await import('../license/license-service');
const type = await getLicenseType();
expect(type).toBe('FREE');
});
});
describe('isFeatureEnabled', () => {
it('未激活时应该返回 false', async () => {
(prisma.systemConfig.findUnique as jest.Mock).mockResolvedValue(null);
const { isFeatureEnabled } = await import('../license/license-service');
const enabled = await isFeatureEnabled('customTemplates');
expect(enabled).toBe(false);
});
it('本地许可证过期后应拒绝功能使用', async () => {
const expiredAt = new Date(Date.now() - 60_000).toISOString();
(prisma.systemConfig.findUnique as jest.Mock).mockResolvedValue({
key: 'license',
value: JSON.stringify(createStoredLocalLicense({
signedLicenseData: {
type: 'PRO',
limits: { maxUsers: -1, maxMaterials: -1 },
features: {
customTemplates: true,
systemSettings: true,
departmentIsolation: true,
apiAccess: true,
intranetPenetration: true,
},
expiresAt: expiredAt,
},
})),
});
const { isFeatureEnabled, getLicenseType, checkLimit } = await import('../license/license-service');
await expect(isFeatureEnabled('customTemplates')).resolves.toBe(false);
await expect(getLicenseType()).resolves.toBe('FREE');
await expect(checkLimit('materials')).resolves.toEqual({ allowed: false, current: 0, max: 0 });
});
it('业务功能检查每次以本地已验签许可证为准,降级后不继续信旧缓存', async () => {
const proLicense = createStoredLocalLicense({
signedLicenseData: {
type: 'PRO',
limits: { maxUsers: -1, maxMaterials: -1 },
features: {
customTemplates: true,
systemSettings: true,
departmentIsolation: true,
apiAccess: true,
intranetPenetration: true,
},
expiresAt: null,
},
});
const freeLicense = createStoredLocalLicense({
signedLicenseData: {
type: 'FREE',
limits: { maxUsers: 10, maxMaterials: 100 },
features: {
customTemplates: false,
systemSettings: false,
departmentIsolation: false,
apiAccess: false,
intranetPenetration: false,
},
expiresAt: null,
},
});
(prisma.systemConfig.findUnique as jest.Mock)
.mockResolvedValueOnce({ key: 'license', value: JSON.stringify(proLicense) })
.mockResolvedValueOnce({ key: 'license', value: JSON.stringify(freeLicense) });
const { isFeatureEnabled } = await import('../license/license-service');
await expect(isFeatureEnabled('customTemplates')).resolves.toBe(true);
await expect(isFeatureEnabled('customTemplates')).resolves.toBe(false);
});
});
describe('checkLimit', () => {
it('未激活时应该返回不允许', async () => {
(prisma.systemConfig.findUnique as jest.Mock).mockResolvedValue(null);
const { checkLimit } = await import('../license/license-service');
const result = await checkLimit('users');
expect(result.allowed).toBe(false);
});
});
describe('verify (许可证吊销检测)', () => {
const mockLocalLicense = createStoredLocalLicense();
it('许可证被吊销时应该返回 revoked: true', async () => {
// Mock 本地有许可证
(prisma.systemConfig.findUnique as jest.Mock).mockResolvedValue({
key: 'license',
value: JSON.stringify(mockLocalLicense),
});
// Mock 本地验证接口返回已吊销
mockFetch.mockResolvedValueOnce({
json: () => Promise.resolve({
valid: false,
revoked: true,
error: '许可证已被禁用'
}),
});
const { verify } = await import('../license/license-service');
const result = await verify();
expect(result.valid).toBe(false);
expect(result.revoked).toBe(true);
expect(result.error).toBe('许可证已被禁用');
});
it('许可证被删除时应该返回 revoked: true', async () => {
// Mock 本地有许可证
(prisma.systemConfig.findUnique as jest.Mock).mockResolvedValue({
key: 'license',
value: JSON.stringify(mockLocalLicense),
});
// Mock 本地验证接口返回已吊销
mockFetch.mockResolvedValueOnce({
json: () => Promise.resolve({
valid: false,
revoked: true,
error: '许可证不存在'
}),
});
const { verify } = await import('../license/license-service');
const result = await verify();
expect(result.valid).toBe(false);
expect(result.revoked).toBe(true);
expect(result.error).toBe('许可证不存在');
});
it('未找到本地许可证时应该返回错误', async () => {
(prisma.systemConfig.findUnique as jest.Mock).mockResolvedValue(null);
const { verify } = await import('../license/license-service');
const result = await verify();
expect(result.valid).toBe(false);
expect(result.error).toBe('未找到许可证');
});
});
describe('本地许可证验签收紧', () => {
it('应优先使用验签后的许可证内容,而不是本地 cachedConfig', async () => {
const tamperedLocalLicense = createStoredLocalLicense({
cachedConfig: {
type: 'PRO',
limits: { maxUsers: -1, maxMaterials: -1 },
features: {
customTemplates: true,
systemSettings: true,
departmentIsolation: true,
apiAccess: true,
intranetPenetration: true,
},
expiresAt: null,
},
});
(prisma.systemConfig.findUnique as jest.Mock).mockResolvedValue({
key: 'license',
value: JSON.stringify(tamperedLocalLicense),
});
const { getLicenseType, isFeatureEnabled } = await import('../license/license-service');
expect(await getLicenseType()).toBe('FREE');
expect(await isFeatureEnabled('customTemplates')).toBe(false);
});
it('本地签名无效时应视为无许可证', async () => {
mockVerifyLicenseSignature.mockReturnValue(false);
(prisma.systemConfig.findUnique as jest.Mock).mockResolvedValue({
key: 'license',
value: JSON.stringify(createStoredLocalLicense()),
});
const { getLocalLicense } = await import('../license/license-service');
const localLicense = await getLocalLicense();
expect(localLicense).toBeNull();
});
});
});
describe('LICENSE_CONFIGS 免费版配置', () => {
it('FREE 版本应该有正确的限制', () => {
expect(LICENSE_CONFIGS.FREE.limits.maxUsers).toBe(10);
expect(LICENSE_CONFIGS.FREE.limits.maxMaterials).toBe(100);
});
it('FREE 版本应该禁用高级功能', () => {
expect(LICENSE_CONFIGS.FREE.features.customTemplates).toBe(false);
expect(LICENSE_CONFIGS.FREE.features.departmentIsolation).toBe(false);
expect(LICENSE_CONFIGS.FREE.features.intranetPenetration).toBe(false);
});
});

View File

@ -0,0 +1,24 @@
import { validatePasswordStrength } from '../password-policy'
describe('validatePasswordStrength', () => {
test('拒绝空密码、短密码和常见弱密码', () => {
expect(validatePasswordStrength('').valid).toBe(false)
expect(validatePasswordStrength('Ab1!').error).toBe('密码长度至少为 10 位')
expect(validatePasswordStrength('admin123').valid).toBe(false)
})
test('拒绝字符类别不足的密码', () => {
const result = validatePasswordStrength('abcdefghij')
expect(result.valid).toBe(false)
expect(result.error).toContain('至少 3 类')
})
test('拒绝全部重复字符', () => {
expect(validatePasswordStrength('!!!!!!!!!!').valid).toBe(false)
})
test('允许长度和复杂度达标的密码', () => {
expect(validatePasswordStrength('Flnxi-2026-Safe').valid).toBe(true)
})
})

View File

@ -0,0 +1,165 @@
jest.mock('@/lib/prisma', () => ({
prisma: {
systemConfig: {
findUnique: jest.fn(),
upsert: jest.fn(),
delete: jest.fn(),
},
$transaction: jest.fn(),
},
}))
jest.mock('bcryptjs', () => ({
hash: jest.fn(async (value: string) => `hashed:${value}`),
}))
jest.mock('@/lib/system-integrity', () => ({
BUILTIN_TEMPLATE_KEYS: ['3dmax-material', 'psd-material'],
ensureBuiltinTemplates: jest.fn(),
}))
import { prisma } from '@/lib/prisma'
import bcrypt from 'bcryptjs'
import { ensureBuiltinTemplates } from '@/lib/system-integrity'
import {
clearInitialAdminPasswordRequirement,
initializeSystem,
isInitialAdminPasswordChangeRequired,
} from '../system-init'
const mockEnsureBuiltinTemplates = ensureBuiltinTemplates as jest.MockedFunction<typeof ensureBuiltinTemplates>
describe('system-init', () => {
beforeEach(() => {
jest.clearAllMocks()
delete process.env.INIT_ADMIN_EMAIL
delete process.env.INIT_ADMIN_PASSWORD
mockEnsureBuiltinTemplates.mockResolvedValue({
created: ['builtin-3dmax-material'],
errors: [],
} as never)
})
it('未配置初始密码时应生成一次性密码并要求首次改密', async () => {
const tx = {
user: {
findFirst: jest.fn()
.mockResolvedValueOnce(null)
.mockResolvedValueOnce(null),
update: jest.fn(),
create: jest.fn().mockResolvedValue({
id: 'admin-1',
email: 'admin@local.flnxi',
username: 'admin',
role: 'ADMIN',
isSuperAdmin: true,
}),
},
department: {
findFirst: jest.fn().mockResolvedValue(null),
create: jest.fn().mockResolvedValue({ name: '默认部门' }),
},
category: {
findFirst: jest.fn().mockResolvedValue(null),
create: jest.fn().mockResolvedValue({ name: '默认店铺' }),
},
scene: {
findFirst: jest.fn().mockResolvedValue(null),
create: jest.fn().mockResolvedValue({ name: '默认场景' }),
},
}
;(prisma.$transaction as jest.Mock).mockImplementation(async (callback) => callback(tx))
;(prisma.systemConfig.upsert as jest.Mock).mockResolvedValue({})
const result = await initializeSystem()
expect(result.initialized).toBe(true)
expect(result.admin?.created).toBe(true)
expect(result.admin?.mustChangePassword).toBe(true)
expect(result.admin?.email).toBe('admin@local.flnxi')
expect(result.admin?.passwordSource).toBe('generated')
expect(result.admin?.initialPassword).toBeTruthy()
expect(prisma.systemConfig.upsert).toHaveBeenCalledWith(
expect.objectContaining({
where: { key: 'initial_admin_state' },
})
)
})
it('使用环境变量初始密码时不应把明文密码回传给接口层', async () => {
process.env.INIT_ADMIN_PASSWORD = 'Env-Password-Should-Not-Return'
const tx = {
user: {
findFirst: jest.fn()
.mockResolvedValueOnce(null)
.mockResolvedValueOnce(null),
update: jest.fn(),
create: jest.fn().mockResolvedValue({
id: 'admin-1',
email: 'admin@local.flnxi',
username: 'admin',
role: 'ADMIN',
isSuperAdmin: true,
}),
},
department: {
findFirst: jest.fn().mockResolvedValue(null),
create: jest.fn().mockResolvedValue({ name: '默认部门' }),
},
category: {
findFirst: jest.fn().mockResolvedValue(null),
create: jest.fn().mockResolvedValue({ name: '默认店铺' }),
},
scene: {
findFirst: jest.fn().mockResolvedValue(null),
create: jest.fn().mockResolvedValue({ name: '默认场景' }),
},
}
;(prisma.$transaction as jest.Mock).mockImplementation(async (callback) => callback(tx))
;(prisma.systemConfig.upsert as jest.Mock).mockResolvedValue({})
const result = await initializeSystem()
expect(bcrypt.hash).toHaveBeenCalledWith('Env-Password-Should-Not-Return', 10)
expect(result.admin?.created).toBe(true)
expect(result.admin?.passwordSource).toBe('environment')
expect(result.admin?.initialPassword).toBeUndefined()
})
it('应根据 initial_admin_state 判断是否需要首次改密', async () => {
;(prisma.systemConfig.findUnique as jest.Mock).mockResolvedValue({
key: 'initial_admin_state',
value: JSON.stringify({
userId: 'user-1',
email: 'admin@local.flnxi',
mustChangePassword: true,
createdAt: new Date().toISOString(),
}),
})
await expect(isInitialAdminPasswordChangeRequired('user-1')).resolves.toBe(true)
await expect(isInitialAdminPasswordChangeRequired('user-2')).resolves.toBe(false)
})
it('首次改密完成后应清除 initial_admin_state', async () => {
;(prisma.systemConfig.findUnique as jest.Mock).mockResolvedValue({
key: 'initial_admin_state',
value: JSON.stringify({
userId: 'user-1',
email: 'admin@local.flnxi',
mustChangePassword: true,
createdAt: new Date().toISOString(),
}),
})
;(prisma.systemConfig.delete as jest.Mock).mockResolvedValue({})
await clearInitialAdminPasswordRequirement('user-1')
expect(prisma.systemConfig.delete).toHaveBeenCalledWith({
where: { key: 'initial_admin_state' },
})
})
})

View File

@ -11,9 +11,22 @@ const INSTANCE_ID_FILE = path.join(process.cwd(), 'data', '.instance-id');
* 获取 machine-id * 获取 machine-id
* Linux: /etc/machine-id 或 /var/lib/dbus/machine-id * Linux: /etc/machine-id 或 /var/lib/dbus/machine-id
* Windows: 使用 hostname + 网卡信息生成 * Windows: 使用 hostname + 网卡信息生成
* Docker: 使用容器 ID 或挂载的 machine-id * Docker: 优先使用持久化的标识符,避免容器重建时变化
*/ */
async function getMachineId(): Promise<string> { async function getMachineId(): Promise<string> {
// 优先尝试读取持久化的 machine-id 文件(与 instance-id 类似)
const persistentMachineIdFile = path.join(process.cwd(), 'data', '.machine-id');
try {
if (fs.existsSync(persistentMachineIdFile)) {
const machineId = fs.readFileSync(persistentMachineIdFile, 'utf8').trim();
if (machineId) {
return machineId;
}
}
} catch {
// 读取失败,继续使用其他方法
}
// 尝试读取 Linux machine-id // 尝试读取 Linux machine-id
const machineIdPaths = [ const machineIdPaths = [
'/etc/machine-id', '/etc/machine-id',
@ -24,6 +37,16 @@ async function getMachineId(): Promise<string> {
try { try {
const machineId = fs.readFileSync(filePath, 'utf8').trim(); const machineId = fs.readFileSync(filePath, 'utf8').trim();
if (machineId) { if (machineId) {
// 保存到持久化文件,以便下次使用
const dir = path.dirname(persistentMachineIdFile);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
try {
fs.writeFileSync(persistentMachineIdFile, machineId);
} catch {
// 写入失败不影响使用
}
return machineId; return machineId;
} }
} catch { } catch {
@ -31,18 +54,7 @@ async function getMachineId(): Promise<string> {
} }
} }
// 尝试读取 Docker 容器 ID // Windows 或其他系统:使用 hostname + 网卡信息生成稳定的标识符
try {
const cgroupContent = fs.readFileSync('/proc/self/cgroup', 'utf8');
const match = cgroupContent.match(/docker[/-]([a-f0-9]{64})/);
if (match) {
return match[1].substring(0, 32);
}
} catch {
// 不在 Docker 容器中
}
// Windows 或其他系统:使用 hostname + 网卡信息生成
const hostname = os.hostname(); const hostname = os.hostname();
const networkInterfaces = os.networkInterfaces(); const networkInterfaces = os.networkInterfaces();
const macs: string[] = []; const macs: string[] = [];
@ -58,7 +70,20 @@ async function getMachineId(): Promise<string> {
} }
const data = `${hostname}-${macs.sort().join('-')}`; const data = `${hostname}-${macs.sort().join('-')}`;
return crypto.createHash('md5').update(data).digest('hex'); const generatedId = crypto.createHash('md5').update(data).digest('hex');
// 保存到持久化文件
const dir = path.dirname(persistentMachineIdFile);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
try {
fs.writeFileSync(persistentMachineIdFile, generatedId);
} catch {
// 写入失败不影响使用
}
return generatedId;
} }
/** /**
@ -66,10 +91,10 @@ async function getMachineId(): Promise<string> {
*/ */
function getPrimaryMacAddress(): string { function getPrimaryMacAddress(): string {
const networkInterfaces = os.networkInterfaces(); const networkInterfaces = os.networkInterfaces();
// 优先查找常见的网卡名称 // 优先查找常见的网卡名称
const preferredNames = ['eth0', 'en0', 'ens33', 'enp0s3', 'Ethernet', 'Wi-Fi']; const preferredNames = ['eth0', 'en0', 'ens33', 'enp0s3', 'Ethernet', 'Wi-Fi'];
for (const name of preferredNames) { for (const name of preferredNames) {
const interfaces = networkInterfaces[name]; const interfaces = networkInterfaces[name];
if (interfaces) { if (interfaces) {
@ -119,7 +144,7 @@ async function getOrCreateInstanceId(): Promise<string> {
// 生成新的实例 ID // 生成新的实例 ID
const instanceId = crypto.randomBytes(16).toString('hex'); const instanceId = crypto.randomBytes(16).toString('hex');
fs.writeFileSync(INSTANCE_ID_FILE, instanceId); fs.writeFileSync(INSTANCE_ID_FILE, instanceId);
return instanceId; return instanceId;
} }

View File

@ -0,0 +1,21 @@
const DEFAULT_LICENSE_SERVER_URL = 'https://license.flnxi.com';
export function getLicenseServerUrl(): string {
return process.env.LICENSE_SERVER_URL || DEFAULT_LICENSE_SERVER_URL;
}
export function getLicenseProjectCode(): string {
const projectCode = process.env.LICENSE_PROJECT_CODE?.trim();
if (!projectCode) {
throw new Error('LICENSE_PROJECT_CODE 环境变量未配置');
}
return projectCode;
}
export function buildLicenseServerLicenseApiUrl(action: string): string {
const baseUrl = getLicenseServerUrl();
const projectCode = encodeURIComponent(getLicenseProjectCode());
return `${baseUrl}/api/license/${projectCode}/${action}`;
}

51
lib/password-policy.ts Normal file
View File

@ -0,0 +1,51 @@
const COMMON_WEAK_PASSWORDS = new Set([
'123456',
'12345678',
'123456789',
'1234567890',
'password',
'password123',
'admin123',
'admin888',
'88888888',
'qwerty123',
'abc123456',
'flnxi123',
])
export const PASSWORD_POLICY_DESCRIPTION = '密码至少 10 位,并包含大写字母、小写字母、数字、符号中的至少 3 类'
export function validatePasswordStrength(password: string | undefined | null): {
valid: boolean
error?: string
} {
if (!password) {
return { valid: false, error: '请提供密码' }
}
if (password.length < 10) {
return { valid: false, error: '密码长度至少为 10 位' }
}
const normalized = password.toLowerCase()
if (COMMON_WEAK_PASSWORDS.has(normalized)) {
return { valid: false, error: '密码过于常见,请换一个更安全的密码' }
}
if (/^(.)\1+$/.test(password)) {
return { valid: false, error: '密码不能全部使用同一个字符' }
}
const categories = [
/[a-z]/.test(password),
/[A-Z]/.test(password),
/\d/.test(password),
/[^a-zA-Z0-9]/.test(password),
].filter(Boolean).length
if (categories < 3) {
return { valid: false, error: PASSWORD_POLICY_DESCRIPTION }
}
return { valid: true }
}

280
lib/system-init.ts Normal file
View File

@ -0,0 +1,280 @@
import { randomBytes } from 'crypto'
import bcrypt from 'bcryptjs'
import { prisma } from './prisma'
import { BUILTIN_TEMPLATE_KEYS, ensureBuiltinTemplates } from './system-integrity'
const INITIAL_ADMIN_STATE_KEY = 'initial_admin_state'
const DEFAULT_INIT_ADMIN_EMAIL = 'admin@local.flnxi'
type PasswordSource = 'environment' | 'generated'
type InitialAdminState = {
userId: string
email: string
mustChangePassword: boolean
createdAt: string
}
export type InitialAdminInfo = {
email: string
username: string
created: boolean
upgraded: boolean
mustChangePassword: boolean
initialPassword?: string
passwordSource?: PasswordSource
}
export type InitializeSystemResult = {
initialized: true
alreadyInitialized: boolean
admin: InitialAdminInfo | null
defaultDepartment: string
defaultCategory: string
defaultScene: string
templates: {
total: number
created: number
createdTemplates: string[]
errors: string[]
}
}
function generateInitialAdminPassword(): string {
return randomBytes(18).toString('base64url')
}
export function getInitAdminEmail(): string {
const configuredEmail = process.env.INIT_ADMIN_EMAIL?.trim()
return configuredEmail || DEFAULT_INIT_ADMIN_EMAIL
}
export function getSystemInitToken(): string {
return process.env.SYSTEM_INIT_TOKEN?.trim() || ''
}
function resolveInitialAdminPassword(): {
password: string
source: PasswordSource
} {
const configuredPassword = process.env.INIT_ADMIN_PASSWORD?.trim()
if (configuredPassword) {
return {
password: configuredPassword,
source: 'environment',
}
}
return {
password: generateInitialAdminPassword(),
source: 'generated',
}
}
async function saveInitialAdminState(userId: string, email: string) {
const payload: InitialAdminState = {
userId,
email,
mustChangePassword: true,
createdAt: new Date().toISOString(),
}
await prisma.systemConfig.upsert({
where: { key: INITIAL_ADMIN_STATE_KEY },
update: { value: JSON.stringify(payload) },
create: {
key: INITIAL_ADMIN_STATE_KEY,
value: JSON.stringify(payload),
},
})
}
export async function getInitialAdminState(): Promise<InitialAdminState | null> {
try {
const config = await prisma.systemConfig.findUnique({
where: { key: INITIAL_ADMIN_STATE_KEY },
})
if (!config) {
return null
}
const parsed = JSON.parse(config.value) as InitialAdminState
if (
!parsed ||
typeof parsed.userId !== 'string' ||
typeof parsed.email !== 'string' ||
typeof parsed.mustChangePassword !== 'boolean'
) {
return null
}
return parsed
} catch (error) {
console.warn('[SystemInit] 读取初始管理员状态失败:', error)
return null
}
}
export async function isInitialAdminPasswordChangeRequired(userId: string): Promise<boolean> {
const state = await getInitialAdminState()
return Boolean(state && state.userId === userId && state.mustChangePassword)
}
export async function clearInitialAdminPasswordRequirement(userId: string): Promise<void> {
const state = await getInitialAdminState()
if (!state || state.userId !== userId) {
return
}
try {
await prisma.systemConfig.delete({
where: { key: INITIAL_ADMIN_STATE_KEY },
})
} catch (error) {
console.warn('[SystemInit] 清除初始管理员改密标记失败:', error)
}
}
export async function initializeSystem(): Promise<InitializeSystemResult> {
const initAdminEmail = getInitAdminEmail()
const initAdminUsername = initAdminEmail.split('@')[0] || 'admin'
const result = await prisma.$transaction(async (tx) => {
let adminCreated = false
let adminUpgraded = false
let initialPassword: string | null = null
let passwordSource: PasswordSource | null = null
let admin = await tx.user.findFirst({
where: { isSuperAdmin: true },
})
if (!admin) {
admin = await tx.user.findFirst({
where: {
OR: [
{ email: initAdminEmail },
{ email: 'admin' },
{ username: initAdminUsername },
],
},
})
if (admin) {
if (!admin.isSuperAdmin || admin.role !== 'ADMIN') {
admin = await tx.user.update({
where: { id: admin.id },
data: { isSuperAdmin: true, role: 'ADMIN' },
})
adminUpgraded = true
}
} else {
const resolvedPassword = resolveInitialAdminPassword()
initialPassword = resolvedPassword.password
passwordSource = resolvedPassword.source
const hashedPassword = await bcrypt.hash(resolvedPassword.password, 10)
admin = await tx.user.create({
data: {
email: initAdminEmail,
username: initAdminUsername,
password: hashedPassword,
role: 'ADMIN',
isSuperAdmin: true,
isActive: true,
canDownload: true,
department: '系统管理',
},
})
adminCreated = true
}
}
const ensureDepartment = async () => {
const existing = await tx.department.findFirst({
where: { name: '默认部门' },
})
if (existing) return { entity: existing, created: false }
const created = await tx.department.create({ data: { name: '默认部门' } })
return { entity: created, created: true }
}
const ensureCategory = async () => {
const existing = await tx.category.findFirst({
where: { name: '默认店铺' },
})
if (existing) return { entity: existing, created: false }
const created = await tx.category.create({ data: { name: '默认店铺' } })
return { entity: created, created: true }
}
const ensureScene = async () => {
const existing = await tx.scene.findFirst({
where: { name: '默认场景' },
})
if (existing) return { entity: existing, created: false }
const created = await tx.scene.create({ data: { name: '默认场景' } })
return { entity: created, created: true }
}
const departmentResult = await ensureDepartment()
const categoryResult = await ensureCategory()
const sceneResult = await ensureScene()
return {
admin,
initialPassword,
passwordSource,
createdFlags: {
admin: adminCreated,
adminUpgraded,
department: departmentResult.created,
category: categoryResult.created,
scene: sceneResult.created,
},
defaultDepartment: departmentResult.entity.name,
defaultCategory: categoryResult.entity.name,
defaultScene: sceneResult.entity.name,
}
})
if (result.createdFlags.admin && result.admin) {
await saveInitialAdminState(result.admin.id, result.admin.email)
}
const templatesResult = await ensureBuiltinTemplates()
const alreadyInitialized =
!result.createdFlags.admin &&
!result.createdFlags.adminUpgraded &&
!result.createdFlags.department &&
!result.createdFlags.category &&
!result.createdFlags.scene &&
templatesResult.created.length === 0
return {
initialized: true,
alreadyInitialized,
admin: result.admin
? {
email: result.admin.email,
username: result.admin.username,
created: result.createdFlags.admin,
upgraded: result.createdFlags.adminUpgraded,
mustChangePassword: result.createdFlags.admin,
initialPassword: result.passwordSource === 'generated' ? result.initialPassword || undefined : undefined,
passwordSource: result.passwordSource || undefined,
}
: null,
defaultDepartment: result.defaultDepartment,
defaultCategory: result.defaultCategory,
defaultScene: result.defaultScene,
templates: {
total: BUILTIN_TEMPLATE_KEYS.length,
created: templatesResult.created.length,
createdTemplates: templatesResult.created,
errors: templatesResult.errors,
},
}
}