123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571 |
- import config from "config";
- import axios from "axios";
- import bcrypt from "bcrypt";
- import sha256 from "sha256";
- import CoreClass from "../core";
- let UsersModule;
- let MailModule;
- let CacheModule;
- 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
- constructor() {
- super("users");
- UsersModule = this;
- }
- /**
- * Initialises the app module
- * @returns {Promise} - returns promise (reject, resolve)
- */
- async initialize() {
- DBModule = this.moduleManager.modules.db;
- MailModule = this.moduleManager.modules.mail;
- 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" });
- this.stationModel = await DBModule.runJob("GET_MODEL", { modelName: "station" });
- this.playlistModel = await DBModule.runJob("GET_MODEL", { modelName: "playlist" });
- this.activityModel = await DBModule.runJob("GET_MODEL", { modelName: "activity" });
- 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")}`;
- // 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 });
- });
- });
- if (config.get("apis.oidc.enabled")) {
- const openidConfigurationResponse = await axios.get(config.get("apis.oidc.openid_configuration_url"));
- const {
- authorization_endpoint: authorizationEndpoint,
- token_endpoint: tokenEndpoint,
- userinfo_endpoint: userinfoEndpoint
- } = openidConfigurationResponse.data;
- // TODO somehow make this endpoint immutable, if possible in some way
- this.oidcAuthorizationEndpoint = authorizationEndpoint;
- this.oidcTokenEndpoint = userinfoEndpoint;
- this.oidcUserinfoEndpoint = userinfoEndpoint;
- this.oidcRedirectUri =
- config.get("apis.oidc.redirect_uri").length > 0
- ? config.get("apis.oidc.redirect_uri")
- : `${this.appUrl}/backend/auth/oidc/authorize/callback`;
- //
- const clientId = config.get("apis.oidc.client_id");
- const clientSecret = config.get("apis.oidc.client_secret");
- this.getOIDCOAuthAccessToken = async code => {
- const tokenResponse = await axios.post(
- tokenEndpoint,
- {
- grant_type: "authorization_code",
- code,
- client_id: clientId,
- client_secret: clientSecret,
- redirect_uri: this.oidcRedirectUri
- },
- {
- headers: {
- "Content-Type": "application/x-www-form-urlencoded"
- }
- }
- );
- const { access_token: accessToken } = tokenResponse.data;
- return { accessToken };
- };
- }
- }
- /**
- * Removes a user and associated data
- * @param {object} payload - object that contains the payload
- * @param {string} payload.userId - id of the user to remove
- * @returns {Promise} - returns a promise (resolve, reject)
- */
- async REMOVE_USER(payload) {
- const { userId } = payload;
- // Create data request, in case the process fails halfway through. An admin can finish the removal manually
- const dataRequest = await UsersModule.dataRequestModel.create({ userId, type: "remove" });
- await WSModule.runJob(
- "EMIT_TO_ROOM",
- {
- room: "admin.users",
- args: ["event:admin.dataRequests.created", { data: { request: dataRequest } }]
- },
- this
- );
- if (config.get("sendDataRequestEmails")) {
- const adminUsers = await UsersModule.userModel.find({ role: "admin" });
- const to = adminUsers.map(adminUser => adminUser.email.address);
- await UsersModule.dataRequestEmailSchema(to, userId, "remove");
- }
- // Delete activities
- await UsersModule.activityModel.deleteMany({ userId });
- // Delete stations and associated data
- const stations = await UsersModule.stationModel.find({ owner: userId });
- const stationJobs = stations.map(station => async () => {
- const { _id: stationId } = station;
- await UsersModule.stationModel.deleteOne({ _id: stationId });
- await CacheModule.runJob("HDEL", { table: "stations", key: stationId }, this);
- if (!station.playlist) return;
- await PlaylistsModule.runJob("DELETE_PLAYLIST", { playlistId: station.playlist }, this);
- });
- await Promise.all(stationJobs);
- // Remove user as dj
- await UsersModule.stationModel.updateMany({ djs: userId }, { $pull: { djs: userId } });
- // Collect songs to adjust ratings for later
- 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) ?? [])
- ];
- // Delete playlists created by user
- await UsersModule.playlistModel.deleteMany({ createdBy: userId });
- // TODO Maybe we don't need to wait for this to finish?
- // Recalculate ratings of songs the user liked/disliked
- const recalculateRatingsJobs = songsToAdjustRatings.map(songsToAdjustRating =>
- MediaModule.runJob("RECALCULATE_RATINGS", { mediaSource: songsToAdjustRating }, this)
- );
- await Promise.all(recalculateRatingsJobs);
- // Delete user object
- await UsersModule.userModel.deleteMany({ _id: userId });
- // Remove sessions from Redis and MongoDB
- await CacheModule.runJob("PUB", { channel: "user.removeSessions", value: userId }, this);
- const sessions = await CacheModule.runJob("HGETALL", { table: "sessions" }, this);
- const sessionIds = Object.keys(sessions);
- const sessionJobs = sessionIds.map(sessionId => async () => {
- const session = sessions[sessionId];
- if (!session || session.userId !== userId) return;
- await CacheModule.runJob("HDEL", { table: "sessions", key: sessionId }, this);
- });
- await Promise.all(sessionJobs);
- await CacheModule.runJob(
- "PUB",
- {
- channel: "user.removeAccount",
- value: userId
- },
- this
- );
- }
- /**
- * 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 OIDC during the oauth process
- * Will be used to either log the user in or register the user
- * @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.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 OIDC_AUTHORIZE_CALLBACK(payload) {
- const { code, error, errorDescription } = payload;
- if (error) throw new Error(errorDescription);
- // Tries to get access token. We don't use the refresh token currently
- const { accessToken } = await UsersModule.getOIDCOAuthAccessToken(code);
- // Gets user data
- const userInfoResponse = await axios.post(
- UsersModule.oidcUserinfoEndpoint,
- {},
- {
- headers: {
- Authorization: `Bearer ${accessToken}`
- }
- }
- );
- if (!userInfoResponse.data.preferred_username) throw new Error("Something went wrong, no preferred_username.");
- // TODO verify sub from userinfo and token response, see 5.3.2 https://openid.net/specs/openid-connect-core-1_0.html
- const user = await UsersModule.userModel.findOne({ "services.oidc.sub": userInfoResponse.data.sub });
- 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 anyways
- user.services.oidc.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(
- "OIDC_AUTHORIZE_CALLBACK_REGISTER",
- { userInfoResponse: userInfoResponse.data, 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.toString())
- },
- this
- );
- return { sessionId, userId, redirectUrl: UsersModule.appUrl };
- }
- /**
- * Handles registering the user in the OIDC login/register callback/process
- * @param {object} payload - object that contains the payload
- * @param {string} payload.userInfoResponse - data we got from the OIDC user info API endpoint
- * @param {string} payload.accessToken - access token for the OIDC user
- * @returns {Promise} - returns a promise (resolve, reject)
- */
- async OIDC_AUTHORIZE_CALLBACK_REGISTER(payload) {
- const { userInfoResponse, accessToken } = payload;
- let user;
- // Check if username already exists
- user = await UsersModule.userModel.findOne({
- username: new RegExp(`^${userInfoResponse.preferred_username}$`, "i")
- });
- if (user) throw new Error(`An account with that username already exists.`); // TODO eventually we'll want users to be able to pick their own username maybe
- const emailAddress = userInfoResponse.email;
- if (!emailAddress) throw new Error("No email address found.");
- user = await UsersModule.userModel.findOne({ "email.address": emailAddress });
- 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: emailAddress
- },
- 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: userInfoResponse.preferred_username,
- name: userInfoResponse.name,
- location: "",
- bio: "",
- email: {
- address: emailAddress,
- verificationToken
- },
- services: {
- oidc: {
- sub: userInfoResponse.sub,
- access_token: accessToken
- }
- },
- avatar: {
- type: "gravatar",
- url: gravatarUrl
- },
- likedSongsPlaylist,
- dislikedSongsPlaylist
- };
- await UsersModule.userModel.create(user);
- await UsersModule.verifyEmailSchema(emailAddress, userInfoResponse.preferred_username, verificationToken);
- await ActivitiesModule.runJob(
- "ADD_ACTIVITY",
- {
- userId,
- type: "user__joined",
- payload: { message: "Welcome to Musare!" }
- },
- this
- );
- return {
- userId
- };
- }
- /**
- * 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 || config.get("apis.oidc.enabled") === 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 = await 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.");
- // }
- }
- export default new _UsersModule();
|