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

Contents of /branches/6.x/src/api/controllers/AuthController.ts

Parent Directory Parent Directory | Revision Log Revision Log


Revision 577 - (show 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 /**
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