diff --git a/.env.example b/.env.example index 5aec645..84108a2 100644 --- a/.env.example +++ b/.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_EMAIL=info@hexahost.de 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= diff --git a/apps/bot/prisma/migrations/20260723120000_verification_captcha_alt/migration.sql b/apps/bot/prisma/migrations/20260723120000_verification_captcha_alt/migration.sql new file mode 100644 index 0000000..7ba745f --- /dev/null +++ b/apps/bot/prisma/migrations/20260723120000_verification_captcha_alt/migration.sql @@ -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 $$; diff --git a/apps/bot/prisma/schema.prisma b/apps/bot/prisma/schema.prisma index be28a14..1e15fe8 100644 --- a/apps/bot/prisma/schema.prisma +++ b/apps/bot/prisma/schema.prisma @@ -20,6 +20,7 @@ model Guild { logChannels LogChannel[] welcomeConfig WelcomeConfig? verificationConfig VerificationConfig? + verificationRecords VerificationRecord[] levelingConfig LevelingConfig? memberLevels MemberLevel[] levelRewards LevelReward[] @@ -263,20 +264,47 @@ model WelcomeConfig { } model VerificationConfig { - id String @id @default(cuid()) - guildId String @unique - enabled Boolean @default(false) - channelId String? - verifiedRoleId String? - unverifiedRoleId String? - mode String @default("BUTTON") - minAccountAgeDays Int @default(0) - failAction String @default("KICK") - panelChannelId String? - panelMessageId String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade) + id String @id @default(cuid()) + guildId String @unique + enabled Boolean @default(false) + channelId String? + verifiedRoleId String? + unverifiedRoleId String? + mode String @default("BUTTON") + /// MATH | RECAPTCHA_V2 | RECAPTCHA_V3 | HCAPTCHA | TURNSTILE + captchaProvider String @default("MATH") + minAccountAgeDays Int @default(0) + failAction String @default("KICK") + altDetectionEnabled Boolean @default(false) + altBlockOnMatch Boolean @default(true) + 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 { diff --git a/apps/bot/src/env.ts b/apps/bot/src/env.ts index 7609f91..f589a77 100644 --- a/apps/bot/src/env.ts +++ b/apps/bot/src/env.ts @@ -39,7 +39,9 @@ const EnvSchema = z.object({ ) ), 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); diff --git a/apps/bot/src/health.ts b/apps/bot/src/health.ts index 74971f9..0efb166 100644 --- a/apps/bot/src/health.ts +++ b/apps/bot/src/health.ts @@ -71,9 +71,17 @@ async function handleCaptchaGet(url: URL, res: ServerResponse): Promise { res.end('Captcha expired'); 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.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 { @@ -92,16 +100,32 @@ async function handleCaptchaPost(req: IncomingMessage, res: ServerResponse): Pro res.end('Captcha expired'); 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.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; } await deleteCaptchaChallenge(redis, token); await verificationQueue.add( 'verificationComplete', - { guildId: challenge.guildId, userId: challenge.userId }, + { + guildId: challenge.guildId, + userId: challenge.userId, + captchaProvider: challenge.provider + }, { removeOnComplete: 1000, removeOnFail: 500 } ); res.statusCode = 200; diff --git a/apps/bot/src/jobs.ts b/apps/bot/src/jobs.ts index dbb0c01..fd371ea 100644 --- a/apps/bot/src/jobs.ts +++ b/apps/bot/src/jobs.ts @@ -65,6 +65,9 @@ type TempBanJob = { type VerificationCompleteJob = { guildId: string; userId: string; + ipHash?: string | null; + userAgentHash?: string | null; + captchaProvider?: string | null; }; type ReminderSendJob = { @@ -228,7 +231,11 @@ export function startWorkers(context: BotContext): Worker[] { if (job.name !== 'verificationComplete') { 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 } ); diff --git a/apps/bot/src/modules/verification/alt-detection.test.ts b/apps/bot/src/modules/verification/alt-detection.test.ts new file mode 100644 index 0000000..ff8bce9 --- /dev/null +++ b/apps/bot/src/modules/verification/alt-detection.test.ts @@ -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(); + }); +}); diff --git a/apps/bot/src/modules/verification/alt-detection.ts b/apps/bot/src/modules/verification/alt-detection.ts new file mode 100644 index 0000000..3d9bf0d --- /dev/null +++ b/apps/bot/src/modules/verification/alt-detection.ts @@ -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; +} diff --git a/apps/bot/src/modules/verification/command-definitions.ts b/apps/bot/src/modules/verification/command-definitions.ts index 8f8e6b7..05f4823 100644 --- a/apps/bot/src/modules/verification/command-definitions.ts +++ b/apps/bot/src/modules/verification/command-definitions.ts @@ -26,6 +26,18 @@ export const verifyCommandData = applyCommandDescription( { 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) => applyOptionDescription(o.setName('min_account_age_days'), 'verify.setup.options.min_account_age_days') .setMinValue(0) @@ -39,6 +51,9 @@ export const verifyCommandData = applyCommandDescription( { name: 'None', value: 'NONE' } ) ) + .addBooleanOption((o) => + applyOptionDescription(o.setName('alt_detection'), 'verify.setup.options.alt_detection') + ) ) .addSubcommand((s) => applyCommandDescription(s.setName('panel'), 'verify.panel.description') diff --git a/apps/bot/src/modules/verification/commands.ts b/apps/bot/src/modules/verification/commands.ts index ddd4ac9..4de4338 100644 --- a/apps/bot/src/modules/verification/commands.ts +++ b/apps/bot/src/modules/verification/commands.ts @@ -50,11 +50,20 @@ const verifyCommand: SlashCommand = { const verifiedRole = interaction.options.getRole('verified_role', true); const unverifiedRole = interaction.options.getRole('unverified_role'); 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 failAction = (interaction.options.getString('fail_action') ?? 'KICK') as | 'KICK' | 'BAN' | 'NONE'; + const altDetectionEnabled = + interaction.options.getBoolean('alt_detection') ?? false; await updateVerificationConfig(context.prisma, guildId, { enabled: true, @@ -62,8 +71,10 @@ const verifyCommand: SlashCommand = { verifiedRoleId: verifiedRole.id, unverifiedRoleId: unverifiedRole?.id ?? null, mode, + captchaProvider, minAccountAgeDays, - failAction + failAction, + altDetectionEnabled }); await interaction.reply({ content: t(locale, 'verification.setupDone'), ...ephemeral() }); diff --git a/apps/bot/src/modules/verification/handlers.ts b/apps/bot/src/modules/verification/handlers.ts index 2e0590d..e1c8061 100644 --- a/apps/bot/src/modules/verification/handlers.ts +++ b/apps/bot/src/modules/verification/handlers.ts @@ -7,7 +7,7 @@ import { type ButtonInteraction, type GuildMember } 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 { getGuildLocale } from '../../i18n.js'; import { @@ -18,10 +18,17 @@ import { verificationButtonId, verificationCaptchaButtonId } from './service.js'; +import { detectAltAccount } from './alt-detection.js'; import { env } from '../../env.js'; import { logger } from '../../logger.js'; import { ephemeral } from '../../interaction-reply.js'; +export type CompleteVerificationOptions = { + ipHash?: string | null; + userAgentHash?: string | null; + captchaProvider?: string | null; +}; + async function applyFailAction( member: GuildMember, failAction: string, @@ -40,7 +47,8 @@ async function applyFailAction( export async function completeVerification( context: BotContext, guildId: string, - userId: string + userId: string, + options: CompleteVerificationOptions = {} ): Promise<{ ok: boolean; reason?: string }> { const config = await getVerificationConfig(context.prisma, guildId); if (!config.enabled || !config.verifiedRoleId) { @@ -63,6 +71,88 @@ export async function completeVerification( 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; if (!me?.permissions.has(PermissionFlagsBits.ManageRoles)) { return { ok: false, reason: 'missing_permissions' }; @@ -83,6 +173,30 @@ export async function completeVerification( 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 }; } @@ -132,10 +246,13 @@ export async function handleVerificationButton( } if (parsed.mode === 'captcha') { + const providerParse = CaptchaProviderSchema.safeParse(config.captchaProvider); + const provider: CaptchaProvider = providerParse.success ? providerParse.data : 'MATH'; const challenge = await createCaptchaChallenge( context.redis, parsed.guildId, - interaction.user.id + interaction.user.id, + provider ); const base = (env.WEBUI_URL ?? env.PUBLIC_BASE_URL).replace(/\/$/, ''); const url = `${base}/verify/captcha?token=${challenge.token}`; diff --git a/apps/bot/src/modules/verification/service.ts b/apps/bot/src/modules/verification/service.ts index 9f7af36..f5a96d4 100644 --- a/apps/bot/src/modules/verification/service.ts +++ b/apps/bot/src/modules/verification/service.ts @@ -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 { VerificationFailAction, VerificationMode } from '@nexumi/shared'; -import { ensureGuild } from '../../guild.js'; import type { Redis } from 'ioredis'; +import { ensureGuild } from '../../guild.js'; 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) { await ensureGuild(prisma, 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( prisma: PrismaClient, guildId: string, - data: { - enabled: boolean; - channelId: string; - verifiedRoleId: string; - unverifiedRoleId?: string | null; - mode: VerificationMode; - minAccountAgeDays: number; - failAction: VerificationFailAction; - } + data: VerificationConfigUpdate ) { await ensureGuild(prisma, guildId); return prisma.verificationConfig.upsert({ @@ -65,27 +73,32 @@ export type CaptchaChallenge = { token: string; guildId: string; userId: string; - question: string; - answerHash: string; + provider: CaptchaProvider; + question?: string; + answerHash?: string; }; export async function createCaptchaChallenge( redis: Redis, guildId: string, - userId: string + userId: string, + provider: CaptchaProvider = 'MATH' ): Promise { - const a = randomInt(2, 12); - const b = randomInt(2, 12); - const answer = String(a + b); const token = randomBytes(24).toString('hex'); - const answerHash = createHash('sha256').update(answer).digest('hex'); const challenge: CaptchaChallenge = { token, guildId, userId, - question: `${a} + ${b}`, - answerHash + provider }; + + 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); return challenge; } @@ -98,7 +111,11 @@ export async function getCaptchaChallenge( if (!raw) { 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 { @@ -113,3 +130,7 @@ export function accountAgeDays(userCreatedAt: Date): number { const ms = Date.now() - userCreatedAt.getTime(); return Math.floor(ms / (1000 * 60 * 60 * 24)); } + +export function hashClientSignal(salt: string, value: string): string { + return createHash('sha256').update(`${salt}:${value}`).digest('hex'); +} diff --git a/apps/webui/src/app/api/guilds/[guildId]/verification/route.ts b/apps/webui/src/app/api/guilds/[guildId]/verification/route.ts index 33d9c64..8d1e109 100644 --- a/apps/webui/src/app/api/guilds/[guildId]/verification/route.ts +++ b/apps/webui/src/app/api/guilds/[guildId]/verification/route.ts @@ -13,8 +13,8 @@ export async function GET(_request: NextRequest, { params }: RouteParams) { const { guildId } = await params; try { await requireGuildAccess(guildId); - const config = await getVerificationDashboard(guildId); - return NextResponse.json(config); + const payload = await getVerificationDashboard(guildId); + return NextResponse.json(payload); } catch (error) { return toApiErrorResponse(error); } @@ -28,15 +28,19 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) { const patch = VerificationConfigDashboardPatchSchema.parse(body); const before = await getVerificationDashboard(guildId); - const after = await updateVerificationDashboard(guildId, patch); + const afterConfig = await updateVerificationDashboard(guildId, patch); + const after = { + config: afterConfig, + availableCaptchaProviders: before.availableCaptchaProviders + }; await writeDashboardAudit(prisma, { guildId, actorUserId: session.user.id, action: 'verification.update', path: `/dashboard/${guildId}/verification`, - before, - after + before: before.config, + after: afterConfig }); return NextResponse.json(after); diff --git a/apps/webui/src/app/dashboard/[guildId]/verification/page.tsx b/apps/webui/src/app/dashboard/[guildId]/verification/page.tsx index b5c859c..7238e81 100644 --- a/apps/webui/src/app/dashboard/[guildId]/verification/page.tsx +++ b/apps/webui/src/app/dashboard/[guildId]/verification/page.tsx @@ -8,7 +8,7 @@ interface VerificationPageProps { export default async function VerificationPage({ params }: VerificationPageProps) { const { guildId } = await params; - const [locale, config] = await Promise.all([getLocale(), getVerificationDashboard(guildId)]); + const [locale, payload] = await Promise.all([getLocale(), getVerificationDashboard(guildId)]); return (
@@ -16,7 +16,11 @@ export default async function VerificationPage({ params }: VerificationPageProps

{t(locale, 'modules.verification.label')}

{t(locale, 'modules.verification.description')}

- + ); } diff --git a/apps/webui/src/app/verify/captcha/route.ts b/apps/webui/src/app/verify/captcha/route.ts index 63992ff..c7afb7c 100644 --- a/apps/webui/src/app/verify/captcha/route.ts +++ b/apps/webui/src/app/verify/captcha/route.ts @@ -1,8 +1,13 @@ import { NextResponse } from 'next/server'; +import type { CaptchaProvider } from '@nexumi/shared'; import { deleteCaptchaChallenge, getCaptchaChallenge, - hashCaptchaAnswer + getCaptchaPublicConfig, + getClientIp, + hashCaptchaAnswer, + hashClientSignal, + verifyProviderToken } from '@/lib/captcha'; import { getVerificationQueue } from '@/lib/queues'; @@ -14,53 +19,120 @@ function escapeHtml(value: string): string { .replaceAll('"', '"'); } -function renderCaptchaPage(token: string, question: string, error?: string): string { - const errorBlock = error ? `

${escapeHtml(error)}

` : ''; +const PAGE_STYLES = ` + body { font-family: system-ui, sans-serif; background:#0b1220; color:#f8fafc; display:flex; justify-content:center; align-items:center; min-height:100vh; margin:0; } + .card { background:#111827; padding:2rem; border-radius:12px; width:min(440px, 92vw); border:1px solid #1f2937; } + h1 { margin:0 0 0.5rem; font-size:1.35rem; } + p { margin:0 0 1rem; color:#cbd5e1; line-height:1.45; } + .error { color:#f87171; margin-bottom:0.75rem; } + input, button { width:100%; padding:0.75rem; margin-top:0.75rem; border-radius:8px; border:1px solid #374151; background:#0b1220; color:#f8fafc; box-sizing:border-box; } + button { background:#4f46e5; border:none; cursor:pointer; font-weight:600; } + .widget { display:flex; justify-content:center; margin:1rem 0; min-height:78px; } +`; + +function renderShell(body: string): string { return ` Nexumi Verification - + - -
+${body} +`; +} + +function renderMathPage(token: string, question: string, error?: string): string { + const errorBlock = error ? `

${escapeHtml(error)}

` : ''; + return renderShell(` +

Nexumi Verification

Solve: ${escapeHtml(question)} = ?

${errorBlock} + +
`); +} + +function renderProviderPage( + token: string, + provider: CaptchaProvider, + siteKey: string, + error?: string +): string { + const errorBlock = error ? `

${escapeHtml(error)}

` : ''; + const labels: Record = { + 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(` +
+

Nexumi Verification

+

${escapeHtml(labels.RECAPTCHA_V3)}

+ ${errorBlock} + + + +
- -`; + + `); + } + + let widget = ''; + let scripts = ''; + if (provider === 'RECAPTCHA_V2') { + widget = `
`; + scripts = ``; + } else if (provider === 'HCAPTCHA') { + widget = `
`; + scripts = ``; + } else if (provider === 'TURNSTILE') { + widget = `
`; + scripts = ``; + } + + return renderShell(` +
+

Nexumi Verification

+

${escapeHtml(labels[provider] ?? 'Complete the captcha below.')}

+ ${errorBlock} + + +
${widget}
+ +
+ ${scripts}`); +} + +function renderMisconfigured(provider: string): string { + return renderShell(` +
+

Captcha unavailable

+

Provider ${escapeHtml(provider)} is not configured on this server. Ask an admin to set the keys in .env, or switch the guild to Math captcha.

+
`); } function renderDonePage(): string { - return ` - - - - - Nexumi Verified - - - -
+ return renderShell(` +

Verified

You can return to Discord.

-
- -`; +
`); } 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 { const token = new URL(request.url).searchParams.get('token'); if (!token) { @@ -79,14 +184,13 @@ export async function GET(request: Request): Promise { if (!challenge) { return new NextResponse('Captcha expired', { status: 404 }); } - return html(renderCaptchaPage(token, challenge.question)); + return renderChallengePage(token, challenge.provider, challenge.question); } export async function POST(request: Request): Promise { const form = await request.formData(); const token = String(form.get('token') ?? ''); - const answer = String(form.get('answer') ?? ''); - if (!token || !answer) { + if (!token) { return new NextResponse('Missing fields', { status: 400 }); } @@ -95,14 +199,43 @@ export async function POST(request: Request): Promise { return new NextResponse('Captcha expired', { status: 404 }); } - if (hashCaptchaAnswer(answer) !== challenge.answerHash) { - return html(renderCaptchaPage(token, challenge.question, 'Wrong answer. Try again.')); + const provider = challenge.provider; + const clientIp = getClientIp(request); + const userAgent = request.headers.get('user-agent') ?? ''; + + if (provider === 'MATH') { + const answer = String(form.get('answer') ?? ''); + if (!answer || !challenge.answerHash || hashCaptchaAnswer(answer) !== challenge.answerHash) { + return renderChallengePage(token, provider, challenge.question, 'Wrong answer. Try again.'); + } + } else { + const responseToken = extractProviderResponse(form, provider); + const verified = await verifyProviderToken(provider, responseToken, clientIp); + if (!verified.ok) { + const message = + verified.error === 'score_too_low' + ? 'reCAPTCHA score too low. Try again.' + : verified.error === 'provider_not_configured' + ? 'Captcha provider is not configured.' + : 'Captcha failed. Try again.'; + return renderChallengePage(token, provider, challenge.question, message); + } } await deleteCaptchaChallenge(token); + + const ipHash = clientIp ? hashClientSignal(clientIp) : null; + const userAgentHash = userAgent ? hashClientSignal(userAgent) : null; + await getVerificationQueue().add( 'verificationComplete', - { guildId: challenge.guildId, userId: challenge.userId }, + { + guildId: challenge.guildId, + userId: challenge.userId, + ipHash, + userAgentHash, + captchaProvider: provider + }, { removeOnComplete: 1000, removeOnFail: 500 } ); diff --git a/apps/webui/src/components/modules/verification-form.tsx b/apps/webui/src/components/modules/verification-form.tsx index 2238d97..0442644 100644 --- a/apps/webui/src/components/modules/verification-form.tsx +++ b/apps/webui/src/components/modules/verification-form.tsx @@ -1,6 +1,6 @@ 'use client'; -import type { VerificationConfigDashboard } from '@nexumi/shared'; +import type { CaptchaProvider, VerificationConfigDashboard } from '@nexumi/shared'; import { FieldAnchor } from '@/components/layout/field-anchor'; import { useTranslations } from '@/components/locale-provider'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; @@ -33,9 +33,14 @@ async function saveVerification(guildId: string, value: VerificationConfigDashbo interface VerificationFormProps { guildId: string; initialValue: VerificationConfigDashboard; + availableCaptchaProviders: CaptchaProvider[]; } -export function VerificationForm({ guildId, initialValue }: VerificationFormProps) { +export function VerificationForm({ + guildId, + initialValue, + availableCaptchaProviders +}: VerificationFormProps) { const t = useTranslations(); return ( @@ -44,113 +49,249 @@ export function VerificationForm({ guildId, initialValue }: VerificationFormProp onSave={(value) => saveVerification(guildId, value)} > {({ value, setValue }) => ( - - - {t('modulePages.verification.title')} - {t('modulePages.verification.description')} - - - -
- - setValue((prev) => ({ ...prev, enabled: checked }))} - /> -
-
+
+ + + {t('modulePages.verification.title')} + {t('modulePages.verification.description')} + + + +
+ + setValue((prev) => ({ ...prev, enabled: checked }))} + /> +
+
-
- -
- - +
+ +
+ + +
+
+ +
+ + +
+
- - -
- - -
-
-
-
- -
- - setValue((prev) => ({ ...prev, channelId }))} - /> -
-
- -
- - setValue((prev) => ({ ...prev, verifiedRoleId }))} - /> -
-
- -
- - setValue((prev) => ({ ...prev, unverifiedRoleId }))} - /> -
-
-
+ {value.mode === 'CAPTCHA' ? ( + +
+ + +

+ {t('modulePages.verification.captchaProviderHint')} +

+
+
+ ) : null} - -
- - - setValue((prev) => ({ ...prev, minAccountAgeDays: Number(event.target.value) || 0 })) - } - /> -
-
- - +
+ +
+ + setValue((prev) => ({ ...prev, channelId }))} + /> +
+
+ +
+ + setValue((prev) => ({ ...prev, verifiedRoleId }))} + /> +
+
+ +
+ + + setValue((prev) => ({ ...prev, unverifiedRoleId })) + } + /> +
+
+
+ + +
+ + + setValue((prev) => ({ + ...prev, + minAccountAgeDays: Number(event.target.value) || 0 + })) + } + /> +
+
+ + + + + + {t('modulePages.verification.altTitle')} + {t('modulePages.verification.altDescription')} + + + +
+ + + setValue((prev) => ({ ...prev, altDetectionEnabled: checked })) + } + /> +
+
+ + +
+
+ +

+ {t('modulePages.verification.altBlockOnMatchHint')} +

+
+ + setValue((prev) => ({ ...prev, altBlockOnMatch: checked })) + } + disabled={!value.altDetectionEnabled} + /> +
+
+ +
+ +
+ + + setValue((prev) => ({ + ...prev, + altIpWindowDays: Number(event.target.value) || 1 + })) + } + /> +
+
+ +
+ + + setValue((prev) => ({ + ...prev, + altInviteWindowHours: Number(event.target.value) || 1 + })) + } + /> +
+
+ +
+ + + setValue((prev) => ({ + ...prev, + altMaxAccountsPerInvite: Number(event.target.value) || 1 + })) + } + /> +
+
+
+
+
+
)} ); diff --git a/apps/webui/src/lib/captcha.ts b/apps/webui/src/lib/captcha.ts index 40dc848..ee699b0 100644 --- a/apps/webui/src/lib/captcha.ts +++ b/apps/webui/src/lib/captcha.ts @@ -1,5 +1,7 @@ import { createHash } from 'node:crypto'; +import type { CaptchaProvider } from '@nexumi/shared'; import { redis } from './redis'; +import { env } from './env'; const CAPTCHA_PREFIX = 'verify:captcha:'; @@ -7,8 +9,9 @@ export type CaptchaChallenge = { token: string; guildId: string; userId: string; - question: string; - answerHash: string; + provider: CaptchaProvider; + question?: string; + answerHash?: string; }; export async function getCaptchaChallenge(token: string): Promise { @@ -17,7 +20,11 @@ export async function getCaptchaChallenge(token: string): Promise { export function hashCaptchaAnswer(answer: string): string { return createHash('sha256').update(answer.trim()).digest('hex'); } + +export function hashClientSignal(value: string): string | null { + const salt = env.CAPTCHA_IP_HASH_SALT; + if (!salt || !value) { + return null; + } + return createHash('sha256').update(`${salt}:${value}`).digest('hex'); +} + +export function getClientIp(request: Request): string | null { + const forwarded = request.headers.get('x-forwarded-for'); + if (forwarded) { + const first = forwarded.split(',')[0]?.trim(); + if (first) { + return first; + } + } + const realIp = request.headers.get('x-real-ip')?.trim(); + return realIp || null; +} + +export type CaptchaPublicConfig = { + provider: CaptchaProvider; + siteKey: string | null; + recaptchaV3MinScore: number; +}; + +export function getAvailableCaptchaProviders(): CaptchaProvider[] { + const providers: CaptchaProvider[] = ['MATH']; + if (env.RECAPTCHA_SITE_KEY && env.RECAPTCHA_SECRET_KEY) { + providers.push('RECAPTCHA_V2'); + } + const v3Site = env.RECAPTCHA_V3_SITE_KEY ?? env.RECAPTCHA_SITE_KEY; + const v3Secret = env.RECAPTCHA_V3_SECRET_KEY ?? env.RECAPTCHA_SECRET_KEY; + if (v3Site && v3Secret) { + providers.push('RECAPTCHA_V3'); + } + if (env.HCAPTCHA_SITE_KEY && env.HCAPTCHA_SECRET_KEY) { + providers.push('HCAPTCHA'); + } + if (env.TURNSTILE_SITE_KEY && env.TURNSTILE_SECRET_KEY) { + providers.push('TURNSTILE'); + } + return providers; +} + +export function getCaptchaPublicConfig(provider: CaptchaProvider): CaptchaPublicConfig { + switch (provider) { + case 'RECAPTCHA_V2': + return { + provider, + siteKey: env.RECAPTCHA_SITE_KEY ?? null, + recaptchaV3MinScore: env.RECAPTCHA_V3_MIN_SCORE + }; + case 'RECAPTCHA_V3': + return { + provider, + siteKey: env.RECAPTCHA_V3_SITE_KEY ?? env.RECAPTCHA_SITE_KEY ?? null, + recaptchaV3MinScore: env.RECAPTCHA_V3_MIN_SCORE + }; + case 'HCAPTCHA': + return { + provider, + siteKey: env.HCAPTCHA_SITE_KEY ?? null, + recaptchaV3MinScore: env.RECAPTCHA_V3_MIN_SCORE + }; + case 'TURNSTILE': + return { + provider, + siteKey: env.TURNSTILE_SITE_KEY ?? null, + recaptchaV3MinScore: env.RECAPTCHA_V3_MIN_SCORE + }; + default: + return { provider: 'MATH', siteKey: null, recaptchaV3MinScore: env.RECAPTCHA_V3_MIN_SCORE }; + } +} + +type SiteVerifyResult = { success: boolean; score?: number; 'error-codes'?: string[] }; + +async function postSiteVerify( + url: string, + secret: string, + responseToken: string, + remoteip?: string | null +): Promise { + 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' }; +} diff --git a/apps/webui/src/lib/dashboard-search-index.ts b/apps/webui/src/lib/dashboard-search-index.ts index f31da22..82b9afa 100644 --- a/apps/webui/src/lib/dashboard-search-index.ts +++ b/apps/webui/src/lib/dashboard-search-index.ts @@ -379,6 +379,48 @@ export const SETTINGS_SEARCH_FIELDS: SearchIndexEntry[] = [ href: 'verification', hash: 'field-verify-age' }, + { + id: 'setting-verify-captcha-provider', + kind: 'setting', + labelKey: 'modulePages.verification.captchaProvider', + href: 'verification', + hash: 'field-verify-captcha-provider' + }, + { + id: 'setting-verify-alt-enabled', + kind: 'setting', + labelKey: 'modulePages.verification.altDetectionEnabled', + href: 'verification', + hash: 'field-verify-alt-enabled' + }, + { + id: 'setting-verify-alt-block', + kind: 'setting', + labelKey: 'modulePages.verification.altBlockOnMatch', + href: 'verification', + hash: 'field-verify-alt-block' + }, + { + id: 'setting-verify-alt-ip-days', + kind: 'setting', + labelKey: 'modulePages.verification.altIpWindowDays', + href: 'verification', + hash: 'field-verify-alt-ip-days' + }, + { + id: 'setting-verify-alt-invite-hours', + kind: 'setting', + labelKey: 'modulePages.verification.altInviteWindowHours', + href: 'verification', + hash: 'field-verify-alt-invite-hours' + }, + { + id: 'setting-verify-alt-max-invite', + kind: 'setting', + labelKey: 'modulePages.verification.altMaxAccountsPerInvite', + href: 'verification', + hash: 'field-verify-alt-max-invite' + }, // Leveling { id: 'setting-leveling-enabled', diff --git a/apps/webui/src/lib/env.ts b/apps/webui/src/lib/env.ts index 37dff20..968e1a6 100644 --- a/apps/webui/src/lib/env.ts +++ b/apps/webui/src/lib/env.ts @@ -35,7 +35,20 @@ const EnvSchema = z.object({ LEGAL_OPERATOR_NAME: z.preprocess(emptyToUndefined, z.string().min(1).optional()), LEGAL_OPERATOR_ADDRESS: z.preprocess(emptyToUndefined, z.string().min(1).optional()), LEGAL_OPERATOR_EMAIL: z.preprocess(emptyToUndefined, z.string().email().optional()), - LEGAL_OPERATOR_PHONE: z.preprocess(emptyToUndefined, z.string().min(1).optional()) + LEGAL_OPERATOR_PHONE: z.preprocess(emptyToUndefined, z.string().min(1).optional()), + + // Captcha providers (site keys are public; secrets stay server-side) + RECAPTCHA_SITE_KEY: z.preprocess(emptyToUndefined, z.string().min(1).optional()), + RECAPTCHA_SECRET_KEY: z.preprocess(emptyToUndefined, z.string().min(1).optional()), + RECAPTCHA_V3_SITE_KEY: z.preprocess(emptyToUndefined, z.string().min(1).optional()), + RECAPTCHA_V3_SECRET_KEY: z.preprocess(emptyToUndefined, z.string().min(1).optional()), + RECAPTCHA_V3_MIN_SCORE: z.coerce.number().min(0).max(1).default(0.5), + HCAPTCHA_SITE_KEY: z.preprocess(emptyToUndefined, z.string().min(1).optional()), + HCAPTCHA_SECRET_KEY: z.preprocess(emptyToUndefined, z.string().min(1).optional()), + TURNSTILE_SITE_KEY: z.preprocess(emptyToUndefined, z.string().min(1).optional()), + TURNSTILE_SECRET_KEY: z.preprocess(emptyToUndefined, z.string().min(1).optional()), + /// Shared with bot for alt-account IP hashing (min 16 chars). + CAPTCHA_IP_HASH_SALT: z.preprocess(emptyToUndefined, z.string().min(16).optional()) }); export const env = EnvSchema.parse(process.env); diff --git a/apps/webui/src/lib/module-configs/verification.ts b/apps/webui/src/lib/module-configs/verification.ts index ef083bd..21ea124 100644 --- a/apps/webui/src/lib/module-configs/verification.ts +++ b/apps/webui/src/lib/module-configs/verification.ts @@ -1,6 +1,7 @@ -import type { VerificationConfigDashboard, VerificationConfigDashboardPatch } from '@nexumi/shared'; +import type { CaptchaProvider, VerificationConfigDashboard, VerificationConfigDashboardPatch } from '@nexumi/shared'; import type { Prisma } from '@prisma/client'; import { prisma } from '../prisma'; +import { getAvailableCaptchaProviders } from '../captcha'; async function ensureVerificationConfig(guildId: string) { await prisma.guild.upsert({ where: { id: guildId }, update: {}, create: { id: guildId } }); @@ -11,21 +12,39 @@ async function ensureVerificationConfig(guildId: string) { }); } -function toDashboard(config: Awaited>): VerificationConfigDashboard { +function toDashboard( + config: Awaited>, + available: CaptchaProvider[] +): VerificationConfigDashboard { + const rawProvider = (config.captchaProvider as CaptchaProvider) ?? 'MATH'; + const captchaProvider = available.includes(rawProvider) ? rawProvider : 'MATH'; return { enabled: config.enabled, channelId: config.channelId ?? '', verifiedRoleId: config.verifiedRoleId ?? '', unverifiedRoleId: config.unverifiedRoleId ?? '', mode: config.mode as VerificationConfigDashboard['mode'], + captchaProvider, minAccountAgeDays: config.minAccountAgeDays, - failAction: config.failAction as VerificationConfigDashboard['failAction'] + failAction: config.failAction as VerificationConfigDashboard['failAction'], + altDetectionEnabled: config.altDetectionEnabled, + altBlockOnMatch: config.altBlockOnMatch, + altIpWindowDays: config.altIpWindowDays, + altInviteWindowHours: config.altInviteWindowHours, + altMaxAccountsPerInvite: config.altMaxAccountsPerInvite }; } -export async function getVerificationDashboard(guildId: string): Promise { +export async function getVerificationDashboard(guildId: string): Promise<{ + config: VerificationConfigDashboard; + availableCaptchaProviders: CaptchaProvider[]; +}> { const config = await ensureVerificationConfig(guildId); - return toDashboard(config); + const availableCaptchaProviders = getAvailableCaptchaProviders(); + return { + config: toDashboard(config, availableCaptchaProviders), + availableCaptchaProviders + }; } export async function updateVerificationDashboard( @@ -34,6 +53,11 @@ export async function updateVerificationDashboard( ): Promise { await ensureVerificationConfig(guildId); + const available = new Set(getAvailableCaptchaProviders()); + if (patch.captchaProvider !== undefined && !available.has(patch.captchaProvider)) { + throw new Error(`Captcha provider ${patch.captchaProvider} is not configured in .env`); + } + const { channelId, verifiedRoleId, unverifiedRoleId, ...rest } = patch; const data: Prisma.VerificationConfigUpdateInput = { ...rest }; if (channelId !== undefined) { @@ -47,5 +71,5 @@ export async function updateVerificationDashboard( } const updated = await prisma.verificationConfig.update({ where: { guildId }, data }); - return toDashboard(updated); + return toDashboard(updated, getAvailableCaptchaProviders()); } diff --git a/apps/webui/src/messages/de.json b/apps/webui/src/messages/de.json index c5941f1..193e6bc 100644 --- a/apps/webui/src/messages/de.json +++ b/apps/webui/src/messages/de.json @@ -477,6 +477,15 @@ "BUTTON": "Button", "CAPTCHA": "Captcha (WebUI)" }, + "captchaProvider": "Captcha-Anbieter", + "captchaProviderHint": "Anbieter mit fehlenden .env-Keys erscheinen nicht in der Liste. Google-Keys: RECAPTCHA_SITE_KEY / RECAPTCHA_SECRET_KEY.", + "captchaProviders": { + "MATH": "Mathe-Aufgabe", + "RECAPTCHA_V2": "Google reCAPTCHA v2", + "RECAPTCHA_V3": "Google reCAPTCHA v3", + "HCAPTCHA": "hCaptcha", + "TURNSTILE": "Cloudflare Turnstile" + }, "failAction": "Aktion bei Fehlschlag", "failActions": { "KICK": "Kick", @@ -486,7 +495,15 @@ "channelId": "Verifizierungs-Kanal", "verifiedRoleId": "Rolle nach Verifizierung", "unverifiedRoleId": "Rolle vor Verifizierung", - "minAccountAgeDays": "Mindest-Accountalter (Tage)" + "minAccountAgeDays": "Mindest-Accountalter (Tage)", + "altTitle": "Alt-Account-Erkennung", + "altDescription": "Erkennt mögliche Zweitaccounts über gemeinsame Captcha-IP (gesalzen gehasht) und Invite-Cluster. Kein Browser-Fingerprinting.", + "altDetectionEnabled": "Alt-Erkennung aktivieren", + "altBlockOnMatch": "Bei Treffer blockieren", + "altBlockOnMatchHint": "Nutzt die konfigurierte Fehlschlag-Aktion (Kick/Ban/Keine). Aus = nur markieren, trotzdem verifizieren.", + "altIpWindowDays": "IP-Fenster (Tage)", + "altInviteWindowHours": "Invite-Cluster-Fenster (Stunden)", + "altMaxAccountsPerInvite": "Max. Accounts pro Invite (Cluster)" }, "leveling": { "title": "Leveling & XP", diff --git a/apps/webui/src/messages/en.json b/apps/webui/src/messages/en.json index f78629f..dc15ea1 100644 --- a/apps/webui/src/messages/en.json +++ b/apps/webui/src/messages/en.json @@ -477,6 +477,15 @@ "BUTTON": "Button", "CAPTCHA": "Captcha (WebUI)" }, + "captchaProvider": "Captcha provider", + "captchaProviderHint": "Providers without .env keys are hidden. Google keys: RECAPTCHA_SITE_KEY / RECAPTCHA_SECRET_KEY.", + "captchaProviders": { + "MATH": "Math challenge", + "RECAPTCHA_V2": "Google reCAPTCHA v2", + "RECAPTCHA_V3": "Google reCAPTCHA v3", + "HCAPTCHA": "hCaptcha", + "TURNSTILE": "Cloudflare Turnstile" + }, "failAction": "Action on failure", "failActions": { "KICK": "Kick", @@ -486,7 +495,15 @@ "channelId": "Verification channel", "verifiedRoleId": "Role after verification", "unverifiedRoleId": "Role before verification", - "minAccountAgeDays": "Minimum account age (days)" + "minAccountAgeDays": "Minimum account age (days)", + "altTitle": "Alt-account detection", + "altDescription": "Flags likely alternate accounts via shared captcha IP (salted hash) and invite clustering. No browser fingerprinting.", + "altDetectionEnabled": "Enable alt detection", + "altBlockOnMatch": "Block on match", + "altBlockOnMatchHint": "Uses the configured fail action (Kick/Ban/None). Off = flag only, still verify.", + "altIpWindowDays": "IP window (days)", + "altInviteWindowHours": "Invite cluster window (hours)", + "altMaxAccountsPerInvite": "Max accounts per invite (cluster)" }, "leveling": { "title": "Leveling & XP", diff --git a/docs/PHASE-TRACKING.md b/docs/PHASE-TRACKING.md index c87875d..58c621b 100644 --- a/docs/PHASE-TRACKING.md +++ b/docs/PHASE-TRACKING.md @@ -220,6 +220,30 @@ - Premium-Zahlungsanbindung - 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 - Deploy auf Traefik-Server manuell verifizieren; danach ggf. `deploy` → `main` mergen (nur auf Freigabe). diff --git a/packages/shared/src/command-locales.ts b/packages/shared/src/command-locales.ts index 239c5ba..cfa30fb 100644 --- a/packages/shared/src/command-locales.ts +++ b/packages/shared/src/command-locales.ts @@ -259,6 +259,10 @@ export const commandLocales = { de: 'Verifizierungsmodus', 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': { de: 'Mindest-Accountalter in Tagen', en: 'Minimum account age in days' @@ -267,6 +271,10 @@ export const commandLocales = { de: 'Aktion bei Fehlschlag', en: 'Action on failure' }, + 'verify.setup.options.alt_detection': { + de: 'Alt-Account-Erkennung aktivieren', + en: 'Enable alt-account detection' + }, 'verify.panel.description': { de: 'Verifizierungs-Panel posten', en: 'Post verification panel' diff --git a/packages/shared/src/dashboard.ts b/packages/shared/src/dashboard.ts index 9be1653..8950045 100644 --- a/packages/shared/src/dashboard.ts +++ b/packages/shared/src/dashboard.ts @@ -235,6 +235,13 @@ export type WelcomeConfigDashboardPatch = z.infer; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 70f7bb1..74dda70 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -186,6 +186,8 @@ const de: Dictionary = { 'verification.error.account_too_young': 'Dein Account ist zu jung für die Verifizierung.', 'verification.error.missing_permissions': 'Dem Bot fehlen Berechtigungen.', '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.captcha.success': 'Captcha bestanden. Du wurdest verifiziert.', '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.missing_permissions': 'Bot lacks permissions.', 'verification.error.invalid_role': 'Verification role is invalid.', + 'verification.error.alt_detected': + 'Verification denied: possible alternate account detected.', 'verification.error.generic': 'Verification failed.', 'verification.captcha.success': 'Captcha passed. You have been verified.', 'verification.captcha.invalid': 'Captcha invalid or expired.', diff --git a/packages/shared/src/phase2.ts b/packages/shared/src/phase2.ts index 3455fa6..c86d6b8 100644 --- a/packages/shared/src/phase2.ts +++ b/packages/shared/src/phase2.ts @@ -207,13 +207,28 @@ export type VerificationMode = z.infer; export const VerificationFailActionSchema = z.enum(['KICK', 'BAN', 'NONE']); export type VerificationFailAction = z.infer; +export const CaptchaProviderSchema = z.enum([ + 'MATH', + 'RECAPTCHA_V2', + 'RECAPTCHA_V3', + 'HCAPTCHA', + 'TURNSTILE' +]); +export type CaptchaProvider = z.infer; + export const VerificationSetupSchema = z.object({ channel: z.string().min(1), verifiedRole: z.string().min(1), unverifiedRole: z.string().optional(), mode: VerificationModeSchema.default('BUTTON'), + captchaProvider: CaptchaProviderSchema.default('MATH'), 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;