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

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

[email protected]
ViewVC Help
Powered by ViewVC 1.1.26