TokenAccessController.mjs 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659
  1. import AuthenticationController from '../Authentication/AuthenticationController.js'
  2. import SessionManager from '../Authentication/SessionManager.js'
  3. import TokenAccessHandler from './TokenAccessHandler.js'
  4. import Errors from '../Errors/Errors.js'
  5. import logger from '@overleaf/logger'
  6. import OError from '@overleaf/o-error'
  7. import { expressify } from '@overleaf/promise-utils'
  8. import AuthorizationManager from '../Authorization/AuthorizationManager.js'
  9. import PrivilegeLevels from '../Authorization/PrivilegeLevels.js'
  10. import ProjectAuditLogHandler from '../Project/ProjectAuditLogHandler.js'
  11. import SplitTestHandler from '../SplitTests/SplitTestHandler.js'
  12. import CollaboratorsInviteHandler from '../Collaborators/CollaboratorsInviteHandler.mjs'
  13. import CollaboratorsHandler from '../Collaborators/CollaboratorsHandler.js'
  14. import EditorRealTimeController from '../Editor/EditorRealTimeController.js'
  15. import CollaboratorsGetter from '../Collaborators/CollaboratorsGetter.js'
  16. import ProjectGetter from '../Project/ProjectGetter.js'
  17. import AsyncFormHelper from '../Helpers/AsyncFormHelper.js'
  18. import AnalyticsManager from '../Analytics/AnalyticsManager.js'
  19. import { canRedirectToAdminDomain } from '../Helpers/AdminAuthorizationHelper.js'
  20. import { getSafeAdminDomainRedirect } from '../Helpers/UrlHelper.js'
  21. import UserGetter from '../User/UserGetter.js'
  22. import Settings from '@overleaf/settings'
  23. import LimitationsManager from '../Subscription/LimitationsManager.js'
  24. const orderedPrivilegeLevels = [
  25. PrivilegeLevels.NONE,
  26. PrivilegeLevels.READ_ONLY,
  27. PrivilegeLevels.REVIEW,
  28. PrivilegeLevels.READ_AND_WRITE,
  29. PrivilegeLevels.OWNER,
  30. ]
  31. async function _userAlreadyHasHigherPrivilege(userId, projectId, tokenType) {
  32. if (!Object.values(TokenAccessHandler.TOKEN_TYPES).includes(tokenType)) {
  33. throw new Error('bad token type')
  34. }
  35. if (!userId) {
  36. return false
  37. }
  38. const privilegeLevel =
  39. await AuthorizationManager.promises.getPrivilegeLevelForProject(
  40. userId,
  41. projectId
  42. )
  43. return (
  44. orderedPrivilegeLevels.indexOf(privilegeLevel) >=
  45. orderedPrivilegeLevels.indexOf(tokenType)
  46. )
  47. }
  48. const makePostUrl = token => {
  49. if (TokenAccessHandler.isReadAndWriteToken(token)) {
  50. return `/${token}/grant`
  51. } else if (TokenAccessHandler.isReadOnlyToken(token)) {
  52. return `/read/${token}/grant`
  53. } else {
  54. throw new Error('invalid token type')
  55. }
  56. }
  57. async function _handleV1Project(token, userId) {
  58. if (!userId) {
  59. return { v1Import: { status: 'mustLogin' } }
  60. } else {
  61. const docInfo = await TokenAccessHandler.promises.getV1DocInfo(
  62. token,
  63. userId
  64. )
  65. // This should not happen anymore, but it does show
  66. // a nice "contact support" message, so it can stay
  67. if (!docInfo) {
  68. return { v1Import: { status: 'cannotImport' } }
  69. }
  70. if (!docInfo.exists) {
  71. return null
  72. }
  73. if (docInfo.exported) {
  74. return null
  75. }
  76. return {
  77. v1Import: {
  78. status: 'canDownloadZip',
  79. projectId: token,
  80. hasOwner: docInfo.has_owner,
  81. name: docInfo.name || 'Untitled',
  82. brandInfo: docInfo.brand_info,
  83. },
  84. }
  85. }
  86. }
  87. async function _isOverleafStaff(userId) {
  88. const emails = await UserGetter.promises.getUserConfirmedEmails(userId)
  89. const adminDomains = Settings.adminDomains ?? []
  90. return emails.some(email =>
  91. adminDomains.some(adminDomain => email.email.endsWith(`@${adminDomain}`))
  92. )
  93. }
  94. async function tokenAccessPage(req, res, next) {
  95. const { token } = req.params
  96. if (!TokenAccessHandler.isValidToken(token)) {
  97. return next(new Errors.NotFoundError())
  98. }
  99. try {
  100. if (TokenAccessHandler.isReadOnlyToken(token)) {
  101. const docPublishedInfo =
  102. await TokenAccessHandler.promises.getV1DocPublishedInfo(token)
  103. if (docPublishedInfo.allow === false) {
  104. return res.redirect(302, docPublishedInfo.published_path)
  105. }
  106. }
  107. res.render('project/token/access-react', {
  108. postUrl: makePostUrl(token),
  109. })
  110. } catch (err) {
  111. return next(
  112. OError.tag(err, 'error while rendering token access page', { token })
  113. )
  114. }
  115. }
  116. async function checkAndGetProjectOrResponseAction(
  117. tokenType,
  118. token,
  119. userId,
  120. tokenHashPrefix,
  121. req,
  122. res,
  123. next
  124. ) {
  125. const isAnonymousUser = !userId
  126. if (
  127. isAnonymousUser &&
  128. tokenType === TokenAccessHandler.TOKEN_TYPES.READ_AND_WRITE &&
  129. !TokenAccessHandler.ANONYMOUS_READ_AND_WRITE_ENABLED
  130. ) {
  131. logger.warn('[TokenAccess] deny anonymous read-and-write token access')
  132. let projectUrlWithToken = TokenAccessHandler.makeTokenUrl(token)
  133. if (tokenHashPrefix && tokenHashPrefix.startsWith('#')) {
  134. projectUrlWithToken += `${tokenHashPrefix}`
  135. }
  136. AuthenticationController.setRedirectInSession(req, projectUrlWithToken)
  137. return [
  138. null,
  139. () => {
  140. res.json({
  141. redirect: '/restricted',
  142. anonWriteAccessDenied: true,
  143. })
  144. },
  145. { action: 'denied anonymous read-and-write token access' },
  146. ]
  147. }
  148. // Try to get the project, and/or an alternative action to take.
  149. // Returns a tuple of [project, action]
  150. const project = await TokenAccessHandler.promises.getProjectByToken(
  151. tokenType,
  152. token
  153. )
  154. if (!project) {
  155. if (Settings.overleaf) {
  156. const v1ImportData = await _handleV1Project(token, userId)
  157. return [
  158. null,
  159. () => {
  160. if (v1ImportData) {
  161. res.json(v1ImportData)
  162. } else {
  163. res.sendStatus(404)
  164. }
  165. },
  166. { action: v1ImportData ? 'import v1' : '404' },
  167. ]
  168. } else {
  169. return [null, null, { action: '404' }]
  170. }
  171. }
  172. const projectId = project._id
  173. const tokenAccessEnabled =
  174. TokenAccessHandler.tokenAccessEnabledForProject(project)
  175. if (isAnonymousUser && tokenAccessEnabled) {
  176. if (tokenType === TokenAccessHandler.TOKEN_TYPES.READ_AND_WRITE) {
  177. if (TokenAccessHandler.ANONYMOUS_READ_AND_WRITE_ENABLED) {
  178. logger.debug({ projectId }, 'granting read-write anonymous access')
  179. TokenAccessHandler.grantSessionTokenAccess(req, projectId, token)
  180. return [
  181. null,
  182. () => {
  183. res.json({
  184. redirect: `/project/${projectId}`,
  185. grantAnonymousAccess: tokenType,
  186. })
  187. },
  188. { projectId, action: 'granting read-write anonymous access' },
  189. ]
  190. } else {
  191. // anonymous read-and-write token access should have been denied already
  192. throw new Error(
  193. 'unreachable: anonymous read-and-write token access bug'
  194. )
  195. }
  196. } else if (tokenType === TokenAccessHandler.TOKEN_TYPES.READ_ONLY) {
  197. logger.debug({ projectId }, 'granting read-only anonymous access')
  198. TokenAccessHandler.grantSessionTokenAccess(req, projectId, token)
  199. return [
  200. null,
  201. () => {
  202. res.json({
  203. redirect: `/project/${projectId}`,
  204. grantAnonymousAccess: tokenType,
  205. })
  206. },
  207. { projectId, action: 'granting read-only anonymous access' },
  208. ]
  209. } else {
  210. throw new Error('unreachable')
  211. }
  212. }
  213. const userHasPrivilege = await _userAlreadyHasHigherPrivilege(
  214. userId,
  215. projectId,
  216. tokenType
  217. )
  218. if (userHasPrivilege) {
  219. return [
  220. null,
  221. () => {
  222. res.json({ redirect: `/project/${project._id}`, higherAccess: true })
  223. },
  224. { projectId, action: 'user already has higher or same privilege' },
  225. ]
  226. }
  227. // Handle admin redirect
  228. // If the project owner is an internal staff (using @overleaf.com email),
  229. // the admin will join the project "for real".
  230. // If the project owner is a external user
  231. // the admin will be redirect to admin domain to view the project.
  232. if (canRedirectToAdminDomain(SessionManager.getSessionUser(req.session))) {
  233. const isProjectOwnerOverleafStaff = await _isOverleafStaff(
  234. project.owner_ref
  235. )
  236. if (isProjectOwnerOverleafStaff) {
  237. logger.warn(
  238. { projectId, userId },
  239. 'letting admin user join staff project'
  240. )
  241. } else {
  242. let projectUrlWithToken = TokenAccessHandler.makeTokenUrl(token)
  243. if (tokenHashPrefix && tokenHashPrefix.startsWith('#')) {
  244. projectUrlWithToken += `${tokenHashPrefix}`
  245. }
  246. return [
  247. null,
  248. () =>
  249. res.json({
  250. redirect: getSafeAdminDomainRedirect(projectUrlWithToken),
  251. }),
  252. { projectId, action: 'redirect admin user to admin domain' },
  253. ]
  254. }
  255. }
  256. if (!tokenAccessEnabled) {
  257. return [
  258. null,
  259. () => {
  260. next(new Errors.NotFoundError())
  261. },
  262. { projectId, action: 'token access not enabled' },
  263. ]
  264. }
  265. return [project, null, { projectId, action: 'continue' }]
  266. }
  267. async function grantTokenAccessReadAndWrite(req, res, next) {
  268. const { token } = req.params
  269. const { confirmedByUser, tokenHashPrefix } = req.body
  270. const userId = SessionManager.getLoggedInUserId(req.session)
  271. if (!TokenAccessHandler.isReadAndWriteToken(token)) {
  272. return res.sendStatus(400)
  273. }
  274. const tokenType = TokenAccessHandler.TOKEN_TYPES.READ_AND_WRITE
  275. try {
  276. const [project, action, logData] = await checkAndGetProjectOrResponseAction(
  277. tokenType,
  278. token,
  279. userId,
  280. tokenHashPrefix,
  281. req,
  282. res,
  283. next
  284. )
  285. TokenAccessHandler.checkTokenHashPrefix(
  286. token,
  287. tokenHashPrefix,
  288. tokenType,
  289. userId,
  290. logData
  291. )
  292. if (action) {
  293. return action()
  294. }
  295. if (!project) {
  296. return next(new Errors.NotFoundError())
  297. }
  298. const linkSharingChanges =
  299. await SplitTestHandler.promises.getAssignmentForUser(
  300. project.owner_ref,
  301. 'link-sharing-warning'
  302. )
  303. if (linkSharingChanges?.variant === 'active') {
  304. if (!confirmedByUser) {
  305. return res.json({
  306. requireAccept: {
  307. linkSharingChanges: true,
  308. projectName: project.name,
  309. },
  310. })
  311. }
  312. const linkSharingEnforcement =
  313. await SplitTestHandler.promises.getAssignmentForUser(
  314. project.owner_ref,
  315. 'link-sharing-enforcement'
  316. )
  317. const pendingEditor =
  318. linkSharingEnforcement?.variant === 'active' &&
  319. !(await LimitationsManager.promises.canAcceptEditCollaboratorInvite(
  320. project._id
  321. ))
  322. await ProjectAuditLogHandler.promises.addEntry(
  323. project._id,
  324. 'accept-via-link-sharing',
  325. userId,
  326. req.ip,
  327. {
  328. privileges: pendingEditor ? 'readOnly' : 'readAndWrite',
  329. ...(pendingEditor && { pendingEditor: true }),
  330. }
  331. )
  332. AnalyticsManager.recordEventForUserInBackground(
  333. userId,
  334. 'project-joined',
  335. {
  336. mode: pendingEditor ? 'read-only' : 'read-write',
  337. projectId: project._id.toString(),
  338. ...(pendingEditor && { pendingEditor: true }),
  339. }
  340. )
  341. await CollaboratorsHandler.promises.addUserIdToProject(
  342. project._id,
  343. undefined,
  344. userId,
  345. pendingEditor
  346. ? PrivilegeLevels.READ_ONLY
  347. : PrivilegeLevels.READ_AND_WRITE,
  348. { pendingEditor }
  349. )
  350. // remove pending invite and notification
  351. const userEmails =
  352. await UserGetter.promises.getUserConfirmedEmails(userId)
  353. await CollaboratorsInviteHandler.promises.revokeInviteForUser(
  354. project._id,
  355. userEmails
  356. )
  357. // Should be a noop if the user is already a member,
  358. // and would redirect transparently into the project.
  359. EditorRealTimeController.emitToRoom(
  360. project._id,
  361. 'project:membership:changed',
  362. { members: true, invites: true }
  363. )
  364. return res.json({
  365. redirect: `/project/${project._id}`,
  366. })
  367. } else {
  368. if (!confirmedByUser) {
  369. return res.json({
  370. requireAccept: {
  371. projectName: project.name,
  372. },
  373. })
  374. }
  375. if (!project.tokenAccessReadAndWrite_refs.some(id => id.equals(userId))) {
  376. await ProjectAuditLogHandler.promises.addEntry(
  377. project._id,
  378. 'join-via-token',
  379. userId,
  380. req.ip,
  381. { privileges: 'readAndWrite' }
  382. )
  383. }
  384. await TokenAccessHandler.promises.addReadAndWriteUserToProject(
  385. userId,
  386. project._id
  387. )
  388. return res.json({
  389. redirect: `/project/${project._id}`,
  390. tokenAccessGranted: tokenType,
  391. })
  392. }
  393. } catch (err) {
  394. return next(
  395. OError.tag(
  396. err,
  397. 'error while trying to grant read-and-write token access',
  398. { token }
  399. )
  400. )
  401. }
  402. }
  403. async function grantTokenAccessReadOnly(req, res, next) {
  404. const { token } = req.params
  405. const { confirmedByUser, tokenHashPrefix } = req.body
  406. const userId = SessionManager.getLoggedInUserId(req.session)
  407. if (!TokenAccessHandler.isReadOnlyToken(token)) {
  408. return res.sendStatus(400)
  409. }
  410. const tokenType = TokenAccessHandler.TOKEN_TYPES.READ_ONLY
  411. const docPublishedInfo =
  412. await TokenAccessHandler.promises.getV1DocPublishedInfo(token)
  413. if (docPublishedInfo.allow === false) {
  414. return res.json({ redirect: docPublishedInfo.published_path })
  415. }
  416. try {
  417. const [project, action, logData] = await checkAndGetProjectOrResponseAction(
  418. tokenType,
  419. token,
  420. userId,
  421. tokenHashPrefix,
  422. req,
  423. res,
  424. next
  425. )
  426. TokenAccessHandler.checkTokenHashPrefix(
  427. token,
  428. tokenHashPrefix,
  429. tokenType,
  430. userId,
  431. logData
  432. )
  433. if (action) {
  434. return action()
  435. }
  436. if (!project) {
  437. return next(new Errors.NotFoundError())
  438. }
  439. if (!confirmedByUser) {
  440. return res.json({
  441. requireAccept: {
  442. projectName: project.name,
  443. },
  444. })
  445. }
  446. if (!project.tokenAccessReadOnly_refs.some(id => id.equals(userId))) {
  447. await ProjectAuditLogHandler.promises.addEntry(
  448. project._id,
  449. 'join-via-token',
  450. userId,
  451. req.ip,
  452. { privileges: 'readOnly' }
  453. )
  454. }
  455. await TokenAccessHandler.promises.addReadOnlyUserToProject(
  456. userId,
  457. project._id
  458. )
  459. return res.json({
  460. redirect: `/project/${project._id}`,
  461. tokenAccessGranted: tokenType,
  462. })
  463. } catch (err) {
  464. return next(
  465. OError.tag(err, 'error while trying to grant read-only token access', {
  466. token,
  467. })
  468. )
  469. }
  470. }
  471. async function ensureUserCanUseSharingUpdatesConsentPage(req, res, next) {
  472. const { Project_id: projectId } = req.params
  473. const userId = SessionManager.getLoggedInUserId(req.session)
  474. const project = await ProjectGetter.promises.getProject(projectId, {
  475. owner_ref: 1,
  476. })
  477. if (!project) {
  478. throw new Errors.NotFoundError()
  479. }
  480. const linkSharingChanges =
  481. await SplitTestHandler.promises.getAssignmentForUser(
  482. project.owner_ref,
  483. 'link-sharing-warning'
  484. )
  485. if (linkSharingChanges?.variant !== 'active') {
  486. return AsyncFormHelper.redirect(req, res, `/project/${projectId}`)
  487. }
  488. const isReadWriteTokenMember =
  489. await CollaboratorsGetter.promises.userIsReadWriteTokenMember(
  490. userId,
  491. projectId
  492. )
  493. if (!isReadWriteTokenMember) {
  494. // If the user is not a read write token member, there are no actions to take
  495. return AsyncFormHelper.redirect(req, res, `/project/${projectId}`)
  496. }
  497. const isReadWriteMember =
  498. await CollaboratorsGetter.promises.isUserInvitedReadWriteMemberOfProject(
  499. userId,
  500. projectId
  501. )
  502. if (isReadWriteMember) {
  503. // If the user is already an invited editor, the actions don't make sense
  504. return AsyncFormHelper.redirect(req, res, `/project/${projectId}`)
  505. }
  506. next()
  507. }
  508. async function sharingUpdatesConsent(req, res, next) {
  509. const { Project_id: projectId } = req.params
  510. AnalyticsManager.recordEventForSession(req.session, 'notification-prompt', {
  511. page: req.path,
  512. name: 'link-sharing-collaborator',
  513. })
  514. res.render('project/token/sharing-updates', {
  515. projectId,
  516. })
  517. }
  518. async function moveReadWriteToCollaborators(req, res, next) {
  519. const { Project_id: projectId } = req.params
  520. const userId = SessionManager.getLoggedInUserId(req.session)
  521. const project = await ProjectGetter.promises.getProject(projectId, {
  522. owner_ref: 1,
  523. })
  524. const isInvitedMember =
  525. await CollaboratorsGetter.promises.isUserInvitedMemberOfProject(
  526. userId,
  527. projectId
  528. )
  529. const linkSharingEnforcement =
  530. await SplitTestHandler.promises.getAssignmentForUser(
  531. project.owner_ref,
  532. 'link-sharing-enforcement'
  533. )
  534. const pendingEditor =
  535. linkSharingEnforcement?.variant === 'active' &&
  536. !(await LimitationsManager.promises.canAcceptEditCollaboratorInvite(
  537. project._id
  538. ))
  539. await ProjectAuditLogHandler.promises.addEntry(
  540. projectId,
  541. 'accept-via-link-sharing',
  542. userId,
  543. req.ip,
  544. {
  545. privileges: pendingEditor
  546. ? PrivilegeLevels.READ_ONLY
  547. : PrivilegeLevels.READ_AND_WRITE,
  548. tokenMember: true,
  549. invitedMember: isInvitedMember,
  550. ...(pendingEditor && { pendingEditor: true }),
  551. }
  552. )
  553. if (isInvitedMember) {
  554. // Read only invited viewer who is gaining edit access via link sharing
  555. await TokenAccessHandler.promises.removeReadAndWriteUserFromProject(
  556. userId,
  557. projectId
  558. )
  559. await CollaboratorsHandler.promises.setCollaboratorPrivilegeLevel(
  560. projectId,
  561. userId,
  562. pendingEditor
  563. ? PrivilegeLevels.READ_ONLY
  564. : PrivilegeLevels.READ_AND_WRITE,
  565. { pendingEditor }
  566. )
  567. } else {
  568. // Normal case, not invited, joining via link sharing
  569. await TokenAccessHandler.promises.removeReadAndWriteUserFromProject(
  570. userId,
  571. projectId
  572. )
  573. await CollaboratorsHandler.promises.addUserIdToProject(
  574. projectId,
  575. undefined,
  576. userId,
  577. pendingEditor
  578. ? PrivilegeLevels.READ_ONLY
  579. : PrivilegeLevels.READ_AND_WRITE,
  580. { pendingEditor }
  581. )
  582. }
  583. EditorRealTimeController.emitToRoom(projectId, 'project:membership:changed', {
  584. members: true,
  585. })
  586. res.sendStatus(204)
  587. }
  588. async function moveReadWriteToReadOnly(req, res, next) {
  589. const { Project_id: projectId } = req.params
  590. const userId = SessionManager.getLoggedInUserId(req.session)
  591. await ProjectAuditLogHandler.promises.addEntry(
  592. projectId,
  593. 'readonly-via-sharing-updates',
  594. userId,
  595. req.ip
  596. )
  597. await TokenAccessHandler.promises.moveReadAndWriteUserToReadOnly(
  598. userId,
  599. projectId
  600. )
  601. res.sendStatus(204)
  602. }
  603. export default {
  604. READ_ONLY_TOKEN_PATTERN: TokenAccessHandler.READ_ONLY_TOKEN_PATTERN,
  605. READ_AND_WRITE_TOKEN_PATTERN: TokenAccessHandler.READ_AND_WRITE_TOKEN_PATTERN,
  606. tokenAccessPage: expressify(tokenAccessPage),
  607. grantTokenAccessReadOnly: expressify(grantTokenAccessReadOnly),
  608. grantTokenAccessReadAndWrite: expressify(grantTokenAccessReadAndWrite),
  609. ensureUserCanUseSharingUpdatesConsentPage: expressify(
  610. ensureUserCanUseSharingUpdatesConsentPage
  611. ),
  612. sharingUpdatesConsent: expressify(sharingUpdatesConsent),
  613. moveReadWriteToCollaborators: expressify(moveReadWriteToCollaborators),
  614. moveReadWriteToReadOnly: expressify(moveReadWriteToReadOnly),
  615. }