summary refs log tree commit diff
path: root/src/api
diff options
context:
space:
mode:
authorMadeline <46743919+MaddyUnderStars@users.noreply.github.com>2023-02-24 23:17:36 +1100
committerGitHub <noreply@github.com>2023-02-24 23:17:36 +1100
commit224e2c8374eee1bf85f6798123da4df90daf1860 (patch)
tree3d698d6b7392a061ff04d0dad145200377ed7fad /src/api
parentFix gateway encoding Date objects as {} when using erlpack. Fixes NaN/NaN/NaN... (diff)
parentmove transporters to their own files (diff)
downloadserver-224e2c8374eee1bf85f6798123da4df90daf1860.tar.xz
Merge pull request #965 from Puyodead1/dev/mail
Email Support
Diffstat (limited to '')
-rw-r--r--src/api/Server.ts20
-rw-r--r--src/api/middlewares/Authentication.ts7
-rw-r--r--src/api/routes/auth/forgot.ts92
-rw-r--r--src/api/routes/auth/login.ts12
-rw-r--r--src/api/routes/auth/register.ts11
-rw-r--r--src/api/routes/auth/reset.ts56
-rw-r--r--src/api/routes/auth/verify/index.ts93
-rw-r--r--src/api/routes/auth/verify/resend.ts52
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;