185 lines
5.5 KiB
TypeScript
185 lines
5.5 KiB
TypeScript
import {
|
|
getClientIp,
|
|
isPublicIp,
|
|
NoopIpIntelligenceProvider,
|
|
ProxyCheckProvider,
|
|
shouldBlockIpClassification,
|
|
type IpClassification,
|
|
type IpIntelligenceProvider,
|
|
type IpIntelligenceResult,
|
|
} from "@minecraft-account-manager/network";
|
|
import { ipIntelligence } from "@minecraft-account-manager/database";
|
|
import { and, eq, gt } from "drizzle-orm";
|
|
import { headers } from "next/headers";
|
|
import { db } from "@/lib/database";
|
|
import { logger } from "@/lib/logger";
|
|
|
|
const classifications = new Set<IpClassification>([
|
|
"unknown",
|
|
"clear",
|
|
"vpn",
|
|
"proxy",
|
|
"hosting",
|
|
"tor",
|
|
]);
|
|
|
|
function configuredProvider(): IpIntelligenceProvider {
|
|
if (process.env.IP_INTELLIGENCE_PROVIDER?.trim().toLowerCase() === "proxycheck") {
|
|
return new ProxyCheckProvider({ apiKey: process.env.PROXYCHECK_API_KEY?.trim() ?? "" });
|
|
}
|
|
return new NoopIpIntelligenceProvider();
|
|
}
|
|
|
|
function cacheHours() {
|
|
const configured = Number(process.env.IP_INTELLIGENCE_CACHE_HOURS ?? "48");
|
|
return Number.isFinite(configured) && configured > 0 ? Math.min(configured, 720) : 48;
|
|
}
|
|
|
|
function serialize(result: IpIntelligenceResult) {
|
|
return JSON.parse(JSON.stringify(result)) as Record<string, unknown>;
|
|
}
|
|
|
|
function deserialize(value: Record<string, unknown> | null): IpIntelligenceResult | null {
|
|
if (!value || !classifications.has(value.classification as IpClassification)) return null;
|
|
return value as unknown as IpIntelligenceResult;
|
|
}
|
|
|
|
async function cacheResult(
|
|
ipAddress: string,
|
|
result: IpIntelligenceResult,
|
|
now: Date,
|
|
ttlMilliseconds: number,
|
|
) {
|
|
await db
|
|
.insert(ipIntelligence)
|
|
.values({
|
|
ipAddress,
|
|
classification: result.classification,
|
|
provider: result.provider,
|
|
rawResponse: serialize(result),
|
|
checkedAt: now,
|
|
expiresAt: new Date(now.getTime() + ttlMilliseconds),
|
|
})
|
|
.onConflictDoUpdate({
|
|
target: ipIntelligence.ipAddress,
|
|
set: {
|
|
classification: result.classification,
|
|
provider: result.provider,
|
|
rawResponse: serialize(result),
|
|
checkedAt: now,
|
|
expiresAt: new Date(now.getTime() + ttlMilliseconds),
|
|
updatedAt: now,
|
|
},
|
|
});
|
|
}
|
|
|
|
export async function getIpIntelligence(
|
|
ipAddress: string,
|
|
options: { now?: Date; forceRefresh?: boolean } = {},
|
|
): Promise<IpIntelligenceResult> {
|
|
if (!isPublicIp(ipAddress)) {
|
|
logger.warn(
|
|
{
|
|
event: "ip_intelligence.skipped",
|
|
reason: "non_public_address",
|
|
},
|
|
"IP intelligence lookup skipped for a non-public client address",
|
|
);
|
|
return { classification: "unknown", provider: null };
|
|
}
|
|
|
|
const now = options.now ?? new Date();
|
|
if (!options.forceRefresh) {
|
|
const [cached] = await db
|
|
.select({ rawResponse: ipIntelligence.rawResponse })
|
|
.from(ipIntelligence)
|
|
.where(andAddressIsFresh(ipAddress, now))
|
|
.limit(1);
|
|
const cachedResult = deserialize(cached?.rawResponse ?? null);
|
|
if (cachedResult) return cachedResult;
|
|
}
|
|
|
|
const provider = configuredProvider();
|
|
try {
|
|
const result = await provider.classify(ipAddress);
|
|
await cacheResult(ipAddress, result, now, cacheHours() * 60 * 60_000);
|
|
return result;
|
|
} catch (error) {
|
|
const providerName = process.env.IP_INTELLIGENCE_PROVIDER?.trim().toLowerCase() || null;
|
|
const result: IpIntelligenceResult = {
|
|
classification: "unknown",
|
|
provider: providerName,
|
|
lookupError: true,
|
|
};
|
|
await cacheResult(ipAddress, result, now, 5 * 60_000).catch((cacheError) => {
|
|
logger.error(
|
|
{ err: cacheError, event: "ip_intelligence.cache_failed", provider: providerName },
|
|
"Failed to cache an IP intelligence lookup error",
|
|
);
|
|
});
|
|
logger.error(
|
|
{ err: error, event: "ip_intelligence.lookup_failed", provider: providerName },
|
|
"IP intelligence lookup failed",
|
|
);
|
|
return result;
|
|
}
|
|
}
|
|
|
|
function andAddressIsFresh(ipAddress: string, now: Date) {
|
|
return and(eq(ipIntelligence.ipAddress, ipAddress), gt(ipIntelligence.expiresAt, now));
|
|
}
|
|
|
|
export function toAuditIpData(result: IpIntelligenceResult) {
|
|
return {
|
|
classification: result.classification,
|
|
provider: result.provider,
|
|
riskScore: result.riskScore ?? null,
|
|
location: result.location ?? null,
|
|
network: result.network ?? null,
|
|
lookupError: result.lookupError ?? false,
|
|
};
|
|
}
|
|
|
|
export function shouldBlockAccountAddition(result: IpIntelligenceResult) {
|
|
return shouldBlockIpClassification(
|
|
result.classification,
|
|
process.env.BLOCK_HOSTING_IPS === "true",
|
|
);
|
|
}
|
|
|
|
export async function checkAccountAdditionNetwork() {
|
|
const requestHeaders = await headers();
|
|
const ipAddress = getClientIp(requestHeaders, process.env.TRUST_PROXY === "true");
|
|
if (!ipAddress) {
|
|
logger.warn(
|
|
{
|
|
event: "client_ip.unavailable",
|
|
trustProxy: process.env.TRUST_PROXY === "true",
|
|
forwardedForPresent: requestHeaders.has("x-forwarded-for"),
|
|
realIpPresent: requestHeaders.has("x-real-ip"),
|
|
},
|
|
"Client IP address was unavailable for account addition",
|
|
);
|
|
return {
|
|
allowed: false as const,
|
|
reason: "unavailable" as const,
|
|
ipAddress: null,
|
|
intelligence: null,
|
|
};
|
|
}
|
|
|
|
const intelligence = await getIpIntelligence(ipAddress);
|
|
if (shouldBlockAccountAddition(intelligence)) {
|
|
return {
|
|
allowed: false as const,
|
|
reason: intelligence.classification === "unknown"
|
|
? "unavailable" as const
|
|
: "blocked" as const,
|
|
ipAddress,
|
|
intelligence,
|
|
};
|
|
}
|
|
|
|
return { allowed: true as const, reason: null, ipAddress, intelligence };
|
|
}
|