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

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