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:
21
.env.example
21
.env.example
@@ -39,3 +39,24 @@ LEGAL_OPERATOR_NAME=HexaHost Inh. Samuel Müller
|
|||||||
LEGAL_OPERATOR_ADDRESS=Richard-Miller-Straße 1, 94051 Hauzenberg, Deutschland
|
LEGAL_OPERATOR_ADDRESS=Richard-Miller-Straße 1, 94051 Hauzenberg, Deutschland
|
||||||
LEGAL_OPERATOR_EMAIL=info@hexahost.de
|
LEGAL_OPERATOR_EMAIL=info@hexahost.de
|
||||||
LEGAL_OPERATOR_PHONE=+49 15566 175855
|
LEGAL_OPERATOR_PHONE=+49 15566 175855
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Verification captcha providers (WebUI). Leave empty to disable a provider.
|
||||||
|
# MATH always works without keys. Per-guild provider is chosen in the dashboard.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Google reCAPTCHA v2 (checkbox) – https://www.google.com/recaptcha/admin
|
||||||
|
RECAPTCHA_SITE_KEY=
|
||||||
|
RECAPTCHA_SECRET_KEY=
|
||||||
|
# Google reCAPTCHA v3 (score-based). Falls back to RECAPTCHA_* if unset.
|
||||||
|
RECAPTCHA_V3_SITE_KEY=
|
||||||
|
RECAPTCHA_V3_SECRET_KEY=
|
||||||
|
RECAPTCHA_V3_MIN_SCORE=0.5
|
||||||
|
# hCaptcha – https://dashboard.hcaptcha.com
|
||||||
|
HCAPTCHA_SITE_KEY=
|
||||||
|
HCAPTCHA_SECRET_KEY=
|
||||||
|
# Cloudflare Turnstile – https://dash.cloudflare.com → Turnstile
|
||||||
|
TURNSTILE_SITE_KEY=
|
||||||
|
TURNSTILE_SECRET_KEY=
|
||||||
|
# Salt for hashing client IPs used in alt-account detection (min 16 chars).
|
||||||
|
# Generate e.g. with: openssl rand -hex 16
|
||||||
|
CAPTCHA_IP_HASH_SALT=
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "VerificationConfig" ADD COLUMN IF NOT EXISTS "captchaProvider" TEXT NOT NULL DEFAULT 'MATH';
|
||||||
|
ALTER TABLE "VerificationConfig" ADD COLUMN IF NOT EXISTS "altDetectionEnabled" BOOLEAN NOT NULL DEFAULT false;
|
||||||
|
ALTER TABLE "VerificationConfig" ADD COLUMN IF NOT EXISTS "altBlockOnMatch" BOOLEAN NOT NULL DEFAULT true;
|
||||||
|
ALTER TABLE "VerificationConfig" ADD COLUMN IF NOT EXISTS "altIpWindowDays" INTEGER NOT NULL DEFAULT 30;
|
||||||
|
ALTER TABLE "VerificationConfig" ADD COLUMN IF NOT EXISTS "altInviteWindowHours" INTEGER NOT NULL DEFAULT 48;
|
||||||
|
ALTER TABLE "VerificationConfig" ADD COLUMN IF NOT EXISTS "altMaxAccountsPerInvite" INTEGER NOT NULL DEFAULT 3;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE IF NOT EXISTS "VerificationRecord" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"guildId" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"inviteCode" TEXT,
|
||||||
|
"inviterId" TEXT,
|
||||||
|
"ipHash" TEXT,
|
||||||
|
"userAgentHash" TEXT,
|
||||||
|
"captchaProvider" TEXT,
|
||||||
|
"flaggedAsAlt" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"matchedUserId" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "VerificationRecord_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "VerificationRecord_guildId_userId_key" ON "VerificationRecord"("guildId", "userId");
|
||||||
|
CREATE INDEX IF NOT EXISTS "VerificationRecord_guildId_ipHash_createdAt_idx" ON "VerificationRecord"("guildId", "ipHash", "createdAt");
|
||||||
|
CREATE INDEX IF NOT EXISTS "VerificationRecord_guildId_inviteCode_createdAt_idx" ON "VerificationRecord"("guildId", "inviteCode", "createdAt");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "VerificationRecord" ADD CONSTRAINT "VerificationRecord_guildId_fkey"
|
||||||
|
FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN NULL;
|
||||||
|
END $$;
|
||||||
@@ -20,6 +20,7 @@ model Guild {
|
|||||||
logChannels LogChannel[]
|
logChannels LogChannel[]
|
||||||
welcomeConfig WelcomeConfig?
|
welcomeConfig WelcomeConfig?
|
||||||
verificationConfig VerificationConfig?
|
verificationConfig VerificationConfig?
|
||||||
|
verificationRecords VerificationRecord[]
|
||||||
levelingConfig LevelingConfig?
|
levelingConfig LevelingConfig?
|
||||||
memberLevels MemberLevel[]
|
memberLevels MemberLevel[]
|
||||||
levelRewards LevelReward[]
|
levelRewards LevelReward[]
|
||||||
@@ -263,20 +264,47 @@ model WelcomeConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model VerificationConfig {
|
model VerificationConfig {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
guildId String @unique
|
guildId String @unique
|
||||||
enabled Boolean @default(false)
|
enabled Boolean @default(false)
|
||||||
channelId String?
|
channelId String?
|
||||||
verifiedRoleId String?
|
verifiedRoleId String?
|
||||||
unverifiedRoleId String?
|
unverifiedRoleId String?
|
||||||
mode String @default("BUTTON")
|
mode String @default("BUTTON")
|
||||||
minAccountAgeDays Int @default(0)
|
/// MATH | RECAPTCHA_V2 | RECAPTCHA_V3 | HCAPTCHA | TURNSTILE
|
||||||
failAction String @default("KICK")
|
captchaProvider String @default("MATH")
|
||||||
panelChannelId String?
|
minAccountAgeDays Int @default(0)
|
||||||
panelMessageId String?
|
failAction String @default("KICK")
|
||||||
createdAt DateTime @default(now())
|
altDetectionEnabled Boolean @default(false)
|
||||||
updatedAt DateTime @updatedAt
|
altBlockOnMatch Boolean @default(true)
|
||||||
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
|
altIpWindowDays Int @default(30)
|
||||||
|
altInviteWindowHours Int @default(48)
|
||||||
|
altMaxAccountsPerInvite Int @default(3)
|
||||||
|
panelChannelId String?
|
||||||
|
panelMessageId String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One row per successful (or blocked-as-alt) verification attempt fingerprint.
|
||||||
|
model VerificationRecord {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
guildId String
|
||||||
|
userId String
|
||||||
|
inviteCode String?
|
||||||
|
inviterId String?
|
||||||
|
ipHash String?
|
||||||
|
userAgentHash String?
|
||||||
|
captchaProvider String?
|
||||||
|
flaggedAsAlt Boolean @default(false)
|
||||||
|
matchedUserId String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([guildId, userId])
|
||||||
|
@@index([guildId, ipHash, createdAt])
|
||||||
|
@@index([guildId, inviteCode, createdAt])
|
||||||
}
|
}
|
||||||
|
|
||||||
model LevelingConfig {
|
model LevelingConfig {
|
||||||
|
|||||||
@@ -39,7 +39,9 @@ const EnvSchema = z.object({
|
|||||||
)
|
)
|
||||||
),
|
),
|
||||||
WEBUI_URL: z.preprocess(emptyToUndefined, z.string().url().optional()),
|
WEBUI_URL: z.preprocess(emptyToUndefined, z.string().url().optional()),
|
||||||
SUPPORT_SERVER_URL: z.preprocess(emptyToUndefined, z.string().url().optional())
|
SUPPORT_SERVER_URL: z.preprocess(emptyToUndefined, z.string().url().optional()),
|
||||||
|
/// Salt for hashing client IPs in verification alt-detection (shared with WebUI).
|
||||||
|
CAPTCHA_IP_HASH_SALT: z.preprocess(emptyToUndefined, z.string().min(16).optional())
|
||||||
});
|
});
|
||||||
|
|
||||||
export const env = EnvSchema.parse(process.env);
|
export const env = EnvSchema.parse(process.env);
|
||||||
|
|||||||
@@ -71,9 +71,17 @@ async function handleCaptchaGet(url: URL, res: ServerResponse): Promise<void> {
|
|||||||
res.end('Captcha expired');
|
res.end('Captcha expired');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// External providers (reCAPTCHA / hCaptcha / Turnstile) are served by the WebUI.
|
||||||
|
if (challenge.provider !== 'MATH' && env.WEBUI_URL) {
|
||||||
|
const target = `${env.WEBUI_URL.replace(/\/$/, '')}/verify/captcha?token=${encodeURIComponent(token)}`;
|
||||||
|
res.statusCode = 302;
|
||||||
|
res.setHeader('Location', target);
|
||||||
|
res.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
res.statusCode = 200;
|
res.statusCode = 200;
|
||||||
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
||||||
res.end(renderCaptchaPage(token, challenge.question));
|
res.end(renderCaptchaPage(token, challenge.question ?? '?'));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleCaptchaPost(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
async function handleCaptchaPost(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
||||||
@@ -92,16 +100,32 @@ async function handleCaptchaPost(req: IncomingMessage, res: ServerResponse): Pro
|
|||||||
res.end('Captcha expired');
|
res.end('Captcha expired');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (hashCaptchaAnswer(answer) !== challenge.answerHash) {
|
if (challenge.provider !== 'MATH') {
|
||||||
|
if (env.WEBUI_URL) {
|
||||||
|
const target = `${env.WEBUI_URL.replace(/\/$/, '')}/verify/captcha?token=${encodeURIComponent(token)}`;
|
||||||
|
res.statusCode = 302;
|
||||||
|
res.setHeader('Location', target);
|
||||||
|
res.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.statusCode = 400;
|
||||||
|
res.end('Use the WebUI captcha URL for this provider');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!challenge.answerHash || hashCaptchaAnswer(answer) !== challenge.answerHash) {
|
||||||
res.statusCode = 200;
|
res.statusCode = 200;
|
||||||
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
||||||
res.end(renderCaptchaPage(token, challenge.question, 'Wrong answer. Try again.'));
|
res.end(renderCaptchaPage(token, challenge.question ?? '?', 'Wrong answer. Try again.'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await deleteCaptchaChallenge(redis, token);
|
await deleteCaptchaChallenge(redis, token);
|
||||||
await verificationQueue.add(
|
await verificationQueue.add(
|
||||||
'verificationComplete',
|
'verificationComplete',
|
||||||
{ guildId: challenge.guildId, userId: challenge.userId },
|
{
|
||||||
|
guildId: challenge.guildId,
|
||||||
|
userId: challenge.userId,
|
||||||
|
captchaProvider: challenge.provider
|
||||||
|
},
|
||||||
{ removeOnComplete: 1000, removeOnFail: 500 }
|
{ removeOnComplete: 1000, removeOnFail: 500 }
|
||||||
);
|
);
|
||||||
res.statusCode = 200;
|
res.statusCode = 200;
|
||||||
|
|||||||
@@ -65,6 +65,9 @@ type TempBanJob = {
|
|||||||
type VerificationCompleteJob = {
|
type VerificationCompleteJob = {
|
||||||
guildId: string;
|
guildId: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
|
ipHash?: string | null;
|
||||||
|
userAgentHash?: string | null;
|
||||||
|
captchaProvider?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ReminderSendJob = {
|
type ReminderSendJob = {
|
||||||
@@ -228,7 +231,11 @@ export function startWorkers(context: BotContext): Worker[] {
|
|||||||
if (job.name !== 'verificationComplete') {
|
if (job.name !== 'verificationComplete') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await completeVerification(context, job.data.guildId, job.data.userId);
|
await completeVerification(context, job.data.guildId, job.data.userId, {
|
||||||
|
ipHash: job.data.ipHash,
|
||||||
|
userAgentHash: job.data.userAgentHash,
|
||||||
|
captchaProvider: job.data.captchaProvider
|
||||||
|
});
|
||||||
},
|
},
|
||||||
{ connection: redis }
|
{ connection: redis }
|
||||||
);
|
);
|
||||||
|
|||||||
83
apps/bot/src/modules/verification/alt-detection.test.ts
Normal file
83
apps/bot/src/modules/verification/alt-detection.test.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { detectAltAccount } from './alt-detection.js';
|
||||||
|
|
||||||
|
describe('detectAltAccount', () => {
|
||||||
|
const basePrior = {
|
||||||
|
userId: 'user-a',
|
||||||
|
inviteCode: 'abc123',
|
||||||
|
ipHash: 'ip-hash-1',
|
||||||
|
createdAt: new Date(),
|
||||||
|
flaggedAsAlt: false
|
||||||
|
};
|
||||||
|
|
||||||
|
it('matches identical IP hash within window', () => {
|
||||||
|
const match = detectAltAccount({
|
||||||
|
userId: 'user-b',
|
||||||
|
accountAgeDays: 30,
|
||||||
|
inviteCode: null,
|
||||||
|
ipHash: 'ip-hash-1',
|
||||||
|
priorRecords: [basePrior],
|
||||||
|
altIpWindowDays: 30,
|
||||||
|
altInviteWindowHours: 48,
|
||||||
|
altMaxAccountsPerInvite: 3
|
||||||
|
});
|
||||||
|
expect(match).toEqual({ reason: 'ip', matchedUserId: 'user-a' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not match when IP window expired', () => {
|
||||||
|
const match = detectAltAccount({
|
||||||
|
userId: 'user-b',
|
||||||
|
accountAgeDays: 30,
|
||||||
|
inviteCode: null,
|
||||||
|
ipHash: 'ip-hash-1',
|
||||||
|
priorRecords: [
|
||||||
|
{
|
||||||
|
...basePrior,
|
||||||
|
createdAt: new Date(Date.now() - 40 * 24 * 60 * 60 * 1000)
|
||||||
|
}
|
||||||
|
],
|
||||||
|
altIpWindowDays: 30,
|
||||||
|
altInviteWindowHours: 48,
|
||||||
|
altMaxAccountsPerInvite: 3
|
||||||
|
});
|
||||||
|
expect(match).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flags invite clusters for young accounts', () => {
|
||||||
|
const now = Date.now();
|
||||||
|
const match = detectAltAccount({
|
||||||
|
userId: 'user-new',
|
||||||
|
accountAgeDays: 2,
|
||||||
|
inviteCode: 'raid',
|
||||||
|
ipHash: null,
|
||||||
|
priorRecords: [
|
||||||
|
{ ...basePrior, userId: 'u1', inviteCode: 'raid', createdAt: new Date(now - 60_000) },
|
||||||
|
{ ...basePrior, userId: 'u2', inviteCode: 'raid', createdAt: new Date(now - 120_000) },
|
||||||
|
{ ...basePrior, userId: 'u3', inviteCode: 'raid', createdAt: new Date(now - 180_000) }
|
||||||
|
],
|
||||||
|
altIpWindowDays: 30,
|
||||||
|
altInviteWindowHours: 48,
|
||||||
|
altMaxAccountsPerInvite: 3
|
||||||
|
});
|
||||||
|
expect(match?.reason).toBe('invite_cluster');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores invite clusters for older accounts', () => {
|
||||||
|
const now = Date.now();
|
||||||
|
const match = detectAltAccount({
|
||||||
|
userId: 'user-old',
|
||||||
|
accountAgeDays: 90,
|
||||||
|
inviteCode: 'raid',
|
||||||
|
ipHash: null,
|
||||||
|
priorRecords: [
|
||||||
|
{ ...basePrior, userId: 'u1', inviteCode: 'raid', createdAt: new Date(now - 60_000) },
|
||||||
|
{ ...basePrior, userId: 'u2', inviteCode: 'raid', createdAt: new Date(now - 120_000) },
|
||||||
|
{ ...basePrior, userId: 'u3', inviteCode: 'raid', createdAt: new Date(now - 180_000) }
|
||||||
|
],
|
||||||
|
altIpWindowDays: 30,
|
||||||
|
altInviteWindowHours: 48,
|
||||||
|
altMaxAccountsPerInvite: 3
|
||||||
|
});
|
||||||
|
expect(match).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
62
apps/bot/src/modules/verification/alt-detection.ts
Normal file
62
apps/bot/src/modules/verification/alt-detection.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
export type AltSignalMatch = {
|
||||||
|
reason: 'ip' | 'invite_cluster';
|
||||||
|
matchedUserId: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AltDetectionInput = {
|
||||||
|
userId: string;
|
||||||
|
accountAgeDays: number;
|
||||||
|
inviteCode: string | null;
|
||||||
|
ipHash: string | null;
|
||||||
|
/** Prior verification records in the same guild (excluding current user). */
|
||||||
|
priorRecords: Array<{
|
||||||
|
userId: string;
|
||||||
|
inviteCode: string | null;
|
||||||
|
ipHash: string | null;
|
||||||
|
createdAt: Date;
|
||||||
|
flaggedAsAlt: boolean;
|
||||||
|
}>;
|
||||||
|
altIpWindowDays: number;
|
||||||
|
altInviteWindowHours: number;
|
||||||
|
altMaxAccountsPerInvite: number;
|
||||||
|
/** Only flag invite clusters when the current account is this young or younger. */
|
||||||
|
youngAccountMaxDays?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detects likely alt accounts from Discord + captcha signals.
|
||||||
|
* Does not use browser fingerprinting beyond salted IP / UA hashes from the captcha page.
|
||||||
|
*/
|
||||||
|
export function detectAltAccount(input: AltDetectionInput): AltSignalMatch | null {
|
||||||
|
const youngMax = input.youngAccountMaxDays ?? 7;
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
if (input.ipHash) {
|
||||||
|
const ipWindowMs = input.altIpWindowDays * 24 * 60 * 60 * 1000;
|
||||||
|
const ipMatch = input.priorRecords.find(
|
||||||
|
(record) =>
|
||||||
|
record.ipHash === input.ipHash &&
|
||||||
|
now - record.createdAt.getTime() <= ipWindowMs
|
||||||
|
);
|
||||||
|
if (ipMatch) {
|
||||||
|
return { reason: 'ip', matchedUserId: ipMatch.userId };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.inviteCode && input.accountAgeDays <= youngMax) {
|
||||||
|
const inviteWindowMs = input.altInviteWindowHours * 60 * 60 * 1000;
|
||||||
|
const recentSameInvite = input.priorRecords.filter(
|
||||||
|
(record) =>
|
||||||
|
record.inviteCode === input.inviteCode &&
|
||||||
|
now - record.createdAt.getTime() <= inviteWindowMs
|
||||||
|
);
|
||||||
|
if (recentSameInvite.length >= input.altMaxAccountsPerInvite) {
|
||||||
|
return {
|
||||||
|
reason: 'invite_cluster',
|
||||||
|
matchedUserId: recentSameInvite[0]?.userId ?? null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -26,6 +26,18 @@ export const verifyCommandData = applyCommandDescription(
|
|||||||
{ name: 'Captcha', value: 'CAPTCHA' }
|
{ name: 'Captcha', value: 'CAPTCHA' }
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
.addStringOption((o) =>
|
||||||
|
applyOptionDescription(
|
||||||
|
o.setName('captcha_provider'),
|
||||||
|
'verify.setup.options.captcha_provider'
|
||||||
|
).addChoices(
|
||||||
|
{ name: 'Math', value: 'MATH' },
|
||||||
|
{ name: 'reCAPTCHA v2', value: 'RECAPTCHA_V2' },
|
||||||
|
{ name: 'reCAPTCHA v3', value: 'RECAPTCHA_V3' },
|
||||||
|
{ name: 'hCaptcha', value: 'HCAPTCHA' },
|
||||||
|
{ name: 'Turnstile', value: 'TURNSTILE' }
|
||||||
|
)
|
||||||
|
)
|
||||||
.addIntegerOption((o) =>
|
.addIntegerOption((o) =>
|
||||||
applyOptionDescription(o.setName('min_account_age_days'), 'verify.setup.options.min_account_age_days')
|
applyOptionDescription(o.setName('min_account_age_days'), 'verify.setup.options.min_account_age_days')
|
||||||
.setMinValue(0)
|
.setMinValue(0)
|
||||||
@@ -39,6 +51,9 @@ export const verifyCommandData = applyCommandDescription(
|
|||||||
{ name: 'None', value: 'NONE' }
|
{ name: 'None', value: 'NONE' }
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
.addBooleanOption((o) =>
|
||||||
|
applyOptionDescription(o.setName('alt_detection'), 'verify.setup.options.alt_detection')
|
||||||
|
)
|
||||||
)
|
)
|
||||||
.addSubcommand((s) =>
|
.addSubcommand((s) =>
|
||||||
applyCommandDescription(s.setName('panel'), 'verify.panel.description')
|
applyCommandDescription(s.setName('panel'), 'verify.panel.description')
|
||||||
|
|||||||
@@ -50,11 +50,20 @@ const verifyCommand: SlashCommand = {
|
|||||||
const verifiedRole = interaction.options.getRole('verified_role', true);
|
const verifiedRole = interaction.options.getRole('verified_role', true);
|
||||||
const unverifiedRole = interaction.options.getRole('unverified_role');
|
const unverifiedRole = interaction.options.getRole('unverified_role');
|
||||||
const mode = (interaction.options.getString('mode') ?? 'BUTTON') as 'BUTTON' | 'CAPTCHA';
|
const mode = (interaction.options.getString('mode') ?? 'BUTTON') as 'BUTTON' | 'CAPTCHA';
|
||||||
|
const captchaProvider = (interaction.options.getString('captcha_provider') ??
|
||||||
|
'MATH') as
|
||||||
|
| 'MATH'
|
||||||
|
| 'RECAPTCHA_V2'
|
||||||
|
| 'RECAPTCHA_V3'
|
||||||
|
| 'HCAPTCHA'
|
||||||
|
| 'TURNSTILE';
|
||||||
const minAccountAgeDays = interaction.options.getInteger('min_account_age_days') ?? 0;
|
const minAccountAgeDays = interaction.options.getInteger('min_account_age_days') ?? 0;
|
||||||
const failAction = (interaction.options.getString('fail_action') ?? 'KICK') as
|
const failAction = (interaction.options.getString('fail_action') ?? 'KICK') as
|
||||||
| 'KICK'
|
| 'KICK'
|
||||||
| 'BAN'
|
| 'BAN'
|
||||||
| 'NONE';
|
| 'NONE';
|
||||||
|
const altDetectionEnabled =
|
||||||
|
interaction.options.getBoolean('alt_detection') ?? false;
|
||||||
|
|
||||||
await updateVerificationConfig(context.prisma, guildId, {
|
await updateVerificationConfig(context.prisma, guildId, {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
@@ -62,8 +71,10 @@ const verifyCommand: SlashCommand = {
|
|||||||
verifiedRoleId: verifiedRole.id,
|
verifiedRoleId: verifiedRole.id,
|
||||||
unverifiedRoleId: unverifiedRole?.id ?? null,
|
unverifiedRoleId: unverifiedRole?.id ?? null,
|
||||||
mode,
|
mode,
|
||||||
|
captchaProvider,
|
||||||
minAccountAgeDays,
|
minAccountAgeDays,
|
||||||
failAction
|
failAction,
|
||||||
|
altDetectionEnabled
|
||||||
});
|
});
|
||||||
|
|
||||||
await interaction.reply({ content: t(locale, 'verification.setupDone'), ...ephemeral() });
|
await interaction.reply({ content: t(locale, 'verification.setupDone'), ...ephemeral() });
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
type ButtonInteraction,
|
type ButtonInteraction,
|
||||||
type GuildMember
|
type GuildMember
|
||||||
} from 'discord.js';
|
} from 'discord.js';
|
||||||
import { t, tf } from '@nexumi/shared';
|
import { CaptchaProviderSchema, t, tf, type CaptchaProvider } from '@nexumi/shared';
|
||||||
import type { BotContext } from '../../types.js';
|
import type { BotContext } from '../../types.js';
|
||||||
import { getGuildLocale } from '../../i18n.js';
|
import { getGuildLocale } from '../../i18n.js';
|
||||||
import {
|
import {
|
||||||
@@ -18,10 +18,17 @@ import {
|
|||||||
verificationButtonId,
|
verificationButtonId,
|
||||||
verificationCaptchaButtonId
|
verificationCaptchaButtonId
|
||||||
} from './service.js';
|
} from './service.js';
|
||||||
|
import { detectAltAccount } from './alt-detection.js';
|
||||||
import { env } from '../../env.js';
|
import { env } from '../../env.js';
|
||||||
import { logger } from '../../logger.js';
|
import { logger } from '../../logger.js';
|
||||||
import { ephemeral } from '../../interaction-reply.js';
|
import { ephemeral } from '../../interaction-reply.js';
|
||||||
|
|
||||||
|
export type CompleteVerificationOptions = {
|
||||||
|
ipHash?: string | null;
|
||||||
|
userAgentHash?: string | null;
|
||||||
|
captchaProvider?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
async function applyFailAction(
|
async function applyFailAction(
|
||||||
member: GuildMember,
|
member: GuildMember,
|
||||||
failAction: string,
|
failAction: string,
|
||||||
@@ -40,7 +47,8 @@ async function applyFailAction(
|
|||||||
export async function completeVerification(
|
export async function completeVerification(
|
||||||
context: BotContext,
|
context: BotContext,
|
||||||
guildId: string,
|
guildId: string,
|
||||||
userId: string
|
userId: string,
|
||||||
|
options: CompleteVerificationOptions = {}
|
||||||
): Promise<{ ok: boolean; reason?: string }> {
|
): Promise<{ ok: boolean; reason?: string }> {
|
||||||
const config = await getVerificationConfig(context.prisma, guildId);
|
const config = await getVerificationConfig(context.prisma, guildId);
|
||||||
if (!config.enabled || !config.verifiedRoleId) {
|
if (!config.enabled || !config.verifiedRoleId) {
|
||||||
@@ -63,6 +71,88 @@ export async function completeVerification(
|
|||||||
return { ok: false, reason: 'account_too_young' };
|
return { ok: false, reason: 'account_too_young' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const inviteJoin = await context.prisma.inviteJoin.findUnique({
|
||||||
|
where: { guildId_userId: { guildId, userId } }
|
||||||
|
});
|
||||||
|
|
||||||
|
let flaggedAsAlt = false;
|
||||||
|
let matchedUserId: string | null = null;
|
||||||
|
|
||||||
|
if (config.altDetectionEnabled) {
|
||||||
|
const lookbackMs = Math.max(
|
||||||
|
config.altIpWindowDays * 24 * 60 * 60 * 1000,
|
||||||
|
config.altInviteWindowHours * 60 * 60 * 1000
|
||||||
|
);
|
||||||
|
const since = new Date(Date.now() - lookbackMs);
|
||||||
|
const priorRecords = await context.prisma.verificationRecord.findMany({
|
||||||
|
where: {
|
||||||
|
guildId,
|
||||||
|
userId: { not: userId },
|
||||||
|
createdAt: { gte: since }
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
userId: true,
|
||||||
|
inviteCode: true,
|
||||||
|
ipHash: true,
|
||||||
|
createdAt: true,
|
||||||
|
flaggedAsAlt: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const match = detectAltAccount({
|
||||||
|
userId,
|
||||||
|
accountAgeDays: ageDays,
|
||||||
|
inviteCode: inviteJoin?.code ?? null,
|
||||||
|
ipHash: options.ipHash ?? null,
|
||||||
|
priorRecords,
|
||||||
|
altIpWindowDays: config.altIpWindowDays,
|
||||||
|
altInviteWindowHours: config.altInviteWindowHours,
|
||||||
|
altMaxAccountsPerInvite: config.altMaxAccountsPerInvite
|
||||||
|
});
|
||||||
|
|
||||||
|
if (match) {
|
||||||
|
flaggedAsAlt = true;
|
||||||
|
matchedUserId = match.matchedUserId;
|
||||||
|
logger.info(
|
||||||
|
{ guildId, userId, reason: match.reason, matchedUserId },
|
||||||
|
'Verification alt-account signal'
|
||||||
|
);
|
||||||
|
|
||||||
|
await context.prisma.verificationRecord.upsert({
|
||||||
|
where: { guildId_userId: { guildId, userId } },
|
||||||
|
create: {
|
||||||
|
guildId,
|
||||||
|
userId,
|
||||||
|
inviteCode: inviteJoin?.code ?? null,
|
||||||
|
inviterId: inviteJoin?.inviterId ?? null,
|
||||||
|
ipHash: options.ipHash ?? null,
|
||||||
|
userAgentHash: options.userAgentHash ?? null,
|
||||||
|
captchaProvider: options.captchaProvider ?? null,
|
||||||
|
flaggedAsAlt: true,
|
||||||
|
matchedUserId
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
inviteCode: inviteJoin?.code ?? null,
|
||||||
|
inviterId: inviteJoin?.inviterId ?? null,
|
||||||
|
ipHash: options.ipHash ?? null,
|
||||||
|
userAgentHash: options.userAgentHash ?? null,
|
||||||
|
captchaProvider: options.captchaProvider ?? null,
|
||||||
|
flaggedAsAlt: true,
|
||||||
|
matchedUserId
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (config.altBlockOnMatch) {
|
||||||
|
await applyFailAction(
|
||||||
|
member,
|
||||||
|
config.failAction,
|
||||||
|
`Verification failed: possible alt account (${match.reason})`
|
||||||
|
);
|
||||||
|
return { ok: false, reason: 'alt_detected' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const me = guild.members.me;
|
const me = guild.members.me;
|
||||||
if (!me?.permissions.has(PermissionFlagsBits.ManageRoles)) {
|
if (!me?.permissions.has(PermissionFlagsBits.ManageRoles)) {
|
||||||
return { ok: false, reason: 'missing_permissions' };
|
return { ok: false, reason: 'missing_permissions' };
|
||||||
@@ -83,6 +173,30 @@ export async function completeVerification(
|
|||||||
await member.roles.add(config.verifiedRoleId);
|
await member.roles.add(config.verifiedRoleId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await context.prisma.verificationRecord.upsert({
|
||||||
|
where: { guildId_userId: { guildId, userId } },
|
||||||
|
create: {
|
||||||
|
guildId,
|
||||||
|
userId,
|
||||||
|
inviteCode: inviteJoin?.code ?? null,
|
||||||
|
inviterId: inviteJoin?.inviterId ?? null,
|
||||||
|
ipHash: options.ipHash ?? null,
|
||||||
|
userAgentHash: options.userAgentHash ?? null,
|
||||||
|
captchaProvider: options.captchaProvider ?? null,
|
||||||
|
flaggedAsAlt,
|
||||||
|
matchedUserId
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
inviteCode: inviteJoin?.code ?? null,
|
||||||
|
inviterId: inviteJoin?.inviterId ?? null,
|
||||||
|
ipHash: options.ipHash ?? null,
|
||||||
|
userAgentHash: options.userAgentHash ?? null,
|
||||||
|
captchaProvider: options.captchaProvider ?? null,
|
||||||
|
flaggedAsAlt,
|
||||||
|
matchedUserId
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,10 +246,13 @@ export async function handleVerificationButton(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (parsed.mode === 'captcha') {
|
if (parsed.mode === 'captcha') {
|
||||||
|
const providerParse = CaptchaProviderSchema.safeParse(config.captchaProvider);
|
||||||
|
const provider: CaptchaProvider = providerParse.success ? providerParse.data : 'MATH';
|
||||||
const challenge = await createCaptchaChallenge(
|
const challenge = await createCaptchaChallenge(
|
||||||
context.redis,
|
context.redis,
|
||||||
parsed.guildId,
|
parsed.guildId,
|
||||||
interaction.user.id
|
interaction.user.id,
|
||||||
|
provider
|
||||||
);
|
);
|
||||||
const base = (env.WEBUI_URL ?? env.PUBLIC_BASE_URL).replace(/\/$/, '');
|
const base = (env.WEBUI_URL ?? env.PUBLIC_BASE_URL).replace(/\/$/, '');
|
||||||
const url = `${base}/verify/captcha?token=${challenge.token}`;
|
const url = `${base}/verify/captcha?token=${challenge.token}`;
|
||||||
|
|||||||
@@ -1,11 +1,27 @@
|
|||||||
import { randomBytes, randomInt, createHash } from 'node:crypto';
|
import { createHash, randomBytes, randomInt } from 'node:crypto';
|
||||||
|
import type { CaptchaProvider, VerificationFailAction, VerificationMode } from '@nexumi/shared';
|
||||||
import type { PrismaClient } from '@prisma/client';
|
import type { PrismaClient } from '@prisma/client';
|
||||||
import type { VerificationFailAction, VerificationMode } from '@nexumi/shared';
|
|
||||||
import { ensureGuild } from '../../guild.js';
|
|
||||||
import type { Redis } from 'ioredis';
|
import type { Redis } from 'ioredis';
|
||||||
|
import { ensureGuild } from '../../guild.js';
|
||||||
|
|
||||||
const CAPTCHA_PREFIX = 'verify:captcha:';
|
const CAPTCHA_PREFIX = 'verify:captcha:';
|
||||||
|
|
||||||
|
export type VerificationConfigUpdate = {
|
||||||
|
enabled: boolean;
|
||||||
|
channelId: string;
|
||||||
|
verifiedRoleId: string;
|
||||||
|
unverifiedRoleId?: string | null;
|
||||||
|
mode: VerificationMode;
|
||||||
|
captchaProvider?: CaptchaProvider;
|
||||||
|
minAccountAgeDays: number;
|
||||||
|
failAction: VerificationFailAction;
|
||||||
|
altDetectionEnabled?: boolean;
|
||||||
|
altBlockOnMatch?: boolean;
|
||||||
|
altIpWindowDays?: number;
|
||||||
|
altInviteWindowHours?: number;
|
||||||
|
altMaxAccountsPerInvite?: number;
|
||||||
|
};
|
||||||
|
|
||||||
export async function getVerificationConfig(prisma: PrismaClient, guildId: string) {
|
export async function getVerificationConfig(prisma: PrismaClient, guildId: string) {
|
||||||
await ensureGuild(prisma, guildId);
|
await ensureGuild(prisma, guildId);
|
||||||
let config = await prisma.verificationConfig.findUnique({ where: { guildId } });
|
let config = await prisma.verificationConfig.findUnique({ where: { guildId } });
|
||||||
@@ -18,15 +34,7 @@ export async function getVerificationConfig(prisma: PrismaClient, guildId: strin
|
|||||||
export async function updateVerificationConfig(
|
export async function updateVerificationConfig(
|
||||||
prisma: PrismaClient,
|
prisma: PrismaClient,
|
||||||
guildId: string,
|
guildId: string,
|
||||||
data: {
|
data: VerificationConfigUpdate
|
||||||
enabled: boolean;
|
|
||||||
channelId: string;
|
|
||||||
verifiedRoleId: string;
|
|
||||||
unverifiedRoleId?: string | null;
|
|
||||||
mode: VerificationMode;
|
|
||||||
minAccountAgeDays: number;
|
|
||||||
failAction: VerificationFailAction;
|
|
||||||
}
|
|
||||||
) {
|
) {
|
||||||
await ensureGuild(prisma, guildId);
|
await ensureGuild(prisma, guildId);
|
||||||
return prisma.verificationConfig.upsert({
|
return prisma.verificationConfig.upsert({
|
||||||
@@ -65,27 +73,32 @@ export type CaptchaChallenge = {
|
|||||||
token: string;
|
token: string;
|
||||||
guildId: string;
|
guildId: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
question: string;
|
provider: CaptchaProvider;
|
||||||
answerHash: string;
|
question?: string;
|
||||||
|
answerHash?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function createCaptchaChallenge(
|
export async function createCaptchaChallenge(
|
||||||
redis: Redis,
|
redis: Redis,
|
||||||
guildId: string,
|
guildId: string,
|
||||||
userId: string
|
userId: string,
|
||||||
|
provider: CaptchaProvider = 'MATH'
|
||||||
): Promise<CaptchaChallenge> {
|
): Promise<CaptchaChallenge> {
|
||||||
const a = randomInt(2, 12);
|
|
||||||
const b = randomInt(2, 12);
|
|
||||||
const answer = String(a + b);
|
|
||||||
const token = randomBytes(24).toString('hex');
|
const token = randomBytes(24).toString('hex');
|
||||||
const answerHash = createHash('sha256').update(answer).digest('hex');
|
|
||||||
const challenge: CaptchaChallenge = {
|
const challenge: CaptchaChallenge = {
|
||||||
token,
|
token,
|
||||||
guildId,
|
guildId,
|
||||||
userId,
|
userId,
|
||||||
question: `${a} + ${b}`,
|
provider
|
||||||
answerHash
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (provider === 'MATH') {
|
||||||
|
const a = randomInt(2, 12);
|
||||||
|
const b = randomInt(2, 12);
|
||||||
|
challenge.question = `${a} + ${b}`;
|
||||||
|
challenge.answerHash = createHash('sha256').update(String(a + b)).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
await redis.set(`${CAPTCHA_PREFIX}${token}`, JSON.stringify(challenge), 'EX', 600);
|
await redis.set(`${CAPTCHA_PREFIX}${token}`, JSON.stringify(challenge), 'EX', 600);
|
||||||
return challenge;
|
return challenge;
|
||||||
}
|
}
|
||||||
@@ -98,7 +111,11 @@ export async function getCaptchaChallenge(
|
|||||||
if (!raw) {
|
if (!raw) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return JSON.parse(raw) as CaptchaChallenge;
|
const parsed = JSON.parse(raw) as CaptchaChallenge;
|
||||||
|
if (!parsed.provider) {
|
||||||
|
parsed.provider = 'MATH';
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteCaptchaChallenge(redis: Redis, token: string): Promise<void> {
|
export async function deleteCaptchaChallenge(redis: Redis, token: string): Promise<void> {
|
||||||
@@ -113,3 +130,7 @@ export function accountAgeDays(userCreatedAt: Date): number {
|
|||||||
const ms = Date.now() - userCreatedAt.getTime();
|
const ms = Date.now() - userCreatedAt.getTime();
|
||||||
return Math.floor(ms / (1000 * 60 * 60 * 24));
|
return Math.floor(ms / (1000 * 60 * 60 * 24));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function hashClientSignal(salt: string, value: string): string {
|
||||||
|
return createHash('sha256').update(`${salt}:${value}`).digest('hex');
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ export async function GET(_request: NextRequest, { params }: RouteParams) {
|
|||||||
const { guildId } = await params;
|
const { guildId } = await params;
|
||||||
try {
|
try {
|
||||||
await requireGuildAccess(guildId);
|
await requireGuildAccess(guildId);
|
||||||
const config = await getVerificationDashboard(guildId);
|
const payload = await getVerificationDashboard(guildId);
|
||||||
return NextResponse.json(config);
|
return NextResponse.json(payload);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return toApiErrorResponse(error);
|
return toApiErrorResponse(error);
|
||||||
}
|
}
|
||||||
@@ -28,15 +28,19 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
|
|||||||
const patch = VerificationConfigDashboardPatchSchema.parse(body);
|
const patch = VerificationConfigDashboardPatchSchema.parse(body);
|
||||||
|
|
||||||
const before = await getVerificationDashboard(guildId);
|
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, {
|
await writeDashboardAudit(prisma, {
|
||||||
guildId,
|
guildId,
|
||||||
actorUserId: session.user.id,
|
actorUserId: session.user.id,
|
||||||
action: 'verification.update',
|
action: 'verification.update',
|
||||||
path: `/dashboard/${guildId}/verification`,
|
path: `/dashboard/${guildId}/verification`,
|
||||||
before,
|
before: before.config,
|
||||||
after
|
after: afterConfig
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json(after);
|
return NextResponse.json(after);
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ interface VerificationPageProps {
|
|||||||
|
|
||||||
export default async function VerificationPage({ params }: VerificationPageProps) {
|
export default async function VerificationPage({ params }: VerificationPageProps) {
|
||||||
const { guildId } = await params;
|
const { guildId } = await params;
|
||||||
const [locale, config] = await Promise.all([getLocale(), getVerificationDashboard(guildId)]);
|
const [locale, payload] = await Promise.all([getLocale(), getVerificationDashboard(guildId)]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<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>
|
<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>
|
<p className="text-sm text-muted-foreground">{t(locale, 'modules.verification.description')}</p>
|
||||||
</div>
|
</div>
|
||||||
<VerificationForm guildId={guildId} initialValue={config} />
|
<VerificationForm
|
||||||
|
guildId={guildId}
|
||||||
|
initialValue={payload.config}
|
||||||
|
availableCaptchaProviders={payload.availableCaptchaProviders}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
|
import type { CaptchaProvider } from '@nexumi/shared';
|
||||||
import {
|
import {
|
||||||
deleteCaptchaChallenge,
|
deleteCaptchaChallenge,
|
||||||
getCaptchaChallenge,
|
getCaptchaChallenge,
|
||||||
hashCaptchaAnswer
|
getCaptchaPublicConfig,
|
||||||
|
getClientIp,
|
||||||
|
hashCaptchaAnswer,
|
||||||
|
hashClientSignal,
|
||||||
|
verifyProviderToken
|
||||||
} from '@/lib/captcha';
|
} from '@/lib/captcha';
|
||||||
import { getVerificationQueue } from '@/lib/queues';
|
import { getVerificationQueue } from '@/lib/queues';
|
||||||
|
|
||||||
@@ -14,53 +19,120 @@ function escapeHtml(value: string): string {
|
|||||||
.replaceAll('"', '"');
|
.replaceAll('"', '"');
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderCaptchaPage(token: string, question: string, error?: string): string {
|
const PAGE_STYLES = `
|
||||||
const errorBlock = error ? `<p style="color:#ef4444">${escapeHtml(error)}</p>` : '';
|
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>
|
return `<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>Nexumi Verification</title>
|
<title>Nexumi Verification</title>
|
||||||
<style>
|
<style>${PAGE_STYLES}</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>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>${body}</body>
|
||||||
<form method="POST" action="/verify/captcha">
|
</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>
|
<h1>Nexumi Verification</h1>
|
||||||
<p>Solve: <strong>${escapeHtml(question)} = ?</strong></p>
|
<p>Solve: <strong>${escapeHtml(question)} = ?</strong></p>
|
||||||
${errorBlock}
|
${errorBlock}
|
||||||
<input type="hidden" name="token" value="${escapeHtml(token)}" />
|
<input type="hidden" name="token" value="${escapeHtml(token)}" />
|
||||||
|
<input type="hidden" name="provider" value="MATH" />
|
||||||
<input type="number" name="answer" required autofocus />
|
<input type="number" name="answer" required autofocus />
|
||||||
<button type="submit">Verify</button>
|
<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>
|
</form>
|
||||||
</body>
|
<script src="https://www.google.com/recaptcha/api.js?render=${escapeHtml(siteKey)}"></script>
|
||||||
</html>`;
|
<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 {
|
function renderDonePage(): string {
|
||||||
return `<!DOCTYPE html>
|
return renderShell(`
|
||||||
<html lang="en">
|
<div class="card" style="text-align:center">
|
||||||
<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>
|
|
||||||
<h1>Verified</h1>
|
<h1>Verified</h1>
|
||||||
<p>You can return to Discord.</p>
|
<p>You can return to Discord.</p>
|
||||||
</div>
|
</div>`);
|
||||||
</body>
|
|
||||||
</html>`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function html(body: string, status = 200): NextResponse {
|
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> {
|
export async function GET(request: Request): Promise<NextResponse> {
|
||||||
const token = new URL(request.url).searchParams.get('token');
|
const token = new URL(request.url).searchParams.get('token');
|
||||||
if (!token) {
|
if (!token) {
|
||||||
@@ -79,14 +184,13 @@ export async function GET(request: Request): Promise<NextResponse> {
|
|||||||
if (!challenge) {
|
if (!challenge) {
|
||||||
return new NextResponse('Captcha expired', { status: 404 });
|
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> {
|
export async function POST(request: Request): Promise<NextResponse> {
|
||||||
const form = await request.formData();
|
const form = await request.formData();
|
||||||
const token = String(form.get('token') ?? '');
|
const token = String(form.get('token') ?? '');
|
||||||
const answer = String(form.get('answer') ?? '');
|
if (!token) {
|
||||||
if (!token || !answer) {
|
|
||||||
return new NextResponse('Missing fields', { status: 400 });
|
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 });
|
return new NextResponse('Captcha expired', { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hashCaptchaAnswer(answer) !== challenge.answerHash) {
|
const provider = challenge.provider;
|
||||||
return html(renderCaptchaPage(token, challenge.question, 'Wrong answer. Try again.'));
|
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);
|
await deleteCaptchaChallenge(token);
|
||||||
|
|
||||||
|
const ipHash = clientIp ? hashClientSignal(clientIp) : null;
|
||||||
|
const userAgentHash = userAgent ? hashClientSignal(userAgent) : null;
|
||||||
|
|
||||||
await getVerificationQueue().add(
|
await getVerificationQueue().add(
|
||||||
'verificationComplete',
|
'verificationComplete',
|
||||||
{ guildId: challenge.guildId, userId: challenge.userId },
|
{
|
||||||
|
guildId: challenge.guildId,
|
||||||
|
userId: challenge.userId,
|
||||||
|
ipHash,
|
||||||
|
userAgentHash,
|
||||||
|
captchaProvider: provider
|
||||||
|
},
|
||||||
{ removeOnComplete: 1000, removeOnFail: 500 }
|
{ removeOnComplete: 1000, removeOnFail: 500 }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import type { VerificationConfigDashboard } from '@nexumi/shared';
|
import type { CaptchaProvider, VerificationConfigDashboard } from '@nexumi/shared';
|
||||||
import { FieldAnchor } from '@/components/layout/field-anchor';
|
import { FieldAnchor } from '@/components/layout/field-anchor';
|
||||||
import { useTranslations } from '@/components/locale-provider';
|
import { useTranslations } from '@/components/locale-provider';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
@@ -33,9 +33,14 @@ async function saveVerification(guildId: string, value: VerificationConfigDashbo
|
|||||||
interface VerificationFormProps {
|
interface VerificationFormProps {
|
||||||
guildId: string;
|
guildId: string;
|
||||||
initialValue: VerificationConfigDashboard;
|
initialValue: VerificationConfigDashboard;
|
||||||
|
availableCaptchaProviders: CaptchaProvider[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function VerificationForm({ guildId, initialValue }: VerificationFormProps) {
|
export function VerificationForm({
|
||||||
|
guildId,
|
||||||
|
initialValue,
|
||||||
|
availableCaptchaProviders
|
||||||
|
}: VerificationFormProps) {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -44,113 +49,249 @@ export function VerificationForm({ guildId, initialValue }: VerificationFormProp
|
|||||||
onSave={(value) => saveVerification(guildId, value)}
|
onSave={(value) => saveVerification(guildId, value)}
|
||||||
>
|
>
|
||||||
{({ value, setValue }) => (
|
{({ value, setValue }) => (
|
||||||
<Card>
|
<div className="space-y-4">
|
||||||
<CardHeader>
|
<Card>
|
||||||
<CardTitle>{t('modulePages.verification.title')}</CardTitle>
|
<CardHeader>
|
||||||
<CardDescription>{t('modulePages.verification.description')}</CardDescription>
|
<CardTitle>{t('modulePages.verification.title')}</CardTitle>
|
||||||
</CardHeader>
|
<CardDescription>{t('modulePages.verification.description')}</CardDescription>
|
||||||
<CardContent className="space-y-4">
|
</CardHeader>
|
||||||
<FieldAnchor id="field-verify-enabled">
|
<CardContent className="space-y-4">
|
||||||
<div className="flex items-center justify-between rounded-md border border-border p-4">
|
<FieldAnchor id="field-verify-enabled">
|
||||||
<Label>{t('modulePages.verification.enabledLabel')}</Label>
|
<div className="flex items-center justify-between rounded-md border border-border p-4">
|
||||||
<Switch
|
<Label>{t('modulePages.verification.enabledLabel')}</Label>
|
||||||
checked={value.enabled}
|
<Switch
|
||||||
onCheckedChange={(checked) => setValue((prev) => ({ ...prev, enabled: checked }))}
|
checked={value.enabled}
|
||||||
/>
|
onCheckedChange={(checked) => setValue((prev) => ({ ...prev, enabled: checked }))}
|
||||||
</div>
|
/>
|
||||||
</FieldAnchor>
|
</div>
|
||||||
|
</FieldAnchor>
|
||||||
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
<FieldAnchor id="field-verify-mode">
|
<FieldAnchor id="field-verify-mode">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>{t('modulePages.verification.mode')}</Label>
|
<Label>{t('modulePages.verification.mode')}</Label>
|
||||||
<Select
|
<Select
|
||||||
value={value.mode}
|
value={value.mode}
|
||||||
onValueChange={(mode) =>
|
onValueChange={(mode) =>
|
||||||
setValue((prev) => ({ ...prev, mode: mode as VerificationConfigDashboard['mode'] }))
|
setValue((prev) => ({ ...prev, mode: mode as VerificationConfigDashboard['mode'] }))
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="BUTTON">{t('modulePages.verification.modes.BUTTON')}</SelectItem>
|
<SelectItem value="BUTTON">{t('modulePages.verification.modes.BUTTON')}</SelectItem>
|
||||||
<SelectItem value="CAPTCHA">{t('modulePages.verification.modes.CAPTCHA')}</SelectItem>
|
<SelectItem value="CAPTCHA">{t('modulePages.verification.modes.CAPTCHA')}</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</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>
|
</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">
|
{value.mode === 'CAPTCHA' ? (
|
||||||
<FieldAnchor id="field-verify-channel">
|
<FieldAnchor id="field-verify-captcha-provider">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>{t('modulePages.verification.channelId')}</Label>
|
<Label>{t('modulePages.verification.captchaProvider')}</Label>
|
||||||
<DiscordChannelSelect
|
<Select
|
||||||
guildId={guildId}
|
value={value.captchaProvider}
|
||||||
value={value.channelId}
|
onValueChange={(captchaProvider) =>
|
||||||
onChange={(channelId) => setValue((prev) => ({ ...prev, channelId }))}
|
setValue((prev) => ({
|
||||||
/>
|
...prev,
|
||||||
</div>
|
captchaProvider: captchaProvider as CaptchaProvider
|
||||||
</FieldAnchor>
|
}))
|
||||||
<FieldAnchor id="field-verify-role">
|
}
|
||||||
<div className="space-y-2">
|
>
|
||||||
<Label>{t('modulePages.verification.verifiedRoleId')}</Label>
|
<SelectTrigger>
|
||||||
<DiscordRoleSelect
|
<SelectValue />
|
||||||
guildId={guildId}
|
</SelectTrigger>
|
||||||
value={value.verifiedRoleId}
|
<SelectContent>
|
||||||
onChange={(verifiedRoleId) => setValue((prev) => ({ ...prev, verifiedRoleId }))}
|
{availableCaptchaProviders.map((provider) => (
|
||||||
/>
|
<SelectItem key={provider} value={provider}>
|
||||||
</div>
|
{t(`modulePages.verification.captchaProviders.${provider}`)}
|
||||||
</FieldAnchor>
|
</SelectItem>
|
||||||
<FieldAnchor id="field-verify-unverified">
|
))}
|
||||||
<div className="space-y-2">
|
</SelectContent>
|
||||||
<Label>{t('modulePages.verification.unverifiedRoleId')}</Label>
|
</Select>
|
||||||
<DiscordRoleSelect
|
<p className="text-xs text-muted-foreground">
|
||||||
guildId={guildId}
|
{t('modulePages.verification.captchaProviderHint')}
|
||||||
value={value.unverifiedRoleId}
|
</p>
|
||||||
onChange={(unverifiedRoleId) => setValue((prev) => ({ ...prev, unverifiedRoleId }))}
|
</div>
|
||||||
/>
|
</FieldAnchor>
|
||||||
</div>
|
) : null}
|
||||||
</FieldAnchor>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<FieldAnchor id="field-verify-age">
|
<div className="grid gap-4 sm:grid-cols-3">
|
||||||
<div className="space-y-2">
|
<FieldAnchor id="field-verify-channel">
|
||||||
<Label>{t('modulePages.verification.minAccountAgeDays')}</Label>
|
<div className="space-y-2">
|
||||||
<Input
|
<Label>{t('modulePages.verification.channelId')}</Label>
|
||||||
type="number"
|
<DiscordChannelSelect
|
||||||
min={0}
|
guildId={guildId}
|
||||||
className="w-40"
|
value={value.channelId}
|
||||||
value={value.minAccountAgeDays}
|
onChange={(channelId) => setValue((prev) => ({ ...prev, channelId }))}
|
||||||
onChange={(event) =>
|
/>
|
||||||
setValue((prev) => ({ ...prev, minAccountAgeDays: Number(event.target.value) || 0 }))
|
</div>
|
||||||
}
|
</FieldAnchor>
|
||||||
/>
|
<FieldAnchor id="field-verify-role">
|
||||||
</div>
|
<div className="space-y-2">
|
||||||
</FieldAnchor>
|
<Label>{t('modulePages.verification.verifiedRoleId')}</Label>
|
||||||
</CardContent>
|
<DiscordRoleSelect
|
||||||
</Card>
|
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>
|
</SettingsForm>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { createHash } from 'node:crypto';
|
import { createHash } from 'node:crypto';
|
||||||
|
import type { CaptchaProvider } from '@nexumi/shared';
|
||||||
import { redis } from './redis';
|
import { redis } from './redis';
|
||||||
|
import { env } from './env';
|
||||||
|
|
||||||
const CAPTCHA_PREFIX = 'verify:captcha:';
|
const CAPTCHA_PREFIX = 'verify:captcha:';
|
||||||
|
|
||||||
@@ -7,8 +9,9 @@ export type CaptchaChallenge = {
|
|||||||
token: string;
|
token: string;
|
||||||
guildId: string;
|
guildId: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
question: string;
|
provider: CaptchaProvider;
|
||||||
answerHash: string;
|
question?: string;
|
||||||
|
answerHash?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function getCaptchaChallenge(token: string): Promise<CaptchaChallenge | null> {
|
export async function getCaptchaChallenge(token: string): Promise<CaptchaChallenge | null> {
|
||||||
@@ -17,7 +20,11 @@ export async function getCaptchaChallenge(token: string): Promise<CaptchaChallen
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
return JSON.parse(raw) as CaptchaChallenge;
|
const parsed = JSON.parse(raw) as CaptchaChallenge;
|
||||||
|
if (!parsed.provider) {
|
||||||
|
parsed.provider = 'MATH';
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -30,3 +37,176 @@ export async function deleteCaptchaChallenge(token: string): Promise<void> {
|
|||||||
export function hashCaptchaAnswer(answer: string): string {
|
export function hashCaptchaAnswer(answer: string): string {
|
||||||
return createHash('sha256').update(answer.trim()).digest('hex');
|
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',
|
href: 'verification',
|
||||||
hash: 'field-verify-age'
|
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
|
// Leveling
|
||||||
{
|
{
|
||||||
id: 'setting-leveling-enabled',
|
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_NAME: z.preprocess(emptyToUndefined, z.string().min(1).optional()),
|
||||||
LEGAL_OPERATOR_ADDRESS: 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_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);
|
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 type { Prisma } from '@prisma/client';
|
||||||
import { prisma } from '../prisma';
|
import { prisma } from '../prisma';
|
||||||
|
import { getAvailableCaptchaProviders } from '../captcha';
|
||||||
|
|
||||||
async function ensureVerificationConfig(guildId: string) {
|
async function ensureVerificationConfig(guildId: string) {
|
||||||
await prisma.guild.upsert({ where: { id: guildId }, update: {}, create: { id: guildId } });
|
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 {
|
return {
|
||||||
enabled: config.enabled,
|
enabled: config.enabled,
|
||||||
channelId: config.channelId ?? '',
|
channelId: config.channelId ?? '',
|
||||||
verifiedRoleId: config.verifiedRoleId ?? '',
|
verifiedRoleId: config.verifiedRoleId ?? '',
|
||||||
unverifiedRoleId: config.unverifiedRoleId ?? '',
|
unverifiedRoleId: config.unverifiedRoleId ?? '',
|
||||||
mode: config.mode as VerificationConfigDashboard['mode'],
|
mode: config.mode as VerificationConfigDashboard['mode'],
|
||||||
|
captchaProvider,
|
||||||
minAccountAgeDays: config.minAccountAgeDays,
|
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);
|
const config = await ensureVerificationConfig(guildId);
|
||||||
return toDashboard(config);
|
const availableCaptchaProviders = getAvailableCaptchaProviders();
|
||||||
|
return {
|
||||||
|
config: toDashboard(config, availableCaptchaProviders),
|
||||||
|
availableCaptchaProviders
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateVerificationDashboard(
|
export async function updateVerificationDashboard(
|
||||||
@@ -34,6 +53,11 @@ export async function updateVerificationDashboard(
|
|||||||
): Promise<VerificationConfigDashboard> {
|
): Promise<VerificationConfigDashboard> {
|
||||||
await ensureVerificationConfig(guildId);
|
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 { channelId, verifiedRoleId, unverifiedRoleId, ...rest } = patch;
|
||||||
const data: Prisma.VerificationConfigUpdateInput = { ...rest };
|
const data: Prisma.VerificationConfigUpdateInput = { ...rest };
|
||||||
if (channelId !== undefined) {
|
if (channelId !== undefined) {
|
||||||
@@ -47,5 +71,5 @@ export async function updateVerificationDashboard(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const updated = await prisma.verificationConfig.update({ where: { guildId }, data });
|
const updated = await prisma.verificationConfig.update({ where: { guildId }, data });
|
||||||
return toDashboard(updated);
|
return toDashboard(updated, getAvailableCaptchaProviders());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -477,6 +477,15 @@
|
|||||||
"BUTTON": "Button",
|
"BUTTON": "Button",
|
||||||
"CAPTCHA": "Captcha (WebUI)"
|
"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",
|
"failAction": "Aktion bei Fehlschlag",
|
||||||
"failActions": {
|
"failActions": {
|
||||||
"KICK": "Kick",
|
"KICK": "Kick",
|
||||||
@@ -486,7 +495,15 @@
|
|||||||
"channelId": "Verifizierungs-Kanal",
|
"channelId": "Verifizierungs-Kanal",
|
||||||
"verifiedRoleId": "Rolle nach Verifizierung",
|
"verifiedRoleId": "Rolle nach Verifizierung",
|
||||||
"unverifiedRoleId": "Rolle vor 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": {
|
"leveling": {
|
||||||
"title": "Leveling & XP",
|
"title": "Leveling & XP",
|
||||||
|
|||||||
@@ -477,6 +477,15 @@
|
|||||||
"BUTTON": "Button",
|
"BUTTON": "Button",
|
||||||
"CAPTCHA": "Captcha (WebUI)"
|
"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",
|
"failAction": "Action on failure",
|
||||||
"failActions": {
|
"failActions": {
|
||||||
"KICK": "Kick",
|
"KICK": "Kick",
|
||||||
@@ -486,7 +495,15 @@
|
|||||||
"channelId": "Verification channel",
|
"channelId": "Verification channel",
|
||||||
"verifiedRoleId": "Role after verification",
|
"verifiedRoleId": "Role after verification",
|
||||||
"unverifiedRoleId": "Role before 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": {
|
"leveling": {
|
||||||
"title": "Leveling & XP",
|
"title": "Leveling & XP",
|
||||||
|
|||||||
@@ -220,6 +220,30 @@
|
|||||||
- Premium-Zahlungsanbindung
|
- Premium-Zahlungsanbindung
|
||||||
- P4-UX (Server-Lock, Ban deleteMessageSeconds) bewusst nachgelagert
|
- P4-UX (Server-Lock, Ban deleteMessageSeconds) bewusst nachgelagert
|
||||||
|
|
||||||
|
## Post-Phase – Verification Captcha-Anbieter + Alt-Erkennung (Status: implementiert)
|
||||||
|
|
||||||
|
### Abgeschlossen (Code)
|
||||||
|
|
||||||
|
- Captcha-Anbieter wählbar pro Guild: `MATH`, `RECAPTCHA_V2`, `RECAPTCHA_V3`, `HCAPTCHA`, `TURNSTILE`
|
||||||
|
- Google/hCaptcha/Turnstile Keys in `.env` (WebUI); Dashboard zeigt nur konfigurierte Anbieter
|
||||||
|
- Captcha-Seite `/verify/captcha` mit Provider-Widgets; Server-side Siteverify
|
||||||
|
- Alt-Account-Erkennung: gesalzener IP-Hash (Captcha-Flow) + Invite-Cluster; optional blockieren via `failAction`
|
||||||
|
- Prisma `VerificationRecord` + Config-Felder; Migration `20260723120000_verification_captcha_alt`
|
||||||
|
- Slash `/verify setup`: `captcha_provider`, `alt_detection`
|
||||||
|
|
||||||
|
### Manuell testen
|
||||||
|
|
||||||
|
- [ ] `.env`: `RECAPTCHA_SITE_KEY`/`SECRET` setzen → Dashboard zeigt reCAPTCHA v2/v3
|
||||||
|
- [ ] Guild Captcha-Modus + Anbieter wählen → Verify-Button → WebUI Captcha → Rolle
|
||||||
|
- [ ] Alt-Erkennung an: zweiter Account gleiche IP → Kick/Ban je nach failAction
|
||||||
|
- [ ] Migration: `prisma migrate deploy` + Bot/WebUI neu bauen
|
||||||
|
- [ ] `CAPTCHA_IP_HASH_SALT` gesetzt (min. 16 Zeichen), sonst keine IP-Alt-Signale
|
||||||
|
|
||||||
|
### Bewusst offen
|
||||||
|
|
||||||
|
- Kein Browser-Fingerprinting (DSGVO); IP nur gehasht mit Salt
|
||||||
|
- Keine manuelle „Alt freigeben“-UI (nur Flag in `VerificationRecord`)
|
||||||
|
|
||||||
## Nächster geplanter Schritt
|
## Nächster geplanter Schritt
|
||||||
|
|
||||||
- Deploy auf Traefik-Server manuell verifizieren; danach ggf. `deploy` → `main` mergen (nur auf Freigabe).
|
- Deploy auf Traefik-Server manuell verifizieren; danach ggf. `deploy` → `main` mergen (nur auf Freigabe).
|
||||||
|
|||||||
@@ -259,6 +259,10 @@ export const commandLocales = {
|
|||||||
de: 'Verifizierungsmodus',
|
de: 'Verifizierungsmodus',
|
||||||
en: 'Verification mode'
|
en: 'Verification mode'
|
||||||
},
|
},
|
||||||
|
'verify.setup.options.captcha_provider': {
|
||||||
|
de: 'Captcha-Anbieter (nur bei Captcha-Modus)',
|
||||||
|
en: 'Captcha provider (captcha mode only)'
|
||||||
|
},
|
||||||
'verify.setup.options.min_account_age_days': {
|
'verify.setup.options.min_account_age_days': {
|
||||||
de: 'Mindest-Accountalter in Tagen',
|
de: 'Mindest-Accountalter in Tagen',
|
||||||
en: 'Minimum account age in days'
|
en: 'Minimum account age in days'
|
||||||
@@ -267,6 +271,10 @@ export const commandLocales = {
|
|||||||
de: 'Aktion bei Fehlschlag',
|
de: 'Aktion bei Fehlschlag',
|
||||||
en: 'Action on failure'
|
en: 'Action on failure'
|
||||||
},
|
},
|
||||||
|
'verify.setup.options.alt_detection': {
|
||||||
|
de: 'Alt-Account-Erkennung aktivieren',
|
||||||
|
en: 'Enable alt-account detection'
|
||||||
|
},
|
||||||
'verify.panel.description': {
|
'verify.panel.description': {
|
||||||
de: 'Verifizierungs-Panel posten',
|
de: 'Verifizierungs-Panel posten',
|
||||||
en: 'Post verification panel'
|
en: 'Post verification panel'
|
||||||
|
|||||||
@@ -235,6 +235,13 @@ export type WelcomeConfigDashboardPatch = z.infer<typeof WelcomeConfigDashboardP
|
|||||||
|
|
||||||
export const VerificationModeDashboardSchema = z.enum(['BUTTON', 'CAPTCHA']);
|
export const VerificationModeDashboardSchema = z.enum(['BUTTON', 'CAPTCHA']);
|
||||||
export const VerificationFailActionDashboardSchema = z.enum(['KICK', 'BAN', 'NONE']);
|
export const VerificationFailActionDashboardSchema = z.enum(['KICK', 'BAN', 'NONE']);
|
||||||
|
export const CaptchaProviderDashboardSchema = z.enum([
|
||||||
|
'MATH',
|
||||||
|
'RECAPTCHA_V2',
|
||||||
|
'RECAPTCHA_V3',
|
||||||
|
'HCAPTCHA',
|
||||||
|
'TURNSTILE'
|
||||||
|
]);
|
||||||
|
|
||||||
export const VerificationConfigDashboardSchema = z.object({
|
export const VerificationConfigDashboardSchema = z.object({
|
||||||
enabled: z.boolean(),
|
enabled: z.boolean(),
|
||||||
@@ -242,8 +249,14 @@ export const VerificationConfigDashboardSchema = z.object({
|
|||||||
verifiedRoleId: OptionalSnowflakeSchema,
|
verifiedRoleId: OptionalSnowflakeSchema,
|
||||||
unverifiedRoleId: OptionalSnowflakeSchema,
|
unverifiedRoleId: OptionalSnowflakeSchema,
|
||||||
mode: VerificationModeDashboardSchema,
|
mode: VerificationModeDashboardSchema,
|
||||||
|
captchaProvider: CaptchaProviderDashboardSchema,
|
||||||
minAccountAgeDays: z.number().int().nonnegative(),
|
minAccountAgeDays: z.number().int().nonnegative(),
|
||||||
failAction: VerificationFailActionDashboardSchema
|
failAction: VerificationFailActionDashboardSchema,
|
||||||
|
altDetectionEnabled: z.boolean(),
|
||||||
|
altBlockOnMatch: z.boolean(),
|
||||||
|
altIpWindowDays: z.number().int().min(1).max(365),
|
||||||
|
altInviteWindowHours: z.number().int().min(1).max(720),
|
||||||
|
altMaxAccountsPerInvite: z.number().int().min(1).max(50)
|
||||||
});
|
});
|
||||||
export type VerificationConfigDashboard = z.infer<typeof VerificationConfigDashboardSchema>;
|
export type VerificationConfigDashboard = z.infer<typeof VerificationConfigDashboardSchema>;
|
||||||
|
|
||||||
|
|||||||
@@ -186,6 +186,8 @@ const de: Dictionary = {
|
|||||||
'verification.error.account_too_young': 'Dein Account ist zu jung für die Verifizierung.',
|
'verification.error.account_too_young': 'Dein Account ist zu jung für die Verifizierung.',
|
||||||
'verification.error.missing_permissions': 'Dem Bot fehlen Berechtigungen.',
|
'verification.error.missing_permissions': 'Dem Bot fehlen Berechtigungen.',
|
||||||
'verification.error.invalid_role': 'Die Verifizierungsrolle ist ungültig.',
|
'verification.error.invalid_role': 'Die Verifizierungsrolle ist ungültig.',
|
||||||
|
'verification.error.alt_detected':
|
||||||
|
'Verifizierung abgelehnt: möglicher Zweitaccount erkannt.',
|
||||||
'verification.error.generic': 'Verifizierung fehlgeschlagen.',
|
'verification.error.generic': 'Verifizierung fehlgeschlagen.',
|
||||||
'verification.captcha.success': 'Captcha bestanden. Du wurdest verifiziert.',
|
'verification.captcha.success': 'Captcha bestanden. Du wurdest verifiziert.',
|
||||||
'verification.captcha.invalid': 'Captcha ungültig oder abgelaufen.',
|
'verification.captcha.invalid': 'Captcha ungültig oder abgelaufen.',
|
||||||
@@ -769,6 +771,8 @@ const en: Dictionary = {
|
|||||||
'verification.error.account_too_young': 'Your account is too young to verify.',
|
'verification.error.account_too_young': 'Your account is too young to verify.',
|
||||||
'verification.error.missing_permissions': 'Bot lacks permissions.',
|
'verification.error.missing_permissions': 'Bot lacks permissions.',
|
||||||
'verification.error.invalid_role': 'Verification role is invalid.',
|
'verification.error.invalid_role': 'Verification role is invalid.',
|
||||||
|
'verification.error.alt_detected':
|
||||||
|
'Verification denied: possible alternate account detected.',
|
||||||
'verification.error.generic': 'Verification failed.',
|
'verification.error.generic': 'Verification failed.',
|
||||||
'verification.captcha.success': 'Captcha passed. You have been verified.',
|
'verification.captcha.success': 'Captcha passed. You have been verified.',
|
||||||
'verification.captcha.invalid': 'Captcha invalid or expired.',
|
'verification.captcha.invalid': 'Captcha invalid or expired.',
|
||||||
|
|||||||
@@ -207,13 +207,28 @@ export type VerificationMode = z.infer<typeof VerificationModeSchema>;
|
|||||||
export const VerificationFailActionSchema = z.enum(['KICK', 'BAN', 'NONE']);
|
export const VerificationFailActionSchema = z.enum(['KICK', 'BAN', 'NONE']);
|
||||||
export type VerificationFailAction = z.infer<typeof VerificationFailActionSchema>;
|
export type VerificationFailAction = z.infer<typeof VerificationFailActionSchema>;
|
||||||
|
|
||||||
|
export const CaptchaProviderSchema = z.enum([
|
||||||
|
'MATH',
|
||||||
|
'RECAPTCHA_V2',
|
||||||
|
'RECAPTCHA_V3',
|
||||||
|
'HCAPTCHA',
|
||||||
|
'TURNSTILE'
|
||||||
|
]);
|
||||||
|
export type CaptchaProvider = z.infer<typeof CaptchaProviderSchema>;
|
||||||
|
|
||||||
export const VerificationSetupSchema = z.object({
|
export const VerificationSetupSchema = z.object({
|
||||||
channel: z.string().min(1),
|
channel: z.string().min(1),
|
||||||
verifiedRole: z.string().min(1),
|
verifiedRole: z.string().min(1),
|
||||||
unverifiedRole: z.string().optional(),
|
unverifiedRole: z.string().optional(),
|
||||||
mode: VerificationModeSchema.default('BUTTON'),
|
mode: VerificationModeSchema.default('BUTTON'),
|
||||||
|
captchaProvider: CaptchaProviderSchema.default('MATH'),
|
||||||
minAccountAgeDays: z.number().int().nonnegative().default(0),
|
minAccountAgeDays: z.number().int().nonnegative().default(0),
|
||||||
failAction: VerificationFailActionSchema.default('KICK')
|
failAction: VerificationFailActionSchema.default('KICK'),
|
||||||
|
altDetectionEnabled: z.boolean().default(false),
|
||||||
|
altBlockOnMatch: z.boolean().default(true),
|
||||||
|
altIpWindowDays: z.number().int().min(1).max(365).default(30),
|
||||||
|
altInviteWindowHours: z.number().int().min(1).max(720).default(48),
|
||||||
|
altMaxAccountsPerInvite: z.number().int().min(1).max(50).default(3)
|
||||||
});
|
});
|
||||||
export type VerificationSetup = z.infer<typeof VerificationSetupSchema>;
|
export type VerificationSetup = z.infer<typeof VerificationSetupSchema>;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user