加固许可证链路和客户交付安全边界
This commit is contained in:
parent
3b5da1c2c2
commit
4696215999
@ -29,7 +29,9 @@ POSTGRES_VOLUME_NAME=flnxi-postgres-data
|
||||
MINIO_VOLUME_NAME=flnxi-minio-data
|
||||
APP_NETWORK_NAME=flnxi-app-network
|
||||
APP_PORT=4320
|
||||
MINIO_API_BIND_HOST=0.0.0.0
|
||||
MINIO_API_PORT=9000
|
||||
MINIO_CONSOLE_BIND_HOST=127.0.0.1
|
||||
MINIO_CONSOLE_PORT=9001
|
||||
|
||||
# Upload limits, in bytes. Defaults: single file 10GB, total request 10GB, text fields 2MB.
|
||||
@ -105,6 +107,8 @@ INIT_ADMIN_PASSWORD=
|
||||
RESTORE_DATA_ENABLED=0
|
||||
RESTORE_DATA_TOKEN=
|
||||
RESTORE_DATA_ALLOWED_IPS=127.0.0.1
|
||||
# Only set to 1 when the app is behind a trusted reverse proxy that strips untrusted forwarding headers.
|
||||
RESTORE_DATA_TRUST_PROXY=0
|
||||
RESTORE_ADMIN_EMAIL=admin@local.flnxi
|
||||
RESTORE_ADMIN_USERNAME=admin
|
||||
RESTORE_ADMIN_PASSWORD=
|
||||
|
||||
@ -258,9 +258,6 @@ COPY --from=builder --chown=nextjs:nodejs /app/prisma/migrations ./prisma/migrat
|
||||
# 4. 版本文件(许可证系统版本检查)
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/VERSION.txt ./VERSION.txt
|
||||
|
||||
# 4. lib 目录(包含 configLoader 等运行时模块)
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/lib ./lib
|
||||
|
||||
# 5. 补充关键模块(standalone 输出可能未完整包含)
|
||||
# 复制 Prisma Client(必需)
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/.prisma ./node_modules/.prisma
|
||||
|
||||
@ -19,4 +19,9 @@ describe('docker-compose.prod.yml', () => {
|
||||
expect(compose).toContain('mc anonymous set download "myminio/$${S3_BUCKET}"')
|
||||
expect(compose).toContain('- S3_SET_ACL=${S3_SET_ACL:-0}')
|
||||
})
|
||||
|
||||
it('MinIO 控制台默认只绑定本机,素材访问端口仍可按客户入口开放', () => {
|
||||
expect(compose).toContain('${MINIO_API_BIND_HOST:-0.0.0.0}:${MINIO_API_PORT:-9000}:9000')
|
||||
expect(compose).toContain('${MINIO_CONSOLE_BIND_HOST:-127.0.0.1}:${MINIO_CONSOLE_PORT:-9001}:9001')
|
||||
})
|
||||
})
|
||||
|
||||
@ -27,12 +27,23 @@ describe('production build hardening', () => {
|
||||
expect(dockerfile).not.toMatch(/COPY\s+--from=builder[\s\S]*\/app\/prisma\s+\.\/prisma/)
|
||||
})
|
||||
|
||||
it('生产运行镜像不能额外复制未混淆源码目录', () => {
|
||||
expect(dockerfile).not.toMatch(/COPY\s+--from=builder[^\n]*\/app\/lib\s+\.\/lib/)
|
||||
expect(dockerfile).not.toMatch(/COPY\s+--from=builder[^\n]*\/app\/app\s+\.\/app/)
|
||||
})
|
||||
|
||||
it('混淆依赖缺失时不能静默跳过', () => {
|
||||
expect(buildScript).toContain('生产混淆不能跳过')
|
||||
expect(buildScript).toContain('process.exit(1)')
|
||||
expect(buildScript).not.toContain('跳过混淆步骤')
|
||||
})
|
||||
|
||||
it('混淆目标缺失或混淆失败时不能继续出包', () => {
|
||||
expect(buildScript).toContain('failedFiles')
|
||||
expect(buildScript).toContain('没有找到需要混淆的目标文件')
|
||||
expect(buildScript).toContain('混淆失败的目标文件')
|
||||
})
|
||||
|
||||
it('混淆配置保持保守,避免破坏 Next.js 服务端产物', () => {
|
||||
const { obfuscatorOptions } = require('../scripts/obfuscate-config')
|
||||
|
||||
|
||||
@ -200,7 +200,10 @@ describe('admin destructive actions require super admin', () => {
|
||||
prisma.$transaction.mockImplementation((callback: any) => callback(tx))
|
||||
|
||||
const response = await resetMaterialRankings(
|
||||
new NextRequest('http://localhost/api/admin/reset-material-rankings', { method: 'POST' })
|
||||
new NextRequest('http://localhost/api/admin/reset-material-rankings', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ confirmPhrase: '重置排行榜数据' }),
|
||||
})
|
||||
)
|
||||
const data = await response.json()
|
||||
|
||||
@ -209,6 +212,26 @@ describe('admin destructive actions require super admin', () => {
|
||||
expect(tx.$executeRawUnsafe).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('最高权限账号重置排行榜数据时也必须输入确认短语', async () => {
|
||||
getServerSession.mockResolvedValue({
|
||||
user: { id: 'super-1', role: 'ADMIN', isSuperAdmin: true },
|
||||
})
|
||||
prisma.user.findUnique.mockResolvedValue({ isSuperAdmin: true })
|
||||
|
||||
const response = await resetMaterialRankings(
|
||||
new NextRequest('http://localhost/api/admin/reset-material-rankings', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ confirmPhrase: '重置' }),
|
||||
})
|
||||
)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(data.error).toContain('确认短语')
|
||||
expect(data.requiredConfirmPhrase).toBe('重置排行榜数据')
|
||||
expect(prisma.$transaction).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('免费版即使是最高权限账号也不能重置排行榜数据', async () => {
|
||||
getServerSession.mockResolvedValue({
|
||||
user: { id: 'super-1', role: 'ADMIN', isSuperAdmin: true },
|
||||
@ -217,7 +240,10 @@ describe('admin destructive actions require super admin', () => {
|
||||
canUseSystemSettings.mockResolvedValue(false)
|
||||
|
||||
const response = await resetMaterialRankings(
|
||||
new NextRequest('http://localhost/api/admin/reset-material-rankings', { method: 'POST' })
|
||||
new NextRequest('http://localhost/api/admin/reset-material-rankings', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ confirmPhrase: '重置排行榜数据' }),
|
||||
})
|
||||
)
|
||||
const data = await response.json()
|
||||
|
||||
@ -306,7 +332,10 @@ describe('admin destructive actions require super admin', () => {
|
||||
prisma.$queryRawUnsafe.mockResolvedValue(1)
|
||||
|
||||
const response = await migrateMaterialFields(
|
||||
new NextRequest('http://localhost/api/admin/migrate-material-fields', { method: 'POST' })
|
||||
new NextRequest('http://localhost/api/admin/migrate-material-fields', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ confirmPhrase: '执行字段迁移' }),
|
||||
})
|
||||
)
|
||||
const data = await response.json()
|
||||
|
||||
@ -316,6 +345,27 @@ describe('admin destructive actions require super admin', () => {
|
||||
expect(prisma.$queryRawUnsafe).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('最高权限账号执行素材字段迁移时也必须输入确认短语', async () => {
|
||||
getServerSession.mockResolvedValue({
|
||||
user: { id: 'super-1', role: 'ADMIN', isSuperAdmin: true },
|
||||
})
|
||||
prisma.user.findUnique.mockResolvedValue({ isSuperAdmin: true })
|
||||
|
||||
const response = await migrateMaterialFields(
|
||||
new NextRequest('http://localhost/api/admin/migrate-material-fields', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ confirmPhrase: '迁移' }),
|
||||
})
|
||||
)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(data.error).toContain('确认短语')
|
||||
expect(data.requiredConfirmPhrase).toBe('执行字段迁移')
|
||||
expect(prisma.$executeRawUnsafe).not.toHaveBeenCalled()
|
||||
expect(prisma.$queryRawUnsafe).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('免费版即使是最高权限账号也不能执行素材字段迁移', async () => {
|
||||
getServerSession.mockResolvedValue({
|
||||
user: { id: 'super-1', role: 'ADMIN', isSuperAdmin: true },
|
||||
|
||||
@ -7,6 +7,8 @@ import { canUseSystemSettings } from '@/lib/license'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const REQUIRED_CONFIRM_PHRASE = '执行字段迁移'
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions)
|
||||
@ -25,6 +27,17 @@ export async function POST(req: NextRequest) {
|
||||
)
|
||||
}
|
||||
|
||||
const body = await req.json().catch(() => ({}))
|
||||
if (body?.confirmPhrase !== REQUIRED_CONFIRM_PHRASE) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: '素材字段迁移是高风险操作,请输入确认短语后再执行',
|
||||
requiredConfirmPhrase: REQUIRED_CONFIRM_PHRASE,
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// 执行 SQL 迁移 - 需要分开执行每个命令
|
||||
const commands = [
|
||||
`ALTER TABLE "materials" ADD COLUMN IF NOT EXISTS "brandName" TEXT`,
|
||||
|
||||
@ -7,6 +7,8 @@ import { canUseSystemSettings } from '@/lib/license'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const REQUIRED_CONFIRM_PHRASE = '重置排行榜数据'
|
||||
|
||||
/**
|
||||
* 重置素材排行榜相关数据:
|
||||
* - 清空点赞记录表 material_likes
|
||||
@ -33,6 +35,17 @@ export async function POST(req: NextRequest) {
|
||||
)
|
||||
}
|
||||
|
||||
const body = await req.json().catch(() => ({}))
|
||||
if (body?.confirmPhrase !== REQUIRED_CONFIRM_PHRASE) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: '重置排行榜是高风险操作,请输入确认短语后再执行',
|
||||
requiredConfirmPhrase: REQUIRED_CONFIRM_PHRASE,
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// 使用事务,确保一致性
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
// 清空点赞记录
|
||||
|
||||
@ -15,6 +15,7 @@ jest.mock('@/lib/license/fingerprint-service', () => ({
|
||||
|
||||
jest.mock('@/lib/license/server-config', () => ({
|
||||
buildLicenseServerLicenseApiUrl: jest.fn(() => 'https://license.example.com/api/license/flnxi/register-free'),
|
||||
getLicenseProjectCode: jest.fn(() => 'flnxi-sucai'),
|
||||
}));
|
||||
|
||||
jest.mock('@/lib/license/crypto-service', () => ({
|
||||
@ -39,6 +40,10 @@ const mockRequireSuperAdminAfterInitialSetup = requireSuperAdminAfterInitialSetu
|
||||
|
||||
const signedFreeLicense = {
|
||||
data: {
|
||||
projectCode: 'flnxi-sucai',
|
||||
licenseKey: 'FLNXI-FREE-TEST-1234-5678-ABCD',
|
||||
instanceId: 'instance-1',
|
||||
fingerprintHash: 'a'.repeat(64),
|
||||
type: 'FREE',
|
||||
limits: { maxUsers: 10, maxMaterials: 100 },
|
||||
features: {
|
||||
@ -179,6 +184,27 @@ describe('POST /api/license/activate-free', () => {
|
||||
expect(mockStartHeartbeat).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('许可证绑定信息不匹配时拒绝激活', async () => {
|
||||
mockRegisterFreeSuccess({
|
||||
license: {
|
||||
...signedFreeLicense,
|
||||
data: {
|
||||
...signedFreeLicense.data,
|
||||
licenseKey: 'FLNXI-FREE-OTHER-1234-5678-ABCD',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const response = await POST(createMockRequest());
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(data.error).toContain('激活码不匹配');
|
||||
expect(mockSaveLicenseLocally).not.toHaveBeenCalled();
|
||||
expect(mockInitializeSystem).not.toHaveBeenCalled();
|
||||
expect(mockStartHeartbeat).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('应该设置 license_activated cookie', async () => {
|
||||
mockRegisterFreeSuccess();
|
||||
|
||||
|
||||
@ -5,6 +5,7 @@ import { saveLicenseLocally, initializeSystem, startHeartbeat } from '@/lib/lice
|
||||
import { buildLicenseServerLicenseApiUrl } from '@/lib/license/server-config';
|
||||
import { buildSignedLicenseRequest } from '@/lib/license/request-signature';
|
||||
import { requireSuperAdminAfterInitialSetup } from '@/lib/license/license-route-guards';
|
||||
import { validateSignedLicenseBinding } from '@/lib/license/license-binding';
|
||||
|
||||
function maskSecret(value: unknown): string {
|
||||
if (typeof value !== 'string' || !value) return '****';
|
||||
@ -138,6 +139,17 @@ export async function POST(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
const bindingResult = validateSignedLicenseBinding(signedLicense, {
|
||||
licenseKey: activationCode,
|
||||
instanceId: fingerprint.instanceId,
|
||||
});
|
||||
if (!bindingResult.valid) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: bindingResult.error },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
console.log('[License] 免费版激活码:', maskSecret(activationCode), data.data.isExisting ? '(已存在)' : '(新注册)');
|
||||
|
||||
// ✅ 在后端保存许可证
|
||||
|
||||
@ -16,6 +16,7 @@ jest.mock('@/lib/license/fingerprint-service', () => ({
|
||||
|
||||
jest.mock('@/lib/license/server-config', () => ({
|
||||
buildLicenseServerLicenseApiUrl: jest.fn(() => 'https://license.example.com/api/license/flnxi/activate'),
|
||||
getLicenseProjectCode: jest.fn(() => 'flnxi-sucai'),
|
||||
}))
|
||||
|
||||
jest.mock('@/lib/license/crypto-service', () => ({
|
||||
@ -45,6 +46,10 @@ const mockRequireSuperAdminAfterInitialSetup = requireSuperAdminAfterInitialSetu
|
||||
|
||||
const signedLicense = {
|
||||
data: {
|
||||
projectCode: 'flnxi-sucai',
|
||||
licenseKey: 'ABCD-1234-EFGH-5678',
|
||||
instanceId: 'instance-1',
|
||||
fingerprintHash: 'a'.repeat(64),
|
||||
type: 'PRO',
|
||||
limits: { maxUsers: 100, maxMaterials: 10000 },
|
||||
features: {
|
||||
@ -233,6 +238,26 @@ describe('POST /api/license/activate', () => {
|
||||
expect(mockSyncPremiumModules).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('许可证绑定信息不匹配时不保存本地许可证', async () => {
|
||||
mockActivateSuccess({
|
||||
license: {
|
||||
...signedLicense,
|
||||
data: {
|
||||
...signedLicense.data,
|
||||
instanceId: 'other-instance-id',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const response = await POST(createMockRequest())
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(data.error).toContain('不属于当前设备')
|
||||
expect(mockSaveLicenseLocally).not.toHaveBeenCalled()
|
||||
expect(mockSyncPremiumModules).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('付费模块清单同步失败不应阻断许可证激活', async () => {
|
||||
const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
mockActivateSuccess()
|
||||
|
||||
@ -6,6 +6,7 @@ import { buildLicenseServerLicenseApiUrl } from '@/lib/license/server-config';
|
||||
import { buildSignedLicenseRequest } from '@/lib/license/request-signature';
|
||||
import { syncPremiumModules } from '@/lib/license/premium-module-service';
|
||||
import { requireSuperAdminAfterInitialSetup } from '@/lib/license/license-route-guards';
|
||||
import { validateSignedLicenseBinding } from '@/lib/license/license-binding';
|
||||
import type { ActivateResponse } from '@/lib/license/types';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
@ -61,6 +62,17 @@ export async function POST(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
const bindingResult = validateSignedLicenseBinding(data.license, {
|
||||
licenseKey,
|
||||
instanceId: fingerprint.instanceId,
|
||||
});
|
||||
if (!bindingResult.valid) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: bindingResult.error },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// ✅ 在后端保存许可证
|
||||
await saveLicenseLocally(licenseKey, data.license);
|
||||
const moduleSyncResult = await syncPremiumModules();
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
import { NextRequest } from 'next/server'
|
||||
|
||||
const VALID_RESTORE_TOKEN = 'restore-token-12345678901234567890'
|
||||
|
||||
jest.mock('@/lib/prisma', () => ({
|
||||
prisma: {
|
||||
user: {
|
||||
@ -18,11 +20,16 @@ jest.mock('@/lib/prisma', () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
function createRequest(token?: string, ip = '127.0.0.1') {
|
||||
function createRequest(
|
||||
token?: string,
|
||||
ip = '127.0.0.1',
|
||||
confirmPhrase?: string
|
||||
) {
|
||||
return new NextRequest('http://localhost/api/restore-data', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...(token ? { 'x-restore-data-token': token } : {}),
|
||||
...(confirmPhrase ? { 'x-restore-data-confirm': confirmPhrase } : {}),
|
||||
'x-forwarded-for': ip,
|
||||
},
|
||||
})
|
||||
@ -42,6 +49,7 @@ describe('POST /api/restore-data', () => {
|
||||
delete process.env.RESTORE_DATA_ENABLED
|
||||
delete process.env.RESTORE_DATA_TOKEN
|
||||
delete process.env.RESTORE_DATA_ALLOWED_IPS
|
||||
delete process.env.RESTORE_DATA_TRUST_PROXY
|
||||
delete process.env.RESTORE_ADMIN_PASSWORD
|
||||
})
|
||||
|
||||
@ -50,11 +58,11 @@ describe('POST /api/restore-data', () => {
|
||||
})
|
||||
|
||||
it('默认未显式启用时拒绝执行', async () => {
|
||||
process.env.RESTORE_DATA_TOKEN = 'restore-token'
|
||||
process.env.RESTORE_DATA_TOKEN = VALID_RESTORE_TOKEN
|
||||
process.env.RESTORE_ADMIN_PASSWORD = 'configured-password'
|
||||
const POST = await loadPost()
|
||||
|
||||
const response = await POST(createRequest('restore-token'))
|
||||
const response = await POST(createRequest(VALID_RESTORE_TOKEN))
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(503)
|
||||
@ -75,13 +83,29 @@ describe('POST /api/restore-data', () => {
|
||||
expect(data.message).toContain('RESTORE_DATA_TOKEN')
|
||||
})
|
||||
|
||||
it('启用且配置令牌后会拒绝错误令牌', async () => {
|
||||
it('启用后会拒绝过短恢复令牌', async () => {
|
||||
process.env.RESTORE_DATA_ENABLED = '1'
|
||||
process.env.RESTORE_DATA_TOKEN = 'restore-token'
|
||||
process.env.RESTORE_DATA_ALLOWED_IPS = '127.0.0.1'
|
||||
process.env.RESTORE_ADMIN_PASSWORD = 'configured-password'
|
||||
const POST = await loadPost()
|
||||
|
||||
const response = await POST(createRequest('restore-token'))
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(503)
|
||||
expect(data.success).toBe(false)
|
||||
expect(data.message).toContain('RESTORE_DATA_TOKEN 长度不足')
|
||||
})
|
||||
|
||||
it('启用且配置令牌后会拒绝错误令牌', async () => {
|
||||
process.env.RESTORE_DATA_ENABLED = '1'
|
||||
process.env.RESTORE_DATA_TOKEN = VALID_RESTORE_TOKEN
|
||||
process.env.RESTORE_DATA_ALLOWED_IPS = '127.0.0.1'
|
||||
process.env.RESTORE_DATA_TRUST_PROXY = '1'
|
||||
process.env.RESTORE_ADMIN_PASSWORD = 'configured-password'
|
||||
const POST = await loadPost()
|
||||
|
||||
const response = await POST(createRequest('wrong-token'))
|
||||
const data = await response.json()
|
||||
|
||||
@ -92,11 +116,11 @@ describe('POST /api/restore-data', () => {
|
||||
|
||||
it('启用且配置令牌后仍要求配置 IP 白名单', async () => {
|
||||
process.env.RESTORE_DATA_ENABLED = '1'
|
||||
process.env.RESTORE_DATA_TOKEN = 'restore-token'
|
||||
process.env.RESTORE_DATA_TOKEN = VALID_RESTORE_TOKEN
|
||||
process.env.RESTORE_ADMIN_PASSWORD = 'configured-password'
|
||||
const POST = await loadPost()
|
||||
|
||||
const response = await POST(createRequest('restore-token'))
|
||||
const response = await POST(createRequest(VALID_RESTORE_TOKEN))
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(503)
|
||||
@ -106,16 +130,49 @@ describe('POST /api/restore-data', () => {
|
||||
|
||||
it('启用且配置令牌后会拒绝非白名单 IP', async () => {
|
||||
process.env.RESTORE_DATA_ENABLED = '1'
|
||||
process.env.RESTORE_DATA_TOKEN = 'restore-token'
|
||||
process.env.RESTORE_DATA_TOKEN = VALID_RESTORE_TOKEN
|
||||
process.env.RESTORE_DATA_ALLOWED_IPS = '127.0.0.1'
|
||||
process.env.RESTORE_DATA_TRUST_PROXY = '1'
|
||||
process.env.RESTORE_ADMIN_PASSWORD = 'configured-password'
|
||||
const POST = await loadPost()
|
||||
|
||||
const response = await POST(createRequest('restore-token', '10.0.0.8'))
|
||||
const response = await POST(createRequest(VALID_RESTORE_TOKEN, '10.0.0.8'))
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(403)
|
||||
expect(data.success).toBe(false)
|
||||
expect(data.message).toBe('当前来源 IP 不允许执行恢复')
|
||||
})
|
||||
|
||||
it('默认不信任可伪造的代理 IP 头', async () => {
|
||||
process.env.RESTORE_DATA_ENABLED = '1'
|
||||
process.env.RESTORE_DATA_TOKEN = VALID_RESTORE_TOKEN
|
||||
process.env.RESTORE_DATA_ALLOWED_IPS = '127.0.0.1'
|
||||
process.env.RESTORE_ADMIN_PASSWORD = 'configured-password'
|
||||
const POST = await loadPost()
|
||||
|
||||
const response = await POST(createRequest(VALID_RESTORE_TOKEN, '127.0.0.1'))
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(403)
|
||||
expect(data.success).toBe(false)
|
||||
expect(data.message).toBe('无法确认可信来源 IP,恢复接口已拒绝')
|
||||
})
|
||||
|
||||
it('通过令牌和白名单后仍要求恢复确认头', async () => {
|
||||
process.env.RESTORE_DATA_ENABLED = '1'
|
||||
process.env.RESTORE_DATA_TOKEN = VALID_RESTORE_TOKEN
|
||||
process.env.RESTORE_DATA_ALLOWED_IPS = '127.0.0.1'
|
||||
process.env.RESTORE_DATA_TRUST_PROXY = '1'
|
||||
process.env.RESTORE_ADMIN_PASSWORD = 'configured-password'
|
||||
const POST = await loadPost()
|
||||
|
||||
const response = await POST(createRequest(VALID_RESTORE_TOKEN, '127.0.0.1'))
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(data.success).toBe(false)
|
||||
expect(data.message).toBe('恢复确认头无效')
|
||||
expect(data.requiredConfirmHeader).toBe('x-restore-data-confirm')
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import * as crypto from 'crypto';
|
||||
import https from 'https';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
@ -11,6 +12,9 @@ const S3_BUCKET_URL = process.env.RESTORE_S3_BUCKET_URL as string;
|
||||
const S3_PUBLIC_URL = process.env.RESTORE_S3_PUBLIC_URL as string;
|
||||
const RESTORE_DATA_ENABLED = process.env.RESTORE_DATA_ENABLED?.trim() === '1';
|
||||
const RESTORE_DATA_TOKEN = process.env.RESTORE_DATA_TOKEN?.trim() || '';
|
||||
const RESTORE_DATA_MIN_TOKEN_LENGTH = 32;
|
||||
const RESTORE_DATA_CONFIRM_PHRASE = 'RESTORE_DATA';
|
||||
const RESTORE_DATA_TRUST_PROXY = process.env.RESTORE_DATA_TRUST_PROXY?.trim() === '1';
|
||||
const RESTORE_DATA_ALLOWED_IPS = (process.env.RESTORE_DATA_ALLOWED_IPS || '')
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
@ -26,11 +30,24 @@ const DEFAULT_RENDERER = 'CR';
|
||||
const DEFAULT_MATERIAL_TYPE = '3DMAX';
|
||||
|
||||
function getClientIp(req: NextRequest): string {
|
||||
const directIp = (req as NextRequest & { ip?: string }).ip?.trim();
|
||||
if (directIp) return directIp;
|
||||
|
||||
if (!RESTORE_DATA_TRUST_PROXY) {
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
const forwardedFor = req.headers.get('x-forwarded-for') || '';
|
||||
const realIp = req.headers.get('x-real-ip') || '';
|
||||
return forwardedFor.split(',')[0]?.trim() || realIp.trim() || 'unknown';
|
||||
}
|
||||
|
||||
function constantTimeEqualText(actual: string, expected: string): boolean {
|
||||
const actualHash = crypto.createHash('sha256').update(actual).digest();
|
||||
const expectedHash = crypto.createHash('sha256').update(expected).digest();
|
||||
return crypto.timingSafeEqual(actualHash, expectedHash);
|
||||
}
|
||||
|
||||
function auditRestoreData(message: string, req: NextRequest, extra?: Record<string, unknown>) {
|
||||
console.warn('[RestoreData]', {
|
||||
message,
|
||||
@ -145,6 +162,14 @@ export async function POST(req: NextRequest) {
|
||||
}, { status: 503 });
|
||||
}
|
||||
|
||||
if (RESTORE_DATA_TOKEN.length < RESTORE_DATA_MIN_TOKEN_LENGTH) {
|
||||
auditRestoreData('restore token too short in environment', req);
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
message: `RESTORE_DATA_TOKEN 长度不足 ${RESTORE_DATA_MIN_TOKEN_LENGTH} 位,恢复接口已禁用`,
|
||||
}, { status: 503 });
|
||||
}
|
||||
|
||||
if (RESTORE_DATA_ALLOWED_IPS.length === 0) {
|
||||
auditRestoreData('restore allowed ip list missing', req);
|
||||
return NextResponse.json({
|
||||
@ -153,6 +178,14 @@ export async function POST(req: NextRequest) {
|
||||
}, { status: 503 });
|
||||
}
|
||||
|
||||
if (clientIp === 'unknown') {
|
||||
auditRestoreData('restore ip unknown', req, { trustProxy: RESTORE_DATA_TRUST_PROXY });
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
message: '无法确认可信来源 IP,恢复接口已拒绝',
|
||||
}, { status: 403 });
|
||||
}
|
||||
|
||||
if (!RESTORE_DATA_ALLOWED_IPS.includes(clientIp)) {
|
||||
auditRestoreData('restore ip rejected', req, { allowedIpCount: RESTORE_DATA_ALLOWED_IPS.length });
|
||||
return NextResponse.json({
|
||||
@ -162,7 +195,7 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
|
||||
const requestToken = req.headers.get('x-restore-data-token')?.trim() || '';
|
||||
if (requestToken !== RESTORE_DATA_TOKEN) {
|
||||
if (!constantTimeEqualText(requestToken, RESTORE_DATA_TOKEN)) {
|
||||
auditRestoreData('restore token rejected', req);
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
@ -170,6 +203,16 @@ export async function POST(req: NextRequest) {
|
||||
}, { status: 401 });
|
||||
}
|
||||
|
||||
const confirmPhrase = req.headers.get('x-restore-data-confirm')?.trim() || '';
|
||||
if (confirmPhrase !== RESTORE_DATA_CONFIRM_PHRASE) {
|
||||
auditRestoreData('restore confirm phrase rejected', req);
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
message: '恢复确认头无效',
|
||||
requiredConfirmHeader: 'x-restore-data-confirm',
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
if (!ADMIN_PASSWORD) {
|
||||
auditRestoreData('restore admin password missing in environment', req);
|
||||
return NextResponse.json({
|
||||
|
||||
@ -45,8 +45,8 @@ services:
|
||||
volumes:
|
||||
- minio_data:/data
|
||||
ports:
|
||||
- "${MINIO_API_PORT:-9000}:9000"
|
||||
- "${MINIO_CONSOLE_PORT:-9001}:9001"
|
||||
- "${MINIO_API_BIND_HOST:-0.0.0.0}:${MINIO_API_PORT:-9000}:9000"
|
||||
- "${MINIO_CONSOLE_BIND_HOST:-127.0.0.1}:${MINIO_CONSOLE_PORT:-9001}:9001"
|
||||
networks:
|
||||
- app-network
|
||||
healthcheck:
|
||||
|
||||
@ -52,6 +52,8 @@
|
||||
飞牛 fnOS 虚拟机上的首装验收记录,用于追溯 Linux/NAS 脚本和真实测试环境结论。
|
||||
- `许可证链路安全评估_20260312.md`
|
||||
历史安全评估快照,部分条目已被后续代码和部署配置修正;保留作溯源参考,不作为当前整改入口,引用前必须按当前代码重新复核。
|
||||
- `许可证链路安全加固记录_20260602.md`
|
||||
本轮针对本地伪造许可证、签名许可证缺少机器绑定、许可证服务构建上下文密钥风险的安全加固记录。
|
||||
|
||||
## 6. 使用原则
|
||||
|
||||
|
||||
@ -103,6 +103,7 @@ MAX_UPLOAD_FIELD_SIZE_BYTES=2097152
|
||||
对象存储也有两种模式:
|
||||
|
||||
- 内置 MinIO:`S3_ENDPOINT=http://minio:9000`,`S3_ACCESS_KEY_ID` / `S3_SECRET_ACCESS_KEY` 可不填,Compose 会使用 `MINIO_ROOT_USER` / `MINIO_ROOT_PASSWORD`。`S3_PUBLIC_DOMAIN` 必须写成用户浏览器能访问的地址,例如 `http://192.168.1.20:9000/flnxi-materials`。
|
||||
- 内置 MinIO 的 API 端口默认通过 `MINIO_API_BIND_HOST=0.0.0.0` 对浏览器可访问地址开放,用于素材预览和下载;管理控制台端口默认通过 `MINIO_CONSOLE_BIND_HOST=127.0.0.1` 只绑定本机。只有明确需要从局域网访问 MinIO 管理界面时,才把 `MINIO_CONSOLE_BIND_HOST` 改成服务器局域网 IP,并配合防火墙限制来源。
|
||||
- 外部 S3 / 外部 MinIO:需要填写真实 `S3_ENDPOINT`、`S3_ACCESS_KEY_ID`、`S3_SECRET_ACCESS_KEY`、`S3_BUCKET`、`S3_PUBLIC_DOMAIN`,并确认 bucket 已存在或当前账号有创建和读写权限。
|
||||
- 商业首版更推荐内置 MinIO,升级脚本可以自动备份素材对象。若使用外部 S3 / MinIO,升级前必须先完成客户侧 bucket / 对象备份;执行 `deploy-registry.ps1` 时同样需显式加 `-ExternalDataBackupConfirmed`。
|
||||
|
||||
@ -553,6 +554,23 @@ docker exec -i flnxi-postgres pg_restore \
|
||||
- `SERVER_IP` 和 `NEXTAUTH_URL` 是否填写成浏览器可访问地址
|
||||
- 防火墙是否放通端口
|
||||
|
||||
### 旧恢复接口被拒绝
|
||||
|
||||
`/api/restore-data` 是历史一次性数据恢复入口,不属于客户日常后台功能。客户环境默认应保持:
|
||||
|
||||
```env
|
||||
RESTORE_DATA_ENABLED=0
|
||||
```
|
||||
|
||||
只有在受控恢复窗口才临时启用,并且必须同时满足:
|
||||
|
||||
- `RESTORE_DATA_TOKEN` 至少 32 字符,不能使用示例值或短口令。
|
||||
- `RESTORE_DATA_ALLOWED_IPS` 只写维护机来源 IP。
|
||||
- 通过可信反向代理传递来源 IP 时,才设置 `RESTORE_DATA_TRUST_PROXY=1`;否则接口默认不信任 `x-forwarded-for`。
|
||||
- 调用时必须同时带 `x-restore-data-token` 和 `x-restore-data-confirm: RESTORE_DATA`。
|
||||
|
||||
恢复完成后必须立刻把 `RESTORE_DATA_ENABLED` 改回 `0`,并重启应用容器。
|
||||
|
||||
### MinIO 预览或下载异常
|
||||
|
||||
优先检查:
|
||||
|
||||
@ -106,6 +106,7 @@ GET /api/version/{projectCode}/agent
|
||||
|
||||
- `activate`、`activate-free`、`verify`、`heartbeat` 会根据 `LICENSE_PROJECT_CODE` 转发到远端项目维度接口。
|
||||
- `activate`、`activate-free`、`verify`、`heartbeat` 转发到远端前会加入 `timestamp`、`nonce` 和 HMAC `signature`;远端会拒绝签名缺失、签名错误、过期和重复 `nonce` 请求。
|
||||
- `activate`、`activate-free`、`verify`、`heartbeat` 返回的签名许可证必须包含 `projectCode`、`licenseKey`、`instanceId` 和 `fingerprintHash`;主应用保存和读取本地许可证时会核对这些绑定字段,缺少绑定字段的旧许可证需要重新激活。
|
||||
- `activate`、`verify`、`heartbeat` 在付费许可证保存或续证成功后会尝试同步 `premium-modules` 付费模块授权清单和对应授权凭证;免费版会跳过,不访问远端模块接口。
|
||||
- `check` 用于在本地许可证缓存存在时恢复激活状态,会先读取并验签本地许可证。
|
||||
- `info` 用于读取当前许可证类型、用量和功能开关。
|
||||
|
||||
93
docs/许可证链路安全加固记录_20260602.md
Normal file
93
docs/许可证链路安全加固记录_20260602.md
Normal file
@ -0,0 +1,93 @@
|
||||
# 许可证链路安全加固记录
|
||||
|
||||
更新时间:2026-06-02
|
||||
|
||||
## 1. 本轮结论
|
||||
|
||||
本轮重点修复的是“本地数据库被直接写入后,伪造许可证变成专业版”的风险。
|
||||
|
||||
修复后,主应用不再只判断“许可证签名是否正确”,还会额外核对签名许可证里的绑定信息:
|
||||
|
||||
- 项目编码必须匹配当前 `LICENSE_PROJECT_CODE`
|
||||
- 激活码必须匹配本地保存的 `licenseKey`
|
||||
- 实例 ID 必须匹配当前系统持久化的 `data/.instance-id`
|
||||
- 签名许可证必须包含 `fingerprintHash`
|
||||
|
||||
缺少这些绑定字段的旧许可证缓存会被视为无效,需要重新激活。
|
||||
|
||||
## 2. 已完成的代码加固
|
||||
|
||||
- `license-server` 激活、免费注册、在线验证、心跳续租返回的许可证,都会把 `projectCode`、`licenseKey`、`instanceId`、`fingerprintHash`、`activationId` 写入 RSA 签名数据。
|
||||
- 主应用保存许可证前,会校验签名数据里的项目、激活码和实例 ID 是否一致。
|
||||
- 主应用每次从 `system_configs.key = license` 读取本地许可证时,也会重新做上述绑定校验。
|
||||
- 旧的无绑定签名许可证、本地激活码不匹配、实例 ID 不匹配,都会被拒绝。
|
||||
- `license-server/.dockerignore` 已补充 `.env*`、私钥和密钥目录排除,避免单独构建许可证服务镜像时把本地密钥带入镜像上下文。
|
||||
|
||||
## 3. P1/P2 继续加固状态
|
||||
|
||||
本轮继续处理了渗透模拟里 P1/P2 中属于产品侧能落地的部分。NAS 管理员密码、Docker 管理权限和客户主机 root/admin 权限不作为应用代码可完全防守边界,但默认交付口子继续收紧:
|
||||
|
||||
- 后台高风险维护接口继续要求最高权限账号、专业版系统设置能力,并增加人工确认短语:
|
||||
- `/api/admin/cleanup-database`:`清理全部素材`
|
||||
- `/api/admin/reset-material-rankings`:`重置排行榜数据`
|
||||
- `/api/admin/migrate-material-fields`:`执行字段迁移`
|
||||
- `upload-logo`、清库、重置排行榜、字段迁移都已纳入许可证覆盖测试,避免以后误删服务端许可证检查。
|
||||
- `/api/restore-data` 继续保持默认关闭,并追加维护窗口保护:恢复令牌至少 32 字符、令牌固定时间比较、默认不信任可伪造的 `x-forwarded-for`、必须配置来源 IP 白名单、必须带 `x-restore-data-confirm: RESTORE_DATA`。
|
||||
- `docker-compose.prod.yml` 中 MinIO 管理控制台默认只绑定 `127.0.0.1`,素材 API 端口仍按 `S3_PUBLIC_DOMAIN` 需要对用户浏览器开放。
|
||||
- `home-filter-fields` 已经使用白名单字段和安全查询路径,并由测试确认不再调用 `$queryRawUnsafe`。
|
||||
|
||||
## 4. 代码混淆和客户镜像边界
|
||||
|
||||
当前不是“完全没做混淆”。生产镜像构建会执行 `npm run build:prod`,在 Next.js 构建后对服务端 chunks 和许可证 API 产物做保守混淆;浏览器 source map 默认关闭。
|
||||
|
||||
本轮继续收紧了两个交付边界:
|
||||
|
||||
- 运行镜像不再额外复制 `/app/lib` 源码目录,避免“构建产物已混淆,但客户镜像里又带一份未混淆源码”。
|
||||
- 混淆脚本如果找不到目标文件,或任何目标文件混淆失败,会直接阻断生产出包。
|
||||
|
||||
注意:代码混淆只能提高逆向成本,不能当成许可证安全边界。真正的安全边界仍然是验证服务器签名、实例绑定、本地缓存校验、服务端权限和许可证检查。
|
||||
|
||||
## 5. 仍然不能靠应用代码完全解决的边界
|
||||
|
||||
如果攻击者已经拥有客户 NAS 的管理员权限、Docker 管理权限或数据库管理权限,他就能修改容器、替换镜像、直接改数据库,应用自身无法完全防住这种本机最高权限攻击。
|
||||
|
||||
商业化防护的正确边界是:
|
||||
|
||||
- 客户侧本地缓存只作为离线短期租约和防误改机制
|
||||
- 许可证最终可信来源是验证服务器
|
||||
- RSA 私钥只能存在于验证服务器运行环境
|
||||
- 客户侧只保留 RSA 公钥和本地缓存加密密钥
|
||||
- 本地缓存必须绑定项目、激活码和机器实例
|
||||
- 付费模块核心能力继续依赖验证服务器签发的实例绑定清单和授权凭证
|
||||
|
||||
## 6. 运维必须处理的事项
|
||||
|
||||
本轮代码已经阻断低成本本地伪造,但如果本地或历史环境里曾经暴露过真实密钥,仍必须轮换:
|
||||
|
||||
1. 重新生成许可证服务 RSA 密钥对。
|
||||
2. 更新 `license-server` 运行环境里的 `RSA_PRIVATE_KEY`。
|
||||
3. 更新主应用运行环境里的 `LICENSE_PUBLIC_KEY`。
|
||||
4. 轮换 `LICENSE_REQUEST_SIGNING_SECRET`、`LOCAL_ENCRYPTION_KEY`、数据库密码、对象存储密钥、`NEXTAUTH_SECRET`。
|
||||
5. 要求已激活环境重新激活,生成带绑定字段的新许可证缓存。
|
||||
6. 客户 NAS 禁用弱密码 SSH,避免暴露公网 SSH,必要时改成密钥登录。
|
||||
|
||||
注意:不要把真实 `.env`、私钥、数据库连接串或服务器密码写入 Git、文档、工单或截图。
|
||||
|
||||
## 7. 验证记录
|
||||
|
||||
本轮已验证:
|
||||
|
||||
- `npm run type-check`
|
||||
- `npx tsc --noEmit`,在 `license-server/` 目录执行
|
||||
- `npm test -- --runInBand lib/__tests__/license-service.test.ts app/api/license/activate/__tests__/route.test.ts app/api/license/activate-free/__tests__/route.test.ts`
|
||||
- `npm test -- --runInBand app/api/admin/__tests__/destructive-actions-superadmin.test.ts app/api/restore-data/__tests__/route.test.ts lib/__tests__/license-route-coverage.test.ts __tests__/production-build-hardening.test.ts __tests__/deployment-compose.test.ts`
|
||||
|
||||
验证重点:
|
||||
|
||||
- 无绑定字段的旧许可证会被拒绝
|
||||
- 绑定到其他实例的许可证会被拒绝
|
||||
- 激活接口收到绑定信息不匹配的许可证时不会保存到本地
|
||||
- 免费激活接口收到绑定信息不匹配的许可证时不会保存到本地
|
||||
- 高风险后台接口必须保留确认短语
|
||||
- 恢复接口默认不信任可伪造代理 IP 头,且必须带确认头
|
||||
- 生产镜像不能额外复制未混淆源码目录
|
||||
@ -63,6 +63,26 @@ describe('许可证业务入口覆盖', () => {
|
||||
helper: 'canUseSystemSettings',
|
||||
reason: '系统设置写操作必须走许可证功能开关',
|
||||
},
|
||||
{
|
||||
file: 'app/api/system-settings/upload-logo/route.ts',
|
||||
helper: 'canUseSystemSettings',
|
||||
reason: '系统 LOGO 上传属于系统设置写操作,必须走许可证功能开关',
|
||||
},
|
||||
{
|
||||
file: 'app/api/admin/cleanup-database/route.ts',
|
||||
helper: 'canUseSystemSettings',
|
||||
reason: '数据库清理属于高风险维护操作,必须走许可证功能开关',
|
||||
},
|
||||
{
|
||||
file: 'app/api/admin/reset-material-rankings/route.ts',
|
||||
helper: 'canUseSystemSettings',
|
||||
reason: '排行榜重置属于高风险维护操作,必须走许可证功能开关',
|
||||
},
|
||||
{
|
||||
file: 'app/api/admin/migrate-material-fields/route.ts',
|
||||
helper: 'canUseSystemSettings',
|
||||
reason: '素材字段迁移属于高风险维护操作,必须走许可证功能开关',
|
||||
},
|
||||
{
|
||||
file: 'app/api/tunnel/start/route.ts',
|
||||
helper: 'canUseIntranetPenetration',
|
||||
@ -96,4 +116,28 @@ describe('许可证业务入口覆盖', () => {
|
||||
expect(source).toContain('PREMIUM_MODULE_IDS.CUSTOM_TEMPLATES_CORE');
|
||||
expect(source).toContain('PREMIUM_MODULE_IDS.INTRANET_PENETRATION_CORE');
|
||||
});
|
||||
|
||||
it('高风险后台维护入口必须要求确认短语', () => {
|
||||
const destructiveRoutes = [
|
||||
{
|
||||
file: 'app/api/admin/cleanup-database/route.ts',
|
||||
phrase: '清理全部素材',
|
||||
},
|
||||
{
|
||||
file: 'app/api/admin/reset-material-rankings/route.ts',
|
||||
phrase: '重置排行榜数据',
|
||||
},
|
||||
{
|
||||
file: 'app/api/admin/migrate-material-fields/route.ts',
|
||||
phrase: '执行字段迁移',
|
||||
},
|
||||
];
|
||||
|
||||
for (const { file, phrase } of destructiveRoutes) {
|
||||
const source = readProjectFile(file);
|
||||
|
||||
expect(source).toContain('confirmPhrase');
|
||||
expect(source).toContain(phrase);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@ -91,6 +91,10 @@ function createSignedLicense(overrides: Record<string, any> = {}): SignedLicense
|
||||
|
||||
return {
|
||||
data: {
|
||||
projectCode: 'flnxi-sucai',
|
||||
licenseKey: 'FLNXI-FREE-TEST-1234-5678-ABCD',
|
||||
instanceId: 'test-instance-id',
|
||||
fingerprintHash: 'a'.repeat(64),
|
||||
type: 'FREE' as const,
|
||||
limits: { maxUsers: 10, maxMaterials: 100 },
|
||||
features: {
|
||||
@ -601,6 +605,41 @@ describe('license-service', () => {
|
||||
|
||||
expect(localLicense).toBeNull();
|
||||
});
|
||||
|
||||
it('缺少签名绑定字段的旧许可证应视为无许可证', async () => {
|
||||
const unboundLicense = createSignedLicense();
|
||||
delete (unboundLicense.data as unknown as Record<string, unknown>).projectCode;
|
||||
delete (unboundLicense.data as unknown as Record<string, unknown>).licenseKey;
|
||||
delete (unboundLicense.data as unknown as Record<string, unknown>).instanceId;
|
||||
delete (unboundLicense.data as unknown as Record<string, unknown>).fingerprintHash;
|
||||
|
||||
(prisma.systemConfig.findUnique as jest.Mock).mockResolvedValue({
|
||||
key: 'license',
|
||||
value: JSON.stringify(createStoredLocalLicense({
|
||||
signedLicense: unboundLicense,
|
||||
})),
|
||||
});
|
||||
|
||||
const { getLocalLicense, isFeatureEnabled } = await import('../license/license-service');
|
||||
|
||||
await expect(getLocalLicense()).resolves.toBeNull();
|
||||
await expect(isFeatureEnabled('customTemplates')).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('本地许可证绑定到其他实例时应视为无许可证', async () => {
|
||||
(prisma.systemConfig.findUnique as jest.Mock).mockResolvedValue({
|
||||
key: 'license',
|
||||
value: JSON.stringify(createStoredLocalLicense({
|
||||
signedLicenseData: {
|
||||
instanceId: 'other-instance-id',
|
||||
},
|
||||
})),
|
||||
});
|
||||
|
||||
const { getLocalLicense } = await import('../license/license-service');
|
||||
|
||||
await expect(getLocalLicense()).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('本地时间回拨检测', () => {
|
||||
|
||||
63
lib/license/license-binding.ts
Normal file
63
lib/license/license-binding.ts
Normal file
@ -0,0 +1,63 @@
|
||||
import { getLicenseProjectCode } from './server-config';
|
||||
import type { SignedLicense } from './types';
|
||||
|
||||
interface ExpectedLicenseBinding {
|
||||
licenseKey: string;
|
||||
instanceId: string;
|
||||
projectCode?: string;
|
||||
fingerprintHash?: string;
|
||||
}
|
||||
|
||||
function isNonEmptyString(value: unknown): value is string {
|
||||
return typeof value === 'string' && value.trim().length > 0;
|
||||
}
|
||||
|
||||
export function validateSignedLicenseBinding(
|
||||
license: SignedLicense,
|
||||
expected: ExpectedLicenseBinding
|
||||
): { valid: true } | { valid: false; error: string } {
|
||||
const data = license.data as unknown as Record<string, unknown>;
|
||||
const projectCode = expected.projectCode ?? getLicenseProjectCode();
|
||||
|
||||
if (
|
||||
!isNonEmptyString(data.projectCode) ||
|
||||
!isNonEmptyString(data.licenseKey) ||
|
||||
!isNonEmptyString(data.instanceId) ||
|
||||
!isNonEmptyString(data.fingerprintHash)
|
||||
) {
|
||||
return {
|
||||
valid: false,
|
||||
error: '许可证缺少机器绑定信息,请重新激活',
|
||||
};
|
||||
}
|
||||
|
||||
if (data.projectCode !== projectCode) {
|
||||
return {
|
||||
valid: false,
|
||||
error: '许可证项目不匹配,请重新激活',
|
||||
};
|
||||
}
|
||||
|
||||
if (data.licenseKey !== expected.licenseKey) {
|
||||
return {
|
||||
valid: false,
|
||||
error: '许可证激活码不匹配,请重新激活',
|
||||
};
|
||||
}
|
||||
|
||||
if (data.instanceId !== expected.instanceId) {
|
||||
return {
|
||||
valid: false,
|
||||
error: '许可证不属于当前设备,请重新激活',
|
||||
};
|
||||
}
|
||||
|
||||
if (expected.fingerprintHash && data.fingerprintHash !== expected.fingerprintHash) {
|
||||
return {
|
||||
valid: false,
|
||||
error: '许可证硬件指纹不匹配,请重新激活',
|
||||
};
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
@ -21,6 +21,7 @@ import { LICENSE_CONFIGS } from './types';
|
||||
import { buildLicenseServerLicenseApiUrl } from './server-config';
|
||||
import { buildSignedLicenseRequest } from './request-signature';
|
||||
import { syncPremiumModules } from './premium-module-service';
|
||||
import { validateSignedLicenseBinding } from './license-binding';
|
||||
|
||||
// 宽限期(7天)
|
||||
const GRACE_PERIOD_DAYS = 7;
|
||||
@ -54,7 +55,15 @@ export function isLicenseConfigExpired(license: LicenseConfig | null): boolean {
|
||||
}
|
||||
|
||||
function extractLicenseConfig(license: SignedLicense): LicenseConfig {
|
||||
const { issuedAt: _issuedAt, ...config } = license.data;
|
||||
const {
|
||||
issuedAt: _issuedAt,
|
||||
projectCode: _projectCode,
|
||||
licenseKey: _licenseKey,
|
||||
instanceId: _instanceId,
|
||||
fingerprintHash: _fingerprintHash,
|
||||
activationId: _activationId,
|
||||
...config
|
||||
} = license.data;
|
||||
return {
|
||||
...config,
|
||||
leaseExpiresAt: config.leaseExpiresAt ?? null,
|
||||
@ -213,6 +222,15 @@ function normalizeLocalLicenseData(localData: LocalLicenseData): LocalLicenseDat
|
||||
return null;
|
||||
}
|
||||
|
||||
const bindingResult = validateSignedLicenseBinding(signedLicense, {
|
||||
licenseKey: localData.licenseKey,
|
||||
instanceId: localData.instanceId,
|
||||
});
|
||||
if (!bindingResult.valid) {
|
||||
console.warn('[License] 本地许可证绑定校验失败:', bindingResult.error);
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...localData,
|
||||
cachedConfig: extractLicenseConfig(signedLicense),
|
||||
@ -689,6 +707,14 @@ export async function saveLicenseLocally(licenseKey: string, license: SignedLice
|
||||
}
|
||||
|
||||
const instanceId = await getInstanceId();
|
||||
const bindingResult = validateSignedLicenseBinding(license, {
|
||||
licenseKey,
|
||||
instanceId,
|
||||
});
|
||||
if (!bindingResult.valid) {
|
||||
throw new Error(bindingResult.error);
|
||||
}
|
||||
|
||||
const currentConfig = extractLicenseConfig(license);
|
||||
const nowIso = new Date().toISOString();
|
||||
|
||||
@ -721,6 +747,14 @@ export async function updateLocalLicense(license: SignedLicense): Promise<void>
|
||||
throw new Error('许可证签名验证失败');
|
||||
}
|
||||
|
||||
const bindingResult = validateSignedLicenseBinding(license, {
|
||||
licenseKey: existing.licenseKey,
|
||||
instanceId: existing.instanceId,
|
||||
});
|
||||
if (!bindingResult.valid) {
|
||||
throw new Error(bindingResult.error);
|
||||
}
|
||||
|
||||
const currentConfig = extractLicenseConfig(license);
|
||||
const nowIso = new Date().toISOString();
|
||||
|
||||
|
||||
@ -19,9 +19,20 @@ export interface LicenseConfig {
|
||||
leaseExpiresAt: string | null; // 在线租约到期时间,旧许可证可为 null
|
||||
}
|
||||
|
||||
// 签名许可证的绑定信息:用于证明这张许可证只属于指定项目、激活码和本机实例。
|
||||
export interface LicenseBinding {
|
||||
projectCode: string;
|
||||
licenseKey: string;
|
||||
instanceId: string;
|
||||
fingerprintHash: string;
|
||||
activationId?: string;
|
||||
}
|
||||
|
||||
export type SignedLicenseData = LicenseConfig & LicenseBinding & { issuedAt: string };
|
||||
|
||||
// 签名后的许可证
|
||||
export interface SignedLicense {
|
||||
data: LicenseConfig & { issuedAt: string };
|
||||
data: SignedLicenseData;
|
||||
signature: string;
|
||||
issuedAt: string;
|
||||
}
|
||||
|
||||
@ -9,3 +9,12 @@ pnpm-debug.log*
|
||||
tsconfig.tsbuildinfo
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
# Local secrets must never enter the license-server image build context.
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
*.pem
|
||||
*.key
|
||||
keys
|
||||
env-backups
|
||||
|
||||
@ -2,8 +2,9 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
import { signLicense, isValidLicenseKeyFormat } from '@/lib/crypto';
|
||||
import { getPublicProjectDb } from '@/lib/project-auth';
|
||||
import { verifyLicenseRequestSignature } from '@/lib/request-signature';
|
||||
import { generateLicenseConfig, type HardwareFingerprint } from '@/lib/types';
|
||||
import { type HardwareFingerprint } from '@/lib/types';
|
||||
import { createPendingAlertOnce, findFreeFingerprintReuse } from '@/lib/abuse-detection';
|
||||
import { buildLicenseSigningPayload } from '@/lib/license-payload';
|
||||
import { z } from 'zod';
|
||||
|
||||
const activateSchema = z.object({
|
||||
@ -97,6 +98,8 @@ export async function POST(
|
||||
},
|
||||
});
|
||||
|
||||
let activationRecord = existingActivation;
|
||||
|
||||
if (existingActivation) {
|
||||
const existingFingerprint = existingActivation.fingerprint as unknown as HardwareFingerprint;
|
||||
if (existingFingerprint.instanceId !== fingerprint.instanceId) {
|
||||
@ -112,7 +115,7 @@ export async function POST(
|
||||
);
|
||||
}
|
||||
|
||||
await projectDbResult.prisma.activation.update({
|
||||
activationRecord = await projectDbResult.prisma.activation.update({
|
||||
where: { id: existingActivation.id },
|
||||
data: {
|
||||
fingerprint: fingerprint as object,
|
||||
@ -140,7 +143,7 @@ export async function POST(
|
||||
}
|
||||
}
|
||||
|
||||
await projectDbResult.prisma.activation.create({
|
||||
activationRecord = await projectDbResult.prisma.activation.create({
|
||||
data: {
|
||||
licenseId: license.id,
|
||||
instanceId: fingerprint.instanceId,
|
||||
@ -150,9 +153,14 @@ export async function POST(
|
||||
});
|
||||
}
|
||||
|
||||
const signedLicense = signLicense(
|
||||
generateLicenseConfig(license.licenseType, license.expiresAt)
|
||||
);
|
||||
const signedLicense = signLicense(buildLicenseSigningPayload({
|
||||
projectCode,
|
||||
licenseKey: license.licenseKey,
|
||||
licenseType: license.licenseType,
|
||||
expiresAt: license.expiresAt,
|
||||
fingerprint,
|
||||
activationId: activationRecord.id,
|
||||
}));
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
|
||||
@ -2,7 +2,8 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
import { hashFingerprint, signLicense } from '@/lib/crypto';
|
||||
import { getPublicProjectDb } from '@/lib/project-auth';
|
||||
import { verifyLicenseRequestSignature } from '@/lib/request-signature';
|
||||
import { generateLicenseConfig, type HardwareFingerprint } from '@/lib/types';
|
||||
import { type HardwareFingerprint } from '@/lib/types';
|
||||
import { buildLicenseSigningPayload } from '@/lib/license-payload';
|
||||
import {
|
||||
calculateFingerprintMatchScore,
|
||||
createPendingAlertOnce,
|
||||
@ -173,9 +174,14 @@ export async function POST(
|
||||
|
||||
await detectAnomaly(projectDbResult.prisma, license.id, fingerprintHash);
|
||||
|
||||
const signedLicense = signLicense(
|
||||
generateLicenseConfig(license.licenseType, license.expiresAt)
|
||||
);
|
||||
const signedLicense = signLicense(buildLicenseSigningPayload({
|
||||
projectCode,
|
||||
licenseKey: license.licenseKey,
|
||||
licenseType: license.licenseType,
|
||||
expiresAt: license.expiresAt,
|
||||
fingerprint,
|
||||
activationId: activation.id,
|
||||
}));
|
||||
|
||||
return NextResponse.json({
|
||||
acknowledged: true,
|
||||
|
||||
@ -2,8 +2,9 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getPublicProjectDb } from '@/lib/project-auth';
|
||||
import { hashFingerprint, signLicense } from '@/lib/crypto';
|
||||
import { verifyLicenseRequestSignature } from '@/lib/request-signature';
|
||||
import { generateLicenseConfig, LICENSE_CONFIGS } from '@/lib/types';
|
||||
import { LICENSE_CONFIGS } from '@/lib/types';
|
||||
import { createPendingAlertOnce, findFreeFingerprintReuse } from '@/lib/abuse-detection';
|
||||
import { buildLicenseSigningPayload } from '@/lib/license-payload';
|
||||
import { z } from 'zod';
|
||||
|
||||
const registerFreeSchema = z.object({
|
||||
@ -103,12 +104,14 @@ export async function POST(
|
||||
);
|
||||
}
|
||||
|
||||
const signedLicense = signLicense(
|
||||
generateLicenseConfig(
|
||||
existingActivation.license.licenseType,
|
||||
existingActivation.license.expiresAt
|
||||
)
|
||||
);
|
||||
const signedLicense = signLicense(buildLicenseSigningPayload({
|
||||
projectCode,
|
||||
licenseKey: existingActivation.license.licenseKey,
|
||||
licenseType: existingActivation.license.licenseType,
|
||||
expiresAt: existingActivation.license.expiresAt,
|
||||
fingerprint,
|
||||
activationId: existingActivation.id,
|
||||
}));
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
@ -153,6 +156,7 @@ export async function POST(
|
||||
}
|
||||
|
||||
const activationCode = generateFreeActivationCode();
|
||||
let createdActivationId = '';
|
||||
const clientIP =
|
||||
request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ||
|
||||
request.headers.get('x-real-ip') ||
|
||||
@ -172,7 +176,7 @@ export async function POST(
|
||||
},
|
||||
});
|
||||
|
||||
await tx.activation.create({
|
||||
const activation = await tx.activation.create({
|
||||
data: {
|
||||
licenseId: license.id,
|
||||
instanceId: fingerprint.instanceId,
|
||||
@ -180,6 +184,7 @@ export async function POST(
|
||||
lastHeartbeat: new Date(),
|
||||
},
|
||||
});
|
||||
createdActivationId = activation.id;
|
||||
|
||||
await tx.heartbeatLog.create({
|
||||
data: {
|
||||
@ -191,7 +196,14 @@ export async function POST(
|
||||
});
|
||||
});
|
||||
|
||||
const signedLicense = signLicense(generateLicenseConfig('FREE', null));
|
||||
const signedLicense = signLicense(buildLicenseSigningPayload({
|
||||
projectCode,
|
||||
licenseKey: activationCode,
|
||||
licenseType: 'FREE',
|
||||
expiresAt: null,
|
||||
fingerprint,
|
||||
activationId: createdActivationId,
|
||||
}));
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
|
||||
@ -2,7 +2,8 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
import { signLicense } from '@/lib/crypto';
|
||||
import { getPublicProjectDb } from '@/lib/project-auth';
|
||||
import { verifyLicenseRequestSignature } from '@/lib/request-signature';
|
||||
import { generateLicenseConfig, type HardwareFingerprint } from '@/lib/types';
|
||||
import { type HardwareFingerprint } from '@/lib/types';
|
||||
import { buildLicenseSigningPayload } from '@/lib/license-payload';
|
||||
import {
|
||||
calculateFingerprintMatchScore,
|
||||
createPendingAlertOnce,
|
||||
@ -153,9 +154,14 @@ export async function POST(
|
||||
data: { lastHeartbeat: new Date() },
|
||||
});
|
||||
|
||||
const signedLicense = signLicense(
|
||||
generateLicenseConfig(license.licenseType, license.expiresAt)
|
||||
);
|
||||
const signedLicense = signLicense(buildLicenseSigningPayload({
|
||||
projectCode,
|
||||
licenseKey: license.licenseKey,
|
||||
licenseType: license.licenseType,
|
||||
expiresAt: license.expiresAt,
|
||||
fingerprint,
|
||||
activationId: activation.id,
|
||||
}));
|
||||
|
||||
return NextResponse.json({
|
||||
valid: true,
|
||||
|
||||
26
license-server/lib/license-payload.ts
Normal file
26
license-server/lib/license-payload.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import { hashFingerprint } from './crypto';
|
||||
import { generateLicenseConfig, type HardwareFingerprint, type LicenseType, type LicenseBinding } from './types';
|
||||
|
||||
interface BuildLicensePayloadParams {
|
||||
projectCode: string;
|
||||
licenseKey: string;
|
||||
licenseType: LicenseType;
|
||||
expiresAt?: Date | null;
|
||||
fingerprint: HardwareFingerprint;
|
||||
activationId?: string;
|
||||
}
|
||||
|
||||
export function buildLicenseSigningPayload(params: BuildLicensePayloadParams) {
|
||||
const binding: LicenseBinding = {
|
||||
projectCode: params.projectCode,
|
||||
licenseKey: params.licenseKey,
|
||||
instanceId: params.fingerprint.instanceId,
|
||||
fingerprintHash: hashFingerprint(params.fingerprint),
|
||||
activationId: params.activationId,
|
||||
};
|
||||
|
||||
return {
|
||||
...generateLicenseConfig(params.licenseType, params.expiresAt),
|
||||
...binding,
|
||||
};
|
||||
}
|
||||
@ -19,9 +19,20 @@ export interface LicenseConfig {
|
||||
leaseExpiresAt: string | null; // 在线租约到期时间,旧许可证可为 null
|
||||
}
|
||||
|
||||
// 签名许可证的绑定信息:用于证明这张许可证只属于指定项目、激活码和本机实例。
|
||||
export interface LicenseBinding {
|
||||
projectCode: string;
|
||||
licenseKey: string;
|
||||
instanceId: string;
|
||||
fingerprintHash: string;
|
||||
activationId?: string;
|
||||
}
|
||||
|
||||
export type SignedLicenseData = LicenseConfig & LicenseBinding & { issuedAt: string };
|
||||
|
||||
// 签名后的许可证
|
||||
export interface SignedLicense {
|
||||
data: LicenseConfig & { issuedAt: string };
|
||||
data: SignedLicenseData;
|
||||
signature: string;
|
||||
issuedAt: string;
|
||||
}
|
||||
|
||||
@ -58,7 +58,9 @@ async function main() {
|
||||
}
|
||||
|
||||
let totalFiles = 0;
|
||||
let targetFiles = 0;
|
||||
let obfuscatedFiles = 0;
|
||||
const failedFiles = [];
|
||||
|
||||
// 查找需要混淆的文件
|
||||
for (const pattern of PATTERNS) {
|
||||
@ -81,6 +83,7 @@ async function main() {
|
||||
|
||||
// 跳过太小的文件(可能是空文件或只有导出)
|
||||
if (code.length < 100) continue;
|
||||
targetFiles++;
|
||||
|
||||
console.log(` 混淆: ${file}`);
|
||||
|
||||
@ -92,7 +95,8 @@ async function main() {
|
||||
fs.writeFileSync(filePath, result.getObfuscatedCode());
|
||||
obfuscatedFiles++;
|
||||
} catch (error) {
|
||||
console.warn(` ⚠️ 跳过 ${file}: ${error.message}`);
|
||||
failedFiles.push(`${file}: ${error.message}`);
|
||||
console.warn(` ⚠️ 混淆失败 ${file}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -116,6 +120,7 @@ async function main() {
|
||||
try {
|
||||
const code = fs.readFileSync(filePath, 'utf-8');
|
||||
if (code.length < 100) continue;
|
||||
targetFiles++;
|
||||
|
||||
console.log(` 混淆 (standalone): ${file}`);
|
||||
|
||||
@ -127,14 +132,29 @@ async function main() {
|
||||
fs.writeFileSync(filePath, result.getObfuscatedCode());
|
||||
obfuscatedFiles++;
|
||||
} catch (error) {
|
||||
console.warn(` ⚠️ 跳过 ${file}: ${error.message}`);
|
||||
failedFiles.push(`${file}: ${error.message}`);
|
||||
console.warn(` ⚠️ 混淆失败 ${file}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (targetFiles === 0) {
|
||||
console.error('\n❌ 没有找到需要混淆的目标文件,生产镜像不能继续出包');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (failedFiles.length > 0) {
|
||||
console.error('\n❌ 混淆失败的目标文件:');
|
||||
for (const failedFile of failedFiles) {
|
||||
console.error(` - ${failedFile}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`\n✅ 混淆完成!`);
|
||||
console.log(` 扫描文件: ${totalFiles}`);
|
||||
console.log(` 目标文件: ${targetFiles}`);
|
||||
console.log(` 混淆文件: ${obfuscatedFiles}`);
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user