- Added support for multiple captcha providers including Google reCAPTCHA (v2 and v3), hCaptcha, and Cloudflare Turnstile. - Introduced new fields in the verification configuration for selecting captcha providers and enabling alt-account detection. - Implemented logic to handle verification attempts and flag potential alt accounts based on IP and invite code analysis. - Updated environment configuration to include necessary keys for captcha providers. - Enhanced the user interface to allow selection of captcha providers in the verification setup. - Improved backend handling of verification records to store additional data related to captcha provider usage.
213 lines
5.9 KiB
TypeScript
213 lines
5.9 KiB
TypeScript
import { createHash } from 'node:crypto';
|
|
import type { CaptchaProvider } from '@nexumi/shared';
|
|
import { redis } from './redis';
|
|
import { env } from './env';
|
|
|
|
const CAPTCHA_PREFIX = 'verify:captcha:';
|
|
|
|
export type CaptchaChallenge = {
|
|
token: string;
|
|
guildId: string;
|
|
userId: string;
|
|
provider: CaptchaProvider;
|
|
question?: string;
|
|
answerHash?: string;
|
|
};
|
|
|
|
export async function getCaptchaChallenge(token: string): Promise<CaptchaChallenge | null> {
|
|
const raw = await redis.get(`${CAPTCHA_PREFIX}${token}`);
|
|
if (!raw) {
|
|
return null;
|
|
}
|
|
try {
|
|
const parsed = JSON.parse(raw) as CaptchaChallenge;
|
|
if (!parsed.provider) {
|
|
parsed.provider = 'MATH';
|
|
}
|
|
return parsed;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export async function deleteCaptchaChallenge(token: string): Promise<void> {
|
|
await redis.del(`${CAPTCHA_PREFIX}${token}`);
|
|
}
|
|
|
|
export function hashCaptchaAnswer(answer: string): string {
|
|
return createHash('sha256').update(answer.trim()).digest('hex');
|
|
}
|
|
|
|
export function hashClientSignal(value: string): string | null {
|
|
const salt = env.CAPTCHA_IP_HASH_SALT;
|
|
if (!salt || !value) {
|
|
return null;
|
|
}
|
|
return createHash('sha256').update(`${salt}:${value}`).digest('hex');
|
|
}
|
|
|
|
export function getClientIp(request: Request): string | null {
|
|
const forwarded = request.headers.get('x-forwarded-for');
|
|
if (forwarded) {
|
|
const first = forwarded.split(',')[0]?.trim();
|
|
if (first) {
|
|
return first;
|
|
}
|
|
}
|
|
const realIp = request.headers.get('x-real-ip')?.trim();
|
|
return realIp || null;
|
|
}
|
|
|
|
export type CaptchaPublicConfig = {
|
|
provider: CaptchaProvider;
|
|
siteKey: string | null;
|
|
recaptchaV3MinScore: number;
|
|
};
|
|
|
|
export function getAvailableCaptchaProviders(): CaptchaProvider[] {
|
|
const providers: CaptchaProvider[] = ['MATH'];
|
|
if (env.RECAPTCHA_SITE_KEY && env.RECAPTCHA_SECRET_KEY) {
|
|
providers.push('RECAPTCHA_V2');
|
|
}
|
|
const v3Site = env.RECAPTCHA_V3_SITE_KEY ?? env.RECAPTCHA_SITE_KEY;
|
|
const v3Secret = env.RECAPTCHA_V3_SECRET_KEY ?? env.RECAPTCHA_SECRET_KEY;
|
|
if (v3Site && v3Secret) {
|
|
providers.push('RECAPTCHA_V3');
|
|
}
|
|
if (env.HCAPTCHA_SITE_KEY && env.HCAPTCHA_SECRET_KEY) {
|
|
providers.push('HCAPTCHA');
|
|
}
|
|
if (env.TURNSTILE_SITE_KEY && env.TURNSTILE_SECRET_KEY) {
|
|
providers.push('TURNSTILE');
|
|
}
|
|
return providers;
|
|
}
|
|
|
|
export function getCaptchaPublicConfig(provider: CaptchaProvider): CaptchaPublicConfig {
|
|
switch (provider) {
|
|
case 'RECAPTCHA_V2':
|
|
return {
|
|
provider,
|
|
siteKey: env.RECAPTCHA_SITE_KEY ?? null,
|
|
recaptchaV3MinScore: env.RECAPTCHA_V3_MIN_SCORE
|
|
};
|
|
case 'RECAPTCHA_V3':
|
|
return {
|
|
provider,
|
|
siteKey: env.RECAPTCHA_V3_SITE_KEY ?? env.RECAPTCHA_SITE_KEY ?? null,
|
|
recaptchaV3MinScore: env.RECAPTCHA_V3_MIN_SCORE
|
|
};
|
|
case 'HCAPTCHA':
|
|
return {
|
|
provider,
|
|
siteKey: env.HCAPTCHA_SITE_KEY ?? null,
|
|
recaptchaV3MinScore: env.RECAPTCHA_V3_MIN_SCORE
|
|
};
|
|
case 'TURNSTILE':
|
|
return {
|
|
provider,
|
|
siteKey: env.TURNSTILE_SITE_KEY ?? null,
|
|
recaptchaV3MinScore: env.RECAPTCHA_V3_MIN_SCORE
|
|
};
|
|
default:
|
|
return { provider: 'MATH', siteKey: null, recaptchaV3MinScore: env.RECAPTCHA_V3_MIN_SCORE };
|
|
}
|
|
}
|
|
|
|
type SiteVerifyResult = { success: boolean; score?: number; 'error-codes'?: string[] };
|
|
|
|
async function postSiteVerify(
|
|
url: string,
|
|
secret: string,
|
|
responseToken: string,
|
|
remoteip?: string | null
|
|
): Promise<SiteVerifyResult> {
|
|
const body = new URLSearchParams();
|
|
body.set('secret', secret);
|
|
body.set('response', responseToken);
|
|
if (remoteip) {
|
|
body.set('remoteip', remoteip);
|
|
}
|
|
const res = await fetch(url, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
body
|
|
});
|
|
if (!res.ok) {
|
|
return { success: false, 'error-codes': [`http_${res.status}`] };
|
|
}
|
|
return (await res.json()) as SiteVerifyResult;
|
|
}
|
|
|
|
export async function verifyProviderToken(
|
|
provider: CaptchaProvider,
|
|
responseToken: string,
|
|
remoteip?: string | null
|
|
): Promise<{ ok: boolean; error?: string }> {
|
|
if (!responseToken.trim()) {
|
|
return { ok: false, error: 'missing_token' };
|
|
}
|
|
|
|
if (provider === 'RECAPTCHA_V2') {
|
|
if (!env.RECAPTCHA_SECRET_KEY) {
|
|
return { ok: false, error: 'provider_not_configured' };
|
|
}
|
|
const result = await postSiteVerify(
|
|
'https://www.google.com/recaptcha/api/siteverify',
|
|
env.RECAPTCHA_SECRET_KEY,
|
|
responseToken,
|
|
remoteip
|
|
);
|
|
return result.success ? { ok: true } : { ok: false, error: 'provider_rejected' };
|
|
}
|
|
|
|
if (provider === 'RECAPTCHA_V3') {
|
|
const secret = env.RECAPTCHA_V3_SECRET_KEY ?? env.RECAPTCHA_SECRET_KEY;
|
|
if (!secret) {
|
|
return { ok: false, error: 'provider_not_configured' };
|
|
}
|
|
const result = await postSiteVerify(
|
|
'https://www.google.com/recaptcha/api/siteverify',
|
|
secret,
|
|
responseToken,
|
|
remoteip
|
|
);
|
|
if (!result.success) {
|
|
return { ok: false, error: 'provider_rejected' };
|
|
}
|
|
const score = result.score ?? 0;
|
|
if (score < env.RECAPTCHA_V3_MIN_SCORE) {
|
|
return { ok: false, error: 'score_too_low' };
|
|
}
|
|
return { ok: true };
|
|
}
|
|
|
|
if (provider === 'HCAPTCHA') {
|
|
if (!env.HCAPTCHA_SECRET_KEY) {
|
|
return { ok: false, error: 'provider_not_configured' };
|
|
}
|
|
const result = await postSiteVerify(
|
|
'https://hcaptcha.com/siteverify',
|
|
env.HCAPTCHA_SECRET_KEY,
|
|
responseToken,
|
|
remoteip
|
|
);
|
|
return result.success ? { ok: true } : { ok: false, error: 'provider_rejected' };
|
|
}
|
|
|
|
if (provider === 'TURNSTILE') {
|
|
if (!env.TURNSTILE_SECRET_KEY) {
|
|
return { ok: false, error: 'provider_not_configured' };
|
|
}
|
|
const result = await postSiteVerify(
|
|
'https://challenges.cloudflare.com/turnstile/v0/siteverify',
|
|
env.TURNSTILE_SECRET_KEY,
|
|
responseToken,
|
|
remoteip
|
|
);
|
|
return result.success ? { ok: true } : { ok: false, error: 'provider_rejected' };
|
|
}
|
|
|
|
return { ok: false, error: 'unsupported_provider' };
|
|
}
|