3 Commits 551de65643 ... 701c648f4e

Author SHA1 Message Date
  Kristian Vos 701c648f4e refactor: move update email/username logic to users module 2 months ago
  Kristian Vos 967b002810 refactor: moved password register logic to users module 2 months ago
  Kristian Vos f916b93942 refactor: move user verify email and github callback logic to users module, small fixes 2 months ago
3 changed files with 548 additions and 636 deletions
  1. 8 241
      backend/logic/actions/users.js
  2. 45 386
      backend/logic/app.js
  3. 495 9
      backend/logic/users.js

+ 8 - 241
backend/logic/actions/users.js

@@ -19,7 +19,6 @@ const CacheModule = moduleManager.modules.cache;
 const MailModule = moduleManager.modules.mail;
 const PunishmentsModule = moduleManager.modules.punishments;
 const ActivitiesModule = moduleManager.modules.activities;
-const PlaylistsModule = moduleManager.modules.playlists;
 const UsersModule = moduleManager.modules.users;
 
 CacheModule.runJob("SUB", {
@@ -503,170 +502,12 @@ export default {
 	 * @param {Function} cb - gets called with the result
 	 */
 	async register(session, username, email, password, recaptcha, cb) {
-		email = email.toLowerCase().trim();
-		const verificationToken = await UtilsModule.runJob("GENERATE_RANDOM_STRING", { length: 64 }, this);
-
-		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
-		const verifyEmailSchema = await MailModule.runJob("GET_SCHEMA", { schemaName: "verifyEmail" }, this);
-
 		async.waterfall(
 			[
 				next => {
-					if (config.get("registrationDisabled") === true)
-						return next("Registration is not allowed at this time.");
-					if (config.get("experimental.registration_email_whitelist")) {
-						const experimentalRegistrationEmailWhitelist = config.get(
-							"experimental.registration_email_whitelist"
-						);
-						if (!Array.isArray(experimentalRegistrationEmailWhitelist)) return next();
-
-						let anyPassed = false;
-
-						experimentalRegistrationEmailWhitelist.forEach(regex => {
-							const newRegex = new RegExp(regex);
-							if (newRegex.test(email)) anyPassed = true;
-						});
-
-						if (!anyPassed) next("Your email is not allowed to register.");
-					}
-					return next();
-				},
-
-				next => {
-					if (!DBModule.passwordValid(password))
-						return next("Invalid password. Check if it meets all the requirements.");
-					return next();
-				},
-
-				// verify the request with google recaptcha
-				next => {
-					if (config.get("apis.recaptcha.enabled") === true)
-						axios
-							.post("https://www.google.com/recaptcha/api/siteverify", {
-								data: {
-									secret: config.get("apis").recaptcha.secret,
-									response: recaptcha
-								}
-							})
-							.then(res => next(null, res.data))
-							.catch(err => next(err));
-					else next(null, null);
-				},
-
-				// check if the response from Google recaptcha is successful
-				// if it is, we check if a user with the requested username already exists
-				(body, next) => {
-					if (config.get("apis.recaptcha.enabled") === true)
-						if (body.success !== true) return next("Response from recaptcha was not successful.");
-
-					return userModel.findOne({ username: new RegExp(`^${username}$`, "i") }, next);
-				},
-
-				// if the user already exists, respond with that
-				// otherwise check if a user with the requested email already exists
-				(user, next) => {
-					if (user) return next("A user with that username already exists.");
-					return userModel.findOne({ "email.address": email }, next);
-				},
-
-				// if the user already exists, respond with that
-				// otherwise, generate a salt to use with hashing the new users password
-				(user, next) => {
-					if (user) return next("A user with that email already exists.");
-					return bcrypt.genSalt(10, next);
-				},
-
-				// hash the password
-				(salt, next) => {
-					bcrypt.hash(sha256(password), salt, next);
-				},
-
-				(hash, next) => {
-					UtilsModule.runJob("GENERATE_RANDOM_STRING", { length: 12 }, this).then(_id => {
-						next(null, hash, _id);
-					});
-				},
-
-				// create the user object
-				(hash, _id, next) => {
-					next(null, {
-						_id,
-						name: username,
-						username,
-						email: {
-							address: email,
-							verificationToken
-						},
-						services: {
-							password: {
-								password: hash
-							}
-						}
-					});
-				},
-
-				// generate the url for gravatar avatar
-				(user, next) => {
-					UtilsModule.runJob("CREATE_GRAVATAR", { email: user.email.address }, this).then(url => {
-						const avatarColors = ["blue", "orange", "green", "purple", "teal"];
-						user.avatar = {
-							type: "initials",
-							color: avatarColors[Math.floor(Math.random() * avatarColors.length)],
-							url
-						};
-						next(null, user);
-					});
-				},
-
-				// save the new user to the database
-				(user, next) => {
-					userModel.create(user, next);
-				},
-
-				// respond with the new user
-				(user, next) => {
-					verifyEmailSchema(email, username, verificationToken, err => {
-						next(err, user._id);
-					});
-				},
-
-				// create a liked songs playlist for the new user
-				(userId, next) => {
-					PlaylistsModule.runJob("CREATE_USER_PLAYLIST", {
-						userId,
-						displayName: "Liked Songs",
-						type: "user-liked"
-					})
-						.then(likedSongsPlaylist => {
-							next(null, likedSongsPlaylist, userId);
-						})
+					UsersModule.runJob("REGISTER", { username, email, password, recaptcha })
+						.then(({ userId }) => next(null, userId))
 						.catch(err => next(err));
-				},
-
-				// create a disliked songs playlist for the new user
-				(likedSongsPlaylist, userId, next) => {
-					PlaylistsModule.runJob("CREATE_USER_PLAYLIST", {
-						userId,
-						displayName: "Disliked Songs",
-						type: "user-disliked"
-					})
-						.then(dislikedSongsPlaylist => {
-							next(null, { likedSongsPlaylist, dislikedSongsPlaylist }, userId);
-						})
-						.catch(err => next(err));
-				},
-
-				// associate liked + disliked songs playlist to the user object
-				({ likedSongsPlaylist, dislikedSongsPlaylist }, userId, next) => {
-					userModel.updateOne(
-						{ _id: userId },
-						{ $set: { likedSongsPlaylist, dislikedSongsPlaylist } },
-						{ runValidators: true },
-						err => {
-							if (err) return next(err);
-							return next(null, userId);
-						}
-					);
 				}
 			],
 			async (err, userId) => {
@@ -1625,8 +1466,6 @@ export default {
 	 * @param {Function} cb - gets called with the result
 	 */
 	updateUsername: isLoginRequired(async function updateUsername(session, updatingUserId, newUsername, cb) {
-		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
-
 		async.waterfall(
 			[
 				next => {
@@ -1636,32 +1475,10 @@ export default {
 						.catch(() => next("Invalid permissions."));
 				},
 
-				next => userModel.findOne({ _id: updatingUserId }, next),
-
-				(user, next) => {
-					if (!user) return next("User not found.");
-					if (user.username === newUsername)
-						return next("New username can't be the same as the old username.");
-					return next(null);
-				},
-
 				next => {
-					userModel.findOne({ username: new RegExp(`^${newUsername}$`, "i") }, next);
-				},
-
-				(user, next) => {
-					if (!user) return next();
-					if (user._id.toString() === updatingUserId) return next();
-					return next("That username is already in use.");
-				},
-
-				next => {
-					userModel.updateOne(
-						{ _id: updatingUserId },
-						{ $set: { username: newUsername } },
-						{ runValidators: true },
-						next
-					);
+					UsersModule.runJob("UPDATE_USERNAME", { userId: updatingUserId, username: newUsername })
+						.then(() => next())
+						.catch(err => next(err));
 				}
 			],
 			async err => {
@@ -1713,10 +1530,6 @@ export default {
 	 */
 	updateEmail: isLoginRequired(async function updateEmail(session, updatingUserId, newEmail, cb) {
 		newEmail = newEmail.toLowerCase();
-		const verificationToken = await UtilsModule.runJob("GENERATE_RANDOM_STRING", { length: 64 }, this);
-
-		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
-		const verifyEmailSchema = await MailModule.runJob("GET_SCHEMA", { schemaName: "verifyEmail" }, this);
 
 		async.waterfall(
 			[
@@ -1727,56 +1540,10 @@ export default {
 						.catch(() => next("Invalid permissions."));
 				},
 
-				next => userModel.findOne({ _id: updatingUserId }, next),
-
-				(user, next) => {
-					if (!user) return next("User not found.");
-					if (user.email.address === newEmail)
-						return next("New email can't be the same as your the old email.");
-					return next();
-				},
-
-				next => {
-					userModel.findOne({ "email.address": newEmail }, next);
-				},
-
-				(user, next) => {
-					if (!user) return next();
-					if (user._id === updatingUserId) return next();
-					return next("That email is already in use.");
-				},
-
-				// regenerate the url for gravatar avatar
 				next => {
-					UtilsModule.runJob("CREATE_GRAVATAR", { email: newEmail }, this).then(url => {
-						next(null, url);
-					});
-				},
-
-				(newAvatarUrl, next) => {
-					userModel.updateOne(
-						{ _id: updatingUserId },
-						{
-							$set: {
-								"avatar.url": newAvatarUrl,
-								"email.address": newEmail,
-								"email.verified": false,
-								"email.verificationToken": verificationToken
-							}
-						},
-						{ runValidators: true },
-						next
-					);
-				},
-
-				(res, next) => {
-					userModel.findOne({ _id: updatingUserId }, next);
-				},
-
-				(user, next) => {
-					verifyEmailSchema(newEmail, user.username, verificationToken, err => {
-						next(err);
-					});
+					UsersModule.runJob("UPDATE_EMAIL", { userId: updatingUserId, email: newEmail })
+						.then(() => next())
+						.catch(err => next(err));
 				}
 			],
 			async err => {

+ 45 - 386
backend/logic/app.js

@@ -1,23 +1,13 @@
 import config from "config";
-import axios from "axios";
-import async from "async";
 import cors from "cors";
 import cookieParser from "cookie-parser";
 import bodyParser from "body-parser";
 import express from "express";
-import oauth from "oauth";
 import http from "http";
 import CoreClass from "../core";
 
-const { OAuth2 } = oauth;
-
 let AppModule;
-let MailModule;
-let CacheModule;
-let DBModule;
-let ActivitiesModule;
-let PlaylistsModule;
-let UtilsModule;
+let UsersModule;
 
 class _AppModule extends CoreClass {
 	// eslint-disable-next-line require-jsdoc
@@ -33,12 +23,7 @@ class _AppModule extends CoreClass {
 	 */
 	initialize() {
 		return new Promise(resolve => {
-			MailModule = this.moduleManager.modules.mail;
-			CacheModule = this.moduleManager.modules.cache;
-			DBModule = this.moduleManager.modules.db;
-			ActivitiesModule = this.moduleManager.modules.activities;
-			PlaylistsModule = this.moduleManager.modules.playlists;
-			UtilsModule = this.moduleManager.modules.utils;
+			UsersModule = this.moduleManager.modules.users;
 
 			const app = (this.app = express());
 			const SIDname = config.get("cookie");
@@ -49,13 +34,6 @@ class _AppModule extends CoreClass {
 			app.use(bodyParser.json());
 			app.use(bodyParser.urlencoded({ extended: true }));
 
-			let userModel;
-			DBModule.runJob("GET_MODEL", { modelName: "user" })
-				.then(model => {
-					userModel = model;
-				})
-				.catch(console.error);
-
 			const appUrl = `${config.get("url.secure") ? "https" : "http"}://${config.get("url.host")}`;
 
 			const corsOptions = JSON.parse(JSON.stringify(config.get("cors")));
@@ -74,15 +52,6 @@ class _AppModule extends CoreClass {
 			}
 
 			if (config.get("apis.github.enabled")) {
-				const oauth2 = new OAuth2(
-					config.get("apis.github.client"),
-					config.get("apis.github.secret"),
-					"https://github.com/",
-					"login/oauth/authorize",
-					"login/oauth/access_token",
-					null
-				);
-
 				const redirectUri =
 					config.get("apis.github.redirect_uri").length > 0
 						? config.get("apis.github.redirect_uri")
@@ -120,7 +89,7 @@ class _AppModule extends CoreClass {
 						`client_id=${config.get("apis.github.client")}`,
 						`redirect_uri=${redirectUri}`,
 						`scope=user:email`,
-						`state=${req.cookies[SIDname]}`
+						`state=${req.cookies[SIDname]}` // TODO don't do this
 					].join("&");
 					return res.redirect(`https://github.com/login/oauth/authorize?${params}`);
 				});
@@ -133,384 +102,74 @@ class _AppModule extends CoreClass {
 							`A user tried to use github authorize, but the APP module is currently not ready.`
 						);
 
-						return redirectOnErr(res, "Something went wrong on our end. Please try again later.");
+						redirectOnErr(res, "Something went wrong on our end. Please try again later.");
+						return;
 					}
 
-					const { code } = req.query;
-					let accessToken;
-					let body;
-					let address;
-
-					const { state } = req.query;
-
-					const verificationToken = await UtilsModule.runJob("GENERATE_RANDOM_STRING", { length: 64 });
-
-					return async.waterfall(
-						[
-							next => {
-								if (req.query.error) return next(req.query.error_description);
-								return next();
-							},
-
-							next => {
-								oauth2.getOAuthAccessToken(code, { redirect_uri: redirectUri }, next);
-							},
-
-							(_accessToken, refreshToken, results, next) => {
-								if (results.error) return next(results.error_description);
-
-								accessToken = _accessToken;
-
-								const options = {
-									headers: {
-										"User-Agent": "request",
-										Authorization: `token ${accessToken}`
-									}
-								};
-
-								return axios
-									.get("https://api.github.com/user", options)
-									.then(github => next(null, github))
-									.catch(err => next(err));
-							},
-
-							(github, next) => {
-								if (github.status !== 200) return next(github.data.message);
-
-								if (state) {
-									return async.waterfall(
-										[
-											next => {
-												CacheModule.runJob("HGET", {
-													table: "sessions",
-													key: state
-												})
-													.then(session => next(null, session))
-													.catch(next);
-											},
-
-											(session, next) => {
-												if (!session) return next("Invalid session.");
-												return userModel.findOne({ _id: session.userId }, next);
-											},
-
-											(user, next) => {
-												if (!user) return next("User not found.");
-												if (user.services.github && user.services.github.id)
-													return next("Account already has GitHub linked.");
-
-												return userModel.updateOne(
-													{ _id: user._id },
-													{
-														$set: {
-															"services.github": {
-																id: github.data.id,
-																access_token: accessToken
-															}
-														}
-													},
-													{ runValidators: true },
-													err => {
-														if (err) return next(err);
-														return next(null, user, github.data);
-													}
-												);
-											},
-
-											user => {
-												CacheModule.runJob("PUB", {
-													channel: "user.linkGithub",
-													value: user._id
-												});
-
-												CacheModule.runJob("PUB", {
-													channel: "user.updated",
-													value: { userId: user._id }
-												});
-
-												res.redirect(`${appUrl}/settings?tab=security`);
-											}
-										],
-										next
-									);
-								}
-
-								if (!github.data.id) return next("Something went wrong, no id.");
-
-								return userModel.findOne({ "services.github.id": github.data.id }, (err, user) => {
-									next(err, user, github.data);
-								});
-							},
-
-							(user, _body, next) => {
-								body = _body;
-
-								if (user) {
-									user.services.github.access_token = accessToken;
-									return user.save(() => next(true, user._id));
-								}
+					const { code, state, error, error_description: errorDescription } = req.query;
 
-								return userModel.findOne(
-									{
-										username: new RegExp(`^${body.login}$`, "i")
-									},
-									(err, user) => next(err, user)
-								);
-							},
-
-							(user, next) => {
-								if (user) return next(`An account with that username already exists.`);
-
-								return axios
-									.get("https://api.github.com/user/emails", {
-										headers: {
-											"User-Agent": "request",
-											Authorization: `token ${accessToken}`
-										}
-									})
-									.then(res => next(null, res.data))
-									.catch(err => next(err));
-							},
-
-							(body, next) => {
-								if (!Array.isArray(body)) return next(body.message);
-
-								body.forEach(email => {
-									if (email.primary) address = email.email.toLowerCase();
-								});
+					// GITHUB_AUTHORIZE_CALLBACK job handles login/register/linking
+					UsersModule.runJob("GITHUB_AUTHORIZE_CALLBACK", { code, state, error, errorDescription })
+						.then(({ redirectUrl, sessionId, userId }) => {
+							if (sessionId) {
+								const date = new Date();
+								date.setTime(new Date().getTime() + 2 * 365 * 24 * 60 * 60 * 1000);
 
-								return userModel.findOne({ "email.address": address }, next);
-							},
-
-							(user, next) => {
-								UtilsModule.runJob("GENERATE_RANDOM_STRING", {
-									length: 12
-								}).then(_id => next(null, user, _id));
-							},
-
-							(user, _id, next) => {
-								if (user) {
-									if (Object.keys(JSON.parse(user.services.github)).length === 0)
-										return next(
-											`An account with that email address exists, but is not linked to GitHub.`
-										);
-									return next(`An account with that email address already exists.`);
-								}
-
-								return next(null, {
-									_id,
-									username: body.login,
-									name: body.name,
-									location: body.location,
-									bio: body.bio,
-									email: {
-										address,
-										verificationToken
-									},
-									services: {
-										github: {
-											id: body.id,
-											access_token: accessToken
-										}
-									}
-								});
-							},
-
-							// generate the url for gravatar avatar
-							(user, next) => {
-								UtilsModule.runJob("CREATE_GRAVATAR", {
-									email: user.email.address
-								}).then(url => {
-									user.avatar = { type: "gravatar", url };
-									next(null, user);
-								});
-							},
-
-							// save the new user to the database
-							(user, next) => {
-								userModel.create(user, next);
-							},
-
-							(user, next) => {
-								MailModule.runJob("GET_SCHEMA", {
-									schemaName: "verifyEmail"
-								}).then(verifyEmailSchema => {
-									verifyEmailSchema(address, body.login, user.email.verificationToken, err => {
-										next(err, user._id);
-									});
-								});
-							},
-
-							// create a liked songs playlist for the new user
-							(userId, next) => {
-								PlaylistsModule.runJob("CREATE_USER_PLAYLIST", {
-									userId,
-									displayName: "Liked Songs",
-									type: "user-liked"
-								})
-									.then(likedSongsPlaylist => {
-										next(null, likedSongsPlaylist, userId);
-									})
-									.catch(err => next(err));
-							},
-
-							// create a disliked songs playlist for the new user
-							(likedSongsPlaylist, userId, next) => {
-								PlaylistsModule.runJob("CREATE_USER_PLAYLIST", {
-									userId,
-									displayName: "Disliked Songs",
-									type: "user-disliked"
-								})
-									.then(dislikedSongsPlaylist => {
-										next(
-											null,
-											{
-												likedSongsPlaylist,
-												dislikedSongsPlaylist
-											},
-											userId
-										);
-									})
-									.catch(err => next(err));
-							},
-
-							// associate liked + disliked songs playlist to the user object
-							({ likedSongsPlaylist, dislikedSongsPlaylist }, userId, next) => {
-								userModel.updateOne(
-									{ _id: userId },
-									{
-										$set: {
-											likedSongsPlaylist,
-											dislikedSongsPlaylist
-										}
-									},
-									{ runValidators: true },
-									err => {
-										if (err) return next(err);
-										return next(null, userId);
-									}
-								);
-							},
-
-							// add the activity of account creation
-							(userId, next) => {
-								ActivitiesModule.runJob("ADD_ACTIVITY", {
-									userId,
-									type: "user__joined",
-									payload: { message: "Welcome to Musare!" }
-								});
-
-								next(null, userId);
-							}
-						],
-						async (err, userId) => {
-							if (err && err !== true) {
-								err = await UtilsModule.runJob("GET_ERROR", {
-									error: err
+								res.cookie(SIDname, sessionId, {
+									expires: date,
+									secure: config.get("url.secure"),
+									path: "/",
+									domain: config.get("url.host")
 								});
 
 								this.log(
-									"ERROR",
+									"INFO",
 									"AUTH_GITHUB_AUTHORIZE_CALLBACK",
-									`Failed to authorize with GitHub. "${err}"`
+									`User "${userId}" successfully authorized with GitHub.`
 								);
-
-								return redirectOnErr(res, err);
 							}
 
-							const sessionId = await UtilsModule.runJob("GUID", {});
-							const sessionSchema = await CacheModule.runJob("GET_SCHEMA", {
-								schemaName: "session"
-							});
-
-							return CacheModule.runJob("HSET", {
-								table: "sessions",
-								key: sessionId,
-								value: sessionSchema(sessionId, userId)
-							})
-								.then(() => {
-									const date = new Date();
-									date.setTime(new Date().getTime() + 2 * 365 * 24 * 60 * 60 * 1000);
-
-									res.cookie(SIDname, sessionId, {
-										expires: date,
-										secure: config.get("url.secure"),
-										path: "/",
-										domain: config.get("url.host")
-									});
-
-									this.log(
-										"INFO",
-										"AUTH_GITHUB_AUTHORIZE_CALLBACK",
-										`User "${userId}" successfully authorized with GitHub.`
-									);
-
-									res.redirect(appUrl);
-								})
-								.catch(err => redirectOnErr(res, err.message));
-						}
-					);
+							res.redirect(redirectUrl);
+						})
+						.catch(err => {
+							this.log(
+								"ERROR",
+								"AUTH_GITHUB_AUTHORIZE_CALLBACK",
+								`Failed to authorize with GitHub. "${err.message}"`
+							);
+
+							return redirectOnErr(res, err.message);
+						});
 				});
 			}
 
-			app.get("/auth/verify_email", async (req, res) => {
+			app.get("/auth/verify_email", (req, res) => {
 				if (this.getStatus() !== "READY") {
 					this.log(
 						"INFO",
 						"APP_REJECTED_VERIFY_EMAIL",
 						`A user tried to use verify email, but the APP module is currently not ready.`
 					);
-					return redirectOnErr(res, "Something went wrong on our end. Please try again later.");
+					redirectOnErr(res, "Something went wrong on our end. Please try again later.");
+					return;
 				}
 
 				const { code } = req.query;
 
-				return async.waterfall(
-					[
-						next => {
-							if (!code) return next("Invalid code.");
-							return next();
-						},
-
-						next => {
-							userModel.findOne({ "email.verificationToken": code }, next);
-						},
-
-						(user, next) => {
-							if (!user) return next("User not found.");
-							if (user.email.verified) return next("This email is already verified.");
-
-							return userModel.updateOne(
-								{ "email.verificationToken": code },
-								{
-									$set: { "email.verified": true },
-									$unset: { "email.verificationToken": "" }
-								},
-								{ runValidators: true },
-								next
-							);
-						}
-					],
-					err => {
-						if (err) {
-							let error = "An error occurred.";
-
-							if (typeof err === "string") error = err;
-							else if (err.message) error = err.message;
-
-							this.log("ERROR", "VERIFY_EMAIL", `Verifying email failed. "${error}"`);
-
-							return res.json({
-								status: "error",
-								message: error
-							});
-						}
-
+				UsersModule.runJob("VERIFY_EMAIL", { code })
+					.then(() => {
 						this.log("INFO", "VERIFY_EMAIL", `Successfully verified email.`);
 
-						return res.redirect(`${appUrl}?toast=Thank you for verifying your email`);
-					}
-				);
+						res.redirect(`${appUrl}?toast=Thank you for verifying your email`);
+					})
+					.catch(err => {
+						this.log("ERROR", "VERIFY_EMAIL", `Verifying email failed. "${err.message}"`);
+
+						res.json({
+							status: "error",
+							message: err.message
+						});
+					});
 			});
 
 			resolve();

+ 495 - 9
backend/logic/users.js

@@ -1,6 +1,12 @@
 import config from "config";
+import oauth from "oauth";
+import axios from "axios";
+import bcrypt from "bcrypt";
+import sha256 from "sha256";
 import CoreClass from "../core";
 
+const { OAuth2 } = oauth;
+
 let UsersModule;
 let MailModule;
 let CacheModule;
@@ -8,6 +14,10 @@ let DBModule;
 let PlaylistsModule;
 let WSModule;
 let MediaModule;
+let UtilsModule;
+let ActivitiesModule;
+
+const avatarColors = ["blue", "orange", "green", "purple", "teal"];
 
 class _UsersModule extends CoreClass {
 	// eslint-disable-next-line require-jsdoc
@@ -27,6 +37,9 @@ class _UsersModule extends CoreClass {
 		WSModule = this.moduleManager.modules.ws;
 		CacheModule = this.moduleManager.modules.cache;
 		MediaModule = this.moduleManager.modules.media;
+		UtilsModule = this.moduleManager.modules.utils;
+		ActivitiesModule = this.moduleManager.modules.activities;
+		PlaylistsModule = this.moduleManager.modules.playlists;
 
 		this.userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" });
 		this.dataRequestModel = await DBModule.runJob("GET_MODEL", { modelName: "dataRequest" });
@@ -34,7 +47,36 @@ class _UsersModule extends CoreClass {
 		this.playlistModel = await DBModule.runJob("GET_MODEL", { modelName: "playlist" });
 		this.activityModel = await DBModule.runJob("GET_MODEL", { modelName: "activity" });
 
-		this.dataRequestEmail = await MailModule.runJob("GET_SCHEMA_ASYNC", { schemaName: "dataRequest" });
+		this.dataRequestEmailSchema = await MailModule.runJob("GET_SCHEMA_ASYNC", { schemaName: "dataRequest" });
+		this.verifyEmailSchema = await MailModule.runJob("GET_SCHEMA_ASYNC", { schemaName: "verifyEmail" });
+
+		this.sessionSchema = await CacheModule.runJob("GET_SCHEMA", {
+			schemaName: "session"
+		});
+
+		this.appUrl = `${config.get("url.secure") ? "https" : "http"}://${config.get("url.host")}`;
+		this.redirectUri =
+			config.get("apis.github.redirect_uri").length > 0
+				? config.get("apis.github.redirect_uri")
+				: `${this.appUrl}/backend/auth/github/authorize/callback`;
+
+		this.oauth2 = new OAuth2(
+			config.get("apis.github.client"),
+			config.get("apis.github.secret"),
+			"https://github.com/",
+			"login/oauth/authorize",
+			"login/oauth/access_token",
+			null
+		);
+
+		// getOAuthAccessToken uses callbacks by default, so make a helper function to turn it into a promise instead
+		this.getOAuthAccessToken = (...args) =>
+			new Promise((resolve, reject) => {
+				this.oauth2.getOAuthAccessToken(...args, (err, accessToken, refreshToken, results) => {
+					if (err) reject(err);
+					else resolve({ accessToken, refreshToken, results });
+				});
+			});
 	}
 
 	/**
@@ -60,7 +102,7 @@ class _UsersModule extends CoreClass {
 		if (config.get("sendDataRequestEmails")) {
 			const adminUsers = await UsersModule.userModel.find({ role: "admin" });
 			const to = adminUsers.map(adminUser => adminUser.email.address);
-			await UsersModule.dataRequestEmail(to, userId, "remove");
+			await UsersModule.dataRequestEmailSchema(to, userId, "remove");
 		}
 
 		// Delete activities
@@ -87,8 +129,8 @@ class _UsersModule extends CoreClass {
 		const likedPlaylist = await UsersModule.playlistModel.findOne({ createdBy: userId, type: "user-liked" });
 		const dislikedPlaylist = await UsersModule.playlistModel.findOne({ createdBy: userId, type: "user-disliked" });
 		const songsToAdjustRatings = [
-			...likedPlaylist.songs.map(({ mediaSource }) => mediaSource),
-			...dislikedPlaylist.songs.map(({ mediaSource }) => mediaSource)
+			...(likedPlaylist?.songs?.map(({ mediaSource }) => mediaSource) ?? []),
+			...(dislikedPlaylist?.songs?.map(({ mediaSource }) => mediaSource) ?? [])
 		];
 
 		// Delete playlists created by user
@@ -127,11 +169,455 @@ class _UsersModule extends CoreClass {
 		);
 	}
 
-	// EXAMPLE_JOB() {
-	// 	return new Promise((resolve, reject) => {
-	// 		if (true) resolve({});
-	// 		else reject(new Error("Nothing changed."));
-	// 	});
+	/**
+	 * Tries to verify email from email verification token/code
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.code - email verification token/code
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	async VERIFY_EMAIL(payload) {
+		const { code } = payload;
+		if (!code) throw new Error("Invalid code.");
+
+		const user = await UsersModule.userModel.findOne({ "email.verificationToken": code });
+		if (!user) throw new Error("User not found.");
+		if (user.email.verified) throw new Error("This email is already verified.");
+
+		await UsersModule.userModel.updateOne(
+			{ "email.verificationToken": code },
+			{
+				$set: { "email.verified": true },
+				$unset: { "email.verificationToken": "" }
+			},
+			{ runValidators: true }
+		);
+	}
+
+	/**
+	 * Handles callback route being accessed, which has data from GitHub during the oauth process
+	 * Will be used to either log the user in, register the user, or link the GitHub account to an existing account
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.code - code we need to use to get the access token
+	 * @param {string} payload.state - custom state we may have passed to GitHub during the first step
+	 * @param {string} payload.error - error code if an error occured
+	 * @param {string} payload.errorDescription - error description if an error occured
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	async GITHUB_AUTHORIZE_CALLBACK(payload) {
+		const { code, state, error, errorDescription } = payload;
+		if (error) throw new Error(errorDescription);
+
+		// Tries to get access token. We don't use the refresh token currently
+		const { accessToken, /* refreshToken, */ results } = await UsersModule.getOAuthAccessToken(code, {
+			redirect_uri: UsersModule.redirectUri
+		});
+		if (!accessToken) throw new Error(results.error_description);
+
+		const options = {
+			headers: {
+				"User-Agent": "request",
+				Authorization: `token ${accessToken}`
+			}
+		};
+		// Gets user data
+		const githubUserData = await axios.get("https://api.github.com/user", options);
+		if (githubUserData.status !== 200) throw new Error(githubUserData.data.message);
+		if (!githubUserData.data.id) throw new Error("Something went wrong, no id.");
+
+		// If we specified a state in the first step when we redirected the user to GitHub, it was to link a
+		// GitHub account to an existing Musare account, so continue with a job specifically for linking the account
+		if (state)
+			return UsersModule.runJob(
+				"GITHUB_AUTHORIZE_CALLBACK_LINK",
+				{ state, githubId: githubUserData.data.id, accessToken },
+				this
+			);
+
+		const user = await UsersModule.userModel.findOne({ "services.github.id": githubUserData.data.id });
+		let userId;
+		if (user) {
+			// Refresh access token, though it's pretty useless as it'll probably expire and then be useless,
+			// and we don't use it afterwards at all
+			user.services.github.access_token = accessToken;
+			await user.save();
+			userId = user._id;
+		} else {
+			// Try to register the user. Will throw an error if it's unable to do so or any error occurs
+			userId = await UsersModule.runJob(
+				"GITHUB_AUTHORIZE_CALLBACK_REGISTER",
+				{ githubUserData, accessToken },
+				this
+			);
+		}
+
+		// Create session for the userId gotten above, as the user existed or was successfully registered
+		const sessionId = await UtilsModule.runJob("GUID", {}, this);
+		await CacheModule.runJob(
+			"HSET",
+			{
+				table: "sessions",
+				key: sessionId,
+				value: UsersModule.sessionSchema(sessionId, userId)
+			},
+			this
+		);
+
+		return { sessionId, userId, redirectUrl: UsersModule.appUrl };
+	}
+
+	/**
+	 * Handles registering the user in the GitHub login/register/link callback/process
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.githubUserData - data we got from the /user API endpoint from GitHub
+	 * @param {string} payload.accessToken - access token for the GitHub user
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	async GITHUB_AUTHORIZE_CALLBACK_REGISTER(payload) {
+		const { githubUserData, accessToken } = payload;
+		let user;
+
+		// Check if username already exists
+		user = await UsersModule.userModel.findOne({ username: new RegExp(`^${githubUserData.data.login}$`, "i") });
+		if (user) throw new Error(`An account with that username already exists.`);
+
+		// Get emails used for GitHub account
+		const githubEmailsData = await axios.get("https://api.github.com/user/emails", {
+			headers: {
+				"User-Agent": "request",
+				Authorization: `token ${accessToken}`
+			}
+		});
+		if (!Array.isArray(githubEmailsData.data)) throw new Error(githubEmailsData.message);
+
+		const primaryEmailAddress = githubEmailsData.data.find(emailAddress => emailAddress.primary)?.email;
+		if (!primaryEmailAddress) throw new Error("No primary email address found.");
+
+		user = await UsersModule.userModel.findOne({ "email.address": primaryEmailAddress });
+		if (user && Object.keys(JSON.parse(user.services.github)).length === 0)
+			throw new Error(`An account with that email address exists, but is not linked to GitHub.`);
+		if (user) throw new Error(`An account with that email address already exists.`);
+
+		const userId = await UtilsModule.runJob(
+			"GENERATE_RANDOM_STRING",
+			{
+				length: 12
+			},
+			this
+		);
+		const verificationToken = await UtilsModule.runJob("GENERATE_RANDOM_STRING", { length: 64 }, this);
+		const gravatarUrl = await UtilsModule.runJob(
+			"CREATE_GRAVATAR",
+			{
+				email: primaryEmailAddress
+			},
+			this
+		);
+		const likedSongsPlaylist = await PlaylistsModule.runJob(
+			"CREATE_USER_PLAYLIST",
+			{
+				userId,
+				displayName: "Liked Songs",
+				type: "user-liked"
+			},
+			this
+		);
+		const dislikedSongsPlaylist = await PlaylistsModule.runJob(
+			"CREATE_USER_PLAYLIST",
+			{
+				userId,
+				displayName: "Disliked Songs",
+				type: "user-disliked"
+			},
+			this
+		);
+
+		user = {
+			_id: userId,
+			username: githubUserData.data.login,
+			name: githubUserData.data.name,
+			location: githubUserData.data.location,
+			bio: githubUserData.data.bio,
+			email: {
+				primaryEmailAddress,
+				verificationToken
+			},
+			services: {
+				github: {
+					id: githubUserData.data.id,
+					access_token: accessToken
+				}
+			},
+			avatar: {
+				type: "gravatar",
+				url: gravatarUrl
+			},
+			likedSongsPlaylist,
+			dislikedSongsPlaylist
+		};
+
+		await UsersModule.userModel.create(user);
+
+		await UsersModule.verifyEmailSchema(primaryEmailAddress, githubUserData.data.login, verificationToken);
+		await ActivitiesModule.runJob(
+			"ADD_ACTIVITY",
+			{
+				userId,
+				type: "user__joined",
+				payload: { message: "Welcome to Musare!" }
+			},
+			this
+		);
+
+		return {
+			userId
+		};
+	}
+
+	/**
+	 * Job to attempt to link a GitHub user to a Musare account
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.state - state we passed to GitHub and got back from GitHub
+	 * @param {string} payload.githubId - GitHub user id
+	 * @param {string} payload.accessToken - GitHub user access token
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	async GITHUB_AUTHORIZE_CALLBACK_LINK(payload) {
+		const { state, githubId, accessToken } = payload;
+
+		// State is currently the session id (SID), so check if that session (still) exists
+		const session = await CacheModule.runJob(
+			"HGET",
+			{
+				table: "sessions",
+				key: state
+			},
+			this
+		);
+		if (!session) throw new Error("Invalid session.");
+
+		const user = await UsersModule.userModel.findOne({ _id: session.userId });
+		if (!user) throw new Error("User not found.");
+		if (user.services.github && user.services.github.id) throw new Error("Account already has GitHub linked.");
+
+		const { _id: userId } = user;
+
+		await UsersModule.userModel.updateOne(
+			{ _id: userId },
+			{
+				$set: {
+					"services.github": {
+						id: githubId,
+						access_token: accessToken
+					}
+				}
+			},
+			{ runValidators: true }
+		);
+
+		await CacheModule.runJob(
+			"PUB",
+			{
+				channel: "user.linkGithub",
+				value: userId
+			},
+			this
+		);
+		await CacheModule.runJob(
+			"PUB",
+			{
+				channel: "user.updated",
+				value: { userId }
+			},
+			this
+		);
+
+		return {
+			redirectUrl: `${UsersModule.appUrl}/settings?tab=security`
+		};
+	}
+
+	/**
+	 * Attempts to register a user
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.email - email
+	 * @param {string} payload.username - username
+	 * @param {string} payload.password - plaintext password
+	 * @param {string} payload.recaptcha - recaptcha, if recaptcha is enabled
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	async REGISTER(payload) {
+		const { username, password, recaptcha } = payload;
+		let { email } = payload;
+		email = email.toLowerCase().trim();
+
+		if (config.get("registrationDisabled") === true) throw new Error("Registration is not allowed at this time.");
+		if (Array.isArray(config.get("experimental.registration_email_whitelist"))) {
+			const experimentalRegistrationEmailWhitelist = config.get("experimental.registration_email_whitelist");
+
+			const anyRegexPassed = experimentalRegistrationEmailWhitelist.find(regex => {
+				const emailWhitelistRegex = new RegExp(regex);
+				return emailWhitelistRegex.test(email);
+			});
+
+			if (!anyRegexPassed) throw new Error("Your email is not allowed to register.");
+		}
+
+		if (!DBModule.passwordValid(password))
+			throw new Error("Invalid password. Check if it meets all the requirements.");
+
+		if (config.get("apis.recaptcha.enabled") === true) {
+			const recaptchaBody = await axios.post("https://www.google.com/recaptcha/api/siteverify", {
+				data: {
+					secret: config.get("apis").recaptcha.secret,
+					response: recaptcha
+				}
+			});
+			if (recaptchaBody.success !== true) throw new Error("Response from recaptcha was not successful.");
+		}
+
+		let user = await UsersModule.userModel.findOne({ username: new RegExp(`^${username}$`, "i") });
+		if (user) throw new Error("A user with that username already exists.");
+		user = await UsersModule.userModel.findOne({ "email.address": email });
+		if (user) throw new Error("A user with that email already exists.");
+
+		const salt = await bcrypt.genSalt(10);
+		const hash = await bcrypt.hash(sha256(password), salt);
+
+		const userId = await UtilsModule.runJob(
+			"GENERATE_RANDOM_STRING",
+			{
+				length: 12
+			},
+			this
+		);
+		const verificationToken = await UtilsModule.runJob("GENERATE_RANDOM_STRING", { length: 64 }, this);
+		const gravatarUrl = await UtilsModule.runJob(
+			"CREATE_GRAVATAR",
+			{
+				email
+			},
+			this
+		);
+		const likedSongsPlaylist = await PlaylistsModule.runJob(
+			"CREATE_USER_PLAYLIST",
+			{
+				userId,
+				displayName: "Liked Songs",
+				type: "user-liked"
+			},
+			this
+		);
+		const dislikedSongsPlaylist = await PlaylistsModule.runJob(
+			"CREATE_USER_PLAYLIST",
+			{
+				userId,
+				displayName: "Disliked Songs",
+				type: "user-disliked"
+			},
+			this
+		);
+
+		user = {
+			_id: userId,
+			name: username,
+			username,
+			email: {
+				address: email,
+				verificationToken
+			},
+			services: {
+				password: {
+					password: hash
+				}
+			},
+			avatar: {
+				type: "initials",
+				color: avatarColors[Math.floor(Math.random() * avatarColors.length)],
+				url: gravatarUrl
+			},
+			likedSongsPlaylist,
+			dislikedSongsPlaylist
+		};
+
+		await UsersModule.userModel.create(user);
+
+		await UsersModule.verifyEmailSchema(email, username, verificationToken);
+		await ActivitiesModule.runJob(
+			"ADD_ACTIVITY",
+			{
+				userId,
+				type: "user__joined",
+				payload: { message: "Welcome to Musare!" }
+			},
+			this
+		);
+
+		return {
+			userId
+		};
+	}
+
+	/**
+	 * Attempts to update the email address of a user
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.userId - userId
+	 * @param {string} payload.email - new email
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	async UPDATE_EMAIL(payload) {
+		const { userId } = payload;
+		let { email } = payload;
+		email = email.toLowerCase().trim();
+
+		const user = await UsersModule.userModel.findOne({ _id: userId });
+		if (!user) throw new Error("User not found.");
+		if (user.email.address === email) throw new Error("New email can't be the same as your the old email.");
+
+		const existingUser = UsersModule.userModel.findOne({ "email.address": email });
+		if (existingUser) throw new Error("That email is already in use.");
+
+		const gravatarUrl = await UtilsModule.runJob("CREATE_GRAVATAR", { email }, this);
+		const verificationToken = await UtilsModule.runJob("GENERATE_RANDOM_STRING", { length: 64 }, this);
+
+		await UsersModule.userModel.updateOne(
+			{ _id: userId },
+			{
+				$set: {
+					"avatar.url": gravatarUrl,
+					"email.address": email,
+					"email.verified": false,
+					"email.verificationToken": verificationToken
+				}
+			},
+			{ runValidators: true }
+		);
+
+		await UsersModule.verifyEmailSchema(email, user.username, verificationToken);
+	}
+
+	/**
+	 * Attempts to update the username of a user
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.userId - userId
+	 * @param {string} payload.username - new username
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	async UPDATE_USERNAME(payload) {
+		const { userId, username } = payload;
+
+		const user = await UsersModule.userModel.findOne({ _id: userId });
+		if (!user) throw new Error("User not found.");
+		if (user.username === username) throw new Error("New username can't be the same as the old username.");
+
+		const existingUser = UsersModule.userModel.findOne({ username: new RegExp(`^${username}$`, "i") });
+		if (existingUser) throw new Error("That username is already in use.");
+
+		await UsersModule.userModel.updateOne({ _id: userId }, { $set: { username } }, { runValidators: true });
+	}
+
+	// async EXAMPLE_JOB() {
+	// 	if (true) return;
+	// 	else throw new Error("Nothing changed.");
 	// }
 }