Skip to content

Commit edeb162

Browse files
feat: email verification
1 parent 3df84f7 commit edeb162

File tree

4 files changed

+153
-1
lines changed

4 files changed

+153
-1
lines changed

prisma/schema.prisma

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,7 @@ model VerificationEntry {
228228
guildId String
229229
code String @unique
230230
attempts Int @default(0)
231+
metadata Json?
231232
expiresAt DateTime
232233
createdAt DateTime @default(now())
233234
updatedAt DateTime @default(now()) @updatedAt

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

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,121 @@ class VerificationController extends Controller {
357357
body: { error: "We're unable to verify your Google Account." }
358358
});
359359
}
360+
361+
@Action("POST", "/start-challenge/email")
362+
@Validate(
363+
z.object({
364+
email: z.string().email(),
365+
token: z.string()
366+
})
367+
)
368+
public async initiateEmailVerification(request: Request) {
369+
if (!env.FRONTEND_KEY) {
370+
return new Response({
371+
status: 403,
372+
body: { error: "Google OAuth is not supported." }
373+
});
374+
}
375+
376+
if (request.headers["x-frontend-key"] !== env.FRONTEND_KEY) {
377+
return new Response({
378+
status: 403,
379+
body: { error: "Forbidden request." }
380+
});
381+
}
382+
383+
const { email, token } = request.parsedBody ?? {};
384+
const entry = await this.verificationService.getVerificationEntry(token);
385+
386+
if (!entry || entry.code !== token) {
387+
return new Response({
388+
status: 400,
389+
body: { error: "Invalid token." }
390+
});
391+
}
392+
393+
const guild = this.application.client.guilds.cache.get(entry.guildId);
394+
395+
if (!guild) {
396+
return new Response({
397+
status: 400,
398+
body: { error: "Guild not found." }
399+
});
400+
}
401+
402+
const emailToken = await this.verificationService.generateEmailToken(entry, email);
403+
404+
if (!emailToken) {
405+
return new Response({
406+
status: 403,
407+
body: { error: "Cannot initiate verification." }
408+
});
409+
}
410+
411+
return new Response({
412+
status: 200,
413+
body: {
414+
emailToken,
415+
email,
416+
guild
417+
}
418+
});
419+
}
420+
421+
@Action("POST", "/challenge/email")
422+
@Validate(
423+
z.object({
424+
email: z.string().email(),
425+
token: z.string(),
426+
emailToken: z.string()
427+
})
428+
)
429+
public async verifyByEmail(request: Request) {
430+
const { email, token, emailToken } = request.parsedBody ?? {};
431+
const entry = await this.verificationService.getVerificationEntry(token);
432+
433+
if (
434+
!entry ||
435+
entry.code !== token ||
436+
!entry.metadata ||
437+
typeof entry.metadata !== "object" ||
438+
!("emailToken" in entry.metadata) ||
439+
entry.metadata.emailToken !== emailToken
440+
) {
441+
return new Response({
442+
status: 403,
443+
body: { error: "We're unable to verify you, please try again." }
444+
});
445+
}
446+
447+
const result = await this.application
448+
.service("verificationService")
449+
.verifyWithEntry(entry, {
450+
email,
451+
method: VerificationMethod.EMAIL
452+
});
453+
454+
if (!result) {
455+
return new Response({
456+
status: 403,
457+
body: { error: "We're unable to verify you, please try again." }
458+
});
459+
}
460+
461+
if (result.error === "record_exists") {
462+
return new Response({
463+
status: 403,
464+
body: { error: "You cannot use this account to verify." }
465+
});
466+
}
467+
468+
return new Response({
469+
status: 200,
470+
body: {
471+
message: "You have been verified successfully."
472+
}
473+
});
474+
}
360475
}
361476

362477
export default VerificationController;

src/main/typescript/automod/VerificationService.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,41 @@ class VerificationService extends Service {
267267
userId: member.id
268268
};
269269
}
270+
271+
public async generateEmailToken(entry: VerificationEntry, email: string) {
272+
const config = this.configFor(entry.guildId);
273+
274+
if (!config?.enabled) {
275+
return null;
276+
}
277+
278+
const token = jwt.sign(
279+
{
280+
guildId: entry.guildId,
281+
userId: entry.userId,
282+
email
283+
},
284+
env.JWT_SECRET,
285+
{
286+
expiresIn: "6h",
287+
issuer: env.JWT_ISSUER
288+
}
289+
);
290+
291+
await this.application.prisma.verificationEntry.update({
292+
where: {
293+
id: entry.id
294+
},
295+
data: {
296+
metadata: {
297+
emailToken: token,
298+
email
299+
}
300+
}
301+
});
302+
303+
return token;
304+
}
270305
}
271306

272307
export type VerificationPayload = {

src/main/typescript/schemas/EnvironmentVariableSchema.ts

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

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

0 commit comments

Comments
 (0)