UserHelper.mjs 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551
  1. import { CookieJar } from 'tough-cookie'
  2. import AuthenticationManager from '../../../../app/src/Features/Authentication/AuthenticationManager.mjs'
  3. import Settings from '@overleaf/settings'
  4. import InstitutionsAPI from '../../../../app/src/Features/Institutions/InstitutionsAPI.mjs'
  5. import UserCreator from '../../../../app/src/Features/User/UserCreator.mjs'
  6. import UserGetter from '../../../../app/src/Features/User/UserGetter.mjs'
  7. import UserUpdater from '../../../../app/src/Features/User/UserUpdater.mjs'
  8. import moment from 'moment'
  9. import fetch from 'node-fetch'
  10. import mongodb from 'mongodb-legacy'
  11. import { UserAuditLogEntry } from '../../../../app/src/models/UserAuditLogEntry.mjs'
  12. // Import the rate limiter so we can clear it between tests
  13. import { RateLimiter } from '../../../../app/src/infrastructure/RateLimiter.mjs'
  14. const { ObjectId } = mongodb
  15. const rateLimiters = {
  16. sendConfirmation: new RateLimiter('send-confirmation'),
  17. }
  18. let globalUserNum = Settings.test.counterInit
  19. const throwIfErrorResponse = async response => {
  20. if (response.status < 200 || response.status >= 300) {
  21. const body = await response.text()
  22. throw new Error(
  23. `request failed: status=${response.status} body=${JSON.stringify(body)}`
  24. )
  25. }
  26. }
  27. class UserHelper {
  28. /**
  29. * Create UserHelper
  30. * @param {object} [user] - Mongo User object
  31. */
  32. constructor(user = null) {
  33. // used for constructing default emails, etc
  34. this.userNum = globalUserNum++
  35. // initialize all internal state properties to defaults
  36. this.reset()
  37. // set user if passed in, may be null
  38. this.user = user
  39. }
  40. /* sync functions */
  41. /**
  42. * Get auditLog, ignore the login
  43. * @return {object[]}
  44. */
  45. getAuditLogWithoutNoise() {
  46. return (this.user.auditLog || []).filter(entry => {
  47. return entry.operation !== 'login'
  48. })
  49. }
  50. /**
  51. * Get auditLog by operation
  52. * @return {object[]}
  53. */
  54. getAuditLogByOperation(operation) {
  55. return (this.user.auditLog || []).filter(entry => {
  56. return entry.operation === operation
  57. })
  58. }
  59. /**
  60. * Generate default email from unique (per instantiation) user number
  61. * @returns {string} email
  62. */
  63. getDefaultEmail() {
  64. return `test.user.${this.userNum}@example.com`
  65. }
  66. /**
  67. * Generate email, password args object. Default values will be used if
  68. * email and password are not passed in args.
  69. * @param {object} [userData]
  70. * @param {string} [userData.email] email to use
  71. * @param {string} [userData.password] password to use
  72. * @returns {object} email, password object
  73. */
  74. getDefaultEmailPassword(userData = {}) {
  75. return {
  76. email: this.getDefaultEmail(),
  77. password: this.getDefaultPassword(),
  78. ...userData,
  79. }
  80. }
  81. /**
  82. * Generate default password from unique (per instantiation) user number
  83. * @returns {string} password
  84. */
  85. getDefaultPassword() {
  86. return `New-Password-${this.userNum}!`
  87. }
  88. /**
  89. * (Re)set internal state of UserHelper object.
  90. */
  91. reset() {
  92. // cached csrf token
  93. this._csrfToken = ''
  94. // used to store mongo user object once created/loaded
  95. this.user = null
  96. // cookie jar
  97. this.jar = new CookieJar()
  98. }
  99. async fetch(url, opts = {}) {
  100. url = UserHelper.url(url)
  101. const headers = {}
  102. const cookieString = this.jar.getCookieStringSync(url.toString())
  103. if (cookieString) {
  104. headers.Cookie = cookieString
  105. }
  106. if (this._csrfToken) {
  107. headers['x-csrf-token'] = this._csrfToken
  108. }
  109. const response = await fetch(url, {
  110. redirect: 'manual',
  111. ...opts,
  112. headers: { ...headers, ...opts.headers },
  113. })
  114. // From https://www.npmjs.com/package/node-fetch#extract-set-cookie-header
  115. const cookies = response.headers.raw()['set-cookie']
  116. if (cookies != null) {
  117. for (const cookie of cookies) {
  118. this.jar.setCookieSync(cookie, url.toString())
  119. }
  120. }
  121. return response
  122. }
  123. /* async http api call methods */
  124. /**
  125. * Requests csrf token unless already cached in internal state
  126. */
  127. async getCsrfToken() {
  128. // get csrf token from api and store
  129. const response = await this.fetch('/dev/csrf')
  130. const body = await response.text()
  131. await throwIfErrorResponse(response)
  132. this._csrfToken = body
  133. }
  134. /**
  135. * Requests user session
  136. */
  137. async getSession() {
  138. const response = await this.fetch('/dev/session')
  139. const body = await response.text()
  140. await throwIfErrorResponse(response)
  141. return JSON.parse(body)
  142. }
  143. async getSplitTestAssignment(splitTestName) {
  144. const response = await this.fetch(
  145. `/dev/split_test/get_assignment?splitTestName=${splitTestName}`
  146. )
  147. await throwIfErrorResponse(response)
  148. const body = await response.text()
  149. return JSON.parse(body)
  150. }
  151. /**
  152. *
  153. * @param {'pendingExistingEmail'|'pendingUserRegistration'|'pendingSecondaryEmail'}sessionKey
  154. * @return {Promise<*>}
  155. */
  156. async getEmailConfirmationCode(sessionKey) {
  157. const session = await this.getSession()
  158. const code = session[sessionKey]?.confirmCode
  159. if (!code) {
  160. throw new Error(`No confirmation code found in session (${sessionKey})`)
  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. await throwIfErrorResponse(response)
  345. const body = await response.json()
  346. if (body.message && body.message.type === 'error') {
  347. throw new Error(`register api error: ${body.message.text}`)
  348. }
  349. if (body.redir === '/sso-login') {
  350. throw new Error(
  351. `cannot register intitutional email: ${options.json.email}`
  352. )
  353. }
  354. const code = await userHelper.getEmailConfirmationCode(
  355. 'pendingUserRegistration'
  356. )
  357. const confirmationResponse = await userHelper.fetch(
  358. '/registration/confirm-email',
  359. {
  360. method: 'POST',
  361. headers: {
  362. 'Content-Type': 'application/json',
  363. Accept: 'application/json',
  364. },
  365. body: JSON.stringify({ code }),
  366. ...options,
  367. }
  368. )
  369. if (confirmationResponse.status !== 200) {
  370. throw new Error(
  371. `email confirmation failed: status=${
  372. response.status
  373. } body=${JSON.stringify(body)}`
  374. )
  375. }
  376. userHelper.user = await UserGetter.promises.getUser({
  377. email: userData.email,
  378. })
  379. if (!userHelper.user) {
  380. throw new Error(`user not found for email: ${userData.email}`)
  381. }
  382. await userHelper.getCsrfToken()
  383. return userHelper
  384. }
  385. async refreshMongoUser() {
  386. this.user = await UserGetter.promises.getUser({
  387. _id: this.user._id,
  388. })
  389. return this.user
  390. }
  391. async addEmail(email) {
  392. const response = await this.fetch('/user/emails/secondary', {
  393. method: 'POST',
  394. body: new URLSearchParams([['email', email]]),
  395. })
  396. await throwIfErrorResponse(response)
  397. }
  398. async addEmailAndConfirm(email) {
  399. await this.addEmail(email)
  400. await this.confirmSecondaryEmail()
  401. }
  402. async changeConfirmationDate(userId, email, date) {
  403. const query = {
  404. _id: userId,
  405. 'emails.email': email,
  406. }
  407. const update = {
  408. $set: {
  409. 'emails.$.confirmedAt': date,
  410. 'emails.$.reconfirmedAt': date,
  411. },
  412. }
  413. await UserUpdater.promises.updateUser(query, update)
  414. await InstitutionsAPI.promises.addAffiliation(userId, email, {
  415. confirmedAt: date,
  416. })
  417. }
  418. async changeConfirmedToNotificationPeriod(
  419. userId,
  420. email,
  421. maxConfirmationMonths
  422. ) {
  423. // set a user's confirmation date so that
  424. // it is within the notification period to reconfirm
  425. // but not older than the last day to reconfirm
  426. const notificationDays = Settings.reconfirmNotificationDays
  427. if (!notificationDays) return
  428. const middleOfNotificationPeriod = Math.ceil(notificationDays / 2)
  429. // use the middle of the notification rather than the start or end due to
  430. // variations in days in months.
  431. const lastDayToReconfirm = moment().subtract(
  432. maxConfirmationMonths,
  433. 'months'
  434. )
  435. const notificationsStart = lastDayToReconfirm
  436. .add(middleOfNotificationPeriod, 'days')
  437. .toDate()
  438. await this.changeConfirmationDate(userId, email, notificationsStart)
  439. }
  440. async changeConfirmedToPastReconfirmation(
  441. userId,
  442. email,
  443. maxConfirmationMonths
  444. ) {
  445. // set a user's confirmation date so that they are past the reconfirmation window
  446. const date = moment()
  447. .subtract(maxConfirmationMonths, 'months')
  448. .subtract(1, 'week')
  449. .toDate()
  450. await this.changeConfirmationDate(userId, email, date)
  451. }
  452. async confirmEmail(email) {
  453. // clear ratelimiting on resend confirmation endpoint
  454. await rateLimiters.sendConfirmation.delete(this.user._id)
  455. const requestConfirmationCode = await this.fetch(
  456. '/user/emails/send-confirmation-code',
  457. {
  458. method: 'POST',
  459. body: new URLSearchParams({ email }),
  460. }
  461. )
  462. await throwIfErrorResponse(requestConfirmationCode)
  463. const code = await this.getEmailConfirmationCode('pendingExistingEmail')
  464. const requestConfirmCode = await this.fetch('/user/emails/confirm-code', {
  465. method: 'POST',
  466. body: new URLSearchParams({ code }),
  467. })
  468. await throwIfErrorResponse(requestConfirmCode)
  469. }
  470. async confirmSecondaryEmail() {
  471. const code = await this.getEmailConfirmationCode('pendingSecondaryEmail')
  472. const requestConfirmCode = await this.fetch(
  473. '/user/emails/confirm-secondary',
  474. {
  475. method: 'POST',
  476. body: new URLSearchParams({ code }),
  477. }
  478. )
  479. await throwIfErrorResponse(requestConfirmCode)
  480. }
  481. async unconfirmEmail(email) {
  482. await UserUpdater.promises.updateUser(
  483. { _id: this.user._id, 'emails.email': email.toLowerCase() },
  484. { $unset: { 'emails.$.confirmedAt': 1, 'emails.$.reconfirmedAt': 1 } }
  485. )
  486. }
  487. }
  488. export default UserHelper