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:
smueller
2026-07-23 11:28:58 +02:00
parent e24f99ad38
commit 04e9ffad28
27 changed files with 1274 additions and 207 deletions

View File

@@ -13,8 +13,8 @@ export async function GET(_request: NextRequest, { params }: RouteParams) {
const { guildId } = await params;
try {
await requireGuildAccess(guildId);
const config = await getVerificationDashboard(guildId);
return NextResponse.json(config);
const payload = await getVerificationDashboard(guildId);
return NextResponse.json(payload);
} catch (error) {
return toApiErrorResponse(error);
}
@@ -28,15 +28,19 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
const patch = VerificationConfigDashboardPatchSchema.parse(body);
const before = await getVerificationDashboard(guildId);
const after = await updateVerificationDashboard(guildId, patch);
const afterConfig = await updateVerificationDashboard(guildId, patch);
const after = {
config: afterConfig,
availableCaptchaProviders: before.availableCaptchaProviders
};
await writeDashboardAudit(prisma, {
guildId,
actorUserId: session.user.id,
action: 'verification.update',
path: `/dashboard/${guildId}/verification`,
before,
after
before: before.config,
after: afterConfig
});
return NextResponse.json(after);

View File

@@ -8,7 +8,7 @@ interface VerificationPageProps {
export default async function VerificationPage({ params }: VerificationPageProps) {
const { guildId } = await params;
const [locale, config] = await Promise.all([getLocale(), getVerificationDashboard(guildId)]);
const [locale, payload] = await Promise.all([getLocale(), getVerificationDashboard(guildId)]);
return (
<div className="space-y-6">
@@ -16,7 +16,11 @@ export default async function VerificationPage({ params }: VerificationPageProps
<h1 className="text-2xl font-semibold">{t(locale, 'modules.verification.label')}</h1>
<p className="text-sm text-muted-foreground">{t(locale, 'modules.verification.description')}</p>
</div>
<VerificationForm guildId={guildId} initialValue={config} />
<VerificationForm
guildId={guildId}
initialValue={payload.config}
availableCaptchaProviders={payload.availableCaptchaProviders}
/>
</div>
);
}

View File

@@ -1,8 +1,13 @@
import { NextResponse } from 'next/server';
import type { CaptchaProvider } from '@nexumi/shared';
import {
deleteCaptchaChallenge,
getCaptchaChallenge,
hashCaptchaAnswer
getCaptchaPublicConfig,
getClientIp,
hashCaptchaAnswer,
hashClientSignal,
verifyProviderToken
} from '@/lib/captcha';
import { getVerificationQueue } from '@/lib/queues';
@@ -14,53 +19,120 @@ function escapeHtml(value: string): string {
.replaceAll('"', '&quot;');
}
function renderCaptchaPage(token: string, question: string, error?: string): string {
const errorBlock = error ? `<p style="color:#ef4444">${escapeHtml(error)}</p>` : '';
const PAGE_STYLES = `
body { font-family: system-ui, sans-serif; background:#0b1220; color:#f8fafc; display:flex; justify-content:center; align-items:center; min-height:100vh; margin:0; }
.card { background:#111827; padding:2rem; border-radius:12px; width:min(440px, 92vw); border:1px solid #1f2937; }
h1 { margin:0 0 0.5rem; font-size:1.35rem; }
p { margin:0 0 1rem; color:#cbd5e1; line-height:1.45; }
.error { color:#f87171; margin-bottom:0.75rem; }
input, button { width:100%; padding:0.75rem; margin-top:0.75rem; border-radius:8px; border:1px solid #374151; background:#0b1220; color:#f8fafc; box-sizing:border-box; }
button { background:#4f46e5; border:none; cursor:pointer; font-weight:600; }
.widget { display:flex; justify-content:center; margin:1rem 0; min-height:78px; }
`;
function renderShell(body: string): string {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Nexumi Verification</title>
<style>
body { font-family: Inter, system-ui, sans-serif; background:#111827; color:#f9fafb; display:flex; justify-content:center; align-items:center; min-height:100vh; margin:0; }
form { background:#1f2937; padding:2rem; border-radius:12px; width:min(420px, 90vw); }
input, button { width:100%; padding:0.75rem; margin-top:0.75rem; border-radius:8px; border:1px solid #374151; background:#111827; color:#f9fafb; box-sizing:border-box; }
button { background:#6366f1; border:none; cursor:pointer; font-weight:600; }
</style>
<style>${PAGE_STYLES}</style>
</head>
<body>
<form method="POST" action="/verify/captcha">
<body>${body}</body>
</html>`;
}
function renderMathPage(token: string, question: string, error?: string): string {
const errorBlock = error ? `<p class="error">${escapeHtml(error)}</p>` : '';
return renderShell(`
<form class="card" method="POST" action="/verify/captcha">
<h1>Nexumi Verification</h1>
<p>Solve: <strong>${escapeHtml(question)} = ?</strong></p>
${errorBlock}
<input type="hidden" name="token" value="${escapeHtml(token)}" />
<input type="hidden" name="provider" value="MATH" />
<input type="number" name="answer" required autofocus />
<button type="submit">Verify</button>
</form>`);
}
function renderProviderPage(
token: string,
provider: CaptchaProvider,
siteKey: string,
error?: string
): string {
const errorBlock = error ? `<p class="error">${escapeHtml(error)}</p>` : '';
const labels: Record<string, string> = {
RECAPTCHA_V2: 'Complete the Google reCAPTCHA below.',
RECAPTCHA_V3: 'Checking with Google reCAPTCHA…',
HCAPTCHA: 'Complete the hCaptcha below.',
TURNSTILE: 'Complete the Cloudflare Turnstile below.'
};
if (provider === 'RECAPTCHA_V3') {
return renderShell(`
<form class="card" id="verify-form" method="POST" action="/verify/captcha">
<h1>Nexumi Verification</h1>
<p>${escapeHtml(labels.RECAPTCHA_V3)}</p>
${errorBlock}
<input type="hidden" name="token" value="${escapeHtml(token)}" />
<input type="hidden" name="provider" value="RECAPTCHA_V3" />
<input type="hidden" name="captcha_response" id="captcha_response" />
<button type="submit" id="submit-btn" disabled>Verifying…</button>
</form>
</body>
</html>`;
<script src="https://www.google.com/recaptcha/api.js?render=${escapeHtml(siteKey)}"></script>
<script>
grecaptcha.ready(function () {
grecaptcha.execute('${escapeHtml(siteKey)}', { action: 'verify' }).then(function (token) {
document.getElementById('captcha_response').value = token;
document.getElementById('verify-form').submit();
});
});
</script>`);
}
let widget = '';
let scripts = '';
if (provider === 'RECAPTCHA_V2') {
widget = `<div class="g-recaptcha" data-sitekey="${escapeHtml(siteKey)}"></div>`;
scripts = `<script src="https://www.google.com/recaptcha/api.js" async defer></script>`;
} else if (provider === 'HCAPTCHA') {
widget = `<div class="h-captcha" data-sitekey="${escapeHtml(siteKey)}"></div>`;
scripts = `<script src="https://js.hcaptcha.com/1/api.js" async defer></script>`;
} else if (provider === 'TURNSTILE') {
widget = `<div class="cf-turnstile" data-sitekey="${escapeHtml(siteKey)}"></div>`;
scripts = `<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>`;
}
return renderShell(`
<form class="card" method="POST" action="/verify/captcha">
<h1>Nexumi Verification</h1>
<p>${escapeHtml(labels[provider] ?? 'Complete the captcha below.')}</p>
${errorBlock}
<input type="hidden" name="token" value="${escapeHtml(token)}" />
<input type="hidden" name="provider" value="${escapeHtml(provider)}" />
<div class="widget">${widget}</div>
<button type="submit">Verify</button>
</form>
${scripts}`);
}
function renderMisconfigured(provider: string): string {
return renderShell(`
<div class="card">
<h1>Captcha unavailable</h1>
<p>Provider <strong>${escapeHtml(provider)}</strong> is not configured on this server. Ask an admin to set the keys in <code>.env</code>, or switch the guild to Math captcha.</p>
</div>`);
}
function renderDonePage(): string {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Nexumi Verified</title>
<style>
body { font-family: Inter, system-ui, sans-serif; background:#111827; color:#f9fafb; display:flex; justify-content:center; align-items:center; min-height:100vh; margin:0; }
div { text-align:center; }
</style>
</head>
<body>
<div>
return renderShell(`
<div class="card" style="text-align:center">
<h1>Verified</h1>
<p>You can return to Discord.</p>
</div>
</body>
</html>`;
</div>`);
}
function html(body: string, status = 200): NextResponse {
@@ -70,6 +142,39 @@ function html(body: string, status = 200): NextResponse {
});
}
function extractProviderResponse(form: FormData, provider: CaptchaProvider): string {
const direct = String(form.get('captcha_response') ?? '').trim();
if (direct) {
return direct;
}
if (provider === 'RECAPTCHA_V2' || provider === 'RECAPTCHA_V3') {
return String(form.get('g-recaptcha-response') ?? '').trim();
}
if (provider === 'HCAPTCHA') {
return String(form.get('h-captcha-response') ?? '').trim();
}
if (provider === 'TURNSTILE') {
return String(form.get('cf-turnstile-response') ?? '').trim();
}
return '';
}
function renderChallengePage(
token: string,
provider: CaptchaProvider,
question: string | undefined,
error?: string
): NextResponse {
if (provider === 'MATH') {
return html(renderMathPage(token, question ?? '?', error));
}
const publicConfig = getCaptchaPublicConfig(provider);
if (!publicConfig.siteKey) {
return html(renderMisconfigured(provider), 503);
}
return html(renderProviderPage(token, provider, publicConfig.siteKey, error));
}
export async function GET(request: Request): Promise<NextResponse> {
const token = new URL(request.url).searchParams.get('token');
if (!token) {
@@ -79,14 +184,13 @@ export async function GET(request: Request): Promise<NextResponse> {
if (!challenge) {
return new NextResponse('Captcha expired', { status: 404 });
}
return html(renderCaptchaPage(token, challenge.question));
return renderChallengePage(token, challenge.provider, challenge.question);
}
export async function POST(request: Request): Promise<NextResponse> {
const form = await request.formData();
const token = String(form.get('token') ?? '');
const answer = String(form.get('answer') ?? '');
if (!token || !answer) {
if (!token) {
return new NextResponse('Missing fields', { status: 400 });
}
@@ -95,14 +199,43 @@ export async function POST(request: Request): Promise<NextResponse> {
return new NextResponse('Captcha expired', { status: 404 });
}
if (hashCaptchaAnswer(answer) !== challenge.answerHash) {
return html(renderCaptchaPage(token, challenge.question, 'Wrong answer. Try again.'));
const provider = challenge.provider;
const clientIp = getClientIp(request);
const userAgent = request.headers.get('user-agent') ?? '';
if (provider === 'MATH') {
const answer = String(form.get('answer') ?? '');
if (!answer || !challenge.answerHash || hashCaptchaAnswer(answer) !== challenge.answerHash) {
return renderChallengePage(token, provider, challenge.question, 'Wrong answer. Try again.');
}
} else {
const responseToken = extractProviderResponse(form, provider);
const verified = await verifyProviderToken(provider, responseToken, clientIp);
if (!verified.ok) {
const message =
verified.error === 'score_too_low'
? 'reCAPTCHA score too low. Try again.'
: verified.error === 'provider_not_configured'
? 'Captcha provider is not configured.'
: 'Captcha failed. Try again.';
return renderChallengePage(token, provider, challenge.question, message);
}
}
await deleteCaptchaChallenge(token);
const ipHash = clientIp ? hashClientSignal(clientIp) : null;
const userAgentHash = userAgent ? hashClientSignal(userAgent) : null;
await getVerificationQueue().add(
'verificationComplete',
{ guildId: challenge.guildId, userId: challenge.userId },
{
guildId: challenge.guildId,
userId: challenge.userId,
ipHash,
userAgentHash,
captchaProvider: provider
},
{ removeOnComplete: 1000, removeOnFail: 500 }
);

View File

@@ -1,6 +1,6 @@
'use client';
import type { VerificationConfigDashboard } from '@nexumi/shared';
import type { CaptchaProvider, VerificationConfigDashboard } from '@nexumi/shared';
import { FieldAnchor } from '@/components/layout/field-anchor';
import { useTranslations } from '@/components/locale-provider';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
@@ -33,9 +33,14 @@ async function saveVerification(guildId: string, value: VerificationConfigDashbo
interface VerificationFormProps {
guildId: string;
initialValue: VerificationConfigDashboard;
availableCaptchaProviders: CaptchaProvider[];
}
export function VerificationForm({ guildId, initialValue }: VerificationFormProps) {
export function VerificationForm({
guildId,
initialValue,
availableCaptchaProviders
}: VerificationFormProps) {
const t = useTranslations();
return (
@@ -44,113 +49,249 @@ export function VerificationForm({ guildId, initialValue }: VerificationFormProp
onSave={(value) => saveVerification(guildId, value)}
>
{({ value, setValue }) => (
<Card>
<CardHeader>
<CardTitle>{t('modulePages.verification.title')}</CardTitle>
<CardDescription>{t('modulePages.verification.description')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<FieldAnchor id="field-verify-enabled">
<div className="flex items-center justify-between rounded-md border border-border p-4">
<Label>{t('modulePages.verification.enabledLabel')}</Label>
<Switch
checked={value.enabled}
onCheckedChange={(checked) => setValue((prev) => ({ ...prev, enabled: checked }))}
/>
</div>
</FieldAnchor>
<div className="space-y-4">
<Card>
<CardHeader>
<CardTitle>{t('modulePages.verification.title')}</CardTitle>
<CardDescription>{t('modulePages.verification.description')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<FieldAnchor id="field-verify-enabled">
<div className="flex items-center justify-between rounded-md border border-border p-4">
<Label>{t('modulePages.verification.enabledLabel')}</Label>
<Switch
checked={value.enabled}
onCheckedChange={(checked) => setValue((prev) => ({ ...prev, enabled: checked }))}
/>
</div>
</FieldAnchor>
<div className="grid gap-4 sm:grid-cols-2">
<FieldAnchor id="field-verify-mode">
<div className="space-y-2">
<Label>{t('modulePages.verification.mode')}</Label>
<Select
value={value.mode}
onValueChange={(mode) =>
setValue((prev) => ({ ...prev, mode: mode as VerificationConfigDashboard['mode'] }))
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="BUTTON">{t('modulePages.verification.modes.BUTTON')}</SelectItem>
<SelectItem value="CAPTCHA">{t('modulePages.verification.modes.CAPTCHA')}</SelectItem>
</SelectContent>
</Select>
<div className="grid gap-4 sm:grid-cols-2">
<FieldAnchor id="field-verify-mode">
<div className="space-y-2">
<Label>{t('modulePages.verification.mode')}</Label>
<Select
value={value.mode}
onValueChange={(mode) =>
setValue((prev) => ({ ...prev, mode: mode as VerificationConfigDashboard['mode'] }))
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="BUTTON">{t('modulePages.verification.modes.BUTTON')}</SelectItem>
<SelectItem value="CAPTCHA">{t('modulePages.verification.modes.CAPTCHA')}</SelectItem>
</SelectContent>
</Select>
</div>
</FieldAnchor>
<FieldAnchor id="field-verify-fail">
<div className="space-y-2">
<Label>{t('modulePages.verification.failAction')}</Label>
<Select
value={value.failAction}
onValueChange={(failAction) =>
setValue((prev) => ({
...prev,
failAction: failAction as VerificationConfigDashboard['failAction']
}))
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="KICK">{t('modulePages.verification.failActions.KICK')}</SelectItem>
<SelectItem value="BAN">{t('modulePages.verification.failActions.BAN')}</SelectItem>
<SelectItem value="NONE">{t('modulePages.verification.failActions.NONE')}</SelectItem>
</SelectContent>
</Select>
</div>
</FieldAnchor>
</div>
</FieldAnchor>
<FieldAnchor id="field-verify-fail">
<div className="space-y-2">
<Label>{t('modulePages.verification.failAction')}</Label>
<Select
value={value.failAction}
onValueChange={(failAction) =>
setValue((prev) => ({ ...prev, failAction: failAction as VerificationConfigDashboard['failAction'] }))
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="KICK">{t('modulePages.verification.failActions.KICK')}</SelectItem>
<SelectItem value="BAN">{t('modulePages.verification.failActions.BAN')}</SelectItem>
<SelectItem value="NONE">{t('modulePages.verification.failActions.NONE')}</SelectItem>
</SelectContent>
</Select>
</div>
</FieldAnchor>
</div>
<div className="grid gap-4 sm:grid-cols-3">
<FieldAnchor id="field-verify-channel">
<div className="space-y-2">
<Label>{t('modulePages.verification.channelId')}</Label>
<DiscordChannelSelect
guildId={guildId}
value={value.channelId}
onChange={(channelId) => setValue((prev) => ({ ...prev, channelId }))}
/>
</div>
</FieldAnchor>
<FieldAnchor id="field-verify-role">
<div className="space-y-2">
<Label>{t('modulePages.verification.verifiedRoleId')}</Label>
<DiscordRoleSelect
guildId={guildId}
value={value.verifiedRoleId}
onChange={(verifiedRoleId) => setValue((prev) => ({ ...prev, verifiedRoleId }))}
/>
</div>
</FieldAnchor>
<FieldAnchor id="field-verify-unverified">
<div className="space-y-2">
<Label>{t('modulePages.verification.unverifiedRoleId')}</Label>
<DiscordRoleSelect
guildId={guildId}
value={value.unverifiedRoleId}
onChange={(unverifiedRoleId) => setValue((prev) => ({ ...prev, unverifiedRoleId }))}
/>
</div>
</FieldAnchor>
</div>
{value.mode === 'CAPTCHA' ? (
<FieldAnchor id="field-verify-captcha-provider">
<div className="space-y-2">
<Label>{t('modulePages.verification.captchaProvider')}</Label>
<Select
value={value.captchaProvider}
onValueChange={(captchaProvider) =>
setValue((prev) => ({
...prev,
captchaProvider: captchaProvider as CaptchaProvider
}))
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{availableCaptchaProviders.map((provider) => (
<SelectItem key={provider} value={provider}>
{t(`modulePages.verification.captchaProviders.${provider}`)}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{t('modulePages.verification.captchaProviderHint')}
</p>
</div>
</FieldAnchor>
) : null}
<FieldAnchor id="field-verify-age">
<div className="space-y-2">
<Label>{t('modulePages.verification.minAccountAgeDays')}</Label>
<Input
type="number"
min={0}
className="w-40"
value={value.minAccountAgeDays}
onChange={(event) =>
setValue((prev) => ({ ...prev, minAccountAgeDays: Number(event.target.value) || 0 }))
}
/>
</div>
</FieldAnchor>
</CardContent>
</Card>
<div className="grid gap-4 sm:grid-cols-3">
<FieldAnchor id="field-verify-channel">
<div className="space-y-2">
<Label>{t('modulePages.verification.channelId')}</Label>
<DiscordChannelSelect
guildId={guildId}
value={value.channelId}
onChange={(channelId) => setValue((prev) => ({ ...prev, channelId }))}
/>
</div>
</FieldAnchor>
<FieldAnchor id="field-verify-role">
<div className="space-y-2">
<Label>{t('modulePages.verification.verifiedRoleId')}</Label>
<DiscordRoleSelect
guildId={guildId}
value={value.verifiedRoleId}
onChange={(verifiedRoleId) => setValue((prev) => ({ ...prev, verifiedRoleId }))}
/>
</div>
</FieldAnchor>
<FieldAnchor id="field-verify-unverified">
<div className="space-y-2">
<Label>{t('modulePages.verification.unverifiedRoleId')}</Label>
<DiscordRoleSelect
guildId={guildId}
value={value.unverifiedRoleId}
onChange={(unverifiedRoleId) =>
setValue((prev) => ({ ...prev, unverifiedRoleId }))
}
/>
</div>
</FieldAnchor>
</div>
<FieldAnchor id="field-verify-age">
<div className="space-y-2">
<Label>{t('modulePages.verification.minAccountAgeDays')}</Label>
<Input
type="number"
min={0}
className="w-40"
value={value.minAccountAgeDays}
onChange={(event) =>
setValue((prev) => ({
...prev,
minAccountAgeDays: Number(event.target.value) || 0
}))
}
/>
</div>
</FieldAnchor>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>{t('modulePages.verification.altTitle')}</CardTitle>
<CardDescription>{t('modulePages.verification.altDescription')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<FieldAnchor id="field-verify-alt-enabled">
<div className="flex items-center justify-between rounded-md border border-border p-4">
<Label>{t('modulePages.verification.altDetectionEnabled')}</Label>
<Switch
checked={value.altDetectionEnabled}
onCheckedChange={(checked) =>
setValue((prev) => ({ ...prev, altDetectionEnabled: checked }))
}
/>
</div>
</FieldAnchor>
<FieldAnchor id="field-verify-alt-block">
<div className="flex items-center justify-between rounded-md border border-border p-4">
<div className="space-y-1 pr-4">
<Label>{t('modulePages.verification.altBlockOnMatch')}</Label>
<p className="text-xs text-muted-foreground">
{t('modulePages.verification.altBlockOnMatchHint')}
</p>
</div>
<Switch
checked={value.altBlockOnMatch}
onCheckedChange={(checked) =>
setValue((prev) => ({ ...prev, altBlockOnMatch: checked }))
}
disabled={!value.altDetectionEnabled}
/>
</div>
</FieldAnchor>
<div className="grid gap-4 sm:grid-cols-3">
<FieldAnchor id="field-verify-alt-ip-days">
<div className="space-y-2">
<Label>{t('modulePages.verification.altIpWindowDays')}</Label>
<Input
type="number"
min={1}
max={365}
value={value.altIpWindowDays}
disabled={!value.altDetectionEnabled}
onChange={(event) =>
setValue((prev) => ({
...prev,
altIpWindowDays: Number(event.target.value) || 1
}))
}
/>
</div>
</FieldAnchor>
<FieldAnchor id="field-verify-alt-invite-hours">
<div className="space-y-2">
<Label>{t('modulePages.verification.altInviteWindowHours')}</Label>
<Input
type="number"
min={1}
max={720}
value={value.altInviteWindowHours}
disabled={!value.altDetectionEnabled}
onChange={(event) =>
setValue((prev) => ({
...prev,
altInviteWindowHours: Number(event.target.value) || 1
}))
}
/>
</div>
</FieldAnchor>
<FieldAnchor id="field-verify-alt-max-invite">
<div className="space-y-2">
<Label>{t('modulePages.verification.altMaxAccountsPerInvite')}</Label>
<Input
type="number"
min={1}
max={50}
value={value.altMaxAccountsPerInvite}
disabled={!value.altDetectionEnabled}
onChange={(event) =>
setValue((prev) => ({
...prev,
altMaxAccountsPerInvite: Number(event.target.value) || 1
}))
}
/>
</div>
</FieldAnchor>
</div>
</CardContent>
</Card>
</div>
)}
</SettingsForm>
);

View File

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

View File

@@ -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',

View File

@@ -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);

View File

@@ -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());
}

View File

@@ -477,6 +477,15 @@
"BUTTON": "Button",
"CAPTCHA": "Captcha (WebUI)"
},
"captchaProvider": "Captcha-Anbieter",
"captchaProviderHint": "Anbieter mit fehlenden .env-Keys erscheinen nicht in der Liste. Google-Keys: RECAPTCHA_SITE_KEY / RECAPTCHA_SECRET_KEY.",
"captchaProviders": {
"MATH": "Mathe-Aufgabe",
"RECAPTCHA_V2": "Google reCAPTCHA v2",
"RECAPTCHA_V3": "Google reCAPTCHA v3",
"HCAPTCHA": "hCaptcha",
"TURNSTILE": "Cloudflare Turnstile"
},
"failAction": "Aktion bei Fehlschlag",
"failActions": {
"KICK": "Kick",
@@ -486,7 +495,15 @@
"channelId": "Verifizierungs-Kanal",
"verifiedRoleId": "Rolle nach Verifizierung",
"unverifiedRoleId": "Rolle vor Verifizierung",
"minAccountAgeDays": "Mindest-Accountalter (Tage)"
"minAccountAgeDays": "Mindest-Accountalter (Tage)",
"altTitle": "Alt-Account-Erkennung",
"altDescription": "Erkennt mögliche Zweitaccounts über gemeinsame Captcha-IP (gesalzen gehasht) und Invite-Cluster. Kein Browser-Fingerprinting.",
"altDetectionEnabled": "Alt-Erkennung aktivieren",
"altBlockOnMatch": "Bei Treffer blockieren",
"altBlockOnMatchHint": "Nutzt die konfigurierte Fehlschlag-Aktion (Kick/Ban/Keine). Aus = nur markieren, trotzdem verifizieren.",
"altIpWindowDays": "IP-Fenster (Tage)",
"altInviteWindowHours": "Invite-Cluster-Fenster (Stunden)",
"altMaxAccountsPerInvite": "Max. Accounts pro Invite (Cluster)"
},
"leveling": {
"title": "Leveling & XP",

View File

@@ -477,6 +477,15 @@
"BUTTON": "Button",
"CAPTCHA": "Captcha (WebUI)"
},
"captchaProvider": "Captcha provider",
"captchaProviderHint": "Providers without .env keys are hidden. Google keys: RECAPTCHA_SITE_KEY / RECAPTCHA_SECRET_KEY.",
"captchaProviders": {
"MATH": "Math challenge",
"RECAPTCHA_V2": "Google reCAPTCHA v2",
"RECAPTCHA_V3": "Google reCAPTCHA v3",
"HCAPTCHA": "hCaptcha",
"TURNSTILE": "Cloudflare Turnstile"
},
"failAction": "Action on failure",
"failActions": {
"KICK": "Kick",
@@ -486,7 +495,15 @@
"channelId": "Verification channel",
"verifiedRoleId": "Role after verification",
"unverifiedRoleId": "Role before verification",
"minAccountAgeDays": "Minimum account age (days)"
"minAccountAgeDays": "Minimum account age (days)",
"altTitle": "Alt-account detection",
"altDescription": "Flags likely alternate accounts via shared captcha IP (salted hash) and invite clustering. No browser fingerprinting.",
"altDetectionEnabled": "Enable alt detection",
"altBlockOnMatch": "Block on match",
"altBlockOnMatchHint": "Uses the configured fail action (Kick/Ban/None). Off = flag only, still verify.",
"altIpWindowDays": "IP window (days)",
"altInviteWindowHours": "Invite cluster window (hours)",
"altMaxAccountsPerInvite": "Max accounts per invite (cluster)"
},
"leveling": {
"title": "Leveling & XP",