From 05453ec14880732c5d0d20fd3575bb2b3952760d Mon Sep 17 00:00:00 2001 From: Puyodead1 Date: Fri, 24 Feb 2023 01:54:10 -0500 Subject: implement password reset --- src/api/middlewares/Authentication.ts | 2 + src/api/routes/auth/forgot.ts | 92 ++++++++++++++++++ src/api/routes/auth/reset.ts | 57 +++++++++++ src/api/routes/auth/verify/resend.ts | 2 +- src/util/config/Config.ts | 3 + .../config/types/PasswordResetConfiguration.ts | 21 ++++ src/util/config/types/index.ts | 1 + src/util/entities/User.ts | 2 +- src/util/schemas/ForgotPasswordSchema.ts | 22 +++++ src/util/schemas/PasswordResetSchema.ts | 22 +++++ src/util/schemas/index.ts | 20 +--- src/util/util/Email.ts | 106 ++++++++++++++++++--- src/util/util/Token.ts | 9 ++ 13 files changed, 326 insertions(+), 33 deletions(-) create mode 100644 src/api/routes/auth/forgot.ts create mode 100644 src/api/routes/auth/reset.ts create mode 100644 src/util/config/types/PasswordResetConfiguration.ts create mode 100644 src/util/schemas/ForgotPasswordSchema.ts create mode 100644 src/util/schemas/PasswordResetSchema.ts (limited to 'src') diff --git a/src/api/middlewares/Authentication.ts b/src/api/middlewares/Authentication.ts index f4c33963..771f0de8 100644 --- a/src/api/middlewares/Authentication.ts +++ b/src/api/middlewares/Authentication.ts @@ -29,6 +29,8 @@ export const NO_AUTHORIZATION_ROUTES = [ "/auth/mfa/totp", "/auth/mfa/webauthn", "/auth/verify", + "/auth/forgot", + "/auth/reset", // Routes with a seperate auth system "/webhooks/", // Public information endpoints diff --git a/src/api/routes/auth/forgot.ts b/src/api/routes/auth/forgot.ts new file mode 100644 index 00000000..faa43dbb --- /dev/null +++ b/src/api/routes/auth/forgot.ts @@ -0,0 +1,92 @@ +import { getIpAdress, route, verifyCaptcha } from "@fosscord/api"; +import { + Config, + Email, + FieldErrors, + ForgotPasswordSchema, + User, +} from "@fosscord/util"; +import { Request, Response, Router } from "express"; +import { HTTPError } from "lambert-server"; +const router = Router(); + +router.post( + "/", + route({ body: "ForgotPasswordSchema" }), + async (req: Request, res: Response) => { + const { login, captcha_key } = req.body as ForgotPasswordSchema; + + const config = Config.get(); + + if ( + config.password_reset.requireCaptcha && + config.security.captcha.enabled + ) { + const { sitekey, service } = config.security.captcha; + if (!captcha_key) { + return res.status(400).json({ + captcha_key: ["captcha-required"], + captcha_sitekey: sitekey, + captcha_service: service, + }); + } + + const ip = getIpAdress(req); + const verify = await verifyCaptcha(captcha_key, ip); + if (!verify.success) { + return res.status(400).json({ + captcha_key: verify["error-codes"], + captcha_sitekey: sitekey, + captcha_service: service, + }); + } + } + + const user = await User.findOneOrFail({ + where: [{ phone: login }, { email: login }], + select: ["username", "id", "disabled", "deleted", "email"], + relations: ["security_keys"], + }).catch(() => { + throw FieldErrors({ + login: { + message: req.t("auth:password_reset.EMAIL_DOES_NOT_EXIST"), + code: "EMAIL_DOES_NOT_EXIST", + }, + }); + }); + + if (!user.email) + throw FieldErrors({ + login: { + message: + "This account does not have an email address associated with it.", + code: "NO_EMAIL", + }, + }); + + if (user.deleted) + return res.status(400).json({ + message: "This account is scheduled for deletion.", + code: 20011, + }); + + if (user.disabled) + return res.status(400).json({ + message: req.t("auth:login.ACCOUNT_DISABLED"), + code: 20013, + }); + + return await Email.sendResetPassword(user, user.email) + .then(() => { + return res.sendStatus(204); + }) + .catch((e) => { + console.error( + `Failed to send password reset email to ${user.username}#${user.discriminator}: ${e}`, + ); + throw new HTTPError("Failed to send password reset email", 500); + }); + }, +); + +export default router; diff --git a/src/api/routes/auth/reset.ts b/src/api/routes/auth/reset.ts new file mode 100644 index 00000000..94053e1a --- /dev/null +++ b/src/api/routes/auth/reset.ts @@ -0,0 +1,57 @@ +import { route } from "@fosscord/api"; +import { + checkToken, + Config, + Email, + FieldErrors, + generateToken, + PasswordResetSchema, + User, +} from "@fosscord/util"; +import bcrypt from "bcrypt"; +import { Request, Response, Router } from "express"; +import { HTTPError } from "lambert-server"; + +const router = Router(); + +router.post( + "/", + route({ body: "PasswordResetSchema" }), + async (req: Request, res: Response) => { + const { password, token } = req.body as PasswordResetSchema; + + try { + const { jwtSecret } = Config.get().security; + const { user } = await checkToken(token, jwtSecret, true); + + // the salt is saved in the password refer to bcrypt docs + const hash = await bcrypt.hash(password, 12); + + const data = { + data: { + hash, + valid_tokens_since: new Date(), + }, + }; + await User.update({ id: user.id }, data); + + // come on, the user has to have an email to reset their password in the first place + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + await Email.sendPasswordChanged(user, user.email!); + + res.json({ token: await generateToken(user.id) }); + } catch (e) { + if ((e as Error).toString() === "Invalid Token") + throw FieldErrors({ + password: { + message: req.t("auth:password_reset.INVALID_TOKEN"), + code: "INVALID_TOKEN", + }, + }); + + throw new HTTPError((e as Error).toString(), 400); + } + }, +); + +export default router; diff --git a/src/api/routes/auth/verify/resend.ts b/src/api/routes/auth/verify/resend.ts index 1cd14f23..918af9a1 100644 --- a/src/api/routes/auth/verify/resend.ts +++ b/src/api/routes/auth/verify/resend.ts @@ -36,7 +36,7 @@ router.post( throw new HTTPError("User does not have an email address", 400); } - await Email.sendVerificationEmail(user, user.email) + await Email.sendVerifyEmail(user, user.email) .then(() => { return res.sendStatus(204); }) diff --git a/src/util/config/Config.ts b/src/util/config/Config.ts index d6f804bf..c056d454 100644 --- a/src/util/config/Config.ts +++ b/src/util/config/Config.ts @@ -31,6 +31,7 @@ import { LimitsConfiguration, LoginConfiguration, MetricsConfiguration, + PasswordResetConfiguration, RabbitMQConfiguration, RegionConfiguration, RegisterConfiguration, @@ -60,4 +61,6 @@ export class ConfigValue { defaults: DefaultsConfiguration = new DefaultsConfiguration(); external: ExternalTokensConfiguration = new ExternalTokensConfiguration(); email: EmailConfiguration = new EmailConfiguration(); + password_reset: PasswordResetConfiguration = + new PasswordResetConfiguration(); } diff --git a/src/util/config/types/PasswordResetConfiguration.ts b/src/util/config/types/PasswordResetConfiguration.ts new file mode 100644 index 00000000..806d77be --- /dev/null +++ b/src/util/config/types/PasswordResetConfiguration.ts @@ -0,0 +1,21 @@ +/* + Fosscord: A FOSS re-implementation and extension of the Discord.com backend. + Copyright (C) 2023 Fosscord and Fosscord Contributors + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +export class PasswordResetConfiguration { + requireCaptcha: boolean = false; +} diff --git a/src/util/config/types/index.ts b/src/util/config/types/index.ts index 1431c128..510e19f8 100644 --- a/src/util/config/types/index.ts +++ b/src/util/config/types/index.ts @@ -30,6 +30,7 @@ export * from "./KafkaConfiguration"; export * from "./LimitConfigurations"; export * from "./LoginConfiguration"; export * from "./MetricsConfiguration"; +export * from "./PasswordResetConfiguration"; export * from "./RabbitMQConfiguration"; export * from "./RegionConfiguration"; export * from "./RegisterConfiguration"; diff --git a/src/util/entities/User.ts b/src/util/entities/User.ts index 2947b205..f99a85e7 100644 --- a/src/util/entities/User.ts +++ b/src/util/entities/User.ts @@ -393,7 +393,7 @@ export class User extends BaseClass { // send verification email if users aren't verified by default and we have an email if (!Config.get().defaults.user.verified && email) { - await Email.sendVerificationEmail(user, email).catch((e) => { + await Email.sendVerifyEmail(user, email).catch((e) => { console.error( `Failed to send verification email to ${user.username}#${user.discriminator}: ${e}`, ); diff --git a/src/util/schemas/ForgotPasswordSchema.ts b/src/util/schemas/ForgotPasswordSchema.ts new file mode 100644 index 00000000..9a28bd18 --- /dev/null +++ b/src/util/schemas/ForgotPasswordSchema.ts @@ -0,0 +1,22 @@ +/* + Fosscord: A FOSS re-implementation and extension of the Discord.com backend. + Copyright (C) 2023 Fosscord and Fosscord Contributors + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +export interface ForgotPasswordSchema { + login: string; + captcha_key?: string; +} diff --git a/src/util/schemas/PasswordResetSchema.ts b/src/util/schemas/PasswordResetSchema.ts new file mode 100644 index 00000000..9cc74940 --- /dev/null +++ b/src/util/schemas/PasswordResetSchema.ts @@ -0,0 +1,22 @@ +/* + Fosscord: A FOSS re-implementation and extension of the Discord.com backend. + Copyright (C) 2023 Fosscord and Fosscord Contributors + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +export interface PasswordResetSchema { + password: string; + token: string; +} diff --git a/src/util/schemas/index.ts b/src/util/schemas/index.ts index 194d8571..44909a3a 100644 --- a/src/util/schemas/index.ts +++ b/src/util/schemas/index.ts @@ -16,6 +16,7 @@ along with this program. If not, see . */ +export * from "./AckBulkSchema"; export * from "./ActivitySchema"; export * from "./ApplicationAuthorizeSchema"; export * from "./ApplicationCreateSchema"; @@ -32,6 +33,7 @@ export * from "./CodesVerificationSchema"; export * from "./DmChannelCreateSchema"; export * from "./EmojiCreateSchema"; export * from "./EmojiModifySchema"; +export * from "./ForgotPasswordSchema"; export * from "./GatewayPayloadSchema"; export * from "./GuildCreateSchema"; export * from "./GuildTemplateCreateSchema"; @@ -45,8 +47,10 @@ export * from "./MemberChangeProfileSchema"; export * from "./MemberChangeSchema"; export * from "./MessageAcknowledgeSchema"; export * from "./MessageCreateSchema"; +export * from "./MessageEditSchema"; export * from "./MfaCodesSchema"; export * from "./ModifyGuildStickerSchema"; +export * from "./PasswordResetSchema"; export * from "./PurgeSchema"; export * from "./RegisterSchema"; export * from "./RelationshipPostSchema"; @@ -69,22 +73,6 @@ export * from "./VanityUrlSchema"; export * from "./VoiceIdentifySchema"; export * from "./VoiceStateUpdateSchema"; export * from "./VoiceVideoSchema"; -export * from "./IdentifySchema"; -export * from "./ActivitySchema"; -export * from "./LazyRequestSchema"; -export * from "./GuildUpdateSchema"; -export * from "./ChannelPermissionOverwriteSchema"; -export * from "./UserGuildSettingsSchema"; -export * from "./GatewayPayloadSchema"; -export * from "./RolePositionUpdateSchema"; -export * from "./ChannelReorderSchema"; -export * from "./UserSettingsSchema"; -export * from "./BotModifySchema"; -export * from "./ApplicationModifySchema"; -export * from "./ApplicationCreateSchema"; -export * from "./ApplicationAuthorizeSchema"; -export * from "./AckBulkSchema"; export * from "./WebAuthnSchema"; export * from "./WebhookCreateSchema"; export * from "./WidgetModifySchema"; -export * from "./MessageEditSchema"; diff --git a/src/util/util/Email.ts b/src/util/util/Email.ts index 3028b063..fa72d9c0 100644 --- a/src/util/util/Email.ts +++ b/src/util/util/Email.ts @@ -194,8 +194,14 @@ const transporters = { export const Email: { transporter: Transporter | null; init: () => Promise; - generateVerificationLink: (id: string, email: string) => Promise; - sendVerificationEmail: ( + generateLink: ( + type: "verify" | "reset", + id: string, + email: string, + ) => Promise; + sendVerifyEmail: (user: User, email: string) => Promise; + sendResetPassword: (user: User, email: string) => Promise; + sendPasswordChanged: ( user: User, email: string, ) => Promise; @@ -231,10 +237,10 @@ export const Email: { * Replaces all placeholders in an email template with the correct values */ doReplacements: function ( - template: string, - user: User, - emailVerificationUrl?: string, - passwordResetUrl?: string, + template, + user, + emailVerificationUrl?, + passwordResetUrl?, ipInfo?: { ip: string; city: string; @@ -285,23 +291,22 @@ export const Email: { * * @param id user id * @param email user email - * @returns a verification link for the user */ - generateVerificationLink: async function (id: string, email: string) { + generateLink: async function (type, id, email) { const token = (await generateToken(id, email)) as string; const instanceUrl = Config.get().general.frontPage || "http://localhost:3001"; - const link = `${instanceUrl}/verify#token=${token}`; + const link = `${instanceUrl}/${type}#token=${token}`; return link; }, - sendVerificationEmail: async function (user: User, email: string) { + /** + * Sends an email to the user with a link to verify their email address + */ + sendVerifyEmail: async function (user, email) { if (!this.transporter) return; // generate a verification link for the user - const verificationLink = await this.generateVerificationLink( - user.id, - email, - ); + const link = await this.generateLink("verify", user.id, email); // load the email template const rawTemplate = fs.readFileSync( @@ -314,7 +319,78 @@ export const Email: { ); // replace email template placeholders - const html = this.doReplacements(rawTemplate, user, verificationLink); + const html = this.doReplacements(rawTemplate, user, link); + + // extract the title from the email template to use as the email subject + const subject = html.match(/(.*)<\/title>/)?.[1] || ""; + + // construct the email + const message = { + from: + Config.get().general.correspondenceEmail || "noreply@localhost", + to: email, + subject, + html, + }; + + // send the email + return this.transporter.sendMail(message); + }, + /** + * Sends an email to the user with a link to reset their password + */ + sendResetPassword: async function (user, email) { + if (!this.transporter) return; + + // generate a password reset link for the user + const link = await this.generateLink("reset", user.id, email); + + // load the email template + const rawTemplate = fs.readFileSync( + path.join( + ASSET_FOLDER_PATH, + "email_templates", + "password_reset_request.html", + ), + { encoding: "utf-8" }, + ); + + // replace email template placeholders + const html = this.doReplacements(rawTemplate, user, undefined, link); + + // extract the title from the email template to use as the email subject + const subject = html.match(/<title>(.*)<\/title>/)?.[1] || ""; + + // construct the email + const message = { + from: + Config.get().general.correspondenceEmail || "noreply@localhost", + to: email, + subject, + html, + }; + + // send the email + return this.transporter.sendMail(message); + }, + /** + * Sends an email to the user notifying them that their password has been changed + */ + sendPasswordChanged: async function (user, email) { + if (!this.transporter) return; + + // load the email template + const rawTemplate = fs.readFileSync( + path.join( + ASSET_FOLDER_PATH, + "email_templates", + "password_changed.html", + ), + { encoding: "utf-8" }, + ); + + // replace email template placeholders + const html = this.doReplacements(rawTemplate, user); // extract the title from the email template to use as the email subject const subject = html.match(/<title>(.*)<\/title>/)?.[1] || ""; diff --git a/src/util/util/Token.ts b/src/util/util/Token.ts index e7b2006d..ffc442aa 100644 --- a/src/util/util/Token.ts +++ b/src/util/util/Token.ts @@ -38,6 +38,15 @@ async function checkEmailToken( where: { email: decoded.email, }, + select: [ + "email", + "id", + "verified", + "deleted", + "disabled", + "username", + "data", + ], }); if (!user) return rej("Invalid Token"); -- cgit 1.4.1