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,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 }
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user