diff options
author | Madeline <46743919+MaddyUnderStars@users.noreply.github.com> | 2023-02-24 23:17:36 +1100 |
---|---|---|
committer | GitHub <noreply@github.com> | 2023-02-24 23:17:36 +1100 |
commit | 224e2c8374eee1bf85f6798123da4df90daf1860 (patch) | |
tree | 3d698d6b7392a061ff04d0dad145200377ed7fad /src/api | |
parent | Fix gateway encoding Date objects as {} when using erlpack. Fixes NaN/NaN/NaN... (diff) | |
parent | move transporters to their own files (diff) | |
download | server-224e2c8374eee1bf85f6798123da4df90daf1860.tar.xz |
Merge pull request #965 from Puyodead1/dev/mail
Email Support
Diffstat (limited to '')
-rw-r--r-- | src/api/Server.ts | 20 | ||||
-rw-r--r-- | src/api/middlewares/Authentication.ts | 7 | ||||
-rw-r--r-- | src/api/routes/auth/forgot.ts | 92 | ||||
-rw-r--r-- | src/api/routes/auth/login.ts | 12 | ||||
-rw-r--r-- | src/api/routes/auth/register.ts | 11 | ||||
-rw-r--r-- | src/api/routes/auth/reset.ts | 56 | ||||
-rw-r--r-- | src/api/routes/auth/verify/index.ts | 93 | ||||
-rw-r--r-- | src/api/routes/auth/verify/resend.ts | 52 |
8 files changed, 332 insertions, 11 deletions
diff --git a/src/api/Server.ts b/src/api/Server.ts index 7eb4e6f1..aec47818 100644 --- a/src/api/Server.ts +++ b/src/api/Server.ts @@ -16,28 +16,29 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. */ -import "missing-native-js-functions"; -import { Server, ServerOptions } from "lambert-server"; -import { Authentication, CORS } from "./middlewares/"; import { Config, + Email, initDatabase, initEvent, JSONReplacer, + registerRoutes, Sentry, WebAuthn, } from "@fosscord/util"; -import { ErrorHandler } from "./middlewares/ErrorHandler"; -import { BodyParser } from "./middlewares/BodyParser"; -import { Router, Request, Response } from "express"; +import { Request, Response, Router } from "express"; +import { Server, ServerOptions } from "lambert-server"; +import "missing-native-js-functions"; +import morgan from "morgan"; import path from "path"; +import { red } from "picocolors"; +import { Authentication, CORS } from "./middlewares/"; +import { BodyParser } from "./middlewares/BodyParser"; +import { ErrorHandler } from "./middlewares/ErrorHandler"; import { initRateLimits } from "./middlewares/RateLimit"; import TestClient from "./middlewares/TestClient"; import { initTranslation } from "./middlewares/Translation"; -import morgan from "morgan"; import { initInstance } from "./util/handlers/Instance"; -import { registerRoutes } from "@fosscord/util"; -import { red } from "picocolors"; export type FosscordServerOptions = ServerOptions; @@ -63,6 +64,7 @@ export class FosscordServer extends Server { await initDatabase(); await Config.init(); await initEvent(); + await Email.init(); await initInstance(); await Sentry.init(this.app); WebAuthn.init(); diff --git a/src/api/middlewares/Authentication.ts b/src/api/middlewares/Authentication.ts index ea0aa312..771f0de8 100644 --- a/src/api/middlewares/Authentication.ts +++ b/src/api/middlewares/Authentication.ts @@ -16,10 +16,10 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. */ -import { NextFunction, Request, Response } from "express"; -import { HTTPError } from "lambert-server"; import { checkToken, Config, Rights } from "@fosscord/util"; import * as Sentry from "@sentry/node"; +import { NextFunction, Request, Response } from "express"; +import { HTTPError } from "lambert-server"; export const NO_AUTHORIZATION_ROUTES = [ // Authentication routes @@ -28,6 +28,9 @@ export const NO_AUTHORIZATION_ROUTES = [ "/auth/location-metadata", "/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/login.ts b/src/api/routes/auth/login.ts index 2b97ec10..e6616731 100644 --- a/src/api/routes/auth/login.ts +++ b/src/api/routes/auth/login.ts @@ -77,6 +77,7 @@ router.post( "mfa_enabled", "webauthn_enabled", "security_keys", + "verified", ], relations: ["security_keys"], }).catch(() => { @@ -102,6 +103,17 @@ router.post( }); } + // return an error for unverified accounts if verification is required + if (config.login.requireVerification && !user.verified) { + throw FieldErrors({ + login: { + code: "ACCOUNT_LOGIN_VERIFICATION_EMAIL", + message: + "Email verification is required, please check your email.", + }, + }); + } + if (user.mfa_enabled && !user.webauthn_enabled) { // TODO: This is not a discord.com ticket. I'm not sure what it is but I'm lazy const ticket = crypto.randomBytes(40).toString("hex"); diff --git a/src/api/routes/auth/register.ts b/src/api/routes/auth/register.ts index 0bf8efae..c941fdf6 100644 --- a/src/api/routes/auth/register.ts +++ b/src/api/routes/auth/register.ts @@ -278,6 +278,17 @@ router.post( await Invite.joinGuild(user.id, body.invite); } + // return an error for unverified accounts if verification is required + if (Config.get().login.requireVerification && !user.verified) { + throw FieldErrors({ + login: { + code: "ACCOUNT_LOGIN_VERIFICATION_EMAIL", + message: + "Email verification is required, please check your email.", + }, + }); + } + return res.json({ token: await generateToken(user.id) }); }, ); diff --git a/src/api/routes/auth/reset.ts b/src/api/routes/auth/reset.ts new file mode 100644 index 00000000..9ab25dca --- /dev/null +++ b/src/api/routes/auth/reset.ts @@ -0,0 +1,56 @@ +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"; + +const router = Router(); + +router.post( + "/", + route({ body: "PasswordResetSchema" }), + async (req: Request, res: Response) => { + const { password, token } = req.body as PasswordResetSchema; + + const { jwtSecret } = Config.get().security; + + let user; + try { + const userTokenData = await checkToken(token, jwtSecret, true); + user = userTokenData.user; + } catch { + throw FieldErrors({ + password: { + message: req.t("auth:password_reset.INVALID_TOKEN"), + code: "INVALID_TOKEN", + }, + }); + } + + // 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) }); + }, +); + +export default router; diff --git a/src/api/routes/auth/verify/index.ts b/src/api/routes/auth/verify/index.ts new file mode 100644 index 00000000..cdbd371a --- /dev/null +++ b/src/api/routes/auth/verify/index.ts @@ -0,0 +1,93 @@ +/* + 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 <https://www.gnu.org/licenses/>. +*/ + +import { getIpAdress, route, verifyCaptcha } from "@fosscord/api"; +import { + checkToken, + Config, + FieldErrors, + generateToken, + User, +} from "@fosscord/util"; +import { Request, Response, Router } from "express"; +const router = Router(); + +async function getToken(user: User) { + const token = await generateToken(user.id); + + // Notice this will have a different token structure, than discord + // Discord header is just the user id as string, which is not possible with npm-jsonwebtoken package + // https://user-images.githubusercontent.com/6506416/81051916-dd8c9900-8ec2-11ea-8794-daf12d6f31f0.png + + return { token }; +} + +router.post( + "/", + route({ body: "VerifyEmailSchema" }), + async (req: Request, res: Response) => { + const { captcha_key, token } = req.body; + + const config = Config.get(); + + if (config.register.requireCaptcha) { + 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 { jwtSecret } = Config.get().security; + let user; + + try { + const userTokenData = await checkToken(token, jwtSecret, true); + user = userTokenData.user; + } catch { + throw FieldErrors({ + password: { + message: req.t("auth:password_reset.INVALID_TOKEN"), + code: "INVALID_TOKEN", + }, + }); + } + + if (user.verified) return res.json(await getToken(user)); + + await User.update({ id: user.id }, { verified: true }); + + return res.json(await getToken(user)); + }, +); + +export default router; diff --git a/src/api/routes/auth/verify/resend.ts b/src/api/routes/auth/verify/resend.ts new file mode 100644 index 00000000..918af9a1 --- /dev/null +++ b/src/api/routes/auth/verify/resend.ts @@ -0,0 +1,52 @@ +/* + 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 <https://www.gnu.org/licenses/>. +*/ + +import { route } from "@fosscord/api"; +import { Email, User } from "@fosscord/util"; +import { Request, Response, Router } from "express"; +import { HTTPError } from "lambert-server"; +const router = Router(); + +router.post( + "/", + route({ right: "RESEND_VERIFICATION_EMAIL" }), + async (req: Request, res: Response) => { + const user = await User.findOneOrFail({ + where: { id: req.user_id }, + select: ["username", "email"], + }); + + if (!user.email) { + // TODO: whats the proper error response for this? + throw new HTTPError("User does not have an email address", 400); + } + + await Email.sendVerifyEmail(user, user.email) + .then(() => { + return res.sendStatus(204); + }) + .catch((e) => { + console.error( + `Failed to send verification email to ${user.username}#${user.discriminator}: ${e}`, + ); + throw new HTTPError("Failed to send verification email", 500); + }); + }, +); + +export default router; |