TokenAccessHandler.mjs 10.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372
  1. import { Project } from '../../models/Project.mjs'
  2. import PublicAccessLevels from '../Authorization/PublicAccessLevels.mjs'
  3. import PrivilegeLevels from '../Authorization/PrivilegeLevels.js'
  4. import mongodb from 'mongodb-legacy'
  5. import Metrics from '@overleaf/metrics'
  6. import Settings from '@overleaf/settings'
  7. import logger from '@overleaf/logger'
  8. import V1Api from '../V1/V1Api.js'
  9. import crypto from 'node:crypto'
  10. import { callbackifyAll } from '@overleaf/promise-utils'
  11. import Analytics from '../Analytics/AnalyticsManager.mjs'
  12. import Features from '../../infrastructure/Features.js'
  13. const { ObjectId } = mongodb
  14. const READ_AND_WRITE_TOKEN_PATTERN = '([0-9]+[a-z]{6,12})'
  15. const READ_ONLY_TOKEN_PATTERN = '([a-z]{12})'
  16. const TokenAccessHandler = {
  17. TOKEN_TYPES: {
  18. READ_ONLY: PrivilegeLevels.READ_ONLY,
  19. READ_AND_WRITE: PrivilegeLevels.READ_AND_WRITE,
  20. },
  21. ANONYMOUS_READ_AND_WRITE_ENABLED:
  22. Settings.allowAnonymousReadAndWriteSharing === true,
  23. READ_AND_WRITE_TOKEN_PATTERN,
  24. READ_ONLY_TOKEN_PATTERN,
  25. _makeReadAndWriteTokenUrl(token) {
  26. return `/${token}`
  27. },
  28. _makeReadOnlyTokenUrl(token) {
  29. return `/read/${token}`
  30. },
  31. makeTokenUrl(token) {
  32. const tokenType = TokenAccessHandler.getTokenType(token)
  33. if (tokenType === TokenAccessHandler.TOKEN_TYPES.READ_AND_WRITE) {
  34. return TokenAccessHandler._makeReadAndWriteTokenUrl(token)
  35. } else if (tokenType === TokenAccessHandler.TOKEN_TYPES.READ_ONLY) {
  36. return TokenAccessHandler._makeReadOnlyTokenUrl(token)
  37. } else {
  38. throw new Error('invalid token type')
  39. }
  40. },
  41. getTokenType(token) {
  42. if (!token) {
  43. return null
  44. }
  45. if (token.match(`^${TokenAccessHandler.READ_ONLY_TOKEN_PATTERN}$`)) {
  46. return TokenAccessHandler.TOKEN_TYPES.READ_ONLY
  47. } else if (
  48. token.match(`^${TokenAccessHandler.READ_AND_WRITE_TOKEN_PATTERN}$`)
  49. ) {
  50. return TokenAccessHandler.TOKEN_TYPES.READ_AND_WRITE
  51. }
  52. return null
  53. },
  54. isReadOnlyToken(token) {
  55. return (
  56. TokenAccessHandler.getTokenType(token) ===
  57. TokenAccessHandler.TOKEN_TYPES.READ_ONLY
  58. )
  59. },
  60. isReadAndWriteToken(token) {
  61. return (
  62. TokenAccessHandler.getTokenType(token) ===
  63. TokenAccessHandler.TOKEN_TYPES.READ_AND_WRITE
  64. )
  65. },
  66. isValidToken(token) {
  67. return TokenAccessHandler.getTokenType(token) != null
  68. },
  69. tokenAccessEnabledForProject(project) {
  70. return project.publicAccesLevel === PublicAccessLevels.TOKEN_BASED
  71. },
  72. async _projectFindOne(query) {
  73. return await Project.findOne(query, {
  74. _id: 1,
  75. tokens: 1,
  76. publicAccesLevel: 1,
  77. owner_ref: 1,
  78. name: 1,
  79. tokenAccessReadOnly_refs: 1,
  80. tokenAccessReadAndWrite_refs: 1,
  81. }).exec()
  82. },
  83. async getProjectByReadOnlyToken(token) {
  84. return await TokenAccessHandler._projectFindOne({
  85. 'tokens.readOnly': token,
  86. })
  87. },
  88. _extractNumericPrefix(token) {
  89. return token.match(/^(\d+)\w+/)
  90. },
  91. _extractStringSuffix(token) {
  92. return token.match(/^\d+(\w+)/)
  93. },
  94. async getProjectByReadAndWriteToken(token) {
  95. const numericPrefixMatch = TokenAccessHandler._extractNumericPrefix(token)
  96. if (!numericPrefixMatch) {
  97. return null
  98. }
  99. const numerics = numericPrefixMatch[1]
  100. const project = await TokenAccessHandler._projectFindOne({
  101. 'tokens.readAndWritePrefix': numerics,
  102. })
  103. if (project == null) {
  104. return null
  105. }
  106. try {
  107. if (
  108. !crypto.timingSafeEqual(
  109. Buffer.from(token),
  110. Buffer.from(project.tokens.readAndWrite)
  111. )
  112. ) {
  113. logger.err(
  114. { projectId: project._id },
  115. 'read-and-write token match on numeric section, but not on full token'
  116. )
  117. return null
  118. } else {
  119. return project
  120. }
  121. } catch (error) {
  122. logger.err({ projectId: project._id, error }, 'error comparing tokens')
  123. return null
  124. }
  125. },
  126. async getProjectByToken(tokenType, token) {
  127. if (tokenType === TokenAccessHandler.TOKEN_TYPES.READ_ONLY) {
  128. return await TokenAccessHandler.getProjectByReadOnlyToken(token)
  129. } else if (tokenType === TokenAccessHandler.TOKEN_TYPES.READ_AND_WRITE) {
  130. return await TokenAccessHandler.getProjectByReadAndWriteToken(token)
  131. }
  132. throw new Error('invalid token type')
  133. },
  134. async addReadOnlyUserToProject(userId, projectId, ownerId) {
  135. if (!Features.hasFeature('link-sharing')) {
  136. throw new Error('link sharing is disabled')
  137. }
  138. userId = new ObjectId(userId.toString())
  139. projectId = new ObjectId(projectId.toString())
  140. Analytics.recordEventForUserInBackground(userId, 'project-joined', {
  141. role: PrivilegeLevels.READ_ONLY,
  142. projectId: projectId.toString(),
  143. source: 'link-sharing',
  144. ownerId: ownerId.toString(),
  145. mode: 'view',
  146. })
  147. return await Project.updateOne(
  148. {
  149. _id: projectId,
  150. },
  151. {
  152. $addToSet: { tokenAccessReadOnly_refs: userId },
  153. }
  154. ).exec()
  155. },
  156. async removeReadAndWriteUserFromProject(userId, projectId) {
  157. userId = new ObjectId(userId.toString())
  158. projectId = new ObjectId(projectId.toString())
  159. return await Project.updateOne(
  160. {
  161. _id: projectId,
  162. },
  163. {
  164. $pull: { tokenAccessReadAndWrite_refs: userId },
  165. }
  166. ).exec()
  167. },
  168. async moveReadAndWriteUserToReadOnly(userId, projectId) {
  169. userId = new ObjectId(userId.toString())
  170. projectId = new ObjectId(projectId.toString())
  171. return await Project.updateOne(
  172. {
  173. _id: projectId,
  174. },
  175. {
  176. $pull: { tokenAccessReadAndWrite_refs: userId },
  177. $addToSet: { tokenAccessReadOnly_refs: userId },
  178. }
  179. ).exec()
  180. },
  181. grantSessionTokenAccess(req, projectId, token) {
  182. if (!Features.hasFeature('link-sharing')) {
  183. throw new Error('link sharing is disabled')
  184. }
  185. if (!req.session) {
  186. return
  187. }
  188. if (!req.session.anonTokenAccess) {
  189. req.session.anonTokenAccess = {}
  190. }
  191. req.session.anonTokenAccess[projectId.toString()] = token
  192. },
  193. getRequestToken(req, projectId) {
  194. const token =
  195. Features.hasFeature('link-sharing') &&
  196. req.session &&
  197. req.session.anonTokenAccess &&
  198. req.session.anonTokenAccess[projectId.toString()]
  199. return token
  200. },
  201. async validateTokenForAnonymousAccess(projectId, token, callback) {
  202. if (!Features.hasFeature('link-sharing') || !token) {
  203. return { isValidReadAndWrite: false, isValidReadOnly: false }
  204. }
  205. const tokenType = TokenAccessHandler.getTokenType(token)
  206. if (!tokenType) {
  207. throw new Error('invalid token type')
  208. }
  209. const project = await TokenAccessHandler.getProjectByToken(tokenType, token)
  210. if (
  211. !project ||
  212. !TokenAccessHandler.tokenAccessEnabledForProject(project) ||
  213. project._id.toString() !== projectId.toString()
  214. ) {
  215. return { isValidReadAndWrite: false, isValidReadOnly: false }
  216. }
  217. // TODO: think about cleaning up this interface and its usage in AuthorizationManager
  218. return {
  219. isValidReadAndWrite:
  220. tokenType === TokenAccessHandler.TOKEN_TYPES.READ_AND_WRITE &&
  221. TokenAccessHandler.ANONYMOUS_READ_AND_WRITE_ENABLED,
  222. isValidReadOnly: tokenType === TokenAccessHandler.TOKEN_TYPES.READ_ONLY,
  223. }
  224. },
  225. async getV1DocPublishedInfo(token) {
  226. // default to allowing access
  227. if (!Settings.apis.v1 || !Settings.apis.v1.url) {
  228. return { allow: true }
  229. }
  230. const { body } = await V1Api.promises.request({
  231. url: `/api/v1/overleaf/docs/${token}/is_published`,
  232. })
  233. return body
  234. },
  235. async getV1DocInfo(token, v2UserId) {
  236. if (!Settings.apis || !Settings.apis.v1) {
  237. return {
  238. exists: true,
  239. exported: false,
  240. }
  241. }
  242. const v1Url = `/api/v1/overleaf/docs/${token}/info`
  243. const { body } = await V1Api.promises.request({ url: v1Url })
  244. return body
  245. },
  246. createTokenHashPrefix(token) {
  247. const hash = crypto.createHash('sha256')
  248. hash.update(token)
  249. return hash.digest('hex').slice(0, 6)
  250. },
  251. normalizeTokenHashPrefix(tokenHashPrefix) {
  252. if (typeof tokenHashPrefix !== 'string') return ''
  253. // remove (encoded) hash
  254. tokenHashPrefix = tokenHashPrefix.replace('#', '').replace('%23', '')
  255. // remove trailing special characters that were copied by accident
  256. tokenHashPrefix = tokenHashPrefix.replace(/[^a-z0-9]+$/i, '')
  257. return tokenHashPrefix
  258. },
  259. checkTokenHashPrefix(token, tokenHashPrefix, type, userId, logData = {}) {
  260. let hashPrefixStatus
  261. tokenHashPrefix =
  262. TokenAccessHandler.normalizeTokenHashPrefix(tokenHashPrefix)
  263. const v1Format = /%2F[0-9]{7,8}%2F/
  264. const isSuspectedV1Format = v1Format.test(tokenHashPrefix)
  265. if (!tokenHashPrefix) {
  266. hashPrefixStatus = 'missing'
  267. } else {
  268. const expectedHashPrefix = TokenAccessHandler.createTokenHashPrefix(token)
  269. if (expectedHashPrefix === tokenHashPrefix) {
  270. hashPrefixStatus = 'match'
  271. } else if (isSuspectedV1Format) {
  272. hashPrefixStatus = 'mismatch-v1-format'
  273. } else {
  274. hashPrefixStatus = 'mismatch'
  275. }
  276. }
  277. if (hashPrefixStatus === 'mismatch') {
  278. logger.info(
  279. {
  280. tokenHashPrefix,
  281. hashPrefixStatus,
  282. userId,
  283. ...logData,
  284. type,
  285. },
  286. 'mismatched token hash prefix'
  287. )
  288. }
  289. Metrics.inc('link-sharing.hash-check', {
  290. path: type,
  291. status: hashPrefixStatus,
  292. })
  293. },
  294. }
  295. export default {
  296. ...TokenAccessHandler,
  297. ...callbackifyAll(TokenAccessHandler, {
  298. multiResult: {
  299. validateTokenForAnonymousAccess: [
  300. 'isValidReadAndWrite',
  301. 'isValidReadOnly',
  302. ],
  303. },
  304. without: [
  305. 'makeTokenUrl',
  306. 'getTokenType',
  307. 'isReadOnlyToken',
  308. 'isReadAndWriteToken',
  309. 'isValidToken',
  310. 'tokenAccessEnabledForProject',
  311. 'grantSessionTokenAccess',
  312. 'getRequestToken',
  313. 'createTokenHashPrefix',
  314. 'normalizeTokenHashPrefix',
  315. 'checkTokenHashPrefix',
  316. '_makeReadAndWriteTokenUrl',
  317. '_makeReadOnlyTokenUrl',
  318. '_projectFindOne',
  319. '_extractNumericPrefix',
  320. '_extractStringSuffix',
  321. ],
  322. }),
  323. promises: TokenAccessHandler,
  324. }