UserHelper.mjs 15 KB

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