TokenAccessHandler.js 9.5 KB

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