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

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

[email protected]
ViewVC Help
Powered by ViewVC 1.1.26