/[sudobot]/branches/8.x/src/api/controllers/AuthController.ts
ViewVC logotype

Annotation of /branches/8.x/src/api/controllers/AuthController.ts

Parent Directory Parent Directory | Revision Log Revision Log


Revision 577 - (hide annotations)
Mon Jul 29 18:52:37 2024 UTC (8 months ago) by rakinar2
File MIME type: application/typescript
File size: 17646 byte(s)
chore: add old version archive branches (2.x to 9.x-dev)
1 rakinar2 577 /**
2     * This file is part of SudoBot.
3     *
4     * Copyright (C) 2021-2023 OSN Developers.
5     *
6     * SudoBot is free software; you can redistribute it and/or modify it
7     * under the terms of the GNU Affero General Public License as published by
8     * the Free Software Foundation, either version 3 of the License, or
9     * (at your option) any later version.
10     *
11     * SudoBot is distributed in the hope that it will be useful, but
12     * WITHOUT ANY WARRANTY; without even the implied warranty of
13     * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14     * GNU Affero General Public License for more details.
15     *
16     * You should have received a copy of the GNU Affero General Public License
17     * along with SudoBot. If not, see <https://www.gnu.org/licenses/>.
18     */
19    
20     import { User } from "@prisma/client";
21     import bcrypt from "bcrypt";
22     import { add } from "date-fns";
23     import {
24     ActionRowBuilder,
25     ButtonBuilder,
26     ButtonStyle,
27     EmbedBuilder,
28     Snowflake,
29     escapeMarkdown
30     } from "discord.js";
31     import jwt from "jsonwebtoken";
32     import { randomInt } from "node:crypto";
33     import { request as undiciRequest } from "undici";
34     import { z } from "zod";
35     import { Action } from "../../decorators/Action";
36     import { Validate } from "../../decorators/Validate";
37     import { logError } from "../../utils/Logger";
38     import { safeUserFetch } from "../../utils/fetch";
39     import Controller from "../Controller";
40     import Request from "../Request";
41     import Response from "../Response";
42    
43     export default class AuthController extends Controller {
44     private isBannedUser(discordId?: Snowflake | null) {
45     if (!discordId) {
46     return true;
47     }
48    
49     if (this.client.commandManager.isBanned(discordId)) {
50     return true;
51     }
52    
53     return false;
54     }
55    
56     private async genToken(user: User) {
57     if (
58     !user.token ||
59     (user.token && user.tokenExpiresAt && user.tokenExpiresAt.getTime() <= Date.now())
60     ) {
61     user.token = jwt.sign(
62     {
63     userId: user.id,
64     random: Math.round(Math.random() * 2000)
65     },
66     process.env.JWT_SECRET!,
67     {
68     expiresIn: "2 days",
69     issuer: process.env.JWT_ISSUER ?? "SudoBot",
70     subject: "Temporary API token for authenticated user"
71     }
72     );
73    
74     user.tokenExpiresAt = new Date(Date.now() + 1000 * 60 * 60 * 24 * 2);
75    
76     await this.client.prisma.user.updateMany({
77     where: {
78     id: user.id
79     },
80     data: {
81     token: user.token,
82     tokenExpiresAt: user.tokenExpiresAt
83     }
84     });
85     }
86     }
87    
88     @Action("POST", "/auth/recovery_token")
89     @Validate(
90     z.object({
91     username: z.string(),
92     code: z.number()
93     })
94     )
95     public async recoveryToken(request: Request) {
96     const { username, code } = request.parsedBody ?? {};
97    
98     const user = await this.client.prisma.user.findFirst({
99     where: {
100     username: username.trim(),
101     recoveryCode: code.toString().trim()
102     }
103     });
104    
105     if (!user) {
106     return new Response({
107     status: 403,
108     body: {
109     error: "Invalid username or code provided"
110     }
111     });
112     }
113    
114     if (this.isBannedUser(user.discordId)) {
115     return new Response({
116     status: 403,
117     body: {
118     error: "Forbidden"
119     }
120     });
121     }
122    
123     try {
124     jwt.verify(user.recoveryToken!, process.env.JWT_SECRET!, {
125     issuer: process.env.JWT_ISSUER ?? "SudoBot",
126     subject: "Temporary recovery token",
127     complete: true
128     });
129     } catch (e) {
130     logError(e);
131    
132     return new Response({
133     status: 403,
134     body: {
135     error: "Invalid username or code provided"
136     }
137     });
138     }
139    
140     if ((user.recoveryTokenExpiresAt?.getTime() ?? Date.now()) <= Date.now()) {
141     return new Response({
142     status: 400,
143     body: {
144     error: "This account recovery request has expired"
145     }
146     });
147     }
148    
149     await this.client.prisma.user.updateMany({
150     where: {
151     id: user.id
152     },
153     data: {
154     recoveryCode: null
155     }
156     });
157    
158     return { success: true, token: user.recoveryToken };
159     }
160    
161     @Action("POST", "/auth/reset")
162     @Validate(
163     z.object({
164     username: z.string(),
165     token: z.string(),
166     new_password: z.string()
167     })
168     )
169     public async reset(request: Request) {
170     const { username, token, new_password } = request.parsedBody ?? {};
171    
172     const user = await this.client.prisma.user.findFirst({
173     where: {
174     username: username.trim(),
175     recoveryToken: token.trim()
176     }
177     });
178    
179     if (!user) {
180     return new Response({
181     status: 403,
182     body: {
183     error: "Invalid username or token provided"
184     }
185     });
186     }
187    
188     if (this.isBannedUser(user.discordId)) {
189     return new Response({
190     status: 403,
191     body: {
192     error: "Forbidden"
193     }
194     });
195     }
196    
197     try {
198     jwt.verify(user.recoveryToken!, process.env.JWT_SECRET!, {
199     issuer: process.env.JWT_ISSUER ?? "SudoBot",
200     subject: "Temporary recovery token",
201     complete: true
202     });
203     } catch (e) {
204     logError(e);
205    
206     return new Response({
207     status: 403,
208     body: {
209     error: "Invalid username or token provided"
210     }
211     });
212     }
213    
214     if ((user.recoveryTokenExpiresAt?.getTime() ?? Date.now()) <= Date.now()) {
215     return new Response({
216     status: 400,
217     body: {
218     error: "This account recovery request has expired"
219     }
220     });
221     }
222    
223     await this.client.prisma.user.updateMany({
224     where: {
225     id: user.id
226     },
227     data: {
228     recoveryAttempts: 0,
229     recoveryToken: null,
230     recoveryTokenExpiresAt: null,
231     password: bcrypt.hashSync(new_password, bcrypt.genSaltSync(10)),
232     token: null,
233     tokenExpiresAt: null,
234     recoveryCode: null
235     }
236     });
237    
238     const discordUser = await safeUserFetch(this.client, user.discordId);
239    
240     await discordUser
241     ?.send({
242     embeds: [
243     new EmbedBuilder({
244     author: {
245     name: "Account Successfully Recovered",
246     icon_url: this.client.user?.displayAvatarURL()
247     },
248     color: 0x007bff,
249     description: `Hey ${escapeMarkdown(
250     discordUser.username
251     )},\n\nYour SudoBot Account was recovered and the password was reset. You've been automatically logged out everywhere you were logged in before.\n\nCheers,\nSudoBot Developers`,
252     footer: {
253     text: "Recovery succeeded"
254     }
255     }).setTimestamp()
256     ],
257     components: [
258     new ActionRowBuilder<ButtonBuilder>().addComponents(
259     new ButtonBuilder()
260     .setStyle(ButtonStyle.Link)
261     .setLabel("Log into your account")
262     .setURL(`${process.env.FRONTEND_URL}/login`)
263     )
264     ]
265     })
266     .catch(logError);
267    
268     return {
269     success: true,
270     message: "Successfully completed account recovery"
271     };
272     }
273    
274     @Action("POST", "/auth/recovery")
275     @Validate(
276     z.object({
277     username: z.string()
278     })
279     )
280     public async recovery(request: Request) {
281     const { username } = request.parsedBody ?? {};
282    
283     const user = await this.client.prisma.user.findFirst({
284     where: {
285     username: username.trim()
286     }
287     });
288    
289     if (!user) {
290     return new Response({
291     status: 403,
292     body: {
293     error: "Invalid username provided"
294     }
295     });
296     }
297    
298     if (this.isBannedUser(user.discordId)) {
299     return new Response({
300     status: 403,
301     body: {
302     error: "Forbidden"
303     }
304     });
305     }
306    
307     if (
308     user.recoveryToken &&
309     user.recoveryTokenExpiresAt &&
310     user.recoveryTokenExpiresAt.getTime() > Date.now() &&
311     user.recoveryAttempts >= 2
312     ) {
313     return new Response({
314     status: 429,
315     body: {
316     error: "This account is already awaiting for recovery"
317     }
318     });
319     }
320    
321     const recoveryToken = jwt.sign(
322     {
323     type: "pwdreset",
324     userId: user.id
325     },
326     process.env.JWT_SECRET!,
327     {
328     expiresIn: "2 days",
329     issuer: process.env.JWT_ISSUER ?? "SudoBot",
330     subject: "Temporary recovery token"
331     }
332     );
333    
334     const recoveryTokenExpiresAt = add(new Date(), {
335     days: 2
336     });
337    
338     const recoveryCode = randomInt(10000, 99999).toString();
339    
340     await this.client.prisma.user.updateMany({
341     where: {
342     id: user.id
343     },
344     data: {
345     recoveryAttempts: {
346     increment: 1
347     },
348     recoveryToken,
349     recoveryTokenExpiresAt,
350     recoveryCode
351     }
352     });
353    
354     const discordUser = await safeUserFetch(this.client, user.discordId);
355    
356     await discordUser
357     ?.send({
358     embeds: [
359     new EmbedBuilder({
360     author: {
361     name: "Account Recovery Request",
362     icon_url: this.client.user?.displayAvatarURL()
363     },
364     color: 0x007bff,
365     description: `Hey ${escapeMarkdown(
366     discordUser.username
367     )},\n\nWe've received a recovery request for your SudoBot Account. Your recovery code is:\n\n# ${recoveryCode}\n\nAlternatively, click the button at the bottom to reset your account's password.\nIf you haven't requested this, feel free to ignore this DM.\n\nCheers,\nSudoBot Developers`,
368     footer: {
369     text: "This account recovery request will be valid for the next 48 hours"
370     }
371     }).setTimestamp()
372     ],
373     components: [
374     new ActionRowBuilder<ButtonBuilder>().addComponents(
375     new ButtonBuilder()
376     .setStyle(ButtonStyle.Link)
377     .setLabel("Reset your password")
378     .setURL(
379     `${process.env.FRONTEND_URL}/account/reset?u=${encodeURIComponent(
380     user.username
381     )}&t=${encodeURIComponent(recoveryToken)}`
382     )
383     )
384     ]
385     })
386     .catch(logError);
387    
388     return {
389     success: true,
390     message: "Successfully initiated account recovery"
391     };
392     }
393    
394     @Action("POST", "/auth/login")
395     @Validate(
396     z.object({
397     username: z.string(),
398     password: z.string()
399     })
400     )
401     public async login(request: Request) {
402     const { username, password } = request.parsedBody ?? {};
403    
404     const user = await this.client.prisma.user.findFirst({
405     where: {
406     username: username.trim()
407     }
408     });
409    
410     if (!user || !bcrypt.compareSync(password.trim(), user.password)) {
411     return new Response({
412     status: 403,
413     body: {
414     error: "Invalid login credentials"
415     }
416     });
417     }
418    
419     if (this.isBannedUser(user.discordId)) {
420     return new Response({
421     status: 403,
422     body: {
423     error: "Unable to log in"
424     }
425     });
426     }
427    
428     await this.genToken(user);
429    
430     const guilds = [];
431     const discordUser = await safeUserFetch(this.client, user.discordId);
432    
433     for (const id of user.guilds) {
434     const guild = this.client.guilds.cache.get(id);
435    
436     if (guild) {
437     guilds.push({
438     id: guild.id,
439     name: guild.name,
440     iconURL: guild.iconURL() ?? undefined
441     });
442     }
443     }
444    
445     return {
446     message: "Login successful",
447     user: {
448     id: user.id,
449     avatarURL: discordUser?.displayAvatarURL(),
450     username: user.username,
451     name: user.name,
452     discordId: user.discordId,
453     guilds,
454     token: user.token,
455     tokenExpiresAt: user.tokenExpiresAt,
456     createdAt: user.createdAt
457     }
458     };
459     }
460    
461     @Action("POST", "/auth/discord")
462     @Validate(
463     z.object({
464     code: z.string()
465     })
466     )
467     async discord(request: Request) {
468     const { parsedBody } = request;
469    
470     try {
471     const tokenResponseData = await undiciRequest("https://discord.com/api/oauth2/token", {
472     method: "POST",
473     body: new URLSearchParams({
474     client_id: process.env.CLIENT_ID!,
475     client_secret: process.env.CLIENT_SECRET!,
476     code: parsedBody!.code,
477     grant_type: "authorization_code",
478     redirect_uri: `${process.env.DISCORD_OAUTH2_REDIRECT_URI}`,
479     scope: "identify"
480     }).toString(),
481     headers: {
482     "Content-Type": "application/x-www-form-urlencoded"
483     }
484     });
485    
486     const oauthData = <Record<string, string>>await tokenResponseData.body.json();
487    
488     if (oauthData?.error) {
489     throw new Error(`${oauthData?.error}: ${oauthData?.error_description}`);
490     }
491    
492     const userResponse = await undiciRequest("https://discord.com/api/users/@me", {
493     headers: {
494     authorization: `${oauthData.token_type} ${oauthData.access_token}`
495     }
496     });
497    
498     const userData = <Record<string, string>>await userResponse.body.json();
499    
500     if (userData?.error) {
501     throw new Error(`${userData?.error}: ${userData?.error_description}`);
502     }
503    
504     const avatarURL = `https://cdn.discordapp.com/avatars/${encodeURIComponent(
505     userData.id
506     )}/${encodeURIComponent(userData.avatar)}.${
507     userData.avatar.startsWith("a_") ? "gif" : "webp"
508     }?size=512`;
509    
510     const user = await this.client.prisma.user.findFirst({
511     where: {
512     discordId: userData.id
513     }
514     });
515    
516     if (!user) {
517     return new Response({ status: 400, body: "Access denied, no such user found" });
518     }
519    
520     if (this.isBannedUser(user.discordId)) {
521     return new Response({
522     status: 403,
523     body: {
524     error: "Unable to log in"
525     }
526     });
527     }
528    
529     await this.genToken(user);
530    
531     const guilds = [];
532    
533     for (const id of user.guilds) {
534     const guild = this.client.guilds.cache.get(id);
535    
536     if (guild) {
537     guilds.push({
538     id: guild.id,
539     name: guild.name,
540     iconURL: guild.iconURL() ?? undefined
541     });
542     }
543     }
544    
545     return {
546     message: "Login successful",
547     user: {
548     id: user.id,
549     avatarURL,
550     username: user.username,
551     name: user.name,
552     discordId: user.discordId,
553     guilds,
554     token: user.token,
555     tokenExpiresAt: user.tokenExpiresAt,
556     createdAt: user.createdAt
557     }
558     };
559     } catch (error) {
560     console.error(error);
561     return new Response({ status: 400, body: "Invalid oauth2 grant code" });
562     }
563     }
564     }

[email protected]
ViewVC Help
Powered by ViewVC 1.1.26