flnxi-Wu/license-server/lib/abuse-detection.ts
2026-05-26 16:47:57 +08:00

210 lines
5.9 KiB
TypeScript

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 };
}