Enhance verification system with multi-provider support and alt-account detection
- 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.
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import type { CaptchaProvider } from '@nexumi/shared';
|
||||
import { redis } from './redis';
|
||||
import { env } from './env';
|
||||
|
||||
const CAPTCHA_PREFIX = 'verify:captcha:';
|
||||
|
||||
@@ -7,8 +9,9 @@ export type CaptchaChallenge = {
|
||||
token: string;
|
||||
guildId: string;
|
||||
userId: string;
|
||||
question: string;
|
||||
answerHash: string;
|
||||
provider: CaptchaProvider;
|
||||
question?: string;
|
||||
answerHash?: string;
|
||||
};
|
||||
|
||||
export async function getCaptchaChallenge(token: string): Promise<CaptchaChallenge | null> {
|
||||
@@ -17,7 +20,11 @@ export async function getCaptchaChallenge(token: string): Promise<CaptchaChallen
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(raw) as CaptchaChallenge;
|
||||
const parsed = JSON.parse(raw) as CaptchaChallenge;
|
||||
if (!parsed.provider) {
|
||||
parsed.provider = 'MATH';
|
||||
}
|
||||
return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -30,3 +37,176 @@ export async function deleteCaptchaChallenge(token: string): Promise<void> {
|
||||
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' };
|
||||
}
|
||||
|
||||
@@ -379,6 +379,48 @@ export const SETTINGS_SEARCH_FIELDS: SearchIndexEntry[] = [
|
||||
href: 'verification',
|
||||
hash: 'field-verify-age'
|
||||
},
|
||||
{
|
||||
id: 'setting-verify-captcha-provider',
|
||||
kind: 'setting',
|
||||
labelKey: 'modulePages.verification.captchaProvider',
|
||||
href: 'verification',
|
||||
hash: 'field-verify-captcha-provider'
|
||||
},
|
||||
{
|
||||
id: 'setting-verify-alt-enabled',
|
||||
kind: 'setting',
|
||||
labelKey: 'modulePages.verification.altDetectionEnabled',
|
||||
href: 'verification',
|
||||
hash: 'field-verify-alt-enabled'
|
||||
},
|
||||
{
|
||||
id: 'setting-verify-alt-block',
|
||||
kind: 'setting',
|
||||
labelKey: 'modulePages.verification.altBlockOnMatch',
|
||||
href: 'verification',
|
||||
hash: 'field-verify-alt-block'
|
||||
},
|
||||
{
|
||||
id: 'setting-verify-alt-ip-days',
|
||||
kind: 'setting',
|
||||
labelKey: 'modulePages.verification.altIpWindowDays',
|
||||
href: 'verification',
|
||||
hash: 'field-verify-alt-ip-days'
|
||||
},
|
||||
{
|
||||
id: 'setting-verify-alt-invite-hours',
|
||||
kind: 'setting',
|
||||
labelKey: 'modulePages.verification.altInviteWindowHours',
|
||||
href: 'verification',
|
||||
hash: 'field-verify-alt-invite-hours'
|
||||
},
|
||||
{
|
||||
id: 'setting-verify-alt-max-invite',
|
||||
kind: 'setting',
|
||||
labelKey: 'modulePages.verification.altMaxAccountsPerInvite',
|
||||
href: 'verification',
|
||||
hash: 'field-verify-alt-max-invite'
|
||||
},
|
||||
// Leveling
|
||||
{
|
||||
id: 'setting-leveling-enabled',
|
||||
|
||||
@@ -35,7 +35,20 @@ const EnvSchema = z.object({
|
||||
LEGAL_OPERATOR_NAME: z.preprocess(emptyToUndefined, z.string().min(1).optional()),
|
||||
LEGAL_OPERATOR_ADDRESS: z.preprocess(emptyToUndefined, z.string().min(1).optional()),
|
||||
LEGAL_OPERATOR_EMAIL: z.preprocess(emptyToUndefined, z.string().email().optional()),
|
||||
LEGAL_OPERATOR_PHONE: z.preprocess(emptyToUndefined, z.string().min(1).optional())
|
||||
LEGAL_OPERATOR_PHONE: z.preprocess(emptyToUndefined, z.string().min(1).optional()),
|
||||
|
||||
// Captcha providers (site keys are public; secrets stay server-side)
|
||||
RECAPTCHA_SITE_KEY: z.preprocess(emptyToUndefined, z.string().min(1).optional()),
|
||||
RECAPTCHA_SECRET_KEY: z.preprocess(emptyToUndefined, z.string().min(1).optional()),
|
||||
RECAPTCHA_V3_SITE_KEY: z.preprocess(emptyToUndefined, z.string().min(1).optional()),
|
||||
RECAPTCHA_V3_SECRET_KEY: z.preprocess(emptyToUndefined, z.string().min(1).optional()),
|
||||
RECAPTCHA_V3_MIN_SCORE: z.coerce.number().min(0).max(1).default(0.5),
|
||||
HCAPTCHA_SITE_KEY: z.preprocess(emptyToUndefined, z.string().min(1).optional()),
|
||||
HCAPTCHA_SECRET_KEY: z.preprocess(emptyToUndefined, z.string().min(1).optional()),
|
||||
TURNSTILE_SITE_KEY: z.preprocess(emptyToUndefined, z.string().min(1).optional()),
|
||||
TURNSTILE_SECRET_KEY: z.preprocess(emptyToUndefined, z.string().min(1).optional()),
|
||||
/// Shared with bot for alt-account IP hashing (min 16 chars).
|
||||
CAPTCHA_IP_HASH_SALT: z.preprocess(emptyToUndefined, z.string().min(16).optional())
|
||||
});
|
||||
|
||||
export const env = EnvSchema.parse(process.env);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { VerificationConfigDashboard, VerificationConfigDashboardPatch } from '@nexumi/shared';
|
||||
import type { CaptchaProvider, VerificationConfigDashboard, VerificationConfigDashboardPatch } from '@nexumi/shared';
|
||||
import type { Prisma } from '@prisma/client';
|
||||
import { prisma } from '../prisma';
|
||||
import { getAvailableCaptchaProviders } from '../captcha';
|
||||
|
||||
async function ensureVerificationConfig(guildId: string) {
|
||||
await prisma.guild.upsert({ where: { id: guildId }, update: {}, create: { id: guildId } });
|
||||
@@ -11,21 +12,39 @@ async function ensureVerificationConfig(guildId: string) {
|
||||
});
|
||||
}
|
||||
|
||||
function toDashboard(config: Awaited<ReturnType<typeof ensureVerificationConfig>>): VerificationConfigDashboard {
|
||||
function toDashboard(
|
||||
config: Awaited<ReturnType<typeof ensureVerificationConfig>>,
|
||||
available: CaptchaProvider[]
|
||||
): VerificationConfigDashboard {
|
||||
const rawProvider = (config.captchaProvider as CaptchaProvider) ?? 'MATH';
|
||||
const captchaProvider = available.includes(rawProvider) ? rawProvider : 'MATH';
|
||||
return {
|
||||
enabled: config.enabled,
|
||||
channelId: config.channelId ?? '',
|
||||
verifiedRoleId: config.verifiedRoleId ?? '',
|
||||
unverifiedRoleId: config.unverifiedRoleId ?? '',
|
||||
mode: config.mode as VerificationConfigDashboard['mode'],
|
||||
captchaProvider,
|
||||
minAccountAgeDays: config.minAccountAgeDays,
|
||||
failAction: config.failAction as VerificationConfigDashboard['failAction']
|
||||
failAction: config.failAction as VerificationConfigDashboard['failAction'],
|
||||
altDetectionEnabled: config.altDetectionEnabled,
|
||||
altBlockOnMatch: config.altBlockOnMatch,
|
||||
altIpWindowDays: config.altIpWindowDays,
|
||||
altInviteWindowHours: config.altInviteWindowHours,
|
||||
altMaxAccountsPerInvite: config.altMaxAccountsPerInvite
|
||||
};
|
||||
}
|
||||
|
||||
export async function getVerificationDashboard(guildId: string): Promise<VerificationConfigDashboard> {
|
||||
export async function getVerificationDashboard(guildId: string): Promise<{
|
||||
config: VerificationConfigDashboard;
|
||||
availableCaptchaProviders: CaptchaProvider[];
|
||||
}> {
|
||||
const config = await ensureVerificationConfig(guildId);
|
||||
return toDashboard(config);
|
||||
const availableCaptchaProviders = getAvailableCaptchaProviders();
|
||||
return {
|
||||
config: toDashboard(config, availableCaptchaProviders),
|
||||
availableCaptchaProviders
|
||||
};
|
||||
}
|
||||
|
||||
export async function updateVerificationDashboard(
|
||||
@@ -34,6 +53,11 @@ export async function updateVerificationDashboard(
|
||||
): Promise<VerificationConfigDashboard> {
|
||||
await ensureVerificationConfig(guildId);
|
||||
|
||||
const available = new Set(getAvailableCaptchaProviders());
|
||||
if (patch.captchaProvider !== undefined && !available.has(patch.captchaProvider)) {
|
||||
throw new Error(`Captcha provider ${patch.captchaProvider} is not configured in .env`);
|
||||
}
|
||||
|
||||
const { channelId, verifiedRoleId, unverifiedRoleId, ...rest } = patch;
|
||||
const data: Prisma.VerificationConfigUpdateInput = { ...rest };
|
||||
if (channelId !== undefined) {
|
||||
@@ -47,5 +71,5 @@ export async function updateVerificationDashboard(
|
||||
}
|
||||
|
||||
const updated = await prisma.verificationConfig.update({ where: { guildId }, data });
|
||||
return toDashboard(updated);
|
||||
return toDashboard(updated, getAvailableCaptchaProviders());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user