| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372 |
- import { Project } from '../../models/Project.mjs'
- import PublicAccessLevels from '../Authorization/PublicAccessLevels.mjs'
- import PrivilegeLevels from '../Authorization/PrivilegeLevels.js'
- import mongodb from 'mongodb-legacy'
- import Metrics from '@overleaf/metrics'
- import Settings from '@overleaf/settings'
- import logger from '@overleaf/logger'
- import V1Api from '../V1/V1Api.js'
- import crypto from 'node:crypto'
- import { callbackifyAll } from '@overleaf/promise-utils'
- import Analytics from '../Analytics/AnalyticsManager.mjs'
- import Features from '../../infrastructure/Features.js'
- const { ObjectId } = mongodb
- const READ_AND_WRITE_TOKEN_PATTERN = '([0-9]+[a-z]{6,12})'
- const READ_ONLY_TOKEN_PATTERN = '([a-z]{12})'
- const TokenAccessHandler = {
- TOKEN_TYPES: {
- READ_ONLY: PrivilegeLevels.READ_ONLY,
- READ_AND_WRITE: PrivilegeLevels.READ_AND_WRITE,
- },
- ANONYMOUS_READ_AND_WRITE_ENABLED:
- Settings.allowAnonymousReadAndWriteSharing === true,
- READ_AND_WRITE_TOKEN_PATTERN,
- READ_ONLY_TOKEN_PATTERN,
- _makeReadAndWriteTokenUrl(token) {
- return `/${token}`
- },
- _makeReadOnlyTokenUrl(token) {
- return `/read/${token}`
- },
- makeTokenUrl(token) {
- const tokenType = TokenAccessHandler.getTokenType(token)
- if (tokenType === TokenAccessHandler.TOKEN_TYPES.READ_AND_WRITE) {
- return TokenAccessHandler._makeReadAndWriteTokenUrl(token)
- } else if (tokenType === TokenAccessHandler.TOKEN_TYPES.READ_ONLY) {
- return TokenAccessHandler._makeReadOnlyTokenUrl(token)
- } else {
- throw new Error('invalid token type')
- }
- },
- getTokenType(token) {
- if (!token) {
- return null
- }
- if (token.match(`^${TokenAccessHandler.READ_ONLY_TOKEN_PATTERN}$`)) {
- return TokenAccessHandler.TOKEN_TYPES.READ_ONLY
- } else if (
- token.match(`^${TokenAccessHandler.READ_AND_WRITE_TOKEN_PATTERN}$`)
- ) {
- return TokenAccessHandler.TOKEN_TYPES.READ_AND_WRITE
- }
- return null
- },
- isReadOnlyToken(token) {
- return (
- TokenAccessHandler.getTokenType(token) ===
- TokenAccessHandler.TOKEN_TYPES.READ_ONLY
- )
- },
- isReadAndWriteToken(token) {
- return (
- TokenAccessHandler.getTokenType(token) ===
- TokenAccessHandler.TOKEN_TYPES.READ_AND_WRITE
- )
- },
- isValidToken(token) {
- return TokenAccessHandler.getTokenType(token) != null
- },
- tokenAccessEnabledForProject(project) {
- return project.publicAccesLevel === PublicAccessLevels.TOKEN_BASED
- },
- async _projectFindOne(query) {
- return await Project.findOne(query, {
- _id: 1,
- tokens: 1,
- publicAccesLevel: 1,
- owner_ref: 1,
- name: 1,
- tokenAccessReadOnly_refs: 1,
- tokenAccessReadAndWrite_refs: 1,
- }).exec()
- },
- async getProjectByReadOnlyToken(token) {
- return await TokenAccessHandler._projectFindOne({
- 'tokens.readOnly': token,
- })
- },
- _extractNumericPrefix(token) {
- return token.match(/^(\d+)\w+/)
- },
- _extractStringSuffix(token) {
- return token.match(/^\d+(\w+)/)
- },
- async getProjectByReadAndWriteToken(token) {
- const numericPrefixMatch = TokenAccessHandler._extractNumericPrefix(token)
- if (!numericPrefixMatch) {
- return null
- }
- const numerics = numericPrefixMatch[1]
- const project = await TokenAccessHandler._projectFindOne({
- 'tokens.readAndWritePrefix': numerics,
- })
- if (project == null) {
- return null
- }
- try {
- if (
- !crypto.timingSafeEqual(
- Buffer.from(token),
- Buffer.from(project.tokens.readAndWrite)
- )
- ) {
- logger.err(
- { projectId: project._id },
- 'read-and-write token match on numeric section, but not on full token'
- )
- return null
- } else {
- return project
- }
- } catch (error) {
- logger.err({ projectId: project._id, error }, 'error comparing tokens')
- return null
- }
- },
- async getProjectByToken(tokenType, token) {
- if (tokenType === TokenAccessHandler.TOKEN_TYPES.READ_ONLY) {
- return await TokenAccessHandler.getProjectByReadOnlyToken(token)
- } else if (tokenType === TokenAccessHandler.TOKEN_TYPES.READ_AND_WRITE) {
- return await TokenAccessHandler.getProjectByReadAndWriteToken(token)
- }
- throw new Error('invalid token type')
- },
- async addReadOnlyUserToProject(userId, projectId, ownerId) {
- if (!Features.hasFeature('link-sharing')) {
- throw new Error('link sharing is disabled')
- }
- userId = new ObjectId(userId.toString())
- projectId = new ObjectId(projectId.toString())
- Analytics.recordEventForUserInBackground(userId, 'project-joined', {
- role: PrivilegeLevels.READ_ONLY,
- projectId: projectId.toString(),
- source: 'link-sharing',
- ownerId: ownerId.toString(),
- mode: 'view',
- })
- return await Project.updateOne(
- {
- _id: projectId,
- },
- {
- $addToSet: { tokenAccessReadOnly_refs: userId },
- }
- ).exec()
- },
- async removeReadAndWriteUserFromProject(userId, projectId) {
- userId = new ObjectId(userId.toString())
- projectId = new ObjectId(projectId.toString())
- return await Project.updateOne(
- {
- _id: projectId,
- },
- {
- $pull: { tokenAccessReadAndWrite_refs: userId },
- }
- ).exec()
- },
- async moveReadAndWriteUserToReadOnly(userId, projectId) {
- userId = new ObjectId(userId.toString())
- projectId = new ObjectId(projectId.toString())
- return await Project.updateOne(
- {
- _id: projectId,
- },
- {
- $pull: { tokenAccessReadAndWrite_refs: userId },
- $addToSet: { tokenAccessReadOnly_refs: userId },
- }
- ).exec()
- },
- grantSessionTokenAccess(req, projectId, token) {
- if (!Features.hasFeature('link-sharing')) {
- throw new Error('link sharing is disabled')
- }
- if (!req.session) {
- return
- }
- if (!req.session.anonTokenAccess) {
- req.session.anonTokenAccess = {}
- }
- req.session.anonTokenAccess[projectId.toString()] = token
- },
- getRequestToken(req, projectId) {
- const token =
- Features.hasFeature('link-sharing') &&
- req.session &&
- req.session.anonTokenAccess &&
- req.session.anonTokenAccess[projectId.toString()]
- return token
- },
- async validateTokenForAnonymousAccess(projectId, token, callback) {
- if (!Features.hasFeature('link-sharing') || !token) {
- return { isValidReadAndWrite: false, isValidReadOnly: false }
- }
- const tokenType = TokenAccessHandler.getTokenType(token)
- if (!tokenType) {
- throw new Error('invalid token type')
- }
- const project = await TokenAccessHandler.getProjectByToken(tokenType, token)
- if (
- !project ||
- !TokenAccessHandler.tokenAccessEnabledForProject(project) ||
- project._id.toString() !== projectId.toString()
- ) {
- return { isValidReadAndWrite: false, isValidReadOnly: false }
- }
- // TODO: think about cleaning up this interface and its usage in AuthorizationManager
- return {
- isValidReadAndWrite:
- tokenType === TokenAccessHandler.TOKEN_TYPES.READ_AND_WRITE &&
- TokenAccessHandler.ANONYMOUS_READ_AND_WRITE_ENABLED,
- isValidReadOnly: tokenType === TokenAccessHandler.TOKEN_TYPES.READ_ONLY,
- }
- },
- async getV1DocPublishedInfo(token) {
- // default to allowing access
- if (!Settings.apis.v1 || !Settings.apis.v1.url) {
- return { allow: true }
- }
- const { body } = await V1Api.promises.request({
- url: `/api/v1/overleaf/docs/${token}/is_published`,
- })
- return body
- },
- async getV1DocInfo(token, v2UserId) {
- if (!Settings.apis || !Settings.apis.v1) {
- return {
- exists: true,
- exported: false,
- }
- }
- const v1Url = `/api/v1/overleaf/docs/${token}/info`
- const { body } = await V1Api.promises.request({ url: v1Url })
- return body
- },
- createTokenHashPrefix(token) {
- const hash = crypto.createHash('sha256')
- hash.update(token)
- return hash.digest('hex').slice(0, 6)
- },
- normalizeTokenHashPrefix(tokenHashPrefix) {
- if (typeof tokenHashPrefix !== 'string') return ''
- // remove (encoded) hash
- tokenHashPrefix = tokenHashPrefix.replace('#', '').replace('%23', '')
- // remove trailing special characters that were copied by accident
- tokenHashPrefix = tokenHashPrefix.replace(/[^a-z0-9]+$/i, '')
- return tokenHashPrefix
- },
- checkTokenHashPrefix(token, tokenHashPrefix, type, userId, logData = {}) {
- let hashPrefixStatus
- tokenHashPrefix =
- TokenAccessHandler.normalizeTokenHashPrefix(tokenHashPrefix)
- const v1Format = /%2F[0-9]{7,8}%2F/
- const isSuspectedV1Format = v1Format.test(tokenHashPrefix)
- if (!tokenHashPrefix) {
- hashPrefixStatus = 'missing'
- } else {
- const expectedHashPrefix = TokenAccessHandler.createTokenHashPrefix(token)
- if (expectedHashPrefix === tokenHashPrefix) {
- hashPrefixStatus = 'match'
- } else if (isSuspectedV1Format) {
- hashPrefixStatus = 'mismatch-v1-format'
- } else {
- hashPrefixStatus = 'mismatch'
- }
- }
- if (hashPrefixStatus === 'mismatch') {
- logger.info(
- {
- tokenHashPrefix,
- hashPrefixStatus,
- userId,
- ...logData,
- type,
- },
- 'mismatched token hash prefix'
- )
- }
- Metrics.inc('link-sharing.hash-check', {
- path: type,
- status: hashPrefixStatus,
- })
- },
- }
- export default {
- ...TokenAccessHandler,
- ...callbackifyAll(TokenAccessHandler, {
- multiResult: {
- validateTokenForAnonymousAccess: [
- 'isValidReadAndWrite',
- 'isValidReadOnly',
- ],
- },
- without: [
- 'makeTokenUrl',
- 'getTokenType',
- 'isReadOnlyToken',
- 'isReadAndWriteToken',
- 'isValidToken',
- 'tokenAccessEnabledForProject',
- 'grantSessionTokenAccess',
- 'getRequestToken',
- 'createTokenHashPrefix',
- 'normalizeTokenHashPrefix',
- 'checkTokenHashPrefix',
- '_makeReadAndWriteTokenUrl',
- '_makeReadOnlyTokenUrl',
- '_projectFindOne',
- '_extractNumericPrefix',
- '_extractStringSuffix',
- ],
- }),
- promises: TokenAccessHandler,
- }
|