完善付费模块包下载校验闭环
This commit is contained in:
parent
2f6f051daf
commit
111bb29a1a
@ -405,9 +405,9 @@
|
||||
- `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,并在硬件指纹明显不匹配时拒绝并告警。
|
||||
- `app/api/license/activate/route.ts`、`app/api/license/verify/route.ts`、`app/api/license/heartbeat/route.ts`、`lib/license/premium-module-service.ts`:主应用在付费激活、验证和心跳续证成功后同步付费模块 manifest,本地加密缓存并校验 RSA 签名、租约和当前许可证类型,避免降级后继续使用旧 PRO 模块清单。
|
||||
- `lib/license/check-limits.ts`:自定义模板和内网穿透已升级为“许可证功能开关 + 签名付费模块清单”双门槛;没有验证服务器下发的模块凭证时,即使本地许可证字段被篡改为开启,也不会通过服务端功能检查。
|
||||
- `license-server/app/api/license/[projectCode]/premium-modules/route.ts`、`license-server/app/api/license/[projectCode]/premium-modules/[moduleId]/route.ts`、`license-server/lib/premium-module-manifest.ts`:新增付费模块清单和单模块下载接口,免费版拒绝,付费版按许可证类型返回签名 manifest,并下发绑定当前实例的加密模块包;硬件指纹明显不匹配时拒绝并告警。
|
||||
- `app/api/license/activate/route.ts`、`app/api/license/verify/route.ts`、`app/api/license/heartbeat/route.ts`、`lib/license/premium-module-service.ts`:主应用在付费激活、验证和心跳续证成功后同步付费模块 manifest,再逐个下载模块包,本地校验 RSA 签名、租约、当前许可证类型、包大小和 SHA256 后加密缓存,避免降级后继续使用旧 PRO 模块包。
|
||||
- `lib/license/check-limits.ts`:自定义模板和内网穿透已升级为“许可证功能开关 + 签名付费模块清单 + 本地有效模块包”三重门槛;没有验证服务器下发并校验通过的模块包时,即使本地许可证字段被篡改为开启,也不会通过服务端功能检查。
|
||||
- `lib/__tests__/license-route-coverage.test.ts`:新增关键业务入口覆盖测试,防止用户数、素材数、自定义模板、部门隔离、系统设置、内网穿透等服务端许可证检查被后续改动漏掉;同时锁定高价值付费能力必须依赖 `hasPremiumModule()`。
|
||||
- `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。
|
||||
@ -436,8 +436,8 @@
|
||||
- 已完成:本地许可证读取已加入轻量时间回拨检测;免费版和租约能力不能再靠把系统时间往回调来继续走本地宽限期。
|
||||
- 已完成:验证服务器默认入口接入 Nginx 限流,免费版重复注册/多设备复用会阻断,付费版异常多设备以告警为主。
|
||||
- 已完成:主应用关键授权入口已有防漏口测试,后续改注册、上传、模板、部门、系统设置、内网穿透接口时会更早暴露许可证检查缺失。
|
||||
- 已完成:付费模块 manifest 基础链路已落地,适合低配置验证服务器和约 5M 带宽;自定义模板和内网穿透已经接入签名模块凭证门槛,当前只传输小清单,不在验证服务器承担重计算或大文件实时分发。
|
||||
- 后续增强:真正把付费功能核心文件从镜像中拆出、加密分发、下载缓存和模块加载仍需单独推进;当前本轮完成的是签名清单、权限校验、缓存校验和后续拆包的接口地基。
|
||||
- 已完成:付费模块 manifest 和实例绑定加密模块包链路已落地,适合低配置验证服务器和约 5M 带宽;自定义模板和内网穿透已经接入本地有效模块包门槛,验证服务器不承担重计算,只做轻量签名、加密包生成和下载。
|
||||
- 后续增强:如果要做到更强的“核心业务实现完全不在镜像里”,还需要继续把自定义模板/内网穿透内部服务端实现逐步移动到模块加载层;当前本轮已经完成的是清单、下载、hash 校验、加密缓存和服务端入口强制依赖模块包的闭环。
|
||||
|
||||
### P1-05 免费版激活保存空签名,后续读取疑似失败(已修复)
|
||||
|
||||
|
||||
@ -53,6 +53,7 @@ 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}/premium-modules/{moduleId}
|
||||
POST /api/license/{projectCode}/generate
|
||||
|
||||
GET /api/version/{projectCode}/check
|
||||
@ -90,7 +91,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`、`verify`、`heartbeat` 在付费许可证保存或续证成功后会尝试同步 `premium-modules` 付费模块清单;免费版会跳过,不访问远端模块清单接口。
|
||||
- `activate`、`verify`、`heartbeat` 在付费许可证保存或续证成功后会尝试同步 `premium-modules` 付费模块清单和对应模块包;免费版会跳过,不访问远端模块接口。
|
||||
- `check` 用于在本地许可证缓存存在时恢复激活状态,会先读取并验签本地许可证。
|
||||
- `info` 用于读取当前许可证类型、用量和功能开关。
|
||||
|
||||
@ -106,9 +107,10 @@ ${LICENSE_SERVER_URL}/api/license/${LICENSE_PROJECT_CODE}/...
|
||||
|
||||
- 远端 `POST /api/license/{projectCode}/generate` 是付费激活码生成接口,当前要求 `X-API-Key`、`timestamp` 和 `sign`,不应被普通前端直接调用。
|
||||
- 远端 `POST /api/license/{projectCode}/premium-modules` 是付费模块清单接口,要求同一套 HMAC 请求签名;免费版、禁用、过期或硬件指纹明显不匹配会被拒绝。
|
||||
- `premium-modules` 当前返回的是签名 manifest(模块清单),用于后续真正拆分付费核心文件时复用权限、签名、缓存和过期链路;当前不直接承载大文件下载,适合低配置、低带宽验证服务器。
|
||||
- 主应用的自定义模板和内网穿透服务端入口已经依赖本地已验签的 `premium-modules` 清单;许可证功能开关开启但没有对应模块凭证时,服务端仍会拒绝。
|
||||
- `activate / verify / heartbeat / register-free / premium-modules` 当前会校验请求 HMAC 签名;返回的许可证和付费模块清单还会分别做服务端签名,主应用本地必须验签后再缓存。
|
||||
- 远端 `POST /api/license/{projectCode}/premium-modules/{moduleId}` 是单个付费模块包下载接口,要求 `premium-module-download` HMAC 请求签名,并复查许可证状态、硬件指纹和模块权限。
|
||||
- `premium-modules` 返回签名 manifest(模块清单),每个模块带真实 `sizeBytes` 和 `sha256`;主应用随后下载实例绑定的加密模块包,校验大小和 SHA256 后再本地加密缓存。
|
||||
- 主应用的自定义模板和内网穿透服务端入口已经依赖本地已验签 manifest 和对应模块包;许可证功能开关开启但没有有效模块包时,服务端仍会拒绝。
|
||||
- `activate / verify / heartbeat / register-free / premium-modules / premium-modules/{moduleId}` 当前会校验请求 HMAC 签名;返回的许可证、付费模块清单和模块包必须通过签名、hash、大小、租约和许可证类型检查后才能缓存或放行。
|
||||
|
||||
## 5. 后台管理入口
|
||||
|
||||
|
||||
@ -24,6 +24,7 @@ jest.mock('../license/license-service', () => ({
|
||||
jest.mock('../license/crypto-service', () => ({
|
||||
encryptLocal: jest.fn((data) => `encrypted:${data}`),
|
||||
decryptLocal: jest.fn((data) => data.replace('encrypted:', '')),
|
||||
hashData: jest.fn((data) => (data === 'module-package' ? 'hash' : 'unknown-hash')),
|
||||
verifySignature: jest.fn(),
|
||||
}));
|
||||
|
||||
@ -38,6 +39,8 @@ jest.mock('../license/request-signature', () => ({
|
||||
|
||||
jest.mock('../license/server-config', () => ({
|
||||
buildLicenseServerLicenseApiUrl: jest.fn((action) => `https://license.test/api/license/flnxi/${action}`),
|
||||
getLicenseServerUrl: jest.fn(() => 'https://license.test'),
|
||||
getLicenseProjectCode: jest.fn(() => 'flnxi'),
|
||||
}));
|
||||
|
||||
import { prisma } from '@/lib/prisma';
|
||||
@ -108,30 +111,50 @@ describe('premium-module-service', () => {
|
||||
|
||||
it('付费版应拉取、验签并加密缓存付费模块清单', async () => {
|
||||
mockPaidLicense('PRO');
|
||||
mockFetch.mockResolvedValue({
|
||||
mockFetch
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
success: true,
|
||||
manifest: {
|
||||
version: 1,
|
||||
licenseType: 'PRO',
|
||||
modules: [{ id: 'intranet-penetration-core', minLicenseType: 'PRO', sizeBytes: 4096, sha256: 'hash' }],
|
||||
modules: [{ id: 'intranet-penetration-core', minLicenseType: 'PRO', sizeBytes: 14, sha256: 'hash' }],
|
||||
issuedAt: new Date().toISOString(),
|
||||
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
||||
},
|
||||
signature: 'valid-signature',
|
||||
}),
|
||||
});
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
success: true,
|
||||
package: {
|
||||
moduleId: 'intranet-penetration-core',
|
||||
contentBase64: 'module-package',
|
||||
sha256: 'hash',
|
||||
sizeBytes: 14,
|
||||
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const { syncPremiumModules } = await import('../license/premium-module-service');
|
||||
const result = await syncPremiumModules();
|
||||
|
||||
expect(result).toEqual({ synced: true });
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
expect(mockFetch).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'https://license.test/api/license/flnxi/premium-modules',
|
||||
expect.objectContaining({ method: 'POST' })
|
||||
);
|
||||
expect(prisma.systemConfig.upsert).toHaveBeenCalled();
|
||||
expect(mockFetch).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'https://license.test/api/license/flnxi/premium-modules/intranet-penetration-core',
|
||||
expect.objectContaining({ method: 'POST' })
|
||||
);
|
||||
expect(prisma.systemConfig.upsert).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('付费模块清单同步网络异常时应返回失败结果而不是抛出', async () => {
|
||||
@ -167,6 +190,76 @@ describe('premium-module-service', () => {
|
||||
await expect(hasPremiumModule('intranet-penetration-core')).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('只有清单但缺少本地模块包时不应视为可用模块', async () => {
|
||||
mockPaidLicense('PRO');
|
||||
(prisma.systemConfig.findUnique as jest.Mock).mockImplementation(({ where }) => {
|
||||
if (where.key === 'premium_module_manifest') {
|
||||
return Promise.resolve({
|
||||
value: `encrypted:${JSON.stringify({
|
||||
manifest: {
|
||||
version: 1,
|
||||
licenseType: 'PRO',
|
||||
modules: [{ id: 'intranet-penetration-core', minLicenseType: 'PRO', sizeBytes: 14, sha256: 'hash' }],
|
||||
issuedAt: new Date().toISOString(),
|
||||
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
||||
},
|
||||
signature: 'valid-signature',
|
||||
syncedAt: new Date().toISOString(),
|
||||
})}`,
|
||||
});
|
||||
}
|
||||
|
||||
return Promise.resolve(null);
|
||||
});
|
||||
|
||||
const { hasPremiumModule } = await import('../license/premium-module-service');
|
||||
|
||||
await expect(hasPremiumModule('intranet-penetration-core')).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('清单和本地模块包都有效时应视为可用模块', async () => {
|
||||
mockPaidLicense('PRO');
|
||||
(prisma.systemConfig.findUnique as jest.Mock).mockImplementation(({ where }) => {
|
||||
if (where.key === 'premium_module_manifest') {
|
||||
return Promise.resolve({
|
||||
value: `encrypted:${JSON.stringify({
|
||||
manifest: {
|
||||
version: 1,
|
||||
licenseType: 'PRO',
|
||||
modules: [{ id: 'intranet-penetration-core', minLicenseType: 'PRO', sizeBytes: 14, sha256: 'hash' }],
|
||||
issuedAt: new Date().toISOString(),
|
||||
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
||||
},
|
||||
signature: 'valid-signature',
|
||||
syncedAt: new Date().toISOString(),
|
||||
})}`,
|
||||
});
|
||||
}
|
||||
|
||||
if (where.key === 'premium_module_packages') {
|
||||
return Promise.resolve({
|
||||
value: `encrypted:${JSON.stringify({
|
||||
packages: {
|
||||
'intranet-penetration-core': {
|
||||
contentBase64: 'module-package',
|
||||
sha256: 'hash',
|
||||
sizeBytes: 14,
|
||||
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
||||
syncedAt: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
})}`,
|
||||
});
|
||||
}
|
||||
|
||||
return Promise.resolve(null);
|
||||
});
|
||||
|
||||
const { hasPremiumModule } = await import('../license/premium-module-service');
|
||||
|
||||
await expect(hasPremiumModule('intranet-penetration-core')).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('缓存清单许可证类型和当前许可证不一致时不应视为可用模块', async () => {
|
||||
mockPaidLicense('STANDARD');
|
||||
(prisma.systemConfig.findUnique as jest.Mock).mockResolvedValue({
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { decryptLocal, encryptLocal, verifySignature } from './crypto-service';
|
||||
import { decryptLocal, encryptLocal, hashData, verifySignature } from './crypto-service';
|
||||
import { generateFingerprint } from './fingerprint-service';
|
||||
import { buildSignedLicenseRequest } from './request-signature';
|
||||
import { buildLicenseServerLicenseApiUrl } from './server-config';
|
||||
import { buildLicenseServerLicenseApiUrl, getLicenseProjectCode, getLicenseServerUrl } from './server-config';
|
||||
import { getLocalLicense, isLicenseConfigExpired } from './license-service';
|
||||
import type { LicenseType } from './types';
|
||||
|
||||
@ -14,6 +14,7 @@ export interface PremiumModuleManifest {
|
||||
minLicenseType: Exclude<LicenseType, 'FREE'>;
|
||||
sizeBytes: number;
|
||||
sha256: string;
|
||||
packageVersion?: number;
|
||||
}>;
|
||||
issuedAt: string;
|
||||
expiresAt: string;
|
||||
@ -32,7 +33,33 @@ interface CachedPremiumModuleManifest {
|
||||
syncedAt: string;
|
||||
}
|
||||
|
||||
interface PremiumModulePackageResponse {
|
||||
success: boolean;
|
||||
package?: {
|
||||
moduleId: string;
|
||||
contentBase64: string;
|
||||
sha256: string;
|
||||
sizeBytes: number;
|
||||
expiresAt: string;
|
||||
};
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface CachedPremiumModulePackages {
|
||||
packages: Record<
|
||||
string,
|
||||
{
|
||||
contentBase64: string;
|
||||
sha256: string;
|
||||
sizeBytes: number;
|
||||
expiresAt: string;
|
||||
syncedAt: string;
|
||||
}
|
||||
>;
|
||||
}
|
||||
|
||||
const PREMIUM_MODULE_CONFIG_KEY = 'premium_module_manifest';
|
||||
const PREMIUM_MODULE_PACKAGES_CONFIG_KEY = 'premium_module_packages';
|
||||
|
||||
export const PREMIUM_MODULE_IDS = {
|
||||
CUSTOM_TEMPLATES_CORE: 'custom-templates-core',
|
||||
@ -69,6 +96,32 @@ async function savePremiumModuleManifest(
|
||||
});
|
||||
}
|
||||
|
||||
async function getCachedPremiumModulePackages(): Promise<CachedPremiumModulePackages> {
|
||||
try {
|
||||
const config = await prisma.systemConfig.findUnique({
|
||||
where: { key: PREMIUM_MODULE_PACKAGES_CONFIG_KEY },
|
||||
});
|
||||
|
||||
if (!config) return { packages: {} };
|
||||
|
||||
const cached = JSON.parse(decryptLocal(config.value)) as CachedPremiumModulePackages;
|
||||
if (!cached.packages || typeof cached.packages !== 'object') return { packages: {} };
|
||||
|
||||
return cached;
|
||||
} catch (error) {
|
||||
console.warn('[License] 读取付费模块包缓存失败:', error);
|
||||
return { packages: {} };
|
||||
}
|
||||
}
|
||||
|
||||
async function savePremiumModulePackages(cached: CachedPremiumModulePackages): Promise<void> {
|
||||
await prisma.systemConfig.upsert({
|
||||
where: { key: PREMIUM_MODULE_PACKAGES_CONFIG_KEY },
|
||||
update: { value: encryptLocal(JSON.stringify(cached)) },
|
||||
create: { key: PREMIUM_MODULE_PACKAGES_CONFIG_KEY, value: encryptLocal(JSON.stringify(cached)) },
|
||||
});
|
||||
}
|
||||
|
||||
export async function getCachedPremiumModuleManifest(): Promise<CachedPremiumModuleManifest | null> {
|
||||
try {
|
||||
const config = await prisma.systemConfig.findUnique({
|
||||
@ -95,6 +148,62 @@ export async function getCachedPremiumModuleManifest(): Promise<CachedPremiumMod
|
||||
}
|
||||
}
|
||||
|
||||
function buildPremiumModuleDownloadUrl(moduleId: string): string {
|
||||
const baseUrl = getLicenseServerUrl();
|
||||
const projectCode = encodeURIComponent(getLicenseProjectCode());
|
||||
return `${baseUrl}/api/license/${projectCode}/premium-modules/${encodeURIComponent(moduleId)}`;
|
||||
}
|
||||
|
||||
function isCachedPackageValid(
|
||||
cached: CachedPremiumModulePackages,
|
||||
module: PremiumModuleManifest['modules'][number]
|
||||
): boolean {
|
||||
const modulePackage = cached.packages[module.id];
|
||||
if (!modulePackage) return false;
|
||||
if (modulePackage.sha256 !== module.sha256) return false;
|
||||
if (modulePackage.sizeBytes !== module.sizeBytes) return false;
|
||||
if (hashData(modulePackage.contentBase64) !== module.sha256) return false;
|
||||
|
||||
const packageExpiresAt = new Date(modulePackage.expiresAt).getTime();
|
||||
return !Number.isNaN(packageExpiresAt) && packageExpiresAt > Date.now();
|
||||
}
|
||||
|
||||
async function downloadPremiumModulePackage(
|
||||
module: PremiumModuleManifest['modules'][number],
|
||||
fingerprint: Awaited<ReturnType<typeof generateFingerprint>>
|
||||
): Promise<PremiumModulePackageResponse['package']> {
|
||||
const signedRequest = buildSignedLicenseRequest('premium-module-download', {
|
||||
moduleId: module.id,
|
||||
instanceId: fingerprint.instanceId,
|
||||
fingerprint,
|
||||
});
|
||||
|
||||
const response = await fetch(buildPremiumModuleDownloadUrl(module.id), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(signedRequest),
|
||||
});
|
||||
const data = (await response.json()) as PremiumModulePackageResponse;
|
||||
|
||||
if (!response.ok || !data.success || !data.package) {
|
||||
throw new Error(data.error || `付费模块 ${module.id} 下载失败`);
|
||||
}
|
||||
|
||||
if (data.package.moduleId !== module.id) {
|
||||
throw new Error(`付费模块 ${module.id} 返回内容不匹配`);
|
||||
}
|
||||
|
||||
if (data.package.sha256 !== module.sha256 || hashData(data.package.contentBase64) !== module.sha256) {
|
||||
throw new Error(`付费模块 ${module.id} 哈希校验失败`);
|
||||
}
|
||||
|
||||
if (data.package.sizeBytes !== module.sizeBytes) {
|
||||
throw new Error(`付费模块 ${module.id} 大小校验失败`);
|
||||
}
|
||||
|
||||
return data.package;
|
||||
}
|
||||
|
||||
export async function syncPremiumModules(): Promise<{ synced: boolean; skipped?: boolean; error?: string }> {
|
||||
try {
|
||||
const localLicense = await getLocalLicense();
|
||||
@ -134,7 +243,28 @@ export async function syncPremiumModules(): Promise<{ synced: boolean; skipped?:
|
||||
return { synced: false, error: '付费模块清单签名验证失败' };
|
||||
}
|
||||
|
||||
const cachedPackages = await getCachedPremiumModulePackages();
|
||||
for (const module of data.manifest.modules) {
|
||||
if (isCachedPackageValid(cachedPackages, module)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const modulePackage = await downloadPremiumModulePackage(module, fingerprint);
|
||||
if (!modulePackage) {
|
||||
throw new Error(`付费模块 ${module.id} 下载结果为空`);
|
||||
}
|
||||
|
||||
cachedPackages.packages[module.id] = {
|
||||
contentBase64: modulePackage.contentBase64,
|
||||
sha256: modulePackage.sha256,
|
||||
sizeBytes: modulePackage.sizeBytes,
|
||||
expiresAt: modulePackage.expiresAt,
|
||||
syncedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
await savePremiumModuleManifest(data.manifest, data.signature);
|
||||
await savePremiumModulePackages(cachedPackages);
|
||||
return { synced: true };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '付费模块清单同步异常';
|
||||
@ -146,5 +276,9 @@ 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);
|
||||
const module = cached.manifest.modules.find((item) => item.id === moduleId);
|
||||
if (!module) return false;
|
||||
|
||||
const cachedPackages = await getCachedPremiumModulePackages();
|
||||
return isCachedPackageValid(cachedPackages, module);
|
||||
}
|
||||
|
||||
@ -0,0 +1,135 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getPublicProjectDb } from '@/lib/project-auth';
|
||||
import { verifyLicenseRequestSignature } from '@/lib/request-signature';
|
||||
import { buildPremiumModulePackage } from '@/lib/premium-module-manifest';
|
||||
import { generateLicenseConfig, type HardwareFingerprint } from '@/lib/types';
|
||||
import { calculateFingerprintMatchScore, createPendingAlertOnce } from '@/lib/abuse-detection';
|
||||
import { z } from 'zod';
|
||||
|
||||
const premiumModuleDownloadSchema = 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; moduleId: string }> }
|
||||
) {
|
||||
try {
|
||||
const { projectCode, moduleId } = await params;
|
||||
const projectDbResult = await getPublicProjectDb(projectCode);
|
||||
|
||||
if (!projectDbResult.success) {
|
||||
return projectDbResult.response;
|
||||
}
|
||||
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const signatureResult = verifyLicenseRequestSignature('premium-module-download', projectCode, {
|
||||
...body,
|
||||
moduleId,
|
||||
});
|
||||
if (!signatureResult.valid) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '请求签名无效' },
|
||||
{ status: signatureResult.response.status }
|
||||
);
|
||||
}
|
||||
|
||||
const parsed = premiumModuleDownloadSchema.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_download_fingerprint_mismatch', {
|
||||
instanceId,
|
||||
moduleId,
|
||||
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 modulePackage = buildPremiumModulePackage({
|
||||
moduleId,
|
||||
licenseType: license.licenseType,
|
||||
instanceId,
|
||||
leaseExpiresAt,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
package: modulePackage,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('付费模块包下载失败:', error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '服务器内部错误' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -110,7 +110,11 @@ export async function POST(
|
||||
|
||||
const config = generateLicenseConfig(license.licenseType, license.expiresAt);
|
||||
const leaseExpiresAt = new Date(config.leaseExpiresAt || Date.now());
|
||||
const signedManifest = buildSignedPremiumModuleManifest(license.licenseType, leaseExpiresAt);
|
||||
const signedManifest = buildSignedPremiumModuleManifest(
|
||||
license.licenseType,
|
||||
leaseExpiresAt,
|
||||
instanceId
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
@ -124,4 +128,3 @@ export async function POST(
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -6,6 +6,7 @@ describe('premium-module-manifest', () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(new Date('2026-05-26T00:00:00.000Z'));
|
||||
process.env.PREMIUM_MODULE_PACKAGE_SECRET = 'p'.repeat(32);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@ -14,21 +15,39 @@ describe('premium-module-manifest', () => {
|
||||
|
||||
it('应为标准版生成自定义模板核心模块清单', async () => {
|
||||
const { buildSignedPremiumModuleManifest } = await import('../premium-module-manifest');
|
||||
const result = buildSignedPremiumModuleManifest('STANDARD', new Date('2026-06-25T00:00:00.000Z'));
|
||||
const result = buildSignedPremiumModuleManifest('STANDARD', new Date('2026-06-25T00:00:00.000Z'), 'instance-1');
|
||||
|
||||
expect(result.manifest.licenseType).toBe('STANDARD');
|
||||
expect(result.manifest.modules.map((module) => module.id)).toEqual(['custom-templates-core']);
|
||||
expect(result.manifest.modules[0].sizeBytes).toBeGreaterThan(0);
|
||||
expect(result.manifest.modules[0].sha256).toMatch(/^[a-f0-9]{64}$/);
|
||||
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'));
|
||||
const result = buildSignedPremiumModuleManifest('PRO', new Date('2026-07-25T00:00:00.000Z'), 'instance-1');
|
||||
|
||||
expect(result.manifest.modules.map((module) => module.id)).toEqual([
|
||||
'custom-templates-core',
|
||||
'intranet-penetration-core',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it('应生成和清单 hash 匹配的实例绑定模块包', async () => {
|
||||
const { buildPremiumModulePackage, buildSignedPremiumModuleManifest } = await import('../premium-module-manifest');
|
||||
const leaseExpiresAt = new Date('2026-07-25T00:00:00.000Z');
|
||||
const manifest = buildSignedPremiumModuleManifest('PRO', leaseExpiresAt, 'instance-1').manifest;
|
||||
const modulePackage = buildPremiumModulePackage({
|
||||
moduleId: 'intranet-penetration-core',
|
||||
licenseType: 'PRO',
|
||||
instanceId: 'instance-1',
|
||||
leaseExpiresAt,
|
||||
});
|
||||
|
||||
const manifestModule = manifest.modules.find((module) => module.id === 'intranet-penetration-core');
|
||||
expect(modulePackage.sha256).toBe(manifestModule?.sha256);
|
||||
expect(modulePackage.sizeBytes).toBe(manifestModule?.sizeBytes);
|
||||
expect(modulePackage.contentBase64).not.toContain('intranetPenetration');
|
||||
});
|
||||
});
|
||||
|
||||
@ -0,0 +1,26 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
const downloadRoutePath = path.resolve(
|
||||
__dirname,
|
||||
'..',
|
||||
'..',
|
||||
'app',
|
||||
'api',
|
||||
'license',
|
||||
'[projectCode]',
|
||||
'premium-modules',
|
||||
'[moduleId]',
|
||||
'route.ts'
|
||||
);
|
||||
|
||||
describe('付费模块下载入口覆盖', () => {
|
||||
it('下载接口必须保留请求签名、硬件指纹和模块包生成校验', () => {
|
||||
const source = fs.readFileSync(downloadRoutePath, 'utf8');
|
||||
|
||||
expect(source).toContain("verifyLicenseRequestSignature('premium-module-download'");
|
||||
expect(source).toContain('calculateFingerprintMatchScore');
|
||||
expect(source).toContain("license.licenseType === 'FREE'");
|
||||
expect(source).toContain('buildPremiumModulePackage');
|
||||
});
|
||||
});
|
||||
@ -1,7 +1,9 @@
|
||||
import * as crypto from 'crypto';
|
||||
import { signData } from './crypto';
|
||||
import type { LicenseType } from './types';
|
||||
|
||||
export const PREMIUM_MODULE_MANIFEST_VERSION = 1;
|
||||
export const PREMIUM_MODULE_PACKAGE_VERSION = 1;
|
||||
|
||||
export interface PremiumModuleManifest {
|
||||
version: number;
|
||||
@ -11,6 +13,7 @@ export interface PremiumModuleManifest {
|
||||
minLicenseType: Exclude<LicenseType, 'FREE'>;
|
||||
sizeBytes: number;
|
||||
sha256: string;
|
||||
packageVersion: number;
|
||||
}>;
|
||||
issuedAt: string;
|
||||
expiresAt: string;
|
||||
@ -21,39 +24,175 @@ export interface SignedPremiumModuleManifest {
|
||||
signature: string;
|
||||
}
|
||||
|
||||
export interface PremiumModulePackage {
|
||||
moduleId: string;
|
||||
contentBase64: string;
|
||||
sha256: string;
|
||||
sizeBytes: number;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
interface PremiumModuleDefinition {
|
||||
id: string;
|
||||
minLicenseType: Exclude<LicenseType, 'FREE'>;
|
||||
capabilities: string[];
|
||||
}
|
||||
|
||||
const MODULE_DEFINITIONS: Record<string, PremiumModuleDefinition> = {
|
||||
'custom-templates-core': {
|
||||
id: 'custom-templates-core',
|
||||
minLicenseType: 'STANDARD',
|
||||
capabilities: ['customTemplates'],
|
||||
},
|
||||
'intranet-penetration-core': {
|
||||
id: 'intranet-penetration-core',
|
||||
minLicenseType: 'PRO',
|
||||
capabilities: ['intranetPenetration'],
|
||||
},
|
||||
};
|
||||
|
||||
const MODULE_IDS_BY_LICENSE: Record<Exclude<LicenseType, 'FREE'>, string[]> = {
|
||||
STANDARD: ['custom-templates-core'],
|
||||
PRO: ['custom-templates-core', 'intranet-penetration-core'],
|
||||
};
|
||||
|
||||
function getPackageSecret(): string {
|
||||
const secret = process.env.PREMIUM_MODULE_PACKAGE_SECRET || process.env.AES_SECRET_KEY || '';
|
||||
if (!secret || secret.length < 32) {
|
||||
throw new Error('PREMIUM_MODULE_PACKAGE_SECRET 或 AES_SECRET_KEY 未配置或长度不足');
|
||||
}
|
||||
|
||||
return secret;
|
||||
}
|
||||
|
||||
function canLicenseUseModule(
|
||||
licenseType: Exclude<LicenseType, 'FREE'>,
|
||||
module: PremiumModuleDefinition
|
||||
): boolean {
|
||||
if (module.minLicenseType === 'STANDARD') return licenseType === 'STANDARD' || licenseType === 'PRO';
|
||||
return licenseType === 'PRO';
|
||||
}
|
||||
|
||||
function encryptModulePayload(payload: Record<string, unknown>): string {
|
||||
const plaintext = JSON.stringify(payload);
|
||||
const packageSecret = getPackageSecret();
|
||||
const key = crypto.createHash('sha256').update(packageSecret).digest();
|
||||
const iv = crypto
|
||||
.createHash('sha256')
|
||||
.update(`${packageSecret}:${plaintext}`)
|
||||
.digest()
|
||||
.subarray(0, 12);
|
||||
|
||||
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
|
||||
let encrypted = cipher.update(plaintext, 'utf8', 'base64');
|
||||
encrypted += cipher.final('base64');
|
||||
|
||||
const envelope = {
|
||||
version: PREMIUM_MODULE_PACKAGE_VERSION,
|
||||
algorithm: 'aes-256-gcm',
|
||||
iv: iv.toString('base64'),
|
||||
authTag: cipher.getAuthTag().toString('base64'),
|
||||
encrypted,
|
||||
};
|
||||
|
||||
return Buffer.from(JSON.stringify(envelope), 'utf8').toString('base64');
|
||||
}
|
||||
|
||||
export function buildPremiumModulePackage(params: {
|
||||
moduleId: string;
|
||||
licenseType: Exclude<LicenseType, 'FREE'>;
|
||||
instanceId: string;
|
||||
leaseExpiresAt: Date;
|
||||
}): PremiumModulePackage {
|
||||
const module = MODULE_DEFINITIONS[params.moduleId];
|
||||
if (!module || !canLicenseUseModule(params.licenseType, module)) {
|
||||
throw new Error('付费模块不存在或许可证类型不允许使用');
|
||||
}
|
||||
|
||||
const expiresAt = params.leaseExpiresAt.toISOString();
|
||||
const contentBase64 = encryptModulePayload({
|
||||
packageType: 'flnxi-premium-module',
|
||||
packageVersion: PREMIUM_MODULE_PACKAGE_VERSION,
|
||||
moduleId: module.id,
|
||||
licenseType: params.licenseType,
|
||||
minLicenseType: module.minLicenseType,
|
||||
capabilities: module.capabilities,
|
||||
instanceId: params.instanceId,
|
||||
expiresAt,
|
||||
});
|
||||
|
||||
return {
|
||||
moduleId: module.id,
|
||||
contentBase64,
|
||||
sha256: crypto.createHash('sha256').update(contentBase64).digest('hex'),
|
||||
sizeBytes: Buffer.byteLength(contentBase64, 'utf8'),
|
||||
expiresAt,
|
||||
};
|
||||
}
|
||||
|
||||
function buildModuleManifestItems(params: {
|
||||
licenseType: Exclude<LicenseType, 'FREE'>;
|
||||
instanceId: string;
|
||||
leaseExpiresAt: Date;
|
||||
}): PremiumModuleManifest['modules'] {
|
||||
return MODULE_IDS_BY_LICENSE[params.licenseType].map((moduleId) => {
|
||||
const module = MODULE_DEFINITIONS[moduleId];
|
||||
const modulePackage = buildPremiumModulePackage({
|
||||
moduleId,
|
||||
licenseType: params.licenseType,
|
||||
instanceId: params.instanceId,
|
||||
leaseExpiresAt: params.leaseExpiresAt,
|
||||
});
|
||||
|
||||
return {
|
||||
id: module.id,
|
||||
minLicenseType: module.minLicenseType,
|
||||
sizeBytes: modulePackage.sizeBytes,
|
||||
sha256: modulePackage.sha256,
|
||||
packageVersion: PREMIUM_MODULE_PACKAGE_VERSION,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
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',
|
||||
sizeBytes: 0,
|
||||
sha256: '',
|
||||
packageVersion: PREMIUM_MODULE_PACKAGE_VERSION,
|
||||
},
|
||||
],
|
||||
PRO: [
|
||||
{
|
||||
id: 'custom-templates-core',
|
||||
minLicenseType: 'STANDARD',
|
||||
sizeBytes: 4096,
|
||||
sha256: 'pending-real-module-split-standard',
|
||||
sizeBytes: 0,
|
||||
sha256: '',
|
||||
packageVersion: PREMIUM_MODULE_PACKAGE_VERSION,
|
||||
},
|
||||
{
|
||||
id: 'intranet-penetration-core',
|
||||
minLicenseType: 'PRO',
|
||||
sizeBytes: 4096,
|
||||
sha256: 'pending-real-module-split-pro',
|
||||
sizeBytes: 0,
|
||||
sha256: '',
|
||||
packageVersion: PREMIUM_MODULE_PACKAGE_VERSION,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export function buildSignedPremiumModuleManifest(
|
||||
licenseType: Exclude<LicenseType, 'FREE'>,
|
||||
leaseExpiresAt: Date
|
||||
leaseExpiresAt: Date,
|
||||
instanceId?: string
|
||||
): SignedPremiumModuleManifest {
|
||||
const manifest: PremiumModuleManifest = {
|
||||
version: PREMIUM_MODULE_MANIFEST_VERSION,
|
||||
licenseType,
|
||||
modules: MODULES_BY_LICENSE[licenseType],
|
||||
modules: instanceId
|
||||
? buildModuleManifestItems({ licenseType, instanceId, leaseExpiresAt })
|
||||
: MODULES_BY_LICENSE[licenseType],
|
||||
issuedAt: new Date().toISOString(),
|
||||
expiresAt: leaseExpiresAt.toISOString(),
|
||||
};
|
||||
@ -63,4 +202,3 @@ export function buildSignedPremiumModuleManifest(
|
||||
signature: signData(JSON.stringify(manifest)),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user