Enhance verification system with multi-provider support and alt-account detection

- Added support for multiple captcha providers including Google reCAPTCHA (v2 and v3), hCaptcha, and Cloudflare Turnstile.
- Introduced new fields in the verification configuration for selecting captcha providers and enabling alt-account detection.
- Implemented logic to handle verification attempts and flag potential alt accounts based on IP and invite code analysis.
- Updated environment configuration to include necessary keys for captcha providers.
- Enhanced the user interface to allow selection of captcha providers in the verification setup.
- Improved backend handling of verification records to store additional data related to captcha provider usage.
This commit is contained in:
smueller
2026-07-23 11:28:58 +02:00
parent e24f99ad38
commit 04e9ffad28
27 changed files with 1274 additions and 207 deletions

View File

@@ -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=

View File

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

View File

@@ -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 {

View File

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

View File

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

View File

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

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

View 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;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
import type { VerificationConfigDashboard, VerificationConfigDashboardPatch } from '@nexumi/shared'; import type { CaptchaProvider, VerificationConfigDashboard, VerificationConfigDashboardPatch } from '@nexumi/shared';
import type { Prisma } from '@prisma/client'; import 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());
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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