users.js 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566
  1. import config from "config";
  2. import oauth from "oauth";
  3. import axios from "axios";
  4. import bcrypt from "bcrypt";
  5. import sha256 from "sha256";
  6. import CoreClass from "../core";
  7. const { OAuth2 } = oauth;
  8. let UsersModule;
  9. let MailModule;
  10. let CacheModule;
  11. let DBModule;
  12. let PlaylistsModule;
  13. let WSModule;
  14. let MediaModule;
  15. let UtilsModule;
  16. let ActivitiesModule;
  17. const avatarColors = ["blue", "orange", "green", "purple", "teal"];
  18. class _UsersModule extends CoreClass {
  19. // eslint-disable-next-line require-jsdoc
  20. constructor() {
  21. super("users");
  22. UsersModule = this;
  23. }
  24. /**
  25. * Initialises the app module
  26. * @returns {Promise} - returns promise (reject, resolve)
  27. */
  28. async initialize() {
  29. DBModule = this.moduleManager.modules.db;
  30. MailModule = this.moduleManager.modules.mail;
  31. WSModule = this.moduleManager.modules.ws;
  32. CacheModule = this.moduleManager.modules.cache;
  33. MediaModule = this.moduleManager.modules.media;
  34. UtilsModule = this.moduleManager.modules.utils;
  35. ActivitiesModule = this.moduleManager.modules.activities;
  36. PlaylistsModule = this.moduleManager.modules.playlists;
  37. this.userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" });
  38. this.dataRequestModel = await DBModule.runJob("GET_MODEL", { modelName: "dataRequest" });
  39. this.stationModel = await DBModule.runJob("GET_MODEL", { modelName: "station" });
  40. this.playlistModel = await DBModule.runJob("GET_MODEL", { modelName: "playlist" });
  41. this.activityModel = await DBModule.runJob("GET_MODEL", { modelName: "activity" });
  42. this.dataRequestEmailSchema = await MailModule.runJob("GET_SCHEMA_ASYNC", { schemaName: "dataRequest" });
  43. this.verifyEmailSchema = await MailModule.runJob("GET_SCHEMA_ASYNC", { schemaName: "verifyEmail" });
  44. this.sessionSchema = await CacheModule.runJob("GET_SCHEMA", {
  45. schemaName: "session"
  46. });
  47. this.appUrl = `${config.get("url.secure") ? "https" : "http"}://${config.get("url.host")}`;
  48. this.redirectUri =
  49. config.get("apis.github.redirect_uri").length > 0
  50. ? config.get("apis.github.redirect_uri")
  51. : `${this.appUrl}/backend/auth/github/authorize/callback`;
  52. this.oauth2 = new OAuth2(
  53. config.get("apis.github.client"),
  54. config.get("apis.github.secret"),
  55. "https://github.com/",
  56. "login/oauth/authorize",
  57. "login/oauth/access_token",
  58. null
  59. );
  60. // getOAuthAccessToken uses callbacks by default, so make a helper function to turn it into a promise instead
  61. this.getOAuthAccessToken = (...args) =>
  62. new Promise((resolve, reject) => {
  63. this.oauth2.getOAuthAccessToken(...args, (err, accessToken, refreshToken, results) => {
  64. if (err) reject(err);
  65. else resolve({ accessToken, refreshToken, results });
  66. });
  67. });
  68. }
  69. /**
  70. * Removes a user and associated data
  71. * @param {object} payload - object that contains the payload
  72. * @param {string} payload.userId - id of the user to remove
  73. * @returns {Promise} - returns a promise (resolve, reject)
  74. */
  75. async REMOVE_USER(payload) {
  76. const { userId } = payload;
  77. // Create data request, in case the process fails halfway through. An admin can finish the removal manually
  78. const dataRequest = await UsersModule.dataRequestModel.create({ userId, type: "remove" });
  79. await WSModule.runJob(
  80. "EMIT_TO_ROOM",
  81. {
  82. room: "admin.users",
  83. args: ["event:admin.dataRequests.created", { data: { request: dataRequest } }]
  84. },
  85. this
  86. );
  87. if (config.get("sendDataRequestEmails")) {
  88. const adminUsers = await UsersModule.userModel.find({ role: "admin" });
  89. const to = adminUsers.map(adminUser => adminUser.email.address);
  90. await UsersModule.dataRequestEmailSchema(to, userId, "remove");
  91. }
  92. // Delete activities
  93. await UsersModule.activityModel.deleteMany({ userId });
  94. // Delete stations and associated data
  95. const stations = await UsersModule.stationModel.find({ owner: userId });
  96. const stationJobs = stations.map(station => async () => {
  97. const { _id: stationId } = station;
  98. await UsersModule.stationModel.deleteOne({ _id: stationId });
  99. await CacheModule.runJob("HDEL", { table: "stations", key: stationId }, this);
  100. if (!station.playlist) return;
  101. await PlaylistsModule.runJob("DELETE_PLAYLIST", { playlistId: station.playlist }, this);
  102. });
  103. await Promise.all(stationJobs);
  104. // Remove user as dj
  105. await UsersModule.stationModel.updateMany({ djs: userId }, { $pull: { djs: userId } });
  106. // Collect songs to adjust ratings for later
  107. const likedPlaylist = await UsersModule.playlistModel.findOne({ createdBy: userId, type: "user-liked" });
  108. const dislikedPlaylist = await UsersModule.playlistModel.findOne({ createdBy: userId, type: "user-disliked" });
  109. const songsToAdjustRatings = [
  110. ...(likedPlaylist?.songs?.map(({ mediaSource }) => mediaSource) ?? []),
  111. ...(dislikedPlaylist?.songs?.map(({ mediaSource }) => mediaSource) ?? [])
  112. ];
  113. // Delete playlists created by user
  114. await UsersModule.playlistModel.deleteMany({ createdBy: userId });
  115. // TODO Maybe we don't need to wait for this to finish?
  116. // Recalculate ratings of songs the user liked/disliked
  117. const recalculateRatingsJobs = songsToAdjustRatings.map(songsToAdjustRating =>
  118. MediaModule.runJob("RECALCULATE_RATINGS", { mediaSource: songsToAdjustRating }, this)
  119. );
  120. await Promise.all(recalculateRatingsJobs);
  121. // Delete user object
  122. await UsersModule.userModel.deleteMany({ _id: userId });
  123. // Remove sessions from Redis and MongoDB
  124. await CacheModule.runJob("PUB", { channel: "user.removeSessions", value: userId }, this);
  125. const sessions = await CacheModule.runJob("HGETALL", { table: "sessions" }, this);
  126. const sessionIds = Object.keys(sessions);
  127. const sessionJobs = sessionIds.map(sessionId => async () => {
  128. const session = sessions[sessionId];
  129. if (!session || session.userId !== userId) return;
  130. await CacheModule.runJob("HDEL", { table: "sessions", key: sessionId }, this);
  131. });
  132. await Promise.all(sessionJobs);
  133. await CacheModule.runJob(
  134. "PUB",
  135. {
  136. channel: "user.removeAccount",
  137. value: userId
  138. },
  139. this
  140. );
  141. }
  142. /**
  143. * Tries to verify email from email verification token/code
  144. * @param {object} payload - object that contains the payload
  145. * @param {string} payload.code - email verification token/code
  146. * @returns {Promise} - returns a promise (resolve, reject)
  147. */
  148. async VERIFY_EMAIL(payload) {
  149. const { code } = payload;
  150. if (!code) throw new Error("Invalid code.");
  151. const user = await UsersModule.userModel.findOne({ "email.verificationToken": code });
  152. if (!user) throw new Error("User not found.");
  153. if (user.email.verified) throw new Error("This email is already verified.");
  154. await UsersModule.userModel.updateOne(
  155. { "email.verificationToken": code },
  156. {
  157. $set: { "email.verified": true },
  158. $unset: { "email.verificationToken": "" }
  159. },
  160. { runValidators: true }
  161. );
  162. }
  163. /**
  164. * Handles callback route being accessed, which has data from GitHub during the oauth process
  165. * Will be used to either log the user in, register the user, or link the GitHub account to an existing account
  166. * @param {object} payload - object that contains the payload
  167. * @param {string} payload.code - code we need to use to get the access token
  168. * @param {string} payload.state - custom state we may have passed to GitHub during the first step
  169. * @param {string} payload.error - error code if an error occured
  170. * @param {string} payload.errorDescription - error description if an error occured
  171. * @returns {Promise} - returns a promise (resolve, reject)
  172. */
  173. async GITHUB_AUTHORIZE_CALLBACK(payload) {
  174. const { code, state, error, errorDescription } = payload;
  175. if (error) throw new Error(errorDescription);
  176. // Tries to get access token. We don't use the refresh token currently
  177. const { accessToken, /* refreshToken, */ results } = await UsersModule.getOAuthAccessToken(code, {
  178. redirect_uri: UsersModule.redirectUri
  179. });
  180. if (!accessToken) throw new Error(results.error_description);
  181. const options = {
  182. headers: {
  183. "User-Agent": "request",
  184. Authorization: `token ${accessToken}`
  185. }
  186. };
  187. // Gets user data
  188. const githubUserData = await axios.get("https://api.github.com/user", options);
  189. if (githubUserData.status !== 200) throw new Error(githubUserData.data.message);
  190. if (!githubUserData.data.id) throw new Error("Something went wrong, no id.");
  191. // If we specified a state in the first step when we redirected the user to GitHub, it was to link a
  192. // GitHub account to an existing Musare account, so continue with a job specifically for linking the account
  193. if (state)
  194. return UsersModule.runJob(
  195. "GITHUB_AUTHORIZE_CALLBACK_LINK",
  196. { state, githubId: githubUserData.data.id, accessToken },
  197. this
  198. );
  199. const user = await UsersModule.userModel.findOne({ "services.github.id": githubUserData.data.id });
  200. let userId;
  201. if (user) {
  202. // Refresh access token, though it's pretty useless as it'll probably expire and then be useless,
  203. // and we don't use it afterwards at all
  204. user.services.github.access_token = accessToken;
  205. await user.save();
  206. userId = user._id;
  207. } else {
  208. // Try to register the user. Will throw an error if it's unable to do so or any error occurs
  209. userId = await UsersModule.runJob(
  210. "GITHUB_AUTHORIZE_CALLBACK_REGISTER",
  211. { githubUserData, accessToken },
  212. this
  213. );
  214. }
  215. // Create session for the userId gotten above, as the user existed or was successfully registered
  216. const sessionId = await UtilsModule.runJob("GUID", {}, this);
  217. await CacheModule.runJob(
  218. "HSET",
  219. {
  220. table: "sessions",
  221. key: sessionId,
  222. value: UsersModule.sessionSchema(sessionId, userId)
  223. },
  224. this
  225. );
  226. return { sessionId, userId, redirectUrl: UsersModule.appUrl };
  227. }
  228. /**
  229. * Handles registering the user in the GitHub login/register/link callback/process
  230. * @param {object} payload - object that contains the payload
  231. * @param {string} payload.githubUserData - data we got from the /user API endpoint from GitHub
  232. * @param {string} payload.accessToken - access token for the GitHub user
  233. * @returns {Promise} - returns a promise (resolve, reject)
  234. */
  235. async GITHUB_AUTHORIZE_CALLBACK_REGISTER(payload) {
  236. const { githubUserData, accessToken } = payload;
  237. let user;
  238. // Check if username already exists
  239. user = await UsersModule.userModel.findOne({ username: new RegExp(`^${githubUserData.data.login}$`, "i") });
  240. if (user) throw new Error(`An account with that username already exists.`);
  241. // Get emails used for GitHub account
  242. const githubEmailsData = await axios.get("https://api.github.com/user/emails", {
  243. headers: {
  244. "User-Agent": "request",
  245. Authorization: `token ${accessToken}`
  246. }
  247. });
  248. if (!Array.isArray(githubEmailsData.data)) throw new Error(githubEmailsData.message);
  249. const primaryEmailAddress = githubEmailsData.data.find(emailAddress => emailAddress.primary)?.email;
  250. if (!primaryEmailAddress) throw new Error("No primary email address found.");
  251. user = await UsersModule.userModel.findOne({ "email.address": primaryEmailAddress });
  252. if (user && Object.keys(JSON.parse(user.services.github)).length === 0)
  253. throw new Error(`An account with that email address exists, but is not linked to GitHub.`);
  254. if (user) throw new Error(`An account with that email address already exists.`);
  255. const userId = await UtilsModule.runJob(
  256. "GENERATE_RANDOM_STRING",
  257. {
  258. length: 12
  259. },
  260. this
  261. );
  262. const verificationToken = await UtilsModule.runJob("GENERATE_RANDOM_STRING", { length: 64 }, this);
  263. const gravatarUrl = await UtilsModule.runJob(
  264. "CREATE_GRAVATAR",
  265. {
  266. email: primaryEmailAddress
  267. },
  268. this
  269. );
  270. const likedSongsPlaylist = await PlaylistsModule.runJob(
  271. "CREATE_USER_PLAYLIST",
  272. {
  273. userId,
  274. displayName: "Liked Songs",
  275. type: "user-liked"
  276. },
  277. this
  278. );
  279. const dislikedSongsPlaylist = await PlaylistsModule.runJob(
  280. "CREATE_USER_PLAYLIST",
  281. {
  282. userId,
  283. displayName: "Disliked Songs",
  284. type: "user-disliked"
  285. },
  286. this
  287. );
  288. user = {
  289. _id: userId,
  290. username: githubUserData.data.login,
  291. name: githubUserData.data.name,
  292. location: githubUserData.data.location,
  293. bio: githubUserData.data.bio,
  294. email: {
  295. primaryEmailAddress,
  296. verificationToken
  297. },
  298. services: {
  299. github: {
  300. id: githubUserData.data.id,
  301. access_token: accessToken
  302. }
  303. },
  304. avatar: {
  305. type: "gravatar",
  306. url: gravatarUrl
  307. },
  308. likedSongsPlaylist,
  309. dislikedSongsPlaylist
  310. };
  311. await UsersModule.userModel.create(user);
  312. await UsersModule.verifyEmailSchema(primaryEmailAddress, githubUserData.data.login, verificationToken);
  313. await ActivitiesModule.runJob(
  314. "ADD_ACTIVITY",
  315. {
  316. userId,
  317. type: "user__joined",
  318. payload: { message: "Welcome to Musare!" }
  319. },
  320. this
  321. );
  322. return {
  323. userId
  324. };
  325. }
  326. /**
  327. * Job to attempt to link a GitHub user to a Musare account
  328. * @param {object} payload - object that contains the payload
  329. * @param {string} payload.state - state we passed to GitHub and got back from GitHub
  330. * @param {string} payload.githubId - GitHub user id
  331. * @param {string} payload.accessToken - GitHub user access token
  332. * @returns {Promise} - returns a promise (resolve, reject)
  333. */
  334. async GITHUB_AUTHORIZE_CALLBACK_LINK(payload) {
  335. const { state, githubId, accessToken } = payload;
  336. // State is currently the session id (SID), so check if that session (still) exists
  337. const session = await CacheModule.runJob(
  338. "HGET",
  339. {
  340. table: "sessions",
  341. key: state
  342. },
  343. this
  344. );
  345. if (!session) throw new Error("Invalid session.");
  346. const user = await UsersModule.userModel.findOne({ _id: session.userId });
  347. if (!user) throw new Error("User not found.");
  348. if (user.services.github && user.services.github.id) throw new Error("Account already has GitHub linked.");
  349. const { _id: userId } = user;
  350. await UsersModule.userModel.updateOne(
  351. { _id: userId },
  352. {
  353. $set: {
  354. "services.github": {
  355. id: githubId,
  356. access_token: accessToken
  357. }
  358. }
  359. },
  360. { runValidators: true }
  361. );
  362. await CacheModule.runJob(
  363. "PUB",
  364. {
  365. channel: "user.linkGithub",
  366. value: userId
  367. },
  368. this
  369. );
  370. await CacheModule.runJob(
  371. "PUB",
  372. {
  373. channel: "user.updated",
  374. value: { userId }
  375. },
  376. this
  377. );
  378. return {
  379. redirectUrl: `${UsersModule.appUrl}/settings?tab=security`
  380. };
  381. }
  382. /**
  383. * Attempts to register a user
  384. * @param {object} payload - object that contains the payload
  385. * @param {string} payload.email - email
  386. * @param {string} payload.username - username
  387. * @param {string} payload.password - plaintext password
  388. * @param {string} payload.recaptcha - recaptcha, if recaptcha is enabled
  389. * @returns {Promise} - returns a promise (resolve, reject)
  390. */
  391. async REGISTER(payload) {
  392. const { username, password, recaptcha } = payload;
  393. let { email } = payload;
  394. email = email.toLowerCase().trim();
  395. if (config.get("registrationDisabled") === true) throw new Error("Registration is not allowed at this time.");
  396. if (Array.isArray(config.get("experimental.registration_email_whitelist"))) {
  397. const experimentalRegistrationEmailWhitelist = config.get("experimental.registration_email_whitelist");
  398. const anyRegexPassed = experimentalRegistrationEmailWhitelist.find(regex => {
  399. const emailWhitelistRegex = new RegExp(regex);
  400. return emailWhitelistRegex.test(email);
  401. });
  402. if (!anyRegexPassed) throw new Error("Your email is not allowed to register.");
  403. }
  404. if (!DBModule.passwordValid(password))
  405. throw new Error("Invalid password. Check if it meets all the requirements.");
  406. if (config.get("apis.recaptcha.enabled") === true) {
  407. const recaptchaBody = await axios.post("https://www.google.com/recaptcha/api/siteverify", {
  408. data: {
  409. secret: config.get("apis").recaptcha.secret,
  410. response: recaptcha
  411. }
  412. });
  413. if (recaptchaBody.success !== true) throw new Error("Response from recaptcha was not successful.");
  414. }
  415. let user = await UsersModule.userModel.findOne({ username: new RegExp(`^${username}$`, "i") });
  416. if (user) throw new Error("A user with that username already exists.");
  417. user = await UsersModule.userModel.findOne({ "email.address": email });
  418. if (user) throw new Error("A user with that email already exists.");
  419. const salt = await bcrypt.genSalt(10);
  420. const hash = await bcrypt.hash(sha256(password), salt);
  421. const userId = await UtilsModule.runJob(
  422. "GENERATE_RANDOM_STRING",
  423. {
  424. length: 12
  425. },
  426. this
  427. );
  428. const verificationToken = await UtilsModule.runJob("GENERATE_RANDOM_STRING", { length: 64 }, this);
  429. const gravatarUrl = await UtilsModule.runJob(
  430. "CREATE_GRAVATAR",
  431. {
  432. email
  433. },
  434. this
  435. );
  436. const likedSongsPlaylist = await PlaylistsModule.runJob(
  437. "CREATE_USER_PLAYLIST",
  438. {
  439. userId,
  440. displayName: "Liked Songs",
  441. type: "user-liked"
  442. },
  443. this
  444. );
  445. const dislikedSongsPlaylist = await PlaylistsModule.runJob(
  446. "CREATE_USER_PLAYLIST",
  447. {
  448. userId,
  449. displayName: "Disliked Songs",
  450. type: "user-disliked"
  451. },
  452. this
  453. );
  454. user = {
  455. _id: userId,
  456. name: username,
  457. username,
  458. email: {
  459. address: email,
  460. verificationToken
  461. },
  462. services: {
  463. password: {
  464. password: hash
  465. }
  466. },
  467. avatar: {
  468. type: "initials",
  469. color: avatarColors[Math.floor(Math.random() * avatarColors.length)],
  470. url: gravatarUrl
  471. },
  472. likedSongsPlaylist,
  473. dislikedSongsPlaylist
  474. };
  475. await UsersModule.userModel.create(user);
  476. await UsersModule.verifyEmailSchema(email, username, verificationToken);
  477. await ActivitiesModule.runJob(
  478. "ADD_ACTIVITY",
  479. {
  480. userId,
  481. type: "user__joined",
  482. payload: { message: "Welcome to Musare!" }
  483. },
  484. this
  485. );
  486. return {
  487. userId
  488. };
  489. }
  490. // async EXAMPLE_JOB() {
  491. // if (true) return;
  492. // else throw new Error("Nothing changed.");
  493. // }
  494. }
  495. export default new _UsersModule();