UserHelper.js 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560
  1. const { CookieJar } = require('tough-cookie')
  2. const AuthenticationManager = require('../../../../app/src/Features/Authentication/AuthenticationManager')
  3. const Settings = require('@overleaf/settings')
  4. const InstitutionsAPI = require('../../../../app/src/Features/Institutions/InstitutionsAPI')
  5. const UserCreator = require('../../../../app/src/Features/User/UserCreator')
  6. const UserGetter = require('../../../../app/src/Features/User/UserGetter')
  7. const UserUpdater = require('../../../../app/src/Features/User/UserUpdater')
  8. const moment = require('moment')
  9. const fetch = require('node-fetch')
  10. const { db } = require('../../../../app/src/infrastructure/mongodb')
  11. const { ObjectId } = require('mongodb')
  12. const {
  13. UserAuditLogEntry,
  14. } = require('../../../../app/src/models/UserAuditLogEntry')
  15. // Import the rate limiter so we can clear it between tests
  16. const {
  17. RateLimiter,
  18. } = require('../../../../app/src/infrastructure/RateLimiter')
  19. const rateLimiters = {
  20. resendConfirmation: new RateLimiter('resend-confirmation'),
  21. }
  22. let globalUserNum = Settings.test.counterInit
  23. class UserHelper {
  24. /**
  25. * Create UserHelper
  26. * @param {object} [user] - Mongo User object
  27. */
  28. constructor(user = null) {
  29. // used for constructing default emails, etc
  30. this.userNum = globalUserNum++
  31. // initialize all internal state properties to defaults
  32. this.reset()
  33. // set user if passed in, may be null
  34. this.user = user
  35. }
  36. /* sync functions */
  37. /**
  38. * Get auditLog, ignore the login
  39. * @return {object[]}
  40. */
  41. getAuditLogWithoutNoise() {
  42. return (this.user.auditLog || []).filter(entry => {
  43. return entry.operation !== 'login'
  44. })
  45. }
  46. /**
  47. * Generate default email from unique (per instantiation) user number
  48. * @returns {string} email
  49. */
  50. getDefaultEmail() {
  51. return `test.user.${this.userNum}@example.com`
  52. }
  53. /**
  54. * Generate email, password args object. Default values will be used if
  55. * email and password are not passed in args.
  56. * @param {object} [userData]
  57. * @param {string} [userData.email] email to use
  58. * @param {string} [userData.password] password to use
  59. * @returns {object} email, password object
  60. */
  61. getDefaultEmailPassword(userData = {}) {
  62. return {
  63. email: this.getDefaultEmail(),
  64. password: this.getDefaultPassword(),
  65. ...userData,
  66. }
  67. }
  68. /**
  69. * Generate default password from unique (per instantiation) user number
  70. * @returns {string} password
  71. */
  72. getDefaultPassword() {
  73. return `New-Password-${this.userNum}!`
  74. }
  75. /**
  76. * (Re)set internal state of UserHelper object.
  77. */
  78. reset() {
  79. // cached csrf token
  80. this._csrfToken = ''
  81. // used to store mongo user object once created/loaded
  82. this.user = null
  83. // cookie jar
  84. this.jar = new CookieJar()
  85. }
  86. async fetch(url, opts = {}) {
  87. url = UserHelper.url(url)
  88. const headers = {}
  89. const cookieString = this.jar.getCookieStringSync(url.toString())
  90. if (cookieString) {
  91. headers.Cookie = cookieString
  92. }
  93. if (this._csrfToken) {
  94. headers['x-csrf-token'] = this._csrfToken
  95. }
  96. const response = await fetch(url, {
  97. redirect: 'manual',
  98. ...opts,
  99. headers: { ...headers, ...opts.headers },
  100. })
  101. // From https://www.npmjs.com/package/node-fetch#extract-set-cookie-header
  102. const cookies = response.headers.raw()['set-cookie']
  103. if (cookies != null) {
  104. for (const cookie of cookies) {
  105. this.jar.setCookieSync(cookie, url.toString())
  106. }
  107. }
  108. return response
  109. }
  110. /* async http api call methods */
  111. /**
  112. * Requests csrf token unless already cached in internal state
  113. */
  114. async getCsrfToken() {
  115. // get csrf token from api and store
  116. const response = await this.fetch('/dev/csrf')
  117. const body = await response.text()
  118. if (response.status !== 200) {
  119. throw new Error(
  120. `get csrf token failed: status=${response.status} body=${JSON.stringify(
  121. body
  122. )}`
  123. )
  124. }
  125. this._csrfToken = body
  126. }
  127. /**
  128. * Requests user session
  129. */
  130. async getSession() {
  131. const response = await this.fetch('/dev/session')
  132. const body = await response.text()
  133. if (response.status !== 200) {
  134. throw new Error(
  135. `get session failed: status=${response.status} body=${JSON.stringify(
  136. body
  137. )}`
  138. )
  139. }
  140. return JSON.parse(body)
  141. }
  142. async getSplitTestAssignment(splitTestName) {
  143. const response = await this.fetch(
  144. `/dev/split_test/get_assignment?splitTestName=${splitTestName}`
  145. )
  146. const body = await response.text()
  147. if (response.status !== 200) {
  148. throw new Error(
  149. `get split test assignment failed: status=${response.status} body=${JSON.stringify(
  150. body
  151. )}`
  152. )
  153. }
  154. return JSON.parse(body)
  155. }
  156. async getEmailConfirmationCode() {
  157. const session = await this.getSession()
  158. const code = session.pendingUserRegistration?.confirmCode
  159. if (!code) {
  160. throw new Error('No confirmation code found in session')
  161. }
  162. return code
  163. }
  164. /**
  165. * Make request to POST /logout
  166. * @param {object} [options] options to pass to request
  167. * @returns {object} http response
  168. */
  169. async logout(options = {}) {
  170. // post logout
  171. const response = await this.fetch('/logout', { method: 'POST', ...options })
  172. if (
  173. response.status !== 302 ||
  174. !response.headers.get('location').includes('/login')
  175. ) {
  176. const body = await response.text()
  177. throw new Error(
  178. `logout failed: status=${response.status} body=${JSON.stringify(
  179. body
  180. )} headers=${JSON.stringify(
  181. Object.fromEntries(response.headers.entries())
  182. )}`
  183. )
  184. }
  185. // after logout CSRF token becomes invalid
  186. this._csrfToken = ''
  187. // resolve with http request response
  188. return response
  189. }
  190. /* static sync methods */
  191. /**
  192. * Generates base URL from env options
  193. * @returns {string} baseUrl
  194. */
  195. static baseUrl() {
  196. return `http://${process.env.HTTP_TEST_HOST || '127.0.0.1'}:23000`
  197. }
  198. /**
  199. * Generates a full URL given a path
  200. */
  201. static url(path) {
  202. return new URL(path, UserHelper.baseUrl())
  203. }
  204. /* static async instantiation methods */
  205. /**
  206. * Create a new user via UserCreator and return UserHelper instance
  207. * @param {object} attributes user data for UserCreator
  208. * @param {object} options options for UserCreator
  209. * @returns {UserHelper}
  210. */
  211. static async createUser(attributes = {}) {
  212. const userHelper = new UserHelper()
  213. attributes = userHelper.getDefaultEmailPassword(attributes)
  214. // hash password and delete plaintext if set
  215. if (attributes.password) {
  216. attributes.hashedPassword =
  217. await AuthenticationManager.promises.hashPassword(attributes.password)
  218. delete attributes.password
  219. }
  220. userHelper.user = await UserCreator.promises.createNewUser(attributes)
  221. return userHelper
  222. }
  223. /**
  224. * Get existing user via UserGetter and return UserHelper instance.
  225. * All args passed to UserGetter.getUser.
  226. * @returns {UserHelper}
  227. */
  228. static async getUser(...args) {
  229. const user = await UserGetter.promises.getUser(...args)
  230. if (!user) {
  231. throw new Error(`no user found for args: ${JSON.stringify([...args])}`)
  232. }
  233. user.auditLog = await UserAuditLogEntry.find(
  234. { userId: user._id },
  235. {},
  236. { sort: { timestamp: 'asc' } }
  237. ).exec()
  238. return new UserHelper(user)
  239. }
  240. /**
  241. * Update an existing user via UserUpdater and return the updated UserHelper
  242. * instance.
  243. * All args passed to UserUpdater.getUser.
  244. * @returns {UserHelper}
  245. */
  246. static async updateUser(userId, update) {
  247. // TODO(das7pad): revert back to args pass-through after mongo upgrades
  248. const user = await UserUpdater.promises.updateUser(
  249. { _id: new ObjectId(userId) },
  250. update
  251. )
  252. if (!user) {
  253. throw new Error(`no user found for args: ${JSON.stringify([userId])}`)
  254. }
  255. return new UserHelper(user)
  256. }
  257. /**
  258. * Login to existing account via request and return UserHelper instance
  259. * @param {object} userData
  260. * @param {string} userData.email
  261. * @param {string} userData.password
  262. * @returns {UserHelper}
  263. */
  264. static async loginUser(userData, expectedRedirect) {
  265. if (!userData || !userData.email || !userData.password) {
  266. throw new Error('email and password required')
  267. }
  268. const userHelper = new UserHelper()
  269. const loginPath = Settings.enableLegacyLogin ? '/login/legacy' : '/login'
  270. await userHelper.getCsrfToken()
  271. const response = await userHelper.fetch(loginPath, {
  272. method: 'POST',
  273. headers: {
  274. 'Content-Type': 'application/json',
  275. Accept: 'application/json',
  276. },
  277. body: JSON.stringify({
  278. 'g-recaptcha-response': 'valid',
  279. ...userData,
  280. }),
  281. })
  282. if (!response.ok) {
  283. const body = await response.text()
  284. const error = new Error(
  285. `login failed: status=${response.status} body=${JSON.stringify(body)}`
  286. )
  287. error.response = response
  288. throw error
  289. }
  290. const body = await response.json()
  291. if (
  292. body.redir !== '/project' &&
  293. expectedRedirect &&
  294. body.redir !== expectedRedirect
  295. ) {
  296. const error = new Error(
  297. `login should redirect to /project: status=${
  298. response.status
  299. } body=${JSON.stringify(body)}`
  300. )
  301. error.response = response
  302. throw error
  303. }
  304. userHelper.user = await UserGetter.promises.getUser({
  305. email: userData.email,
  306. })
  307. if (!userHelper.user) {
  308. throw new Error(`user not found for email: ${userData.email}`)
  309. }
  310. await userHelper.getCsrfToken()
  311. return userHelper
  312. }
  313. /**
  314. * Check if user is logged in by requesting an endpoint behind authentication.
  315. * @returns {Boolean}
  316. */
  317. async isLoggedIn() {
  318. const response = await this.fetch('/user/sessions', {
  319. redirect: 'follow',
  320. })
  321. return !response.redirected
  322. }
  323. /**
  324. * Register new account via request and return UserHelper instance.
  325. * If userData is not provided the default email and password will be used.
  326. * @param {object} [userData]
  327. * @param {string} [userData.email]
  328. * @param {string} [userData.password]
  329. * @returns {UserHelper}
  330. */
  331. static async registerUser(userData, options = {}) {
  332. const userHelper = new UserHelper()
  333. await userHelper.getCsrfToken()
  334. userData = userHelper.getDefaultEmailPassword(userData)
  335. const response = await userHelper.fetch('/register', {
  336. method: 'POST',
  337. headers: {
  338. 'Content-Type': 'application/json',
  339. Accept: 'application/json',
  340. },
  341. body: JSON.stringify(userData),
  342. ...options,
  343. })
  344. const body = await response.json()
  345. if (response.status !== 200) {
  346. throw new Error(
  347. `register failed: status=${response.status} body=${JSON.stringify(
  348. body
  349. )}`
  350. )
  351. }
  352. if (body.message && body.message.type === 'error') {
  353. throw new Error(`register api error: ${body.message.text}`)
  354. }
  355. if (body.redir === '/sso-login') {
  356. throw new Error(
  357. `cannot register intitutional email: ${options.json.email}`
  358. )
  359. }
  360. const code = await userHelper.getEmailConfirmationCode()
  361. const confirmationResponse = await userHelper.fetch(
  362. '/registration/confirm-email',
  363. {
  364. method: 'POST',
  365. headers: {
  366. 'Content-Type': 'application/json',
  367. Accept: 'application/json',
  368. },
  369. body: JSON.stringify({ code }),
  370. ...options,
  371. }
  372. )
  373. if (confirmationResponse.status !== 200) {
  374. throw new Error(
  375. `email confirmation failed: status=${
  376. response.status
  377. } body=${JSON.stringify(body)}`
  378. )
  379. }
  380. userHelper.user = await UserGetter.promises.getUser({
  381. email: userData.email,
  382. })
  383. if (!userHelper.user) {
  384. throw new Error(`user not found for email: ${userData.email}`)
  385. }
  386. await userHelper.getCsrfToken()
  387. return userHelper
  388. }
  389. async refreshMongoUser() {
  390. this.user = await UserGetter.promises.getUser({
  391. _id: this.user._id,
  392. })
  393. return this.user
  394. }
  395. async addEmail(email) {
  396. const response = await this.fetch('/user/emails', {
  397. method: 'POST',
  398. body: new URLSearchParams([['email', email]]),
  399. })
  400. const body = await response.text()
  401. if (response.status !== 204) {
  402. throw new Error(
  403. `add email failed: status=${response.status} body=${JSON.stringify(
  404. body
  405. )}`
  406. )
  407. }
  408. }
  409. async addEmailAndConfirm(userId, email) {
  410. await this.addEmail(email)
  411. await this.confirmEmail(userId, email)
  412. }
  413. async changeConfirmationDate(userId, email, date) {
  414. const query = {
  415. _id: userId,
  416. 'emails.email': email,
  417. }
  418. const update = {
  419. $set: {
  420. 'emails.$.confirmedAt': date,
  421. 'emails.$.reconfirmedAt': date,
  422. },
  423. }
  424. await UserUpdater.promises.updateUser(query, update)
  425. await InstitutionsAPI.promises.addAffiliation(userId, email, {
  426. confirmedAt: date,
  427. })
  428. }
  429. async changeConfirmedToNotificationPeriod(
  430. userId,
  431. email,
  432. maxConfirmationMonths
  433. ) {
  434. // set a user's confirmation date so that
  435. // it is within the notification period to reconfirm
  436. // but not older than the last day to reconfirm
  437. const notificationDays = Settings.reconfirmNotificationDays
  438. if (!notificationDays) return
  439. const middleOfNotificationPeriod = Math.ceil(notificationDays / 2)
  440. // use the middle of the notification rather than the start or end due to
  441. // variations in days in months.
  442. const lastDayToReconfirm = moment().subtract(
  443. maxConfirmationMonths,
  444. 'months'
  445. )
  446. const notificationsStart = lastDayToReconfirm
  447. .add(middleOfNotificationPeriod, 'days')
  448. .toDate()
  449. await this.changeConfirmationDate(userId, email, notificationsStart)
  450. }
  451. async changeConfirmedToPastReconfirmation(
  452. userId,
  453. email,
  454. maxConfirmationMonths
  455. ) {
  456. // set a user's confirmation date so that they are past the reconfirmation window
  457. const date = moment()
  458. .subtract(maxConfirmationMonths, 'months')
  459. .subtract(1, 'week')
  460. .toDate()
  461. await this.changeConfirmationDate(userId, email, date)
  462. }
  463. async confirmEmail(userId, email) {
  464. // clear ratelimiting on resend confirmation endpoint
  465. await rateLimiters.resendConfirmation.delete(userId)
  466. // UserHelper.createUser does not create a confirmation token
  467. let response = await this.fetch('/user/emails/resend_confirmation', {
  468. method: 'POST',
  469. body: new URLSearchParams([['email', email]]),
  470. })
  471. if (response.status !== 200) {
  472. const body = await response.text()
  473. throw new Error(
  474. `resend confirmation failed: status=${
  475. response.status
  476. } body=${JSON.stringify(body)}`
  477. )
  478. }
  479. const tokenData = await db.tokens
  480. .find({
  481. use: 'email_confirmation',
  482. 'data.user_id': userId.toString(),
  483. 'data.email': email,
  484. usedAt: { $exists: false },
  485. })
  486. .next()
  487. response = await this.fetch('/user/emails/confirm', {
  488. method: 'POST',
  489. body: new URLSearchParams([['token', tokenData.token]]),
  490. })
  491. if (response.status !== 200) {
  492. const body = await response.text()
  493. throw new Error(
  494. `confirm email failed: status=${response.status} body=${JSON.stringify(
  495. body
  496. )}`
  497. )
  498. }
  499. }
  500. }
  501. module.exports = UserHelper