| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541 |
- import { CookieJar } from 'tough-cookie'
- import AuthenticationManager from '../../../../app/src/Features/Authentication/AuthenticationManager.js'
- import Settings from '@overleaf/settings'
- import InstitutionsAPI from '../../../../app/src/Features/Institutions/InstitutionsAPI.js'
- import UserCreator from '../../../../app/src/Features/User/UserCreator.mjs'
- import UserGetter from '../../../../app/src/Features/User/UserGetter.js'
- import UserUpdater from '../../../../app/src/Features/User/UserUpdater.js'
- import moment from 'moment'
- import fetch from 'node-fetch'
- import mongodb from 'mongodb-legacy'
- import { UserAuditLogEntry } from '../../../../app/src/models/UserAuditLogEntry.js'
- // Import the rate limiter so we can clear it between tests
- import { RateLimiter } from '../../../../app/src/infrastructure/RateLimiter.js'
- const { ObjectId } = mongodb
- const rateLimiters = {
- sendConfirmation: new RateLimiter('send-confirmation'),
- }
- let globalUserNum = Settings.test.counterInit
- const throwIfErrorResponse = async response => {
- if (response.status < 200 || response.status >= 300) {
- const body = await response.text()
- throw new Error(
- `request failed: status=${response.status} body=${JSON.stringify(body)}`
- )
- }
- }
- class UserHelper {
- /**
- * Create UserHelper
- * @param {object} [user] - Mongo User object
- */
- constructor(user = null) {
- // used for constructing default emails, etc
- this.userNum = globalUserNum++
- // initialize all internal state properties to defaults
- this.reset()
- // set user if passed in, may be null
- this.user = user
- }
- /* sync functions */
- /**
- * Get auditLog, ignore the login
- * @return {object[]}
- */
- getAuditLogWithoutNoise() {
- return (this.user.auditLog || []).filter(entry => {
- return entry.operation !== 'login'
- })
- }
- /**
- * Generate default email from unique (per instantiation) user number
- * @returns {string} email
- */
- getDefaultEmail() {
- return `test.user.${this.userNum}@example.com`
- }
- /**
- * Generate email, password args object. Default values will be used if
- * email and password are not passed in args.
- * @param {object} [userData]
- * @param {string} [userData.email] email to use
- * @param {string} [userData.password] password to use
- * @returns {object} email, password object
- */
- getDefaultEmailPassword(userData = {}) {
- return {
- email: this.getDefaultEmail(),
- password: this.getDefaultPassword(),
- ...userData,
- }
- }
- /**
- * Generate default password from unique (per instantiation) user number
- * @returns {string} password
- */
- getDefaultPassword() {
- return `New-Password-${this.userNum}!`
- }
- /**
- * (Re)set internal state of UserHelper object.
- */
- reset() {
- // cached csrf token
- this._csrfToken = ''
- // used to store mongo user object once created/loaded
- this.user = null
- // cookie jar
- this.jar = new CookieJar()
- }
- async fetch(url, opts = {}) {
- url = UserHelper.url(url)
- const headers = {}
- const cookieString = this.jar.getCookieStringSync(url.toString())
- if (cookieString) {
- headers.Cookie = cookieString
- }
- if (this._csrfToken) {
- headers['x-csrf-token'] = this._csrfToken
- }
- const response = await fetch(url, {
- redirect: 'manual',
- ...opts,
- headers: { ...headers, ...opts.headers },
- })
- // From https://www.npmjs.com/package/node-fetch#extract-set-cookie-header
- const cookies = response.headers.raw()['set-cookie']
- if (cookies != null) {
- for (const cookie of cookies) {
- this.jar.setCookieSync(cookie, url.toString())
- }
- }
- return response
- }
- /* async http api call methods */
- /**
- * Requests csrf token unless already cached in internal state
- */
- async getCsrfToken() {
- // get csrf token from api and store
- const response = await this.fetch('/dev/csrf')
- const body = await response.text()
- await throwIfErrorResponse(response)
- this._csrfToken = body
- }
- /**
- * Requests user session
- */
- async getSession() {
- const response = await this.fetch('/dev/session')
- const body = await response.text()
- await throwIfErrorResponse(response)
- return JSON.parse(body)
- }
- async getSplitTestAssignment(splitTestName) {
- const response = await this.fetch(
- `/dev/split_test/get_assignment?splitTestName=${splitTestName}`
- )
- await throwIfErrorResponse(response)
- const body = await response.text()
- return JSON.parse(body)
- }
- /**
- *
- * @param {'pendingExistingEmail'|'pendingUserRegistration'|'pendingSecondaryEmail'}sessionKey
- * @return {Promise<*>}
- */
- async getEmailConfirmationCode(sessionKey) {
- const session = await this.getSession()
- const code = session[sessionKey]?.confirmCode
- if (!code) {
- throw new Error(`No confirmation code found in session (${sessionKey})`)
- }
- return code
- }
- /**
- * Make request to POST /logout
- * @param {object} [options] options to pass to request
- * @returns {object} http response
- */
- async logout(options = {}) {
- // post logout
- const response = await this.fetch('/logout', { method: 'POST', ...options })
- if (
- response.status !== 302 ||
- !response.headers.get('location').includes('/login')
- ) {
- const body = await response.text()
- throw new Error(
- `logout failed: status=${response.status} body=${JSON.stringify(
- body
- )} headers=${JSON.stringify(
- Object.fromEntries(response.headers.entries())
- )}`
- )
- }
- // after logout CSRF token becomes invalid
- this._csrfToken = ''
- // resolve with http request response
- return response
- }
- /* static sync methods */
- /**
- * Generates base URL from env options
- * @returns {string} baseUrl
- */
- static baseUrl() {
- return `http://${process.env.HTTP_TEST_HOST || '127.0.0.1'}:23000`
- }
- /**
- * Generates a full URL given a path
- */
- static url(path) {
- return new URL(path, UserHelper.baseUrl())
- }
- /* static async instantiation methods */
- /**
- * Create a new user via UserCreator and return UserHelper instance
- * @param {object} attributes user data for UserCreator
- * @param {object} options options for UserCreator
- * @returns {UserHelper}
- */
- static async createUser(attributes = {}) {
- const userHelper = new UserHelper()
- attributes = userHelper.getDefaultEmailPassword(attributes)
- // hash password and delete plaintext if set
- if (attributes.password) {
- attributes.hashedPassword =
- await AuthenticationManager.promises.hashPassword(attributes.password)
- delete attributes.password
- }
- userHelper.user = await UserCreator.promises.createNewUser(attributes)
- return userHelper
- }
- /**
- * Get existing user via UserGetter and return UserHelper instance.
- * All args passed to UserGetter.getUser.
- * @returns {UserHelper}
- */
- static async getUser(...args) {
- const user = await UserGetter.promises.getUser(...args)
- if (!user) {
- throw new Error(`no user found for args: ${JSON.stringify([...args])}`)
- }
- user.auditLog = await UserAuditLogEntry.find(
- { userId: user._id },
- {},
- { sort: { timestamp: 'asc' } }
- ).exec()
- return new UserHelper(user)
- }
- /**
- * Update an existing user via UserUpdater and return the updated UserHelper
- * instance.
- * All args passed to UserUpdater.getUser.
- * @returns {UserHelper}
- */
- static async updateUser(userId, update) {
- // TODO(das7pad): revert back to args pass-through after mongo upgrades
- const user = await UserUpdater.promises.updateUser(
- { _id: new ObjectId(userId) },
- update
- )
- if (!user) {
- throw new Error(`no user found for args: ${JSON.stringify([userId])}`)
- }
- return new UserHelper(user)
- }
- /**
- * Login to existing account via request and return UserHelper instance
- * @param {object} userData
- * @param {string} userData.email
- * @param {string} userData.password
- * @returns {UserHelper}
- */
- static async loginUser(userData, expectedRedirect) {
- if (!userData || !userData.email || !userData.password) {
- throw new Error('email and password required')
- }
- const userHelper = new UserHelper()
- const loginPath = Settings.enableLegacyLogin ? '/login/legacy' : '/login'
- await userHelper.getCsrfToken()
- const response = await userHelper.fetch(loginPath, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- Accept: 'application/json',
- },
- body: JSON.stringify({
- 'g-recaptcha-response': 'valid',
- ...userData,
- }),
- })
- if (!response.ok) {
- const body = await response.text()
- const error = new Error(
- `login failed: status=${response.status} body=${JSON.stringify(body)}`
- )
- error.response = response
- throw error
- }
- const body = await response.json()
- if (
- body.redir !== '/project' &&
- expectedRedirect &&
- body.redir !== expectedRedirect
- ) {
- const error = new Error(
- `login should redirect to /project: status=${
- response.status
- } body=${JSON.stringify(body)}`
- )
- error.response = response
- throw error
- }
- userHelper.user = await UserGetter.promises.getUser({
- email: userData.email,
- })
- if (!userHelper.user) {
- throw new Error(`user not found for email: ${userData.email}`)
- }
- await userHelper.getCsrfToken()
- return userHelper
- }
- /**
- * Check if user is logged in by requesting an endpoint behind authentication.
- * @returns {Boolean}
- */
- async isLoggedIn() {
- const response = await this.fetch('/user/sessions', {
- redirect: 'follow',
- })
- return !response.redirected
- }
- /**
- * Register new account via request and return UserHelper instance.
- * If userData is not provided the default email and password will be used.
- * @param {object} [userData]
- * @param {string} [userData.email]
- * @param {string} [userData.password]
- * @returns {UserHelper}
- */
- static async registerUser(userData, options = {}) {
- const userHelper = new UserHelper()
- await userHelper.getCsrfToken()
- userData = userHelper.getDefaultEmailPassword(userData)
- const response = await userHelper.fetch('/register', {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- Accept: 'application/json',
- },
- body: JSON.stringify(userData),
- ...options,
- })
- await throwIfErrorResponse(response)
- const body = await response.json()
- if (body.message && body.message.type === 'error') {
- throw new Error(`register api error: ${body.message.text}`)
- }
- if (body.redir === '/sso-login') {
- throw new Error(
- `cannot register intitutional email: ${options.json.email}`
- )
- }
- const code = await userHelper.getEmailConfirmationCode(
- 'pendingUserRegistration'
- )
- const confirmationResponse = await userHelper.fetch(
- '/registration/confirm-email',
- {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- Accept: 'application/json',
- },
- body: JSON.stringify({ code }),
- ...options,
- }
- )
- if (confirmationResponse.status !== 200) {
- throw new Error(
- `email confirmation failed: status=${
- response.status
- } body=${JSON.stringify(body)}`
- )
- }
- userHelper.user = await UserGetter.promises.getUser({
- email: userData.email,
- })
- if (!userHelper.user) {
- throw new Error(`user not found for email: ${userData.email}`)
- }
- await userHelper.getCsrfToken()
- return userHelper
- }
- async refreshMongoUser() {
- this.user = await UserGetter.promises.getUser({
- _id: this.user._id,
- })
- return this.user
- }
- async addEmail(email) {
- const response = await this.fetch('/user/emails/secondary', {
- method: 'POST',
- body: new URLSearchParams([['email', email]]),
- })
- await throwIfErrorResponse(response)
- }
- async addEmailAndConfirm(email) {
- await this.addEmail(email)
- await this.confirmSecondaryEmail()
- }
- async changeConfirmationDate(userId, email, date) {
- const query = {
- _id: userId,
- 'emails.email': email,
- }
- const update = {
- $set: {
- 'emails.$.confirmedAt': date,
- 'emails.$.reconfirmedAt': date,
- },
- }
- await UserUpdater.promises.updateUser(query, update)
- await InstitutionsAPI.promises.addAffiliation(userId, email, {
- confirmedAt: date,
- })
- }
- async changeConfirmedToNotificationPeriod(
- userId,
- email,
- maxConfirmationMonths
- ) {
- // set a user's confirmation date so that
- // it is within the notification period to reconfirm
- // but not older than the last day to reconfirm
- const notificationDays = Settings.reconfirmNotificationDays
- if (!notificationDays) return
- const middleOfNotificationPeriod = Math.ceil(notificationDays / 2)
- // use the middle of the notification rather than the start or end due to
- // variations in days in months.
- const lastDayToReconfirm = moment().subtract(
- maxConfirmationMonths,
- 'months'
- )
- const notificationsStart = lastDayToReconfirm
- .add(middleOfNotificationPeriod, 'days')
- .toDate()
- await this.changeConfirmationDate(userId, email, notificationsStart)
- }
- async changeConfirmedToPastReconfirmation(
- userId,
- email,
- maxConfirmationMonths
- ) {
- // set a user's confirmation date so that they are past the reconfirmation window
- const date = moment()
- .subtract(maxConfirmationMonths, 'months')
- .subtract(1, 'week')
- .toDate()
- await this.changeConfirmationDate(userId, email, date)
- }
- async confirmEmail(email) {
- // clear ratelimiting on resend confirmation endpoint
- await rateLimiters.sendConfirmation.delete(this.user._id)
- const requestConfirmationCode = await this.fetch(
- '/user/emails/send-confirmation-code',
- {
- method: 'POST',
- body: new URLSearchParams({ email }),
- }
- )
- await throwIfErrorResponse(requestConfirmationCode)
- const code = await this.getEmailConfirmationCode('pendingExistingEmail')
- const requestConfirmCode = await this.fetch('/user/emails/confirm-code', {
- method: 'POST',
- body: new URLSearchParams({ code }),
- })
- await throwIfErrorResponse(requestConfirmCode)
- }
- async confirmSecondaryEmail() {
- const code = await this.getEmailConfirmationCode('pendingSecondaryEmail')
- const requestConfirmCode = await this.fetch(
- '/user/emails/confirm-secondary',
- {
- method: 'POST',
- body: new URLSearchParams({ code }),
- }
- )
- await throwIfErrorResponse(requestConfirmCode)
- }
- async unconfirmEmail(email) {
- await UserUpdater.promises.updateUser(
- { _id: this.user._id, 'emails.email': email.toLowerCase() },
- { $unset: { 'emails.$.confirmedAt': 1, 'emails.$.reconfirmedAt': 1 } }
- )
- }
- }
- export default UserHelper
|