完善许可证防破解收尾闭环
This commit is contained in:
parent
074b1e1bdc
commit
3c9591cdeb
@ -5,6 +5,7 @@ import { buildLicenseServerLicenseApiUrl } from '@/lib/license/server-config';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { verifyLicenseSignature } from '@/lib/license/crypto-service';
|
||||
import { buildSignedLicenseRequest } from '@/lib/license/request-signature';
|
||||
import { syncPremiumModules } from '@/lib/license/premium-module-service';
|
||||
import type { HeartbeatResponse } from '@/lib/license/types';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
@ -59,6 +60,10 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
// ✅ 在后端更新本地缓存
|
||||
await updateLocalLicense(data.license);
|
||||
const moduleSyncResult = await syncPremiumModules();
|
||||
if (!moduleSyncResult.synced && !moduleSyncResult.skipped) {
|
||||
console.warn('[License] 付费模块清单同步失败:', moduleSyncResult.error);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
|
||||
@ -9,6 +9,7 @@ import {
|
||||
import { verifyLicenseSignature } from '@/lib/license/crypto-service';
|
||||
import { buildLicenseServerLicenseApiUrl } from '@/lib/license/server-config';
|
||||
import { buildSignedLicenseRequest } from '@/lib/license/request-signature';
|
||||
import { syncPremiumModules } from '@/lib/license/premium-module-service';
|
||||
import type { VerifyResponse } from '@/lib/license/types';
|
||||
|
||||
// 宽限期(7天)
|
||||
@ -77,6 +78,10 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
// ✅ 在后端更新本地缓存
|
||||
await updateLocalLicense(data.license);
|
||||
const moduleSyncResult = await syncPremiumModules();
|
||||
if (!moduleSyncResult.synced && !moduleSyncResult.skipped) {
|
||||
console.warn('[License] 付费模块清单同步失败:', moduleSyncResult.error);
|
||||
}
|
||||
|
||||
return NextResponse.json({ valid: true });
|
||||
} catch (error) {
|
||||
|
||||
@ -37,6 +37,8 @@ docker-compose.prod.yml
|
||||
|
||||
这些组件根据交付场景单独部署或由安装 Agent 编排。
|
||||
|
||||
独立许可证服务的默认编排在 `license-server/docker-compose.yml`。该编排会由 Nginx 对外暴露 `9527`,再反代到内部 `license-server:3000`;应用容器不再直接暴露宿主机端口。这样登录和 API 限流配置会进入默认链路,避免验证服务器在低配置机器上被暴力请求直接打到 Next 服务。
|
||||
|
||||
## 2. 部署前准备
|
||||
|
||||
### 2.1 机器条件
|
||||
@ -297,6 +299,8 @@ docker exec -i flnxi-postgres pg_restore \
|
||||
- `LICENSE_REQUEST_SIGNING_SECRET` 是否与许可证服务一致
|
||||
- 许可证服务是否存在对应项目编码
|
||||
- 主应用 `/api/license/check`、`/api/license/verify` 响应
|
||||
- 如果独立部署 `license-server/docker-compose.yml`,确认对外访问的是 Nginx 暴露的 `9527`,不是容器内部 `3000`。
|
||||
- 如果请求返回 `429`,优先检查 Nginx 限流是否被触发,再看是否存在脚本重试、暴力激活或心跳频率异常。
|
||||
|
||||
### 数据迁移失败
|
||||
|
||||
|
||||
@ -402,11 +402,18 @@
|
||||
- `app/api/license/info/route.ts`:管理端许可证信息改为读取 `getLocalLicense()` 的已校验本地许可证,不再只看进程内缓存,避免回拨阻断后管理页仍显示正常激活。
|
||||
- `lib/license/request-signature.ts`、`license-server/lib/request-signature.ts`:主应用转发到验证服务器的 `activate / register-free / verify / heartbeat` 请求新增 HMAC 签名、`nonce` 和 5 分钟时间窗口;验证服务器会拒绝无签名、签名错误、过期和重复 `nonce` 请求,降低抓包重放和脚本伪造成本。
|
||||
- `.env.example`、`docker-compose.prod.yml`、`license-server/docker-compose.yml`:新增并强制要求 `LICENSE_REQUEST_SIGNING_SECRET`,主应用和验证服务器必须使用同一个共享签名密钥,且不能写入 Git。
|
||||
- `license-server/docker-compose.yml`、`license-server/nginx/nginx.conf`:许可证服务默认 Compose 已改为由 Nginx 对外暴露 `9527`,应用容器只在内部网络暴露 `3000`;现有登录和 API 限流不再只是孤立配置。
|
||||
- `license-server/lib/abuse-detection.ts`、`license-server/app/api/license/[projectCode]/register-free/route.ts`、`activate/verify/heartbeat`:免费版新增同设备重复注册、多实例/多指纹复用阻断;付费版多设备疑似复用先告警,不直接误伤客户。
|
||||
- `license-server/app/api/license/[projectCode]/register-free/route.ts`:免费注册复用已有激活前会检查许可证禁用和过期状态;首条心跳记录统一写入硬件指纹哈希,避免后续被误判为多设备。
|
||||
- `license-server/app/api/license/[projectCode]/premium-modules/route.ts`、`license-server/lib/premium-module-manifest.ts`:新增付费模块清单接口,免费版拒绝,付费版按许可证类型返回签名 manifest,并在硬件指纹明显不匹配时拒绝并告警。
|
||||
- `lib/license/premium-module-service.ts`:主应用在验证和心跳续证成功后同步付费模块 manifest,本地加密缓存并校验 RSA 签名、租约和当前许可证类型,避免降级后继续使用旧 PRO 模块清单。
|
||||
- `lib/__tests__/license-route-coverage.test.ts`:新增关键业务入口覆盖测试,防止用户数、素材数、自定义模板、部门隔离、系统设置、内网穿透等服务端许可证检查被后续改动漏掉。
|
||||
- `lib/__tests__/license-service.test.ts`:新增测试覆盖许可证过期、在线租约过期、高级功能不可用、数量限制不放行、本地许可证从 Pro 降级到 Free 后业务检查立即按 Free 生效,以及旧许可证缺少 `leaseExpiresAt` 时的兼容行为。
|
||||
- `lib/__tests__/license-types.test.ts`、`app/api/license/check/__tests__/route.test.ts`:新增测试锁定默认租约天数、租约截断逻辑,以及租约过期时不恢复激活 cookie。
|
||||
- `lib/__tests__/license-request-signature.test.ts`、`license-server/lib/__tests__/request-signature.test.ts`:新增测试覆盖签名生成、篡改拒绝、重复 `nonce` 拒绝、过期拒绝和缺少服务端共享密钥时拒绝静默降级。
|
||||
- `lib/__tests__/license-service.test.ts`:新增测试覆盖旧本地许可证自动补时间锚点、小幅回拨不误伤、明显回拨拒绝功能和额度、本地时间状态被篡改后拒绝,以及网络失败宽限期前也先检查回拨状态。
|
||||
- `lib/__tests__/check-limits.test.ts`:功能检查 helper 已全部改为异步调用底层许可证检查。
|
||||
- `lib/__tests__/premium-module-service.test.ts`、`license-server/lib/__tests__/premium-module-manifest.test.ts`、`license-server/lib/__tests__/free-registration-route-coverage.test.ts`:新增测试覆盖付费模块 manifest 同步/验签/降级失效、验证服务器 manifest 生成,以及免费注册入口的防误判和禁用/过期护栏。
|
||||
|
||||
验证:
|
||||
|
||||
@ -415,6 +422,8 @@
|
||||
- `npm test -- lib/__tests__/license-request-signature.test.ts app/api/license/activate/__tests__/route.test.ts app/api/license/activate-free/__tests__/route.test.ts --runInBand`
|
||||
- `npm test -- lib/__tests__/license-service.test.ts app/api/license/check/__tests__/route.test.ts app/api/license/info/__tests__/route.test.ts --runInBand`
|
||||
- `npm test -- license-server/lib/__tests__/request-signature.test.ts --runInBand`
|
||||
- `npm test -- lib/__tests__/premium-module-service.test.ts lib/__tests__/license-route-coverage.test.ts --runInBand`
|
||||
- `npm test -- license-server/lib/__tests__/abuse-detection.test.ts license-server/lib/__tests__/premium-module-manifest.test.ts license-server/lib/__tests__/free-registration-route-coverage.test.ts --runInBand`
|
||||
- `npx tsc --noEmit --project license-server/tsconfig.json`
|
||||
- `npm run type-check`
|
||||
|
||||
@ -424,7 +433,10 @@
|
||||
- 已完成:免费版采用更短在线租约,适合主要防破解和防白嫖;付费版采用更长租约,优先避免误伤正常客户,同时仍能限制付费凭证长期滥用。
|
||||
- 已完成:主应用到验证服务器的公开许可证请求已有轻量签名和防重放,不再只依赖项目编码、时间戳和硬件指纹。
|
||||
- 已完成:本地许可证读取已加入轻量时间回拨检测;免费版和租约能力不能再靠把系统时间往回调来继续走本地宽限期。
|
||||
- 后续增强:异常设备聚合、验证服务器限流、模块包下载和付费核心文件拆分仍需单独推进;当前本轮先保证过期、降级、租约过期、抓包重放和明显本地时间回拨不会因为本地旧缓存继续放行。
|
||||
- 已完成:验证服务器默认入口接入 Nginx 限流,免费版重复注册/多设备复用会阻断,付费版异常多设备以告警为主。
|
||||
- 已完成:主应用关键授权入口已有防漏口测试,后续改注册、上传、模板、部门、系统设置、内网穿透接口时会更早暴露许可证检查缺失。
|
||||
- 已完成:付费模块 manifest 基础链路已落地,适合低配置验证服务器和约 5M 带宽;当前只传输小清单,不在验证服务器承担重计算或大文件实时分发。
|
||||
- 后续增强:真正把付费功能核心文件从镜像中拆出、加密分发、下载缓存和模块加载仍需单独推进;当前本轮完成的是签名清单、权限校验、缓存校验和后续拆包的接口地基。
|
||||
|
||||
### P1-05 免费版激活保存空签名,后续读取疑似失败(已修复)
|
||||
|
||||
|
||||
@ -52,6 +52,7 @@ POST /api/license/{projectCode}/activate
|
||||
POST /api/license/{projectCode}/verify
|
||||
POST /api/license/{projectCode}/heartbeat
|
||||
POST /api/license/{projectCode}/register-free
|
||||
POST /api/license/{projectCode}/premium-modules
|
||||
POST /api/license/{projectCode}/generate
|
||||
|
||||
GET /api/version/{projectCode}/check
|
||||
@ -89,6 +90,7 @@ GET /api/version/{projectCode}/agent
|
||||
|
||||
- `activate`、`activate-free`、`verify`、`heartbeat` 会根据 `LICENSE_PROJECT_CODE` 转发到远端项目维度接口。
|
||||
- `activate`、`activate-free`、`verify`、`heartbeat` 转发到远端前会加入 `timestamp`、`nonce` 和 HMAC `signature`;远端会拒绝签名缺失、签名错误、过期和重复 `nonce` 请求。
|
||||
- `verify`、`heartbeat` 在续证成功后会尝试同步 `premium-modules` 付费模块清单;免费版会跳过,不访问远端模块清单接口。
|
||||
- `check` 用于在本地许可证缓存存在时恢复激活状态,会先读取并验签本地许可证。
|
||||
- `info` 用于读取当前许可证类型、用量和功能开关。
|
||||
|
||||
@ -103,7 +105,9 @@ ${LICENSE_SERVER_URL}/api/license/${LICENSE_PROJECT_CODE}/...
|
||||
补充:
|
||||
|
||||
- 远端 `POST /api/license/{projectCode}/generate` 是付费激活码生成接口,当前要求 `X-API-Key`、`timestamp` 和 `sign`,不应被普通前端直接调用。
|
||||
- `activate / verify / heartbeat / register-free` 当前会校验请求 HMAC 签名和服务端返回许可证签名;前者防抓包重放和伪造请求,后者防伪造许可证。
|
||||
- 远端 `POST /api/license/{projectCode}/premium-modules` 是付费模块清单接口,要求同一套 HMAC 请求签名;免费版、禁用、过期或硬件指纹明显不匹配会被拒绝。
|
||||
- `premium-modules` 当前返回的是签名 manifest(模块清单),用于后续真正拆分付费核心文件时复用权限、签名、缓存和过期链路;当前不直接承载大文件下载,适合低配置、低带宽验证服务器。
|
||||
- `activate / verify / heartbeat / register-free / premium-modules` 当前会校验请求 HMAC 签名;返回的许可证和付费模块清单还会分别做服务端签名,主应用本地必须验签后再缓存。
|
||||
|
||||
## 5. 后台管理入口
|
||||
|
||||
|
||||
76
lib/__tests__/license-route-coverage.test.ts
Normal file
76
lib/__tests__/license-route-coverage.test.ts
Normal file
@ -0,0 +1,76 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
const rootDir = path.resolve(__dirname, '..', '..');
|
||||
|
||||
function readProjectFile(relativePath: string): string {
|
||||
return fs.readFileSync(path.join(rootDir, relativePath), 'utf8');
|
||||
}
|
||||
|
||||
describe('许可证业务入口覆盖', () => {
|
||||
const requiredChecks: Array<{
|
||||
file: string;
|
||||
helper: string;
|
||||
reason: string;
|
||||
}> = [
|
||||
{
|
||||
file: 'app/api/auth/register/route.ts',
|
||||
helper: 'canCreateUser',
|
||||
reason: '免费版用户数限制必须在注册入口服务端执行',
|
||||
},
|
||||
{
|
||||
file: 'app/api/materials/upload/route.ts',
|
||||
helper: 'canUploadMaterial',
|
||||
reason: '免费版素材数限制必须在上传入口服务端执行',
|
||||
},
|
||||
{
|
||||
file: 'app/api/admin/upload-templates/route.ts',
|
||||
helper: 'canUseCustomTemplates',
|
||||
reason: '自定义模板新增必须走许可证功能开关',
|
||||
},
|
||||
{
|
||||
file: 'app/api/admin/upload-templates/[id]/route.ts',
|
||||
helper: 'canUseCustomTemplates',
|
||||
reason: '自定义模板编辑和删除必须走许可证功能开关',
|
||||
},
|
||||
{
|
||||
file: 'app/api/admin/upload-templates/[id]/migrate/route.ts',
|
||||
helper: 'canUseCustomTemplates',
|
||||
reason: '模板迁移必须走许可证功能开关',
|
||||
},
|
||||
{
|
||||
file: 'app/api/departments/route.ts',
|
||||
helper: 'canUseDepartmentIsolation',
|
||||
reason: '部门隔离相关写操作必须走许可证功能开关',
|
||||
},
|
||||
{
|
||||
file: 'app/api/system-settings/route.ts',
|
||||
helper: 'canUseSystemSettings',
|
||||
reason: '系统设置写操作必须走许可证功能开关',
|
||||
},
|
||||
{
|
||||
file: 'app/api/tunnel/start/route.ts',
|
||||
helper: 'canUseIntranetPenetration',
|
||||
reason: '内网穿透启动必须走专业版功能开关',
|
||||
},
|
||||
{
|
||||
file: 'app/api/tunnel/status/route.ts',
|
||||
helper: 'canUseIntranetPenetration',
|
||||
reason: '内网穿透状态查询不能绕过专业版功能开关',
|
||||
},
|
||||
{
|
||||
file: 'app/api/tunnel/stop/route.ts',
|
||||
helper: 'canUseIntranetPenetration',
|
||||
reason: '内网穿透停止入口也必须维持同一许可证边界',
|
||||
},
|
||||
];
|
||||
|
||||
it.each(requiredChecks)('$file 应调用 $helper', ({ file, helper, reason }) => {
|
||||
const source = readProjectFile(file);
|
||||
|
||||
expect(source.includes(helper)).toBe(true);
|
||||
if (!source.includes(helper)) {
|
||||
throw new Error(reason);
|
||||
}
|
||||
});
|
||||
});
|
||||
190
lib/__tests__/premium-module-service.test.ts
Normal file
190
lib/__tests__/premium-module-service.test.ts
Normal file
@ -0,0 +1,190 @@
|
||||
jest.mock('@/lib/prisma', () => ({
|
||||
prisma: {
|
||||
systemConfig: {
|
||||
findUnique: jest.fn(),
|
||||
upsert: jest.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('../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('../license/license-service', () => ({
|
||||
getLocalLicense: jest.fn(),
|
||||
isLicenseConfigExpired: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../license/crypto-service', () => ({
|
||||
encryptLocal: jest.fn((data) => `encrypted:${data}`),
|
||||
decryptLocal: jest.fn((data) => data.replace('encrypted:', '')),
|
||||
verifySignature: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../license/request-signature', () => ({
|
||||
buildSignedLicenseRequest: jest.fn((action, payload) => ({
|
||||
...payload,
|
||||
timestamp: 1779753600000,
|
||||
nonce: 'nonce',
|
||||
signature: `signature:${action}`,
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('../license/server-config', () => ({
|
||||
buildLicenseServerLicenseApiUrl: jest.fn((action) => `https://license.test/api/license/flnxi/${action}`),
|
||||
}));
|
||||
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { verifySignature } from '../license/crypto-service';
|
||||
import { getLocalLicense, isLicenseConfigExpired } from '../license/license-service';
|
||||
|
||||
const mockFetch = jest.fn();
|
||||
global.fetch = mockFetch;
|
||||
|
||||
const mockVerifySignature = verifySignature as jest.MockedFunction<typeof verifySignature>;
|
||||
const mockGetLocalLicense = getLocalLicense as jest.MockedFunction<typeof getLocalLicense>;
|
||||
const mockIsLicenseConfigExpired = isLicenseConfigExpired as jest.MockedFunction<typeof isLicenseConfigExpired>;
|
||||
|
||||
function mockPaidLicense(type: 'STANDARD' | 'PRO' = 'PRO') {
|
||||
mockGetLocalLicense.mockResolvedValue({
|
||||
instanceId: 'instance-1',
|
||||
licenseKey: `FLNXI-${type}-TEST`,
|
||||
licenseData: 'encrypted-license',
|
||||
lastVerified: new Date().toISOString(),
|
||||
cachedConfig: {
|
||||
type,
|
||||
limits: { maxUsers: -1, maxMaterials: -1 },
|
||||
features: {
|
||||
customTemplates: true,
|
||||
systemSettings: true,
|
||||
departmentIsolation: true,
|
||||
apiAccess: true,
|
||||
intranetPenetration: type === 'PRO',
|
||||
},
|
||||
expiresAt: null,
|
||||
leaseExpiresAt: new Date(Date.now() + 60_000).toISOString(),
|
||||
},
|
||||
} as any);
|
||||
mockIsLicenseConfigExpired.mockReturnValue(false);
|
||||
}
|
||||
|
||||
describe('premium-module-service', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockFetch.mockReset();
|
||||
mockVerifySignature.mockReturnValue(true);
|
||||
});
|
||||
|
||||
it('免费版同步付费模块时应跳过,不访问验证服务器', async () => {
|
||||
mockGetLocalLicense.mockResolvedValue({
|
||||
cachedConfig: {
|
||||
type: 'FREE',
|
||||
limits: { maxUsers: 10, maxMaterials: 100 },
|
||||
features: {
|
||||
customTemplates: false,
|
||||
systemSettings: false,
|
||||
departmentIsolation: false,
|
||||
apiAccess: false,
|
||||
intranetPenetration: false,
|
||||
},
|
||||
expiresAt: null,
|
||||
leaseExpiresAt: new Date(Date.now() + 60_000).toISOString(),
|
||||
},
|
||||
} as any);
|
||||
mockIsLicenseConfigExpired.mockReturnValue(false);
|
||||
|
||||
const { syncPremiumModules } = await import('../license/premium-module-service');
|
||||
const result = await syncPremiumModules();
|
||||
|
||||
expect(result).toMatchObject({ synced: false, skipped: true });
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('付费版应拉取、验签并加密缓存付费模块清单', async () => {
|
||||
mockPaidLicense('PRO');
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
success: true,
|
||||
manifest: {
|
||||
version: 1,
|
||||
licenseType: 'PRO',
|
||||
modules: [{ id: 'intranet-penetration-core', minLicenseType: 'PRO', sizeBytes: 4096, sha256: 'hash' }],
|
||||
issuedAt: new Date().toISOString(),
|
||||
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
||||
},
|
||||
signature: 'valid-signature',
|
||||
}),
|
||||
});
|
||||
|
||||
const { syncPremiumModules } = await import('../license/premium-module-service');
|
||||
const result = await syncPremiumModules();
|
||||
|
||||
expect(result).toEqual({ synced: true });
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'https://license.test/api/license/flnxi/premium-modules',
|
||||
expect.objectContaining({ method: 'POST' })
|
||||
);
|
||||
expect(prisma.systemConfig.upsert).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('付费模块清单同步网络异常时应返回失败结果而不是抛出', async () => {
|
||||
mockPaidLicense('PRO');
|
||||
mockFetch.mockRejectedValue(new Error('network down'));
|
||||
|
||||
const { syncPremiumModules } = await import('../license/premium-module-service');
|
||||
const result = await syncPremiumModules();
|
||||
|
||||
expect(result).toEqual({ synced: false, error: 'network down' });
|
||||
expect(prisma.systemConfig.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('缓存清单签名无效时不应视为可用模块', async () => {
|
||||
mockPaidLicense('PRO');
|
||||
mockVerifySignature.mockReturnValue(false);
|
||||
(prisma.systemConfig.findUnique as jest.Mock).mockResolvedValue({
|
||||
value: `encrypted:${JSON.stringify({
|
||||
manifest: {
|
||||
version: 1,
|
||||
licenseType: 'PRO',
|
||||
modules: [{ id: 'intranet-penetration-core', minLicenseType: 'PRO', sizeBytes: 4096, sha256: 'hash' }],
|
||||
issuedAt: new Date().toISOString(),
|
||||
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
||||
},
|
||||
signature: 'bad-signature',
|
||||
syncedAt: new Date().toISOString(),
|
||||
})}`,
|
||||
});
|
||||
|
||||
const { hasPremiumModule } = await import('../license/premium-module-service');
|
||||
|
||||
await expect(hasPremiumModule('intranet-penetration-core')).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('缓存清单许可证类型和当前许可证不一致时不应视为可用模块', async () => {
|
||||
mockPaidLicense('STANDARD');
|
||||
(prisma.systemConfig.findUnique as jest.Mock).mockResolvedValue({
|
||||
value: `encrypted:${JSON.stringify({
|
||||
manifest: {
|
||||
version: 1,
|
||||
licenseType: 'PRO',
|
||||
modules: [{ id: 'intranet-penetration-core', minLicenseType: 'PRO', sizeBytes: 4096, sha256: 'hash' }],
|
||||
issuedAt: new Date().toISOString(),
|
||||
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
||||
},
|
||||
signature: 'valid-signature',
|
||||
syncedAt: new Date().toISOString(),
|
||||
})}`,
|
||||
});
|
||||
|
||||
const { hasPremiumModule } = await import('../license/premium-module-service');
|
||||
|
||||
await expect(hasPremiumModule('intranet-penetration-core')).resolves.toBe(false);
|
||||
});
|
||||
});
|
||||
@ -28,6 +28,12 @@ export {
|
||||
canUseApiAccess,
|
||||
} from './check-limits';
|
||||
|
||||
export {
|
||||
getCachedPremiumModuleManifest,
|
||||
syncPremiumModules,
|
||||
hasPremiumModule,
|
||||
} from './premium-module-service';
|
||||
|
||||
// 导出指纹服务
|
||||
export {
|
||||
generateFingerprint,
|
||||
|
||||
145
lib/license/premium-module-service.ts
Normal file
145
lib/license/premium-module-service.ts
Normal file
@ -0,0 +1,145 @@
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { decryptLocal, encryptLocal, verifySignature } from './crypto-service';
|
||||
import { generateFingerprint } from './fingerprint-service';
|
||||
import { buildSignedLicenseRequest } from './request-signature';
|
||||
import { buildLicenseServerLicenseApiUrl } from './server-config';
|
||||
import { getLocalLicense, isLicenseConfigExpired } from './license-service';
|
||||
import type { LicenseType } from './types';
|
||||
|
||||
export interface PremiumModuleManifest {
|
||||
version: number;
|
||||
licenseType: Exclude<LicenseType, 'FREE'>;
|
||||
modules: Array<{
|
||||
id: string;
|
||||
minLicenseType: Exclude<LicenseType, 'FREE'>;
|
||||
sizeBytes: number;
|
||||
sha256: string;
|
||||
}>;
|
||||
issuedAt: string;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
interface SignedPremiumModuleManifest {
|
||||
success: boolean;
|
||||
manifest?: PremiumModuleManifest;
|
||||
signature?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface CachedPremiumModuleManifest {
|
||||
manifest: PremiumModuleManifest;
|
||||
signature: string;
|
||||
syncedAt: string;
|
||||
}
|
||||
|
||||
const PREMIUM_MODULE_CONFIG_KEY = 'premium_module_manifest';
|
||||
|
||||
function isPaidLicense(type: LicenseType): type is Exclude<LicenseType, 'FREE'> {
|
||||
return type === 'STANDARD' || type === 'PRO';
|
||||
}
|
||||
|
||||
function isManifestExpired(manifest: PremiumModuleManifest): boolean {
|
||||
const expiresAt = new Date(manifest.expiresAt).getTime();
|
||||
return Number.isNaN(expiresAt) || expiresAt <= Date.now();
|
||||
}
|
||||
|
||||
function verifyPremiumModuleManifest(manifest: PremiumModuleManifest, signature: string): boolean {
|
||||
return verifySignature(JSON.stringify(manifest), signature);
|
||||
}
|
||||
|
||||
async function savePremiumModuleManifest(
|
||||
manifest: PremiumModuleManifest,
|
||||
signature: string
|
||||
): Promise<void> {
|
||||
const cached: CachedPremiumModuleManifest = {
|
||||
manifest,
|
||||
signature,
|
||||
syncedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
await prisma.systemConfig.upsert({
|
||||
where: { key: PREMIUM_MODULE_CONFIG_KEY },
|
||||
update: { value: encryptLocal(JSON.stringify(cached)) },
|
||||
create: { key: PREMIUM_MODULE_CONFIG_KEY, value: encryptLocal(JSON.stringify(cached)) },
|
||||
});
|
||||
}
|
||||
|
||||
export async function getCachedPremiumModuleManifest(): Promise<CachedPremiumModuleManifest | null> {
|
||||
try {
|
||||
const config = await prisma.systemConfig.findUnique({
|
||||
where: { key: PREMIUM_MODULE_CONFIG_KEY },
|
||||
});
|
||||
|
||||
if (!config) return null;
|
||||
|
||||
const cached = JSON.parse(decryptLocal(config.value)) as CachedPremiumModuleManifest;
|
||||
if (!cached.manifest || !cached.signature) return null;
|
||||
|
||||
if (isManifestExpired(cached.manifest)) return null;
|
||||
if (!verifyPremiumModuleManifest(cached.manifest, cached.signature)) return null;
|
||||
|
||||
const localLicense = await getLocalLicense();
|
||||
if (!localLicense || isLicenseConfigExpired(localLicense.cachedConfig)) return null;
|
||||
if (!isPaidLicense(localLicense.cachedConfig.type)) return null;
|
||||
if (cached.manifest.licenseType !== localLicense.cachedConfig.type) return null;
|
||||
|
||||
return cached;
|
||||
} catch (error) {
|
||||
console.warn('[License] 读取付费模块缓存失败:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function syncPremiumModules(): Promise<{ synced: boolean; skipped?: boolean; error?: string }> {
|
||||
try {
|
||||
const localLicense = await getLocalLicense();
|
||||
if (!localLicense || isLicenseConfigExpired(localLicense.cachedConfig)) {
|
||||
return { synced: false, skipped: true, error: '本地许可证不可用' };
|
||||
}
|
||||
|
||||
if (!isPaidLicense(localLicense.cachedConfig.type)) {
|
||||
return { synced: false, skipped: true, error: '免费版不需要付费模块' };
|
||||
}
|
||||
|
||||
const fingerprint = await generateFingerprint();
|
||||
const signedRequest = buildSignedLicenseRequest('premium-modules', {
|
||||
instanceId: fingerprint.instanceId,
|
||||
fingerprint,
|
||||
});
|
||||
|
||||
const response = await fetch(buildLicenseServerLicenseApiUrl('premium-modules'), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(signedRequest),
|
||||
});
|
||||
const data = (await response.json()) as SignedPremiumModuleManifest;
|
||||
|
||||
if (!response.ok || !data.success || !data.manifest || !data.signature) {
|
||||
return {
|
||||
synced: false,
|
||||
error: data.error || '付费模块清单同步失败',
|
||||
};
|
||||
}
|
||||
|
||||
if (data.manifest.licenseType !== localLicense.cachedConfig.type) {
|
||||
return { synced: false, error: '付费模块清单许可证类型不匹配' };
|
||||
}
|
||||
|
||||
if (!verifyPremiumModuleManifest(data.manifest, data.signature)) {
|
||||
return { synced: false, error: '付费模块清单签名验证失败' };
|
||||
}
|
||||
|
||||
await savePremiumModuleManifest(data.manifest, data.signature);
|
||||
return { synced: true };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '付费模块清单同步异常';
|
||||
return { synced: false, error: message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function hasPremiumModule(moduleId: string): Promise<boolean> {
|
||||
const cached = await getCachedPremiumModuleManifest();
|
||||
if (!cached) return false;
|
||||
|
||||
return cached.manifest.modules.some((module) => module.id === moduleId);
|
||||
}
|
||||
@ -3,6 +3,7 @@ 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 { createPendingAlertOnce, findFreeFingerprintReuse } from '@/lib/abuse-detection';
|
||||
import { z } from 'zod';
|
||||
|
||||
const activateSchema = z.object({
|
||||
@ -99,6 +100,12 @@ export async function POST(
|
||||
if (existingActivation) {
|
||||
const existingFingerprint = existingActivation.fingerprint as unknown as HardwareFingerprint;
|
||||
if (existingFingerprint.instanceId !== fingerprint.instanceId) {
|
||||
await createPendingAlertOnce(projectDbResult.prisma, license.id, 'activation_on_other_device', {
|
||||
existingInstanceId: existingFingerprint.instanceId,
|
||||
requestedInstanceId: fingerprint.instanceId,
|
||||
licenseType: license.licenseType,
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '许可证已在其他设备上激活,请联系管理员重置' },
|
||||
{ status: 409 }
|
||||
@ -113,6 +120,26 @@ export async function POST(
|
||||
},
|
||||
});
|
||||
} else {
|
||||
if (license.licenseType === 'FREE') {
|
||||
const reuseResult = await findFreeFingerprintReuse(projectDbResult.prisma, fingerprint);
|
||||
if (reuseResult.reused && reuseResult.existingLicenseKey !== license.licenseKey) {
|
||||
await createPendingAlertOnce(projectDbResult.prisma, license.id, 'free_reactivation_blocked', {
|
||||
requestedInstanceId: fingerprint.instanceId,
|
||||
existingInstanceId: reuseResult.existingInstanceId,
|
||||
existingLicenseKey: reuseResult.existingLicenseKey,
|
||||
matchScore: reuseResult.matchScore,
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: '该设备已绑定过其他免费许可证,请继续使用原免费许可证或升级付费版',
|
||||
},
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await projectDbResult.prisma.activation.create({
|
||||
data: {
|
||||
licenseId: license.id,
|
||||
|
||||
@ -3,30 +3,13 @@ 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 {
|
||||
calculateFingerprintMatchScore,
|
||||
createPendingAlertOnce,
|
||||
enforceDeviceAbusePolicy,
|
||||
} from '@/lib/abuse-detection';
|
||||
import { z } from 'zod';
|
||||
|
||||
function calculateFingerprintMatchScore(
|
||||
stored: HardwareFingerprint,
|
||||
current: HardwareFingerprint
|
||||
): number {
|
||||
let score = 0;
|
||||
let total = 0;
|
||||
|
||||
if (stored.machineId === current.machineId) score += 40;
|
||||
total += 40;
|
||||
|
||||
if (stored.hostname === current.hostname) score += 20;
|
||||
total += 20;
|
||||
|
||||
if (stored.macAddress === current.macAddress) score += 30;
|
||||
total += 30;
|
||||
|
||||
if (stored.instanceId === current.instanceId) score += 10;
|
||||
total += 10;
|
||||
|
||||
return (score / total) * 100;
|
||||
}
|
||||
|
||||
const heartbeatSchema = z.object({
|
||||
instanceId: z.string(),
|
||||
fingerprint: z.object({
|
||||
@ -64,26 +47,9 @@ async function detectAnomaly(
|
||||
uniqueFingerprints.add(fingerprintHash);
|
||||
|
||||
if (uniqueFingerprints.size > 1) {
|
||||
const existingAlert = await prisma.alert.findFirst({
|
||||
where: {
|
||||
licenseId,
|
||||
alertType: 'multi_device',
|
||||
status: 'pending',
|
||||
},
|
||||
await createPendingAlertOnce(prisma, licenseId, 'multi_device', {
|
||||
fingerprints: Array.from(uniqueFingerprints),
|
||||
});
|
||||
|
||||
if (!existingAlert) {
|
||||
await prisma.alert.create({
|
||||
data: {
|
||||
licenseId,
|
||||
alertType: 'multi_device',
|
||||
details: {
|
||||
fingerprints: Array.from(uniqueFingerprints),
|
||||
detectedAt: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -159,6 +125,13 @@ export async function POST(
|
||||
const matchScore = calculateFingerprintMatchScore(storedFingerprint, fingerprint);
|
||||
|
||||
if (matchScore < heartbeatThreshold) {
|
||||
await createPendingAlertOnce(projectDbResult.prisma, license.id, 'fingerprint_mismatch', {
|
||||
instanceId,
|
||||
matchScore,
|
||||
threshold: heartbeatThreshold,
|
||||
licenseType: license.licenseType,
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{ acknowledged: false, error: '硬件指纹不匹配' },
|
||||
{ status: 403 }
|
||||
@ -167,6 +140,20 @@ export async function POST(
|
||||
|
||||
const fingerprintHash = hashFingerprint(fingerprint);
|
||||
|
||||
const abusePolicy = await enforceDeviceAbusePolicy(projectDbResult.prisma, {
|
||||
licenseId: license.id,
|
||||
licenseType: license.licenseType,
|
||||
instanceId,
|
||||
fingerprint,
|
||||
});
|
||||
|
||||
if (!abusePolicy.allowed) {
|
||||
return NextResponse.json(
|
||||
{ acknowledged: false, error: abusePolicy.error },
|
||||
{ status: abusePolicy.status }
|
||||
);
|
||||
}
|
||||
|
||||
await projectDbResult.prisma.heartbeatLog.create({
|
||||
data: {
|
||||
licenseId: license.id,
|
||||
|
||||
@ -0,0 +1,127 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getPublicProjectDb } from '@/lib/project-auth';
|
||||
import { verifyLicenseRequestSignature } from '@/lib/request-signature';
|
||||
import { buildSignedPremiumModuleManifest } from '@/lib/premium-module-manifest';
|
||||
import { generateLicenseConfig, type HardwareFingerprint } from '@/lib/types';
|
||||
import { calculateFingerprintMatchScore, createPendingAlertOnce } from '@/lib/abuse-detection';
|
||||
import { z } from 'zod';
|
||||
|
||||
const premiumModulesSchema = z.object({
|
||||
instanceId: z.string(),
|
||||
fingerprint: z.object({
|
||||
machineId: z.string(),
|
||||
hostname: z.string(),
|
||||
macAddress: z.string(),
|
||||
cpuId: z.string().optional(),
|
||||
instanceId: z.string(),
|
||||
}),
|
||||
timestamp: z.number(),
|
||||
});
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ projectCode: string }> }
|
||||
) {
|
||||
try {
|
||||
const { projectCode } = await params;
|
||||
const projectDbResult = await getPublicProjectDb(projectCode);
|
||||
|
||||
if (!projectDbResult.success) {
|
||||
return projectDbResult.response;
|
||||
}
|
||||
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const signatureResult = verifyLicenseRequestSignature('premium-modules', projectCode, body);
|
||||
if (!signatureResult.valid) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '请求签名无效' },
|
||||
{ status: signatureResult.response.status }
|
||||
);
|
||||
}
|
||||
|
||||
const parsed = premiumModulesSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '请求参数无效' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { instanceId, fingerprint, timestamp } = parsed.data;
|
||||
if (Math.abs(Date.now() - timestamp) > 5 * 60 * 1000) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '请求已过期' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const activation = await projectDbResult.prisma.activation.findFirst({
|
||||
where: {
|
||||
instanceId,
|
||||
isActive: true,
|
||||
},
|
||||
include: {
|
||||
license: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!activation) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '激活记录不存在' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
const { license } = activation;
|
||||
if (license.licenseType === 'FREE') {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '免费版不提供付费模块包' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
if (license.status === 'disabled') {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '许可证已被禁用' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
if (license.status === 'expired' || (license.expiresAt && license.expiresAt < new Date())) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '许可证已过期' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const storedFingerprint = activation.fingerprint as unknown as HardwareFingerprint;
|
||||
const matchScore = calculateFingerprintMatchScore(storedFingerprint, fingerprint);
|
||||
if (matchScore < 70) {
|
||||
await createPendingAlertOnce(projectDbResult.prisma, license.id, 'premium_module_fingerprint_mismatch', {
|
||||
instanceId,
|
||||
matchScore,
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '硬件指纹不匹配,无法下载付费模块包' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const config = generateLicenseConfig(license.licenseType, license.expiresAt);
|
||||
const leaseExpiresAt = new Date(config.leaseExpiresAt || Date.now());
|
||||
const signedManifest = buildSignedPremiumModuleManifest(license.licenseType, leaseExpiresAt);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
...signedManifest,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('付费模块包清单生成失败:', error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '服务器内部错误' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getPublicProjectDb } from '@/lib/project-auth';
|
||||
import { signLicense } from '@/lib/crypto';
|
||||
import { hashFingerprint, signLicense } from '@/lib/crypto';
|
||||
import { verifyLicenseRequestSignature } from '@/lib/request-signature';
|
||||
import { generateLicenseConfig, LICENSE_CONFIGS } from '@/lib/types';
|
||||
import { createPendingAlertOnce, findFreeFingerprintReuse } from '@/lib/abuse-detection';
|
||||
import { z } from 'zod';
|
||||
|
||||
const registerFreeSchema = z.object({
|
||||
@ -85,6 +86,23 @@ export async function POST(
|
||||
});
|
||||
|
||||
if (existingActivation) {
|
||||
if (existingActivation.license.status === 'disabled') {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '许可证已被禁用' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
existingActivation.license.status === 'expired' ||
|
||||
(existingActivation.license.expiresAt && existingActivation.license.expiresAt < new Date())
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '许可证已过期' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const signedLicense = signLicense(
|
||||
generateLicenseConfig(
|
||||
existingActivation.license.licenseType,
|
||||
@ -109,6 +127,31 @@ export async function POST(
|
||||
});
|
||||
}
|
||||
|
||||
const reuseResult = await findFreeFingerprintReuse(projectDbResult.prisma, fingerprint);
|
||||
if (reuseResult.reused) {
|
||||
const relatedLicense = await projectDbResult.prisma.license.findUnique({
|
||||
where: { licenseKey: reuseResult.existingLicenseKey },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (relatedLicense) {
|
||||
await createPendingAlertOnce(projectDbResult.prisma, relatedLicense.id, 'free_reactivation_blocked', {
|
||||
requestedInstanceId: fingerprint.instanceId,
|
||||
existingInstanceId: reuseResult.existingInstanceId,
|
||||
existingLicenseKey: reuseResult.existingLicenseKey,
|
||||
matchScore: reuseResult.matchScore,
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: '该设备已注册过免费版,请继续使用原免费许可证或升级付费版',
|
||||
},
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
const activationCode = generateFreeActivationCode();
|
||||
const clientIP =
|
||||
request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ||
|
||||
@ -142,7 +185,7 @@ export async function POST(
|
||||
data: {
|
||||
licenseId: license.id,
|
||||
instanceId: fingerprint.instanceId,
|
||||
fingerprintHash: fingerprint.machineId,
|
||||
fingerprintHash: hashFingerprint(fingerprint),
|
||||
ipAddress: clientIP,
|
||||
},
|
||||
});
|
||||
|
||||
@ -3,6 +3,11 @@ 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 {
|
||||
calculateFingerprintMatchScore,
|
||||
createPendingAlertOnce,
|
||||
enforceDeviceAbusePolicy,
|
||||
} from '@/lib/abuse-detection';
|
||||
import { z } from 'zod';
|
||||
|
||||
const verifySchema = z.object({
|
||||
@ -17,28 +22,6 @@ const verifySchema = z.object({
|
||||
timestamp: z.number(),
|
||||
});
|
||||
|
||||
function calculateFingerprintMatchScore(
|
||||
stored: HardwareFingerprint,
|
||||
current: HardwareFingerprint
|
||||
): number {
|
||||
let score = 0;
|
||||
let total = 0;
|
||||
|
||||
if (stored.machineId === current.machineId) score += 40;
|
||||
total += 40;
|
||||
|
||||
if (stored.hostname === current.hostname) score += 20;
|
||||
total += 20;
|
||||
|
||||
if (stored.macAddress === current.macAddress) score += 30;
|
||||
total += 30;
|
||||
|
||||
if (stored.instanceId === current.instanceId) score += 10;
|
||||
total += 10;
|
||||
|
||||
return (score / total) * 100;
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ projectCode: string }> }
|
||||
@ -122,6 +105,13 @@ export async function POST(
|
||||
if (isInstanceIdMatch && !isMachineIdMatch) {
|
||||
const matchScore = calculateFingerprintMatchScore(storedFingerprint, fingerprint);
|
||||
if (matchScore < strictThreshold) {
|
||||
await createPendingAlertOnce(projectDbResult.prisma, license.id, 'fingerprint_mismatch', {
|
||||
instanceId,
|
||||
matchScore,
|
||||
threshold: strictThreshold,
|
||||
licenseType: license.licenseType,
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{ valid: false, error: '硬件指纹不匹配,请重新激活' },
|
||||
{ status: 403 }
|
||||
@ -130,6 +120,13 @@ export async function POST(
|
||||
} else if (!isInstanceIdMatch) {
|
||||
const matchScore = calculateFingerprintMatchScore(storedFingerprint, fingerprint);
|
||||
if (matchScore < normalThreshold) {
|
||||
await createPendingAlertOnce(projectDbResult.prisma, license.id, 'fingerprint_mismatch', {
|
||||
instanceId,
|
||||
matchScore,
|
||||
threshold: normalThreshold,
|
||||
licenseType: license.licenseType,
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{ valid: false, error: '硬件指纹不匹配,请重新激活' },
|
||||
{ status: 403 }
|
||||
@ -137,6 +134,20 @@ export async function POST(
|
||||
}
|
||||
}
|
||||
|
||||
const abusePolicy = await enforceDeviceAbusePolicy(projectDbResult.prisma, {
|
||||
licenseId: license.id,
|
||||
licenseType: license.licenseType,
|
||||
instanceId,
|
||||
fingerprint,
|
||||
});
|
||||
|
||||
if (!abusePolicy.allowed) {
|
||||
return NextResponse.json(
|
||||
{ valid: false, error: abusePolicy.error },
|
||||
{ status: abusePolicy.status }
|
||||
);
|
||||
}
|
||||
|
||||
await projectDbResult.prisma.activation.update({
|
||||
where: { id: activation.id },
|
||||
data: { lastHeartbeat: new Date() },
|
||||
|
||||
@ -1,12 +1,23 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
nginx:
|
||||
image: ${NGINX_IMAGE:-nginx:1.27-alpine}
|
||||
container_name: license-nginx
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "9527:80"
|
||||
volumes:
|
||||
- ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
depends_on:
|
||||
- license-server
|
||||
networks:
|
||||
- license-network
|
||||
|
||||
license-server:
|
||||
build: .
|
||||
container_name: license-server
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "9527:3000"
|
||||
expose:
|
||||
- "3000"
|
||||
environment:
|
||||
- MAIN_DATABASE_URL=${MAIN_DATABASE_URL:?MAIN_DATABASE_URL is required}
|
||||
- MAIN_DB_ENCRYPTION_KEY=${MAIN_DB_ENCRYPTION_KEY:?MAIN_DB_ENCRYPTION_KEY is required}
|
||||
|
||||
90
license-server/lib/__tests__/abuse-detection.test.ts
Normal file
90
license-server/lib/__tests__/abuse-detection.test.ts
Normal file
@ -0,0 +1,90 @@
|
||||
jest.mock('../crypto', () => ({
|
||||
hashFingerprint: jest.fn(() => 'a'.repeat(64)),
|
||||
}));
|
||||
|
||||
import { enforceDeviceAbusePolicy } from '../abuse-detection';
|
||||
import type { HardwareFingerprint } from '../types';
|
||||
|
||||
function createFingerprint(instanceId = 'instance-1'): HardwareFingerprint {
|
||||
return {
|
||||
machineId: 'machine-1',
|
||||
hostname: 'nas-1',
|
||||
macAddress: 'aa:bb:cc:dd:ee:ff',
|
||||
instanceId,
|
||||
};
|
||||
}
|
||||
|
||||
function createPrismaMock(recentHeartbeats: Array<{ instanceId: string; fingerprintHash: string }>) {
|
||||
return {
|
||||
heartbeatLog: {
|
||||
findMany: jest.fn().mockResolvedValue(recentHeartbeats),
|
||||
},
|
||||
alert: {
|
||||
findFirst: jest.fn().mockResolvedValue(null),
|
||||
create: jest.fn().mockResolvedValue({}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('abuse-detection', () => {
|
||||
it('免费版同许可证出现多个有效指纹时应阻断', async () => {
|
||||
const prisma = createPrismaMock([
|
||||
{ instanceId: 'instance-2', fingerprintHash: 'b'.repeat(64) },
|
||||
]);
|
||||
|
||||
const result = await enforceDeviceAbusePolicy(prisma as any, {
|
||||
licenseId: 'license-1',
|
||||
licenseType: 'FREE',
|
||||
instanceId: 'instance-1',
|
||||
fingerprint: createFingerprint(),
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({ allowed: false, status: 403 });
|
||||
expect(prisma.alert.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
alertType: 'free_multi_device_blocked',
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('付费版多个有效指纹应告警但不直接阻断', async () => {
|
||||
const prisma = createPrismaMock([
|
||||
{ instanceId: 'instance-2', fingerprintHash: 'b'.repeat(64) },
|
||||
{ instanceId: 'instance-3', fingerprintHash: 'c'.repeat(64) },
|
||||
]);
|
||||
|
||||
const result = await enforceDeviceAbusePolicy(prisma as any, {
|
||||
licenseId: 'license-1',
|
||||
licenseType: 'PRO',
|
||||
instanceId: 'instance-1',
|
||||
fingerprint: createFingerprint(),
|
||||
});
|
||||
|
||||
expect(result).toEqual({ allowed: true });
|
||||
expect(prisma.alert.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
alertType: 'paid_multi_device_suspected',
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('旧版本非哈希心跳记录不应导致免费版误判多设备', async () => {
|
||||
const prisma = createPrismaMock([
|
||||
{ instanceId: 'instance-legacy', fingerprintHash: 'machine-legacy' },
|
||||
]);
|
||||
|
||||
const result = await enforceDeviceAbusePolicy(prisma as any, {
|
||||
licenseId: 'license-1',
|
||||
licenseType: 'FREE',
|
||||
instanceId: 'instance-1',
|
||||
fingerprint: createFingerprint(),
|
||||
});
|
||||
|
||||
expect(result).toEqual({ allowed: true });
|
||||
expect(prisma.alert.create).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,32 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
const routePath = path.resolve(
|
||||
__dirname,
|
||||
'..',
|
||||
'..',
|
||||
'app',
|
||||
'api',
|
||||
'license',
|
||||
'[projectCode]',
|
||||
'register-free',
|
||||
'route.ts'
|
||||
);
|
||||
|
||||
describe('免费版注册入口覆盖', () => {
|
||||
it('首条心跳记录应写入统一的硬件指纹哈希,避免后续误判多设备', () => {
|
||||
const source = fs.readFileSync(routePath, 'utf8');
|
||||
|
||||
expect(source).toContain("import { hashFingerprint, signLicense } from '@/lib/crypto'");
|
||||
expect(source).toContain('fingerprintHash: hashFingerprint(fingerprint)');
|
||||
expect(source).not.toContain('fingerprintHash: fingerprint.machineId');
|
||||
});
|
||||
|
||||
it('复用已有免费激活前应检查许可证禁用和过期状态', () => {
|
||||
const source = fs.readFileSync(routePath, 'utf8');
|
||||
|
||||
expect(source).toContain("existingActivation.license.status === 'disabled'");
|
||||
expect(source).toContain("existingActivation.license.status === 'expired'");
|
||||
expect(source).toContain('existingActivation.license.expiresAt && existingActivation.license.expiresAt < new Date()');
|
||||
});
|
||||
});
|
||||
34
license-server/lib/__tests__/premium-module-manifest.test.ts
Normal file
34
license-server/lib/__tests__/premium-module-manifest.test.ts
Normal file
@ -0,0 +1,34 @@
|
||||
jest.mock('../crypto', () => ({
|
||||
signData: jest.fn((data) => `signed:${data}`),
|
||||
}));
|
||||
|
||||
describe('premium-module-manifest', () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(new Date('2026-05-26T00:00:00.000Z'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('应为标准版生成自定义模板核心模块清单', async () => {
|
||||
const { buildSignedPremiumModuleManifest } = await import('../premium-module-manifest');
|
||||
const result = buildSignedPremiumModuleManifest('STANDARD', new Date('2026-06-25T00:00:00.000Z'));
|
||||
|
||||
expect(result.manifest.licenseType).toBe('STANDARD');
|
||||
expect(result.manifest.modules.map((module) => module.id)).toEqual(['custom-templates-core']);
|
||||
expect(result.signature).toContain('signed:');
|
||||
});
|
||||
|
||||
it('应为专业版生成全部付费模块清单', async () => {
|
||||
const { buildSignedPremiumModuleManifest } = await import('../premium-module-manifest');
|
||||
const result = buildSignedPremiumModuleManifest('PRO', new Date('2026-07-25T00:00:00.000Z'));
|
||||
|
||||
expect(result.manifest.modules.map((module) => module.id)).toEqual([
|
||||
'custom-templates-core',
|
||||
'intranet-penetration-core',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
209
license-server/lib/abuse-detection.ts
Normal file
209
license-server/lib/abuse-detection.ts
Normal file
@ -0,0 +1,209 @@
|
||||
import { hashFingerprint } from './crypto';
|
||||
import type { HardwareFingerprint, LicenseType } from './types';
|
||||
|
||||
const FREE_REACTIVATION_WINDOW_DAYS = 30;
|
||||
const FREE_REACTIVATION_WINDOW_MS = FREE_REACTIVATION_WINDOW_DAYS * 24 * 60 * 60 * 1000;
|
||||
const FREE_FINGERPRINT_REUSE_THRESHOLD = 85;
|
||||
const PAID_MULTI_DEVICE_ALERT_THRESHOLD = 2;
|
||||
const SHA256_HEX_PATTERN = /^[a-f0-9]{64}$/i;
|
||||
|
||||
type ProjectDb = Awaited<ReturnType<typeof import('./db').getProjectDb>>;
|
||||
|
||||
export function calculateFingerprintMatchScore(
|
||||
stored: HardwareFingerprint,
|
||||
current: HardwareFingerprint
|
||||
): number {
|
||||
let score = 0;
|
||||
let total = 0;
|
||||
|
||||
if (stored.machineId && stored.machineId === current.machineId) score += 40;
|
||||
total += 40;
|
||||
|
||||
if (stored.hostname && stored.hostname === current.hostname) score += 20;
|
||||
total += 20;
|
||||
|
||||
if (stored.macAddress && stored.macAddress === current.macAddress) score += 30;
|
||||
total += 30;
|
||||
|
||||
if (stored.instanceId && stored.instanceId === current.instanceId) score += 10;
|
||||
total += 10;
|
||||
|
||||
return (score / total) * 100;
|
||||
}
|
||||
|
||||
function toHardwareFingerprint(value: unknown): HardwareFingerprint | null {
|
||||
if (!value || typeof value !== 'object') return null;
|
||||
|
||||
const fingerprint = value as Partial<HardwareFingerprint>;
|
||||
if (
|
||||
typeof fingerprint.machineId !== 'string' ||
|
||||
typeof fingerprint.hostname !== 'string' ||
|
||||
typeof fingerprint.macAddress !== 'string' ||
|
||||
typeof fingerprint.instanceId !== 'string'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
machineId: fingerprint.machineId,
|
||||
hostname: fingerprint.hostname,
|
||||
macAddress: fingerprint.macAddress,
|
||||
cpuId: typeof fingerprint.cpuId === 'string' ? fingerprint.cpuId : undefined,
|
||||
instanceId: fingerprint.instanceId,
|
||||
};
|
||||
}
|
||||
|
||||
function isStoredFingerprintHash(value: string): boolean {
|
||||
return SHA256_HEX_PATTERN.test(value);
|
||||
}
|
||||
|
||||
export async function createPendingAlertOnce(
|
||||
prisma: ProjectDb,
|
||||
licenseId: string,
|
||||
alertType: string,
|
||||
details: Record<string, unknown>
|
||||
): Promise<void> {
|
||||
const existingAlert = await prisma.alert.findFirst({
|
||||
where: {
|
||||
licenseId,
|
||||
alertType,
|
||||
status: 'pending',
|
||||
},
|
||||
});
|
||||
|
||||
if (existingAlert) {
|
||||
return;
|
||||
}
|
||||
|
||||
await prisma.alert.create({
|
||||
data: {
|
||||
licenseId,
|
||||
alertType,
|
||||
details: {
|
||||
...details,
|
||||
detectedAt: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function findFreeFingerprintReuse(
|
||||
prisma: ProjectDb,
|
||||
fingerprint: HardwareFingerprint
|
||||
): Promise<{
|
||||
reused: boolean;
|
||||
existingLicenseKey?: string;
|
||||
existingInstanceId?: string;
|
||||
matchScore?: number;
|
||||
}> {
|
||||
const since = new Date(Date.now() - FREE_REACTIVATION_WINDOW_MS);
|
||||
const recentFreeActivations = await prisma.activation.findMany({
|
||||
where: {
|
||||
isActive: true,
|
||||
activatedAt: { gte: since },
|
||||
license: {
|
||||
licenseType: 'FREE',
|
||||
status: 'active',
|
||||
},
|
||||
},
|
||||
select: {
|
||||
instanceId: true,
|
||||
fingerprint: true,
|
||||
license: {
|
||||
select: {
|
||||
licenseKey: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
take: 200,
|
||||
orderBy: { activatedAt: 'desc' },
|
||||
});
|
||||
|
||||
for (const activation of recentFreeActivations) {
|
||||
if (activation.instanceId === fingerprint.instanceId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const storedFingerprint = toHardwareFingerprint(activation.fingerprint);
|
||||
if (!storedFingerprint) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const matchScore = calculateFingerprintMatchScore(storedFingerprint, fingerprint);
|
||||
const sameMachine = storedFingerprint.machineId === fingerprint.machineId;
|
||||
const sameMacAddress =
|
||||
storedFingerprint.macAddress !== 'unknown' &&
|
||||
fingerprint.macAddress !== 'unknown' &&
|
||||
storedFingerprint.macAddress === fingerprint.macAddress;
|
||||
|
||||
if (sameMachine || sameMacAddress || matchScore >= FREE_FINGERPRINT_REUSE_THRESHOLD) {
|
||||
return {
|
||||
reused: true,
|
||||
existingLicenseKey: activation.license.licenseKey,
|
||||
existingInstanceId: activation.instanceId,
|
||||
matchScore,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { reused: false };
|
||||
}
|
||||
|
||||
export async function enforceDeviceAbusePolicy(
|
||||
prisma: ProjectDb,
|
||||
params: {
|
||||
licenseId: string;
|
||||
licenseType: LicenseType;
|
||||
instanceId: string;
|
||||
fingerprint: HardwareFingerprint;
|
||||
}
|
||||
): Promise<{ allowed: true } | { allowed: false; status: number; error: string }> {
|
||||
const fingerprintHash = hashFingerprint(params.fingerprint);
|
||||
|
||||
const recentHeartbeats = await prisma.heartbeatLog.findMany({
|
||||
where: {
|
||||
licenseId: params.licenseId,
|
||||
createdAt: { gte: new Date(Date.now() - 24 * 60 * 60 * 1000) },
|
||||
},
|
||||
select: {
|
||||
instanceId: true,
|
||||
fingerprintHash: true,
|
||||
},
|
||||
distinct: ['fingerprintHash'],
|
||||
});
|
||||
|
||||
const normalizedHeartbeats = recentHeartbeats.filter((item) =>
|
||||
isStoredFingerprintHash(item.fingerprintHash)
|
||||
);
|
||||
|
||||
// 兼容旧版本免费注册首条心跳曾写入 machineId 的数据,避免升级后误判多设备。
|
||||
const distinctFingerprints = new Set(normalizedHeartbeats.map((item) => item.fingerprintHash));
|
||||
distinctFingerprints.add(fingerprintHash);
|
||||
|
||||
const distinctInstances = new Set(normalizedHeartbeats.map((item) => item.instanceId));
|
||||
distinctInstances.add(params.instanceId);
|
||||
|
||||
if (params.licenseType === 'FREE' && (distinctFingerprints.size > 1 || distinctInstances.size > 1)) {
|
||||
await createPendingAlertOnce(prisma, params.licenseId, 'free_multi_device_blocked', {
|
||||
instanceIds: Array.from(distinctInstances),
|
||||
fingerprintCount: distinctFingerprints.size,
|
||||
fingerprintHash,
|
||||
});
|
||||
|
||||
return {
|
||||
allowed: false,
|
||||
status: 403,
|
||||
error: '免费版许可证检测到多设备复用,请重新激活或升级付费版',
|
||||
};
|
||||
}
|
||||
|
||||
if (params.licenseType !== 'FREE' && distinctFingerprints.size > PAID_MULTI_DEVICE_ALERT_THRESHOLD) {
|
||||
await createPendingAlertOnce(prisma, params.licenseId, 'paid_multi_device_suspected', {
|
||||
instanceIds: Array.from(distinctInstances),
|
||||
fingerprintCount: distinctFingerprints.size,
|
||||
fingerprintHash,
|
||||
});
|
||||
}
|
||||
|
||||
return { allowed: true };
|
||||
}
|
||||
66
license-server/lib/premium-module-manifest.ts
Normal file
66
license-server/lib/premium-module-manifest.ts
Normal file
@ -0,0 +1,66 @@
|
||||
import { signData } from './crypto';
|
||||
import type { LicenseType } from './types';
|
||||
|
||||
export const PREMIUM_MODULE_MANIFEST_VERSION = 1;
|
||||
|
||||
export interface PremiumModuleManifest {
|
||||
version: number;
|
||||
licenseType: Exclude<LicenseType, 'FREE'>;
|
||||
modules: Array<{
|
||||
id: string;
|
||||
minLicenseType: Exclude<LicenseType, 'FREE'>;
|
||||
sizeBytes: number;
|
||||
sha256: string;
|
||||
}>;
|
||||
issuedAt: string;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
export interface SignedPremiumModuleManifest {
|
||||
manifest: PremiumModuleManifest;
|
||||
signature: string;
|
||||
}
|
||||
|
||||
const MODULES_BY_LICENSE: Record<Exclude<LicenseType, 'FREE'>, PremiumModuleManifest['modules']> = {
|
||||
STANDARD: [
|
||||
{
|
||||
id: 'custom-templates-core',
|
||||
minLicenseType: 'STANDARD',
|
||||
sizeBytes: 4096,
|
||||
sha256: 'pending-real-module-split-standard',
|
||||
},
|
||||
],
|
||||
PRO: [
|
||||
{
|
||||
id: 'custom-templates-core',
|
||||
minLicenseType: 'STANDARD',
|
||||
sizeBytes: 4096,
|
||||
sha256: 'pending-real-module-split-standard',
|
||||
},
|
||||
{
|
||||
id: 'intranet-penetration-core',
|
||||
minLicenseType: 'PRO',
|
||||
sizeBytes: 4096,
|
||||
sha256: 'pending-real-module-split-pro',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export function buildSignedPremiumModuleManifest(
|
||||
licenseType: Exclude<LicenseType, 'FREE'>,
|
||||
leaseExpiresAt: Date
|
||||
): SignedPremiumModuleManifest {
|
||||
const manifest: PremiumModuleManifest = {
|
||||
version: PREMIUM_MODULE_MANIFEST_VERSION,
|
||||
licenseType,
|
||||
modules: MODULES_BY_LICENSE[licenseType],
|
||||
issuedAt: new Date().toISOString(),
|
||||
expiresAt: leaseExpiresAt.toISOString(),
|
||||
};
|
||||
|
||||
return {
|
||||
manifest,
|
||||
signature: signData(JSON.stringify(manifest)),
|
||||
};
|
||||
}
|
||||
|
||||
@ -5,7 +5,10 @@ limit_conn_zone $binary_remote_addr zone=conn_limit:10m;
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name lic.flnxi.com;
|
||||
server_name _;
|
||||
|
||||
limit_req_status 429;
|
||||
limit_conn_status 429;
|
||||
|
||||
# 基础安全头
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
@ -23,7 +26,7 @@ server {
|
||||
# 登录接口 - 严格限流(防暴力破解)
|
||||
location /api/admin/auth/login {
|
||||
limit_req zone=login_limit burst=3 nodelay;
|
||||
proxy_pass http://127.0.0.1:9527;
|
||||
proxy_pass http://license-server:3000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
@ -33,7 +36,7 @@ server {
|
||||
# API 接口 - 普通限流
|
||||
location /api/ {
|
||||
limit_req zone=api_limit burst=20 nodelay;
|
||||
proxy_pass http://127.0.0.1:9527;
|
||||
proxy_pass http://license-server:3000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
@ -42,7 +45,7 @@ server {
|
||||
|
||||
# 静态资源和页面
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:9527;
|
||||
proxy_pass http://license-server:3000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user