Skip to content

Commit ffe7086

Browse files
feat(verification): captcha support
1 parent 7e8bda6 commit ffe7086

File tree

5 files changed

+125
-7
lines changed

5 files changed

+125
-7
lines changed

src/main/typescript/api/controllers/VerificationController.ts

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { Inject } from "@framework/container/Inject";
77
import { auth, oauth2 } from "@googleapis/oauth2";
88
import type VerificationService from "@main/automod/VerificationService";
99
import { env } from "@main/env/env";
10+
import type ConfigurationManager from "@main/services/ConfigurationManager";
1011
import { VerificationMethod } from "@prisma/client";
1112
import undici from "undici";
1213
import { z } from "zod";
@@ -15,6 +16,9 @@ class VerificationController extends Controller {
1516
@Inject("verificationService")
1617
private readonly verificationService!: VerificationService;
1718

19+
@Inject("configManager")
20+
private readonly configManager!: ConfigurationManager;
21+
1822
private readonly googleOauth2Client =
1923
env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET
2024
? new auth.OAuth2({
@@ -44,16 +48,105 @@ class VerificationController extends Controller {
4448
return new Response({ status: 403, body: { error: "Guild not found." } });
4549
}
4650

51+
const config = this.configManager.get(guild.id)?.member_verification;
52+
4753
return new Response({
4854
status: 200,
4955
body: {
5056
guild: {
5157
id: guild.id,
5258
name: guild.name,
5359
icon: guild.iconURL({ forceStatic: false }) ?? null
60+
},
61+
needs_captcha:
62+
!!config?.require_captcha &&
63+
!(entry.metadata as Record<string, boolean> | null)?.captcha_completed,
64+
supported_methods: config?.allowed_methods ?? [
65+
"discord",
66+
"google",
67+
"github",
68+
"email"
69+
]
70+
}
71+
});
72+
}
73+
74+
@Action("PUT", "/challenge/captcha")
75+
@Validate(
76+
z.object({
77+
recaptchaResponse: z.string(),
78+
token: z.string()
79+
})
80+
)
81+
public async completeCaptcha(request: Request) {
82+
if (!env.RECAPTCHA_SECRET_KEY) {
83+
return new Response({ status: 403, body: { error: "Recaptcha is not supported." } });
84+
}
85+
86+
const { recaptchaResponse, token } = request.parsedBody ?? {};
87+
const entry = await this.verificationService.getVerificationEntry(token);
88+
89+
if (!entry) {
90+
return new Response({ status: 403, body: { error: "Invalid token." } });
91+
}
92+
93+
const guild = this.application.client.guilds.cache.get(entry.guildId);
94+
95+
if (!guild) {
96+
return new Response({ status: 403, body: { error: "Guild not found." } });
97+
}
98+
99+
const config = this.configManager.get(guild.id);
100+
101+
if (!config?.member_verification?.require_captcha) {
102+
return new Response({ status: 403, body: { error: "Captcha is not required." } });
103+
}
104+
105+
if ((entry.metadata as Record<string, boolean> | null)?.captcha_completed) {
106+
return new Response({
107+
status: 403,
108+
body: { error: "Captcha has already been completed." }
109+
});
110+
}
111+
112+
const response = await undici.request("https://www.google.com/recaptcha/api/siteverify", {
113+
method: "POST",
114+
body: new URLSearchParams({
115+
secret: env.RECAPTCHA_SECRET_KEY,
116+
response: recaptchaResponse
117+
}).toString(),
118+
headers: {
119+
"Content-Type": "application/x-www-form-urlencoded"
120+
}
121+
});
122+
123+
if (response.statusCode > 299 || response.statusCode < 200) {
124+
return new Response({ status: 403, body: { error: "Failed to verify captcha." } });
125+
}
126+
127+
const captchaData = (await response.body.json()) as {
128+
success: boolean;
129+
challenge_ts: number;
130+
"error-codes"?: Array<unknown>;
131+
};
132+
133+
if (!captchaData.success) {
134+
return new Response({ status: 403, body: { error: "Failed to verify captcha." } });
135+
}
136+
137+
await this.application.prisma.verificationEntry.update({
138+
where: {
139+
id: entry.id
140+
},
141+
data: {
142+
metadata: {
143+
...(entry.metadata as Record<string, string>),
144+
captcha_completed: true
54145
}
55146
}
56147
});
148+
149+
return new Response({ status: 200, body: { message: "Captcha has been completed." } });
57150
}
58151

59152
@Action("POST", "/challenge/discord")

src/main/typescript/automod/VerificationService.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,10 @@ class VerificationService extends Service {
9898
guildId: member.guild.id,
9999
userId: member.id,
100100
code,
101-
expiresAt: new Date(Date.now() + (config.max_duration ?? 24 * 60 * 60) * 1000)
101+
expiresAt: new Date(Date.now() + (config.max_duration ?? 24 * 60 * 60) * 1000),
102+
metadata: {
103+
captcha_completed: false
104+
}
102105
}
103106
});
104107

@@ -231,6 +234,22 @@ class VerificationService extends Service {
231234
return null;
232235
}
233236

237+
if (
238+
!!config?.require_captcha &&
239+
!(entry.metadata as Record<string, boolean> | null)?.captcha_completed
240+
) {
241+
return null;
242+
}
243+
244+
if (
245+
!config ||
246+
!config.allowed_methods.includes(
247+
payload.method.toLowerCase() as Lowercase<VerificationMethod>
248+
)
249+
) {
250+
return null;
251+
}
252+
234253
if (payload.method === VerificationMethod.EMAIL) {
235254
if (
236255
typeof entry.metadata !== "object" ||
@@ -348,6 +367,7 @@ class VerificationService extends Service {
348367
},
349368
data: {
350369
metadata: {
370+
...(entry.metadata as Record<string, string>),
351371
emailToken: token,
352372
email
353373
}

src/main/typescript/commands/settings/ConfigCommand.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,10 @@ import ConfigurationManager from "@main/services/ConfigurationManager";
1010
import PermissionManagerService from "@main/services/PermissionManagerService";
1111
import {
1212
ChatInputCommandInteraction,
13-
codeBlock,
1413
EmbedBuilder,
15-
escapeInlineCode,
1614
GuildMember,
15+
codeBlock,
16+
escapeInlineCode,
1717
inlineCode,
1818
type Interaction
1919
} from "discord.js";
@@ -405,7 +405,7 @@ class ConfigCommand extends Command {
405405
{
406406
description: `### ${context.emoji(
407407
"error"
408-
)} Failed to parse the value as JSON\n\n${error.slice(0, 1800)}${
408+
)} Failed to parse the value as JSON\n\n${codeBlock(error.slice(0, 1800))}${
409409
error.length > 1800
410410
? "\n... The error message is loo long."
411411
: ""
@@ -444,7 +444,7 @@ class ConfigCommand extends Command {
444444
.setDescription(
445445
`### ${context.emoji("error")} The configuration is invalid (${inlineCode(
446446
error.type
447-
)})\n\nThe changes were not saved.\n\n${errorString.slice(0, 1800)}${
447+
)})\n\nThe changes were not saved.\n\n${codeBlock(errorString.slice(0, 1800))}${
448448
errorString.length > 1800 ? "\n... The error description is loo long." : ""
449449
}`
450450
)

src/main/typescript/schemas/EnvironmentVariableSchema.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,8 @@ export const EnvironmentVariableSchema = z.object({
4141
GITHUB_CLIENT_SECRET: z.string().optional(),
4242
GOOGLE_CLIENT_ID: z.string().optional(),
4343
GOOGLE_CLIENT_SECRET: z.string().optional(),
44-
FRONTEND_KEY: z.string().optional()
44+
FRONTEND_KEY: z.string().optional(),
45+
RECAPTCHA_SECRET_KEY: z.string().optional()
4546
});
4647

4748
export type EnvironmentVariableRecord = z.infer<typeof EnvironmentVariableSchema>;

src/main/typescript/schemas/GuildConfigSchema.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -212,13 +212,17 @@ export const GuildConfigSchema = z.object({
212212
unverified_roles: z.array(zSnowflake).default([]),
213213
verified_roles: z.array(zSnowflake).default([]),
214214
expired_actions: z.array(ModerationActionSchema).default([]),
215+
require_captcha: z.boolean().optional(),
215216
verification_message: z.string().optional(),
216217
success_message: z.string().optional(),
217218
max_duration: z
218219
.number()
219220
.describe("Max verification duration (in seconds)")
220221
.int()
221-
.optional()
222+
.optional(),
223+
allowed_methods: z
224+
.array(z.enum(["discord", "google", "github", "email"]))
225+
.default(["discord", "google", "github", "email"])
222226
})
223227
.optional(),
224228
quick_mute: z

0 commit comments

Comments
 (0)