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

Contents of /branches/7.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: 17591 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, 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