TokenAccessHandler.js 9.7 KB

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