flnxi-Wu/lib/license/license-service.ts
2026-06-02 19:25:48 +08:00

849 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { prisma } from '@/lib/prisma';
import { generateFingerprint, getInstanceId } from './fingerprint-service';
import {
verifyLicenseSignature,
encryptLocal,
decryptLocal,
signLocalIntegrity,
verifyLocalIntegrity,
} from './crypto-service';
import { initializeSystem as initializeSystemCore } from '../system-init';
import type {
LicenseConfig,
SignedLicense,
ActivateResponse,
VerifyResponse,
HeartbeatResponse,
FeatureKey,
LocalLicenseData,
} from './types';
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;
// 心跳间隔(1小时)
const HEARTBEAT_INTERVAL = 60 * 60 * 1000;
// 容忍正常 NTP 校时的小幅波动,超过后视为疑似回拨绕过租约
const CLOCK_ROLLBACK_TOLERANCE_MS = 10 * 60 * 1000;
const CLOCK_STATE_REFRESH_INTERVAL_MS = 5 * 60 * 1000;
// 缓存的许可证配置
let cachedLicense: LicenseConfig | null = null;
let lastVerified: Date | null = null;
let heartbeatTimer: NodeJS.Timeout | null = null;
function isDateStringExpired(value: string | null | undefined): boolean {
if (!value) return false;
const expiresAt = new Date(value).getTime();
if (Number.isNaN(expiresAt)) return true;
return expiresAt < Date.now();
}
export function isLicenseConfigExpired(license: LicenseConfig | null): boolean {
return Boolean(
license &&
(isDateStringExpired(license.expiresAt) || isDateStringExpired(license.leaseExpiresAt))
);
}
function extractLicenseConfig(license: SignedLicense): LicenseConfig {
const {
issuedAt: _issuedAt,
projectCode: _projectCode,
licenseKey: _licenseKey,
instanceId: _instanceId,
fingerprintHash: _fingerprintHash,
activationId: _activationId,
...config
} = license.data;
return {
...config,
leaseExpiresAt: config.leaseExpiresAt ?? null,
};
}
function buildClockIntegrityPayload(localData: LocalLicenseData): string {
return JSON.stringify({
instanceId: localData.instanceId,
licenseKey: localData.licenseKey,
licenseData: localData.licenseData,
lastVerified: localData.lastVerified,
lastSeenAt: localData.lastSeenAt ?? null,
clockRollbackDetectedAt: localData.clockRollbackDetectedAt ?? null,
});
}
function withClockIntegrity(localData: LocalLicenseData): LocalLicenseData {
const dataWithDefaults: LocalLicenseData = {
...localData,
clockRollbackDetectedAt: localData.clockRollbackDetectedAt ?? null,
};
return {
...dataWithDefaults,
clockIntegrity: signLocalIntegrity(buildClockIntegrityPayload(dataWithDefaults)),
};
}
function hasClockState(localData: LocalLicenseData): boolean {
return Boolean(
localData.lastSeenAt ||
localData.clockRollbackDetectedAt ||
localData.clockIntegrity
);
}
function isClockStateIntact(localData: LocalLicenseData): boolean {
if (!hasClockState(localData)) {
return true;
}
if (!localData.lastSeenAt || !localData.clockIntegrity) {
return false;
}
return verifyLocalIntegrity(
buildClockIntegrityPayload({
...localData,
clockRollbackDetectedAt: localData.clockRollbackDetectedAt ?? null,
}),
localData.clockIntegrity
);
}
async function persistLocalLicenseData(localData: LocalLicenseData): Promise<void> {
try {
await prisma.systemConfig.update({
where: { key: 'license' },
data: { value: JSON.stringify(localData) },
});
} catch (error) {
console.warn('[License] 本地许可证时间状态写入失败:', error);
}
}
async function enforceLocalClockState(localData: LocalLicenseData): Promise<LocalLicenseData | null> {
if (!isClockStateIntact(localData)) {
console.warn('[License] 本地许可证时间状态校验失败,已拒绝使用');
return null;
}
const nowMs = Date.now();
const nowIso = new Date(nowMs).toISOString();
const lastVerifiedMs = new Date(localData.lastVerified).getTime();
if (Number.isNaN(lastVerifiedMs)) {
console.warn('[License] 本地许可证验证时间格式异常,已拒绝使用');
return null;
}
if (nowMs + CLOCK_ROLLBACK_TOLERANCE_MS < lastVerifiedMs) {
const rollbackState = withClockIntegrity({
...localData,
lastSeenAt: localData.lastSeenAt ?? localData.lastVerified,
clockRollbackDetectedAt: localData.clockRollbackDetectedAt ?? nowIso,
});
await persistLocalLicenseData(rollbackState);
console.warn('[License] 本地许可证验证时间晚于当前系统时间,已拒绝使用');
return null;
}
if (!localData.lastSeenAt) {
const initialized = withClockIntegrity({
...localData,
lastSeenAt: nowIso,
clockRollbackDetectedAt: null,
});
await persistLocalLicenseData(initialized);
return initialized;
}
const lastSeenMs = new Date(localData.lastSeenAt).getTime();
if (Number.isNaN(lastSeenMs)) {
console.warn('[License] 本地许可证时间状态格式异常,已拒绝使用');
return null;
}
if (nowMs + CLOCK_ROLLBACK_TOLERANCE_MS < lastSeenMs) {
const rollbackState = withClockIntegrity({
...localData,
clockRollbackDetectedAt: localData.clockRollbackDetectedAt ?? nowIso,
});
await persistLocalLicenseData(rollbackState);
console.warn('[License] 检测到系统时间明显回拨,已拒绝使用本地许可证');
return null;
}
const shouldRefreshLastSeen =
nowMs > lastSeenMs &&
nowMs - lastSeenMs >= CLOCK_STATE_REFRESH_INTERVAL_MS;
const shouldClearRollback = Boolean(localData.clockRollbackDetectedAt);
if (shouldRefreshLastSeen || shouldClearRollback || !localData.clockIntegrity) {
const refreshed = withClockIntegrity({
...localData,
lastSeenAt: shouldRefreshLastSeen ? nowIso : localData.lastSeenAt,
clockRollbackDetectedAt: null,
});
await persistLocalLicenseData(refreshed);
return refreshed;
}
return localData;
}
function normalizeLocalLicenseData(localData: LocalLicenseData): LocalLicenseData | null {
if (
!localData ||
typeof localData.licenseKey !== 'string' ||
typeof localData.licenseData !== 'string' ||
typeof localData.lastVerified !== 'string'
) {
return null;
}
try {
const decrypted = decryptLocal(localData.licenseData);
const signedLicense = JSON.parse(decrypted) as SignedLicense;
if (!signedLicense?.data || !signedLicense?.signature) {
return null;
}
if (!verifyLicenseSignature(signedLicense.data, signedLicense.signature)) {
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),
};
} catch (error) {
console.warn('[License] 本地许可证校验失败:', error);
return null;
}
}
async function getCurrentLicenseConfig(): Promise<LicenseConfig | null> {
const localLicense = await getLocalLicense();
const currentConfig = localLicense?.cachedConfig ?? null;
if (!localLicense || !currentConfig || isLicenseConfigExpired(currentConfig)) {
cachedLicense = null;
lastVerified = localLicense?.lastVerified ? new Date(localLicense.lastVerified) : null;
return null;
}
cachedLicense = currentConfig;
lastVerified = new Date(localLicense.lastVerified);
return currentConfig;
}
/**
* 激活许可证
*
* ⚠️ 注意:此函数现在只用于客户端调用素材系统自己的API
* 实际的激活逻辑已移至 app/api/license/activate/route.ts(后端代理)
*
* 安全改进:
* - 客户端不再直接访问许可证服务器
* - 硬件指纹在后端生成(更安全)
* - 许可证服务器地址完全隐藏
*/
export async function activate(licenseKey: string): Promise<{ success: boolean; error?: string }> {
try {
// ✅ 安全改进:调用素材系统自己的API(不再直接调用许可证服务器)
const response = await fetch('/api/license/activate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
licenseKey,
}),
});
const data = await response.json();
if (!data.success) {
return { success: false, error: data.error || '激活失败' };
}
// 重新加载许可证(因为后端已保存)
await loadLicenseFromLocal();
// 更新缓存
if (cachedLicense) {
lastVerified = new Date();
}
return { success: true };
} catch (error) {
console.error('激活失败:', error);
return { success: false, error: '网络错误,请检查网络连接' };
}
}
/**
* 激活免费试用版
*
* ⚠️ 注意:此函数现在只用于客户端调用素材系统自己的API
* 实际的注册逻辑已移至 app/api/license/activate-free/route.ts(后端代理)
*
* 安全改进:
* - 客户端不再直接访问许可证服务器
* - 硬件指纹在后端生成(更安全)
* - 许可证服务器地址完全隐藏
*
* @param customerName 客户名称(用于许可证系统后台管理)
* @param contactInfo 联系方式(手机/微信/邮箱)
*/
export async function activateFreeTrial(customerName: string, contactInfo: string = ''): Promise<{ success: boolean; error?: string }> {
try {
// ✅ 安全改进:调用素材系统自己的API(不再直接调用许可证服务器)
const response = await fetch('/api/license/activate-free', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
customerName,
contactInfo,
}),
});
const data = await response.json();
if (!data.success) {
return { success: false, error: data.error || '激活失败,请检查网络连接' };
}
// 重新加载许可证(因为后端已保存)
await loadLicenseFromLocal();
// 更新缓存
if (cachedLicense) {
lastVerified = new Date();
}
return { success: true };
} catch (error) {
console.error('免费试用激活失败:', error);
return { success: false, error: '网络错误,请检查网络连接后重试' };
}
}
/**
* 初始化系统(创建默认管理员、部门、店铺等)
*/
export async function initializeSystem() {
return initializeSystemCore();
}
/**
* 验证许可证
*
* ⚠️ 注意:此函数现在只用于客户端调用素材系统自己的API
* 实际的验证逻辑已移至 app/api/license/verify/route.ts(后端代理)
*
* 安全改进:
* - 客户端不再直接访问许可证服务器
* - 硬件指纹在后端生成(更安全)
* - 许可证服务器地址完全隐藏
*/
export async function verify(): Promise<{ valid: boolean; error?: string; revoked?: boolean }> {
try {
// 先检查本地缓存
const localLicense = await getLocalLicense();
if (!localLicense) {
return { valid: false, error: '未找到许可证' };
}
// ✅ 安全改进:调用素材系统自己的API(不再直接调用许可证服务器)
const response = await fetch('/api/license/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
const data = await response.json();
if (!data.valid) {
// 检查是否是被吊销
if (data.revoked) {
console.log('[License] 许可证已被吊销:', data.error);
cachedLicense = null;
return { valid: false, error: data.error || '许可证已被吊销', revoked: true };
}
// 其他错误,检查宽限期
if (!isLicenseConfigExpired(localLicense.cachedConfig) && isWithinGracePeriod(localLicense.lastVerified)) {
cachedLicense = localLicense.cachedConfig;
return { valid: true };
}
return { valid: false, error: data.error || '验证失败' };
}
// 重新加载许可证(因为后端已更新)
await loadLicenseFromLocal();
// 更新内存缓存
if (cachedLicense) {
lastVerified = new Date();
}
return { valid: true };
} catch (error) {
console.error('验证失败:', error);
// 网络错误时检查宽限期
const localLicense = await getLocalLicense();
if (
localLicense &&
!isLicenseConfigExpired(localLicense.cachedConfig) &&
isWithinGracePeriod(localLicense.lastVerified)
) {
cachedLicense = localLicense.cachedConfig;
return { valid: true };
}
return { valid: false, error: '网络错误' };
}
}
export async function runLicenseVerification(): Promise<{ valid: boolean; error?: string; revoked?: boolean }> {
try {
const localLicense = await getLocalLicense();
if (!localLicense) {
return { valid: false, error: '未找到许可证' };
}
const fingerprint = await generateFingerprint();
const signedRequest = buildSignedLicenseRequest('verify', {
instanceId: fingerprint.instanceId,
fingerprint,
});
const response = await fetch(buildLicenseServerLicenseApiUrl('verify'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(signedRequest),
});
const data: VerifyResponse = await response.json();
if (!data.valid || !data.license) {
const isRevoked = response.status === 403 || response.status === 404;
if (isRevoked) {
console.log('[License] 许可证已被吊销:', data.error);
await clearLocalLicense();
return {
valid: false,
error: data.error || '许可证已被吊销',
revoked: true,
};
}
if (!isLicenseConfigExpired(localLicense.cachedConfig) && isWithinGracePeriod(localLicense.lastVerified)) {
return { valid: true };
}
return { valid: false, error: data.error || '验证失败' };
}
if (!verifyLicenseSignature(data.license.data, data.license.signature)) {
return { valid: false, error: '许可证签名验证失败' };
}
await updateLocalLicense(data.license);
const moduleSyncResult = await syncPremiumModules();
if (!moduleSyncResult.synced && !moduleSyncResult.skipped) {
console.warn('[License] 授权模块清单同步失败:', moduleSyncResult.error);
}
return { valid: true };
} catch (error) {
console.error('验证失败:', error);
const localLicense = await getLocalLicense();
if (
localLicense &&
!isLicenseConfigExpired(localLicense.cachedConfig) &&
isWithinGracePeriod(localLicense.lastVerified)
) {
return { valid: true };
}
return { valid: false, error: '网络错误' };
}
}
/**
* 发送心跳
*
* ⚠️ 注意:此函数现在只用于客户端调用素材系统自己的API
* 实际的心跳逻辑已移至 app/api/license/heartbeat/route.ts(后端代理)
*
* 安全改进:
* - 客户端不再直接访问许可证服务器
* - 硬件指纹在后端生成(更安全)
* - 许可证服务器地址完全隐藏
*
* @returns 如果许可证被吊销返回 true
*/
export async function heartbeat(): Promise<boolean> {
try {
const localLicense = await getLocalLicense();
if (!localLicense) {
return false;
}
// ✅ 安全改进:调用素材系统自己的API(不再直接调用许可证服务器)
const response = await fetch('/api/license/heartbeat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
const data = await response.json();
// 检查是否被吊销
if (data.revoked) {
console.log('[License] 心跳检测到许可证已被吊销');
cachedLicense = null;
return true; // 返回 true 表示被吊销
}
if (data.acknowledged) {
// 重新加载许可证(因为后端已更新)
await loadLicenseFromLocal();
lastVerified = new Date();
}
return false;
} catch (error) {
console.error('心跳发送失败:', error);
return false;
}
}
export async function runLicenseHeartbeat(): Promise<{ acknowledged: boolean; error?: string; revoked?: boolean }> {
try {
const localLicense = await getLocalLicense();
if (!localLicense) {
return { acknowledged: false, error: '未找到许可证' };
}
const fingerprint = await generateFingerprint();
const [userCount, materialCount] = await Promise.all([
prisma.user.count(),
prisma.material.count(),
]);
const signedRequest = buildSignedLicenseRequest('heartbeat', {
instanceId: fingerprint.instanceId,
fingerprint,
usage: { userCount, materialCount },
});
const response = await fetch(buildLicenseServerLicenseApiUrl('heartbeat'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(signedRequest),
});
if (response.status === 403 || response.status === 404) {
console.log('[License] 心跳检测到许可证已被吊销');
await clearLocalLicense();
return {
acknowledged: false,
revoked: true,
error: '许可证已被吊销',
};
}
const data: HeartbeatResponse = await response.json();
if (data.acknowledged && data.license) {
if (!verifyLicenseSignature(data.license.data, data.license.signature)) {
return {
acknowledged: false,
error: '许可证签名验证失败',
};
}
await updateLocalLicense(data.license);
const moduleSyncResult = await syncPremiumModules();
if (!moduleSyncResult.synced && !moduleSyncResult.skipped) {
console.warn('[License] 授权模块清单同步失败:', moduleSyncResult.error);
}
}
return {
acknowledged: data.acknowledged || false,
};
} catch (error) {
console.error('心跳发送失败:', error);
return {
acknowledged: false,
error: '网络错误',
};
}
}
/**
* 获取当前许可证配置
*/
export function getLicense(): LicenseConfig | null {
if (isLicenseConfigExpired(cachedLicense)) {
cachedLicense = null;
return null;
}
return cachedLicense;
}
/**
* 检查功能是否可用
*/
export async function isFeatureEnabled(feature: FeatureKey): Promise<boolean> {
const currentLicense = await getCurrentLicenseConfig();
return Boolean(currentLicense?.features[feature]);
}
/**
* 检查是否达到限制
*/
export async function checkLimit(type: 'users' | 'materials'): Promise<{ allowed: boolean; current: number; max: number }> {
const currentLicense = await getCurrentLicenseConfig();
if (!currentLicense) {
return { allowed: false, current: 0, max: 0 };
}
const max = type === 'users' ? currentLicense.limits.maxUsers : currentLicense.limits.maxMaterials;
// -1 表示无限制
if (max === -1) {
return { allowed: true, current: 0, max: -1 };
}
const current = type === 'users'
? await prisma.user.count()
: await prisma.material.count();
return {
allowed: current < max,
current,
max,
};
}
/**
* 获取许可证类型
*/
export async function getLicenseType(): Promise<string> {
const currentLicense = await getCurrentLicenseConfig();
return currentLicense?.type || 'FREE';
}
/**
* 检查许可证是否已过期
*/
export function isExpired(): boolean {
return isLicenseConfigExpired(cachedLicense);
}
/**
* 启动心跳定时器
*/
export function startHeartbeat(): void {
if (heartbeatTimer) {
clearInterval(heartbeatTimer);
}
heartbeatTimer = setInterval(() => {
void runLicenseHeartbeat();
}, HEARTBEAT_INTERVAL);
}
/**
* 停止心跳定时器
*/
export function stopHeartbeat(): void {
if (heartbeatTimer) {
clearInterval(heartbeatTimer);
heartbeatTimer = null;
}
}
/**
* 初始化许可证服务
*/
export async function initLicenseService(): Promise<void> {
const result = await runLicenseVerification();
if (result.valid) {
startHeartbeat();
}
}
// ============ 内部辅助函数 ============
export async function saveLicenseLocally(licenseKey: string, license: SignedLicense): Promise<void> {
if (!verifyLicenseSignature(license.data, license.signature)) {
throw new Error('许可证签名验证失败');
}
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();
const localData: LocalLicenseData = {
instanceId,
licenseKey,
licenseData: encryptLocal(JSON.stringify(license)),
lastVerified: nowIso,
cachedConfig: currentConfig,
lastSeenAt: nowIso,
clockRollbackDetectedAt: null,
};
const signedLocalData = withClockIntegrity(localData);
await prisma.systemConfig.upsert({
where: { key: 'license' },
update: { value: JSON.stringify(signedLocalData) },
create: { key: 'license', value: JSON.stringify(signedLocalData) },
});
cachedLicense = isLicenseConfigExpired(currentConfig) ? null : currentConfig;
lastVerified = new Date(signedLocalData.lastVerified);
}
export async function updateLocalLicense(license: SignedLicense): Promise<void> {
const existing = await getLocalLicense();
if (!existing) return;
if (!verifyLicenseSignature(license.data, license.signature)) {
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();
const localData: LocalLicenseData = {
...existing,
licenseData: encryptLocal(JSON.stringify(license)),
lastVerified: nowIso,
cachedConfig: currentConfig,
lastSeenAt: nowIso,
clockRollbackDetectedAt: null,
};
const signedLocalData = withClockIntegrity(localData);
await prisma.systemConfig.update({
where: { key: 'license' },
data: { value: JSON.stringify(signedLocalData) },
});
cachedLicense = isLicenseConfigExpired(currentConfig) ? null : currentConfig;
lastVerified = new Date(signedLocalData.lastVerified);
}
/**
* 从本地加载许可证并更新内存缓存
*/
async function loadLicenseFromLocal(): Promise<void> {
const localLicense = await getLocalLicense();
if (localLicense) {
cachedLicense = localLicense.cachedConfig;
lastVerified = new Date(localLicense.lastVerified);
} else {
cachedLicense = null;
lastVerified = null;
}
}
export async function getLocalLicense(): Promise<LocalLicenseData | null> {
try {
const config = await prisma.systemConfig.findUnique({
where: { key: 'license' },
});
if (!config) return null;
const parsed = JSON.parse(config.value) as LocalLicenseData;
const normalized = normalizeLocalLicenseData(parsed);
if (!normalized) return null;
return await enforceLocalClockState(normalized);
} catch {
return null;
}
}
export async function getLocalLicenseReadOnly(): Promise<LocalLicenseData | null> {
try {
const config = await prisma.systemConfig.findUnique({
where: { key: 'license' },
});
if (!config) return null;
const parsed = JSON.parse(config.value) as LocalLicenseData;
const normalized = normalizeLocalLicenseData(parsed);
if (!normalized || !isClockStateIntact(normalized)) return null;
return normalized;
} catch {
return null;
}
}
/**
* 清除本地许可证缓存(许可证被吊销时调用)
*/
export async function clearLocalLicense(): Promise<void> {
try {
await prisma.systemConfig.delete({
where: { key: 'license' },
});
console.log('[License] 本地许可证缓存已清除');
} catch (error) {
console.error('[License] 清除本地许可证缓存失败:', error);
}
}
function isWithinGracePeriod(lastVerifiedStr: string): boolean {
const lastVerified = new Date(lastVerifiedStr);
const gracePeriodEnd = new Date(lastVerified.getTime() + GRACE_PERIOD_DAYS * 24 * 60 * 60 * 1000);
return new Date() < gracePeriodEnd;
}