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

Contents of /branches/8.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 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 /**
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