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:
@@ -0,0 +1,37 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "VerificationConfig" ADD COLUMN IF NOT EXISTS "captchaProvider" TEXT NOT NULL DEFAULT 'MATH';
|
||||
ALTER TABLE "VerificationConfig" ADD COLUMN IF NOT EXISTS "altDetectionEnabled" BOOLEAN NOT NULL DEFAULT false;
|
||||
ALTER TABLE "VerificationConfig" ADD COLUMN IF NOT EXISTS "altBlockOnMatch" BOOLEAN NOT NULL DEFAULT true;
|
||||
ALTER TABLE "VerificationConfig" ADD COLUMN IF NOT EXISTS "altIpWindowDays" INTEGER NOT NULL DEFAULT 30;
|
||||
ALTER TABLE "VerificationConfig" ADD COLUMN IF NOT EXISTS "altInviteWindowHours" INTEGER NOT NULL DEFAULT 48;
|
||||
ALTER TABLE "VerificationConfig" ADD COLUMN IF NOT EXISTS "altMaxAccountsPerInvite" INTEGER NOT NULL DEFAULT 3;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE IF NOT EXISTS "VerificationRecord" (
|
||||
"id" TEXT NOT NULL,
|
||||
"guildId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"inviteCode" TEXT,
|
||||
"inviterId" TEXT,
|
||||
"ipHash" TEXT,
|
||||
"userAgentHash" TEXT,
|
||||
"captchaProvider" TEXT,
|
||||
"flaggedAsAlt" BOOLEAN NOT NULL DEFAULT false,
|
||||
"matchedUserId" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "VerificationRecord_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "VerificationRecord_guildId_userId_key" ON "VerificationRecord"("guildId", "userId");
|
||||
CREATE INDEX IF NOT EXISTS "VerificationRecord_guildId_ipHash_createdAt_idx" ON "VerificationRecord"("guildId", "ipHash", "createdAt");
|
||||
CREATE INDEX IF NOT EXISTS "VerificationRecord_guildId_inviteCode_createdAt_idx" ON "VerificationRecord"("guildId", "inviteCode", "createdAt");
|
||||
|
||||
-- AddForeignKey
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "VerificationRecord" ADD CONSTRAINT "VerificationRecord_guildId_fkey"
|
||||
FOREIGN KEY ("guildId") REFERENCES "Guild"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
@@ -20,6 +20,7 @@ model Guild {
|
||||
logChannels LogChannel[]
|
||||
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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -71,9 +71,17 @@ async function handleCaptchaGet(url: URL, res: ServerResponse): Promise<void> {
|
||||
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<void> {
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
|
||||
83
apps/bot/src/modules/verification/alt-detection.test.ts
Normal file
83
apps/bot/src/modules/verification/alt-detection.test.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { detectAltAccount } from './alt-detection.js';
|
||||
|
||||
describe('detectAltAccount', () => {
|
||||
const basePrior = {
|
||||
userId: 'user-a',
|
||||
inviteCode: 'abc123',
|
||||
ipHash: 'ip-hash-1',
|
||||
createdAt: new Date(),
|
||||
flaggedAsAlt: false
|
||||
};
|
||||
|
||||
it('matches identical IP hash within window', () => {
|
||||
const match = detectAltAccount({
|
||||
userId: 'user-b',
|
||||
accountAgeDays: 30,
|
||||
inviteCode: null,
|
||||
ipHash: 'ip-hash-1',
|
||||
priorRecords: [basePrior],
|
||||
altIpWindowDays: 30,
|
||||
altInviteWindowHours: 48,
|
||||
altMaxAccountsPerInvite: 3
|
||||
});
|
||||
expect(match).toEqual({ reason: 'ip', matchedUserId: 'user-a' });
|
||||
});
|
||||
|
||||
it('does not match when IP window expired', () => {
|
||||
const match = detectAltAccount({
|
||||
userId: 'user-b',
|
||||
accountAgeDays: 30,
|
||||
inviteCode: null,
|
||||
ipHash: 'ip-hash-1',
|
||||
priorRecords: [
|
||||
{
|
||||
...basePrior,
|
||||
createdAt: new Date(Date.now() - 40 * 24 * 60 * 60 * 1000)
|
||||
}
|
||||
],
|
||||
altIpWindowDays: 30,
|
||||
altInviteWindowHours: 48,
|
||||
altMaxAccountsPerInvite: 3
|
||||
});
|
||||
expect(match).toBeNull();
|
||||
});
|
||||
|
||||
it('flags invite clusters for young accounts', () => {
|
||||
const now = Date.now();
|
||||
const match = detectAltAccount({
|
||||
userId: 'user-new',
|
||||
accountAgeDays: 2,
|
||||
inviteCode: 'raid',
|
||||
ipHash: null,
|
||||
priorRecords: [
|
||||
{ ...basePrior, userId: 'u1', inviteCode: 'raid', createdAt: new Date(now - 60_000) },
|
||||
{ ...basePrior, userId: 'u2', inviteCode: 'raid', createdAt: new Date(now - 120_000) },
|
||||
{ ...basePrior, userId: 'u3', inviteCode: 'raid', createdAt: new Date(now - 180_000) }
|
||||
],
|
||||
altIpWindowDays: 30,
|
||||
altInviteWindowHours: 48,
|
||||
altMaxAccountsPerInvite: 3
|
||||
});
|
||||
expect(match?.reason).toBe('invite_cluster');
|
||||
});
|
||||
|
||||
it('ignores invite clusters for older accounts', () => {
|
||||
const now = Date.now();
|
||||
const match = detectAltAccount({
|
||||
userId: 'user-old',
|
||||
accountAgeDays: 90,
|
||||
inviteCode: 'raid',
|
||||
ipHash: null,
|
||||
priorRecords: [
|
||||
{ ...basePrior, userId: 'u1', inviteCode: 'raid', createdAt: new Date(now - 60_000) },
|
||||
{ ...basePrior, userId: 'u2', inviteCode: 'raid', createdAt: new Date(now - 120_000) },
|
||||
{ ...basePrior, userId: 'u3', inviteCode: 'raid', createdAt: new Date(now - 180_000) }
|
||||
],
|
||||
altIpWindowDays: 30,
|
||||
altInviteWindowHours: 48,
|
||||
altMaxAccountsPerInvite: 3
|
||||
});
|
||||
expect(match).toBeNull();
|
||||
});
|
||||
});
|
||||
62
apps/bot/src/modules/verification/alt-detection.ts
Normal file
62
apps/bot/src/modules/verification/alt-detection.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
export type AltSignalMatch = {
|
||||
reason: 'ip' | 'invite_cluster';
|
||||
matchedUserId: string | null;
|
||||
};
|
||||
|
||||
export type AltDetectionInput = {
|
||||
userId: string;
|
||||
accountAgeDays: number;
|
||||
inviteCode: string | null;
|
||||
ipHash: string | null;
|
||||
/** Prior verification records in the same guild (excluding current user). */
|
||||
priorRecords: Array<{
|
||||
userId: string;
|
||||
inviteCode: string | null;
|
||||
ipHash: string | null;
|
||||
createdAt: Date;
|
||||
flaggedAsAlt: boolean;
|
||||
}>;
|
||||
altIpWindowDays: number;
|
||||
altInviteWindowHours: number;
|
||||
altMaxAccountsPerInvite: number;
|
||||
/** Only flag invite clusters when the current account is this young or younger. */
|
||||
youngAccountMaxDays?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Detects likely alt accounts from Discord + captcha signals.
|
||||
* Does not use browser fingerprinting beyond salted IP / UA hashes from the captcha page.
|
||||
*/
|
||||
export function detectAltAccount(input: AltDetectionInput): AltSignalMatch | null {
|
||||
const youngMax = input.youngAccountMaxDays ?? 7;
|
||||
const now = Date.now();
|
||||
|
||||
if (input.ipHash) {
|
||||
const ipWindowMs = input.altIpWindowDays * 24 * 60 * 60 * 1000;
|
||||
const ipMatch = input.priorRecords.find(
|
||||
(record) =>
|
||||
record.ipHash === input.ipHash &&
|
||||
now - record.createdAt.getTime() <= ipWindowMs
|
||||
);
|
||||
if (ipMatch) {
|
||||
return { reason: 'ip', matchedUserId: ipMatch.userId };
|
||||
}
|
||||
}
|
||||
|
||||
if (input.inviteCode && input.accountAgeDays <= youngMax) {
|
||||
const inviteWindowMs = input.altInviteWindowHours * 60 * 60 * 1000;
|
||||
const recentSameInvite = input.priorRecords.filter(
|
||||
(record) =>
|
||||
record.inviteCode === input.inviteCode &&
|
||||
now - record.createdAt.getTime() <= inviteWindowMs
|
||||
);
|
||||
if (recentSameInvite.length >= input.altMaxAccountsPerInvite) {
|
||||
return {
|
||||
reason: 'invite_cluster',
|
||||
matchedUserId: recentSameInvite[0]?.userId ?? null
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -26,6 +26,18 @@ export const verifyCommandData = applyCommandDescription(
|
||||
{ name: 'Captcha', value: 'CAPTCHA' }
|
||||
)
|
||||
)
|
||||
.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')
|
||||
|
||||
@@ -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() });
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user