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

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

View File

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

View File

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

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