diff --git a/.gitignore b/.gitignore index f0233f2..327be29 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,4 @@ # Temporary files -sharelatex/ sharelatex_ori/ # Compiled Object files diff --git a/ldap-overleaf-sl/Dockerfile b/ldap-overleaf-sl/Dockerfile index e1e035b..b65e71d 100644 --- a/ldap-overleaf-sl/Dockerfile +++ b/ldap-overleaf-sl/Dockerfile @@ -1,7 +1,5 @@ -FROM sharelatex/sharelatex:5.1.1 -# FROM sharelatex/sharelatex:latest -# latest might not be tested -# e.g. the AuthenticationManager.js script had to be adapted after versions 2.3.1 +FROM tuetenk0pp/sharelatex-full:5.1.1 + LABEL maintainer="Simon Haller-Seeber" LABEL version="0.1" @@ -13,40 +11,26 @@ ARG admin_is_sysadmin # set workdir (might solve issue #2 - see https://stackoverflow.com/questions/57534295/) WORKDIR /overleaf/services/web - # install latest npm -RUN npm install -g npm && \ - ## clean cache (might solve issue #2) - # npm cache clean --force && \ - npm install ldap-escape ldapts-search ldapts@3.2.4 && \ - # npm install bcrypt@5.0.0 && \ - apt-get update && \ - apt-get -y install libxml-libxslt-perl cpanminus libbtparse2 python-pygments && \ - # now install latest texlive2023 from tlmgr - tlmgr update --self --all && \ - tlmgr install scheme-full --verify-repo=none && \ - apt-get clean && \ - rm -rf /var/lib/apt/lists/* - # latex-bin must be on path to be found in compilation process # needed for biber epstopdf and others ENV PATH="/usr/local/texlive/2023/bin/x86_64-linux:${PATH};" # overwrite some files -COPY sharelatex/AuthenticationManager.js /overleaf/services/web/app/src/Features/Authentication/ -COPY sharelatex/AuthenticationController.js /overleaf/services/web/app/src/Features/Authentication/ -COPY sharelatex/ContactController.js /overleaf/services/web/app/src/Features/Contacts/ -COPY sharelatex/router.js /overleaf/services/web/app/src/router.js +COPY ldap-overleaf-sl/sharelatex/AuthenticationManager.js /overleaf/services/web/app/src/Features/Authentication/ +COPY ldap-overleaf-sl/sharelatex/AuthenticationController.js /overleaf/services/web/app/src/Features/Authentication/ +COPY ldap-overleaf-sl/sharelatex/ContactController.js /overleaf/services/web/app/src/Features/Contacts/ +COPY ldap-overleaf-sl/sharelatex/router.js /overleaf/services/web/app/src/router.js # Too much changes to do inline (>10 Lines). -COPY sharelatex/settings.pug /overleaf/services/web/app/views/user/ -COPY sharelatex/login.pug /overleaf/services/web/app/views/user/ -COPY sharelatex/register.pug /overleaf/services/web/app/views/user/ -COPY sharelatex/navbar-website-redesign.pug /overleaf/services/web/app/views/layout/ -COPY sharelatex/navbar-marketing.pug /overleaf/services/web/app/views/layout/ +COPY ldap-overleaf-sl/sharelatex/settings.pug /overleaf/services/web/app/views/user/ +COPY ldap-overleaf-sl/sharelatex/login.pug /overleaf/services/web/app/views/user/ +COPY ldap-overleaf-sl/sharelatex/register.pug /overleaf/services/web/app/views/user/ +COPY ldap-overleaf-sl/sharelatex/navbar-website-redesign.pug /overleaf/services/web/app/views/layout/ +COPY ldap-overleaf-sl/sharelatex/navbar-marketing.pug /overleaf/services/web/app/views/layout/ # Non LDAP User Registration for Admins -COPY sharelatex/admin-index.pug /overleaf/services/web/app/views/admin/index.pug -COPY sharelatex/admin-sysadmin.pug /tmp/admin-sysadmin.pug +COPY ldap-overleaf-sl/sharelatex/admin-index.pug /overleaf/services/web/app/views/admin/index.pug +COPY ldap-overleaf-sl/sharelatex/admin-sysadmin.pug /tmp/admin-sysadmin.pug ## comment out this line to prevent sed accidently remove the brackets of the email(username) field # sed -iE '/email@example.com/{n;N;N;d}' /overleaf/services/web/app/views/user/login.pug && \ diff --git a/ldap-overleaf-sl/sharelatex/AuthenticationController.js b/ldap-overleaf-sl/sharelatex/AuthenticationController.js new file mode 100644 index 0000000..1c256ba --- /dev/null +++ b/ldap-overleaf-sl/sharelatex/AuthenticationController.js @@ -0,0 +1,756 @@ +const AuthenticationManager = require('./AuthenticationManager') +const SessionManager = require('./SessionManager') +const OError = require('@overleaf/o-error') +const LoginRateLimiter = require('../Security/LoginRateLimiter') +const UserUpdater = require('../User/UserUpdater') +const Metrics = require('@overleaf/metrics') +const logger = require('@overleaf/logger') +const querystring = require('querystring') +const Settings = require('@overleaf/settings') +const basicAuth = require('basic-auth') +const tsscmp = require('tsscmp') +const UserHandler = require('../User/UserHandler') +const UserSessionsManager = require('../User/UserSessionsManager') +const Analytics = require('../Analytics/AnalyticsManager') +const passport = require('passport') +const NotificationsBuilder = require('../Notifications/NotificationsBuilder') +const UrlHelper = require('../Helpers/UrlHelper') +const AsyncFormHelper = require('../Helpers/AsyncFormHelper') +const _ = require('lodash') +const UserAuditLogHandler = require('../User/UserAuditLogHandler') +const AnalyticsRegistrationSourceHelper = require('../Analytics/AnalyticsRegistrationSourceHelper') +const { + acceptsJson, +} = require('../../infrastructure/RequestContentTypeDetection') +const { + ParallelLoginError, + PasswordReusedError, +} = require('./AuthenticationErrors') +const { hasAdminAccess } = require('../Helpers/AdminAuthorizationHelper') +const Modules = require('../../infrastructure/Modules') +const { expressify, promisify } = require('@overleaf/promise-utils') + +function send401WithChallenge(res) { + res.setHeader('WWW-Authenticate', 'OverleafLogin') + res.sendStatus(401) +} + +function checkCredentials(userDetailsMap, user, password) { + const expectedPassword = userDetailsMap.get(user) + const userExists = userDetailsMap.has(user) && expectedPassword // user exists with a non-null password + const isValid = userExists && tsscmp(expectedPassword, password) + if (!isValid) { + logger.err({ user }, 'invalid login details') + } + Metrics.inc('security.http-auth.check-credentials', 1, { + path: userExists ? 'known-user' : 'unknown-user', + status: isValid ? 'pass' : 'fail', + }) + return isValid +} + +function reduceStaffAccess(staffAccess) { + const reducedStaffAccess = {} + for (const field in staffAccess) { + if (staffAccess[field]) { + reducedStaffAccess[field] = true + } + } + return reducedStaffAccess +} + +function userHasStaffAccess(user) { + return user.staffAccess && Object.values(user.staffAccess).includes(true) +} + +// TODO: Finish making these methods async +const AuthenticationController = { + serializeUser(user, callback) { + if (!user._id || !user.email) { + const err = new Error('serializeUser called with non-user object') + logger.warn({ user }, err.message) + return callback(err) + } + const lightUser = { + _id: user._id, + first_name: user.first_name, + last_name: user.last_name, + email: user.email, + referal_id: user.referal_id, + session_created: new Date().toISOString(), + ip_address: user._login_req_ip, + must_reconfirm: user.must_reconfirm, + v1_id: user.overleaf != null ? user.overleaf.id : undefined, + analyticsId: user.analyticsId || user._id, + alphaProgram: user.alphaProgram || undefined, // only store if set + betaProgram: user.betaProgram || undefined, // only store if set + } + if (user.isAdmin) { + lightUser.isAdmin = true + } + if (userHasStaffAccess(user)) { + lightUser.staffAccess = reduceStaffAccess(user.staffAccess) + } + + callback(null, lightUser) + }, + + deserializeUser(user, cb) { + cb(null, user) + }, + + passportLogin(req, res, next) { + // This function is middleware which wraps the passport.authenticate middleware, + // so we can send back our custom `{message: {text: "", type: ""}}` responses on failure, + // and send a `{redir: ""}` response on success + passport.authenticate( + 'local', + { keepSessionInfo: true }, + function (err, user, info) { + if (err) { + return next(err) + } + if (user) { + // `user` is either a user object or false + AuthenticationController.setAuditInfo(req, { + method: 'Password login', + }) + return AuthenticationController.finishLogin(user, req, res, next) + } else { + if (info.redir != null) { + return res.json({ redir: info.redir }) + } else { + res.status(info.status || 200) + delete info.status + const body = { message: info } + const { errorReason } = info + if (errorReason) { + body.errorReason = errorReason + delete info.errorReason + } + return res.json(body) + } + } + } + )(req, res, next) + }, + + async _finishLoginAsync(user, req, res) { + if (user === false) { + return AsyncFormHelper.redirect(req, res, '/login') + } // OAuth2 'state' mismatch + + if (user.suspended) { + return AsyncFormHelper.redirect(req, res, '/account-suspended') + } + + if (Settings.adminOnlyLogin && !hasAdminAccess(user)) { + return res.status(403).json({ + message: { type: 'error', text: 'Admin only panel' }, + }) + } + + const auditInfo = AuthenticationController.getAuditInfo(req) + + const anonymousAnalyticsId = req.session.analyticsId + const isNewUser = req.session.justRegistered || false + + const results = await Modules.promises.hooks.fire( + 'preFinishLogin', + req, + res, + user + ) + + if (results.some(result => result && result.doNotFinish)) { + return + } + + if (user.must_reconfirm) { + return AuthenticationController._redirectToReconfirmPage(req, res, user) + } + + const redir = + AuthenticationController.getRedirectFromSession(req) || '/project' + + _loginAsyncHandlers(req, user, anonymousAnalyticsId, isNewUser) + const userId = user._id + + await UserAuditLogHandler.promises.addEntry( + userId, + 'login', + userId, + req.ip, + auditInfo + ) + + await _afterLoginSessionSetupAsync(req, user) + + AuthenticationController._clearRedirectFromSession(req) + AnalyticsRegistrationSourceHelper.clearSource(req.session) + AnalyticsRegistrationSourceHelper.clearInbound(req.session) + AsyncFormHelper.redirect(req, res, redir) + }, + + finishLogin(user, req, res, next) { + AuthenticationController._finishLoginAsync(user, req, res).catch(err => + next(err) + ) + }, + + doPassportLogin(req, username, password, done) { + const email = username.toLowerCase() + Modules.hooks.fire( + 'preDoPassportLogin', + req, + email, + function (err, infoList) { + if (err) { + return done(err) + } + const info = infoList.find(i => i != null) + if (info != null) { + return done(null, false, info) + } + LoginRateLimiter.processLoginRequest(email, function (err, isAllowed) { + if (err) { + return done(err) + } + if (!isAllowed) { + logger.debug({ email }, 'too many login requests') + return done(null, null, { + text: req.i18n.translate('to_many_login_requests_2_mins'), + type: 'error', + key: 'to-many-login-requests-2-mins', + status: 429, + }) + } + const { fromKnownDevice } = AuthenticationController.getAuditInfo(req) + const auditLog = { + ipAddress: req.ip, + info: { method: 'Password login', fromKnownDevice }, + } + AuthenticationManager.authenticate( + { email }, + password, + auditLog, + { + enforceHIBPCheck: !fromKnownDevice, + }, + function (error, user, isPasswordReused) { + if (error != null) { + if (error instanceof ParallelLoginError) { + return done(null, false, { status: 429 }) + } else if (error instanceof PasswordReusedError) { + const text = `${req.i18n + .translate( + 'password_compromised_try_again_or_use_known_device_or_reset' + ) + .replace('<0>', '') + .replace('', ' (https://haveibeenpwned.com/passwords)') + .replace('<1>', '') + .replace( + '', + ` (${Settings.siteUrl}/user/password/reset)` + )}.` + return done(null, false, { + status: 400, + type: 'error', + key: 'password-compromised', + text, + }) + } + return done(error) + } + if ( + user && + AuthenticationController.captchaRequiredForLogin(req, user) + ) { + done(null, false, { + text: req.i18n.translate('cannot_verify_user_not_robot'), + type: 'error', + errorReason: 'cannot_verify_user_not_robot', + status: 400, + }) + } else if (user) { + if ( + isPasswordReused && + AuthenticationController.getRedirectFromSession(req) == null + ) { + AuthenticationController.setRedirectInSession( + req, + '/compromised-password' + ) + } + + // async actions + done(null, user) + } else { + AuthenticationController._recordFailedLogin() + logger.debug({ email }, 'failed log in') + done(null, false, { + type: 'error', + key: 'invalid-password-retry-or-reset', + status: 401, + }) + } + } + ) + }) + } + ) + }, + + captchaRequiredForLogin(req, user) { + switch (AuthenticationController.getAuditInfo(req).captcha) { + case 'trusted': + case 'disabled': + return false + case 'solved': + return false + case 'skipped': { + let required = false + if (user.lastFailedLogin) { + const requireCaptchaUntil = + user.lastFailedLogin.getTime() + + Settings.elevateAccountSecurityAfterFailedLogin + required = requireCaptchaUntil >= Date.now() + } + Metrics.inc('force_captcha_on_login', 1, { + status: required ? 'yes' : 'no', + }) + return required + } + default: + throw new Error('captcha middleware missing in handler chain') + } + }, + + +// >>>>>>>>>>>>>>>>>>>>>>>>>>>>>> + oauth2Redirect(req, res, next) { + // random state + const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789' + const state = new Array(6).fill(0).map(() => characters.charAt(Math.floor(Math.random() * characters.length))).join("") + req.session.oauth2State = state + + const redirectURI = encodeURIComponent(`${process.env.OVERLEAF_SITE_URL}/oauth/callback`) + const authURL = ( + process.env.OAUTH2_AUTHORIZATION_URL + + `?response_type=code` + + `&client_id=${process.env.OAUTH2_CLIENT_ID}` + + `&redirect_uri=${redirectURI}` + + `&scope=${process.env.OAUTH2_SCOPE ?? ""} ` + + `&state=${state}` + ) + res.redirect(authURL) + }, + + async oauth2Callback(req, res, next) { + console.log(`OAuth, receive code ${req.query.code} and state ${req.query.state}`) + const saveState = req.session.oauth2State + delete req.session.oauth2State + if (saveState !== req.query.state) { + return AuthenticationController.finishLogin(false, req, res, next) + } + + try { + const contentType = process.env.OAUTH2_TOKEN_CONTENT_TYPE || 'application/x-www-form-urlencoded' + const bodyParams = { + grant_type: "authorization_code", + client_id: process.env.OAUTH2_CLIENT_ID, + client_secret: process.env.OAUTH2_CLIENT_SECRET, + code: req.query.code, + redirect_uri: `${process.env.OVERLEAF_SITE_URL}/oauth/callback`, + } + const body = contentType === 'application/json' + ? JSON.stringify(bodyParams) + : new URLSearchParams(bodyParams).toString() + + const tokenResponse = await fetch(process.env.OAUTH2_TOKEN_URL, { + method: 'POST', + headers: { + "Accept": "application/json", + "Content-Type": contentType, + }, + body + }) + + const tokenData = await tokenResponse.json() + console.log("OAuth2 respond", JSON.stringify(tokenData)) + + const profileResponse = await fetch(process.env.OAUTH2_PROFILE_URL, { + method: 'GET', + headers: { + "Accept": "application/json", + "Authorization": `Bearer ${tokenData.access_token}`, + } + }); + const profile = await profileResponse.json() + console.log("OAuth2 user profile", JSON.stringify(profile)) + + const email = profile[process.env.OAUTH2_USER_ATTR_EMAIL ?? "email"] + const uid = profile[process.env.OAUTH2_USER_ATTR_UID ?? "uid"] + const firstname = profile?.[process.env.OAUTH2_USER_ATTR_FIRSTNAME] ?? email + const lastname = process.env.OAUTH2_USER_ATTR_LASTNAME + ? profile?.[process.env.OAUTH2_USER_ATTR_LASTNAME] ?? "" + : "" + const isAdmin = process.env.OAUTH2_USER_ATTR_IS_ADMIN + ? !!profile?.[process.env.OAUTH2_USER_ATTR_IS_ADMIN] ?? false + : false + + const query = { email } + + const { user } = await AuthenticationManager.createIfNotFoundAndLogin( + query, + uid, + firstname, + lastname, + email, + isAdmin + ) + AuthenticationController.finishLogin(user, req, res, next); + } catch(e) { + res.redirect("/login") + console.error("Fails to access by OAuth2: " + String(e)) + console.error(e); + } + }, +// <<<<<<<<<<<<<<<<<<<<<<<<<<<<<< + ipMatchCheck(req, user) { + if (req.ip !== user.lastLoginIp) { + NotificationsBuilder.ipMatcherAffiliation(user._id).create( + req.ip, + () => {} + ) + } + return UserUpdater.updateUser( + user._id.toString(), + { + $set: { lastLoginIp: req.ip }, + }, + () => {} + ) + }, + + requireLogin() { + const doRequest = function (req, res, next) { + if (next == null) { + next = function () {} + } + if (!SessionManager.isUserLoggedIn(req.session)) { + if (acceptsJson(req)) return send401WithChallenge(res) + return AuthenticationController._redirectToLoginOrRegisterPage(req, res) + } else { + req.user = SessionManager.getSessionUser(req.session) + return next() + } + } + + return doRequest + }, + + /** + * @param {string} scope + * @return {import('express').Handler} + */ + requireOauth(scope) { + if (typeof scope !== 'string' || !scope) { + throw new Error( + "requireOauth() expects a non-empty string as 'scope' parameter" + ) + } + + // require this here because module may not be included in some versions + const Oauth2Server = require('../../../../modules/oauth2-server/app/src/Oauth2Server') + const middleware = async (req, res, next) => { + const request = new Oauth2Server.Request(req) + const response = new Oauth2Server.Response(res) + try { + const token = await Oauth2Server.server.authenticate( + request, + response, + { scope } + ) + req.oauth = { access_token: token.accessToken } + req.oauth_token = token + req.oauth_user = token.user + next() + } catch (err) { + if ( + err.code === 400 && + err.message === 'Invalid request: malformed authorization header' + ) { + err.code = 401 + } + // send all other errors + res + .status(err.code) + .json({ error: err.name, error_description: err.message }) + } + } + return expressify(middleware) + }, + + _globalLoginWhitelist: [], + addEndpointToLoginWhitelist(endpoint) { + return AuthenticationController._globalLoginWhitelist.push(endpoint) + }, + + requireGlobalLogin(req, res, next) { + if ( + AuthenticationController._globalLoginWhitelist.includes( + req._parsedUrl.pathname + ) + ) { + return next() + } + + if (req.headers.authorization != null) { + AuthenticationController.requirePrivateApiAuth()(req, res, next) + } else if (SessionManager.isUserLoggedIn(req.session)) { + next() + } else { + logger.debug( + { url: req.url }, + 'user trying to access endpoint not in global whitelist' + ) + if (acceptsJson(req)) return send401WithChallenge(res) + AuthenticationController.setRedirectInSession(req) + res.redirect('/login') + } + }, + + validateAdmin(req, res, next) { + const adminDomains = Settings.adminDomains + if ( + !adminDomains || + !(Array.isArray(adminDomains) && adminDomains.length) + ) { + return next() + } + const user = SessionManager.getSessionUser(req.session) + if (!hasAdminAccess(user)) { + return next() + } + const email = user.email + if (email == null) { + return next( + new OError('[ValidateAdmin] Admin user without email address', { + userId: user._id, + }) + ) + } + if (!adminDomains.find(domain => email.endsWith(`@${domain}`))) { + return next( + new OError('[ValidateAdmin] Admin user with invalid email domain', { + email, + userId: user._id, + }) + ) + } + return next() + }, + + checkCredentials, + + requireBasicAuth: function (userDetails) { + const userDetailsMap = new Map(Object.entries(userDetails)) + return function (req, res, next) { + const credentials = basicAuth(req) + if ( + !credentials || + !checkCredentials(userDetailsMap, credentials.name, credentials.pass) + ) { + send401WithChallenge(res) + Metrics.inc('security.http-auth', 1, { status: 'reject' }) + } else { + Metrics.inc('security.http-auth', 1, { status: 'accept' }) + next() + } + } + }, + + requirePrivateApiAuth() { + return AuthenticationController.requireBasicAuth(Settings.httpAuthUsers) + }, + + setAuditInfo(req, info) { + if (!req.__authAuditInfo) { + req.__authAuditInfo = {} + } + Object.assign(req.__authAuditInfo, info) + }, + + getAuditInfo(req) { + return req.__authAuditInfo || {} + }, + + setRedirectInSession(req, value) { + if (value == null) { + value = + Object.keys(req.query).length > 0 + ? `${req.path}?${querystring.stringify(req.query)}` + : `${req.path}` + } + if ( + req.session != null && + !/^\/(socket.io|js|stylesheets|img)\/.*$/.test(value) && + !/^.*\.(png|jpeg|svg)$/.test(value) + ) { + const safePath = UrlHelper.getSafeRedirectPath(value) + return (req.session.postLoginRedirect = safePath) + } + }, + + _redirectToLoginOrRegisterPage(req, res) { + if ( + req.query.zipUrl != null || + req.session.sharedProjectData || + req.path === '/user/subscription/new' + ) { + AuthenticationController._redirectToRegisterPage(req, res) + } else { + AuthenticationController._redirectToLoginPage(req, res) + } + }, + + _redirectToLoginPage(req, res) { + logger.debug( + { url: req.url }, + 'user not logged in so redirecting to login page' + ) + AuthenticationController.setRedirectInSession(req) + const url = `/login?${querystring.stringify(req.query)}` + res.redirect(url) + Metrics.inc('security.login-redirect') + }, + + _redirectToReconfirmPage(req, res, user) { + logger.debug( + { url: req.url }, + 'user needs to reconfirm so redirecting to reconfirm page' + ) + req.session.reconfirm_email = user != null ? user.email : undefined + const redir = '/user/reconfirm' + AsyncFormHelper.redirect(req, res, redir) + }, + + _redirectToRegisterPage(req, res) { + logger.debug( + { url: req.url }, + 'user not logged in so redirecting to register page' + ) + AuthenticationController.setRedirectInSession(req) + const url = `/register?${querystring.stringify(req.query)}` + res.redirect(url) + Metrics.inc('security.login-redirect') + }, + + _recordSuccessfulLogin(userId, callback) { + if (callback == null) { + callback = function () {} + } + UserUpdater.updateUser( + userId.toString(), + { + $set: { lastLoggedIn: new Date() }, + $inc: { loginCount: 1 }, + }, + function (error) { + if (error != null) { + callback(error) + } + Metrics.inc('user.login.success') + callback() + } + ) + }, + + _recordFailedLogin(callback) { + Metrics.inc('user.login.failed') + if (callback) callback() + }, + + getRedirectFromSession(req) { + let safePath + const value = _.get(req, ['session', 'postLoginRedirect']) + if (value) { + safePath = UrlHelper.getSafeRedirectPath(value) + } + return safePath || null + }, + + _clearRedirectFromSession(req) { + if (req.session != null) { + delete req.session.postLoginRedirect + } + }, +} + +function _afterLoginSessionSetup(req, user, callback) { + req.login(user, { keepSessionInfo: true }, function (err) { + if (err) { + OError.tag(err, 'error from req.login', { + user_id: user._id, + }) + return callback(err) + } + delete req.session.__tmp + delete req.session.csrfSecret + req.session.save(function (err) { + if (err) { + OError.tag(err, 'error saving regenerated session after login', { + user_id: user._id, + }) + return callback(err) + } + UserSessionsManager.trackSession(user, req.sessionID, function () {}) + if (!req.deviceHistory) { + // Captcha disabled or SSO-based login. + return callback() + } + req.deviceHistory.add(user.email) + req.deviceHistory + .serialize(req.res) + .catch(err => { + logger.err({ err }, 'cannot serialize deviceHistory') + }) + .finally(() => callback()) + }) + }) +} + +const _afterLoginSessionSetupAsync = promisify(_afterLoginSessionSetup) + +function _loginAsyncHandlers(req, user, anonymousAnalyticsId, isNewUser) { + UserHandler.setupLoginData(user, err => { + if (err != null) { + logger.warn({ err }, 'error setting up login data') + } + }) + LoginRateLimiter.recordSuccessfulLogin(user.email, () => {}) + AuthenticationController._recordSuccessfulLogin(user._id, () => {}) + AuthenticationController.ipMatchCheck(req, user) + Analytics.recordEventForUserInBackground(user._id, 'user-logged-in', { + source: req.session.saml + ? 'saml' + : req.user_info?.auth_provider || 'email-password', + }) + Analytics.identifyUser(user._id, anonymousAnalyticsId, isNewUser) + + logger.debug( + { email: user.email, userId: user._id.toString() }, + 'successful log in' + ) + + req.session.justLoggedIn = true + // capture the request ip for use when creating the session + return (user._login_req_ip = req.ip) +} + +AuthenticationController.promises = { + finishLogin: AuthenticationController._finishLoginAsync, +} + +module.exports = AuthenticationController diff --git a/ldap-overleaf-sl/sharelatex/AuthenticationManager.js b/ldap-overleaf-sl/sharelatex/AuthenticationManager.js new file mode 100644 index 0000000..3011a9f --- /dev/null +++ b/ldap-overleaf-sl/sharelatex/AuthenticationManager.js @@ -0,0 +1,748 @@ +const Settings = require('@overleaf/settings') +const { User } = require('../../models/User') +const { db, ObjectId } = require('../../infrastructure/mongodb') +const bcrypt = require('bcrypt') +const EmailHelper = require('../Helpers/EmailHelper') +const { + InvalidEmailError, + InvalidPasswordError, + ParallelLoginError, + PasswordMustBeDifferentError, + PasswordReusedError, +} = require('./AuthenticationErrors') +const { + callbackify, + callbackifyMultiResult, +} = require('@overleaf/promise-utils') +const HaveIBeenPwned = require('./HaveIBeenPwned') +const UserAuditLogHandler = require('../User/UserAuditLogHandler') +const logger = require('@overleaf/logger') +const DiffHelper = require('../Helpers/DiffHelper') +const Metrics = require('@overleaf/metrics') + +// >>>>>>>>>>>>>>>>>>>>>>>>>>>>>> +// TODO(ldap) +// const fs = require("fs") +// const { Client } = require("ldapts") +// const ldapEscape = require("ldap-escape") +// <<<<<<<<<<<<<<<<<<<<<<<<<<<<<< + +const BCRYPT_ROUNDS = Settings.security.bcryptRounds || 12 +const BCRYPT_MINOR_VERSION = Settings.security.bcryptMinorVersion || 'a' +const MAX_SIMILARITY = 0.7 + +function _exceedsMaximumLengthRatio(password, maxSimilarity, value) { + const passwordLength = password.length + const lengthBoundSimilarity = (maxSimilarity / 2) * passwordLength + const valueLength = value.length + return ( + passwordLength >= 10 * valueLength && valueLength < lengthBoundSimilarity + ) +} + +const _checkWriteResult = function (result) { + // for MongoDB + return !!(result && result.modifiedCount === 1) +} + +function _validatePasswordNotTooLong(password) { + // bcrypt has a hard limit of 72 characters. + if (password.length > 72) { + return new InvalidPasswordError({ + message: 'password is too long', + info: { code: 'too_long' }, + }) + } + return null +} + +function _metricsForSuccessfulPasswordMatch(password) { + const validationResult = AuthenticationManager.validatePassword(password) + const status = + validationResult === null ? 'success' : validationResult?.info?.code + Metrics.inc('check-password', { status }) + return null +} + +const AuthenticationManager = { + async _checkUserPassword(query, password) { + // Using Mongoose for legacy reasons here. The returned User instance + // gets serialized into the session and there may be subtle differences + // between the user returned by Mongoose vs mongodb (such as default values) + const user = await User.findOne(query).exec() + + if (!user || !user.hashedPassword) { + return { user: null, match: null } + } + + let rounds = 0 + try { + rounds = bcrypt.getRounds(user.hashedPassword) + } catch (err) { + let prefix, suffix, length + if (typeof user.hashedPassword === 'string') { + length = user.hashedPassword.length + if (user.hashedPassword.length > 50) { + // A full bcrypt hash is 60 characters long. + prefix = user.hashedPassword.slice(0, '$2a$12$x'.length) + suffix = user.hashedPassword.slice(-4) + } else if (user.hashedPassword.length > 20) { + prefix = user.hashedPassword.slice(0, 4) + suffix = user.hashedPassword.slice(-4) + } else { + prefix = user.hashedPassword.slice(0, 4) + } + } + logger.warn( + { + err, + userId: user._id, + hashedPassword: { + type: typeof user.hashedPassword, + length, + prefix, + suffix, + }, + }, + 'unexpected user.hashedPassword value' + ) + } + Metrics.inc('bcrypt', 1, { + method: 'compare', + path: rounds, + }) + + const match = await bcrypt.compare(password, user.hashedPassword) + + if (match) { + _metricsForSuccessfulPasswordMatch(password) + } + + return { user, match } + }, + + // >>>>>>>>>>>>>>>>>>>>>>>>>>>>>> + async _checkUserPassword2(query, password) { + // leave original _checkUserPassword untouched, because it will be called by + // setUserPasswordInV2 (e.g. UserRegistrationHandler.js ) + const user = await User.findOne(query).exec() + + return await AuthenticationManager.authUserObj(user, query, password) + }, + // <<<<<<<<<<<<<<<<<<<<<<<<<<<<<< + + async authenticate(query, password, auditLog, { enforceHIBPCheck = true }) { + // >>>>>>>>>>>>>>>>>>>>>>>>>>>>>> + const { user, match } = await AuthenticationManager._checkUserPassword2( + // <<<<<<<<<<<<<<<<<<<<<<<<<<<<<< + query, + password + ) + + if (!user) { + return { user: null } + } + + const update = { $inc: { loginEpoch: 1 } } + if (!match) { + update.$set = { lastFailedLogin: new Date() } + } + + const result = await User.updateOne( + { _id: user._id, loginEpoch: user.loginEpoch }, + update, + {} + ).exec() + + if (result.modifiedCount !== 1) { + throw new ParallelLoginError() + } + + if (!match) { + if (!auditLog) { + return { user: null } + } else { + try { + await UserAuditLogHandler.promises.addEntry( + user._id, + 'failed-password-match', + user._id, + auditLog.ipAddress, + auditLog.info + ) + } catch (err) { + logger.error( + { userId: user._id, err, info: auditLog.info }, + 'Error while adding AuditLog entry for failed-password-match' + ) + } + return { user: null } + } + } + await AuthenticationManager.checkRounds(user, user.hashedPassword, password) + + let isPasswordReused + try { + isPasswordReused = + await HaveIBeenPwned.promises.checkPasswordForReuse(password) + } catch (err) { + logger.err({ err }, 'cannot check password for re-use') + } + + if (isPasswordReused && enforceHIBPCheck) { + throw new PasswordReusedError() + } + + return { user, isPasswordReused } + }, + + + // >>>>>>>>>>>>>>>>>>>>>>>>>>>>>> + + async createIfNotFoundAndLogin( + query, + uid, + firstname, + lastname, + mail, + isAdmin + ) { + const user = await User.findOne(query).exec() + + return await AuthenticationManager.createIfNotExistAndLogin( + query, + user, + uid, + firstname, + lastname, + mail, + isAdmin + ) + }, + + async createIfNotExistAndLogin( + query, + user, + uid, + firstname, + lastname, + mail, + isAdmin + ) { + if (!user) { + //create random pass for local userdb, does not get checked for ldap users during login + const pass = require("crypto").randomBytes(32).toString("hex") + console.log('Creating User', { mail, uid, firstname, lastname, isAdmin, pass }) + + const userRegHand = require("../User/UserRegistrationHandler.js") + const createdUser = await userRegHand.promises.registerNewUser( + { + email: mail, + first_name: firstname, + last_name: lastname, + password: pass, + } + ) // end register user + return { user: createdUser, match: true } + } else { + console.log('User exists', { mail }) + return { user: user, match: true } + } + }, + + async authUserObj(user, query, password) { + if (process.env.ALLOW_EMAIL_LOGIN && user && user.hashedPassword) { + console.log("email login for existing user " + query.email) + // check passwd against local db + const match = await bcrypt.compare(password, user.hashedPassword) + if (match) { + console.log("Local user password match") + _metricsForSuccessfulPasswordMatch(password) + return { user, match } + } else { + console.log("Local user password mismatch, trying LDAP") + // check passwd against ldap + // TODO(ldap) + return { user: null, match: null } + // return AuthenticationManager.ldapAuth( + // query, + // password, + // AuthenticationManager.createIfNotExistAndLogin, + // user + // ) + } + + } else { + // No local passwd check user has to be in ldap and use ldap credentials + // TODO(ldap) + return { user: null, match: null } + // return AuthenticationManager.ldapAuth( + // query, + // password, + // AuthenticationManager.createIfNotExistAndLogin, + // user + // ) + } + }, + + // TODO(ldap) + // async ldapAuth( + // query, + // password, + // onSuccessCreateUserIfNotExistent, + // user + // ) { + // const client = fs.existsSync(process.env.LDAP_SERVER_CACERT) + // ? new Client({ + // url: process.env.LDAP_SERVER, + // tlsOptions: { + // ca: [fs.readFileSync(process.env.LDAP_SERVER_CACERT)], + // }, + // }) + // : new Client({ + // url: process.env.LDAP_SERVER, + // }) + + // const ldap_reader = process.env.LDAP_BIND_USER + // const ldap_reader_pass = process.env.LDAP_BIND_PW + // const ldap_base = process.env.LDAP_BASE + + // var mail = query.email + // var uid = query.email.split("@")[0] + // var firstname = "" + // var lastname = "" + // var isAdmin = false + // var userDn = "" + + // //replace all appearences of %u with uid and all %m with mail: + // const replacerUid = new RegExp("%u", "g") + // const replacerMail = new RegExp("%m", "g") + // const filterstr = process.env.LDAP_USER_FILTER.replace( + // replacerUid, + // ldapEscape.filter`${uid}` + // ).replace(replacerMail, ldapEscape.filter`${mail}`) //replace all appearances + // // check bind + // try { + // if (process.env.LDAP_BINDDN) { + // //try to bind directly with the user trying to log in + // userDn = process.env.LDAP_BINDDN.replace( + // replacerUid, + // ldapEscape.filter`${uid}` + // ).replace(replacerMail, ldapEscape.filter`${mail}`) + // await client.bind(userDn, password) + // } else { + // // use fixed bind user + // await client.bind(ldap_reader, ldap_reader_pass) + // } + // } catch (ex) { + // if (process.env.LDAP_BINDDN) { + // console.log("Could not bind user: " + userDn) + // } else { + // console.log( + // "Could not bind LDAP reader: " + ldap_reader + " err: " + String(ex) + // ) + // } + // return { user: null, match: null } + // } + + // // get user data + // try { + // const { searchEntries, searchRef } = await client.search(ldap_base, { + // scope: "sub", + // filter: filterstr, + // }) + // await searchEntries + // console.log(JSON.stringify(searchEntries)) + // if (searchEntries[0]) { + // mail = searchEntries[0].mail + // uid = searchEntries[0].uid + // firstname = searchEntries[0].givenName + // lastname = searchEntries[0].sn + // if (!process.env.LDAP_BINDDN) { + // //dn is already correctly assembled + // userDn = searchEntries[0].dn + // } + // console.log( + // `Found user: ${mail} Name: ${firstname} ${lastname} DN: ${userDn}` + // ) + // } + // } catch (ex) { + // console.log( + // "An Error occured while getting user data during ldapsearch: " + + // String(ex) + // ) + // await client.unbind() + // return { user: null, match: null } + // } + + // try { + // // if admin filter is set - only set admin for user in ldap group + // // does not matter - admin is deactivated: managed through ldap + // if (process.env.LDAP_ADMIN_GROUP_FILTER) { + // const adminfilter = process.env.LDAP_ADMIN_GROUP_FILTER.replace( + // replacerUid, + // ldapEscape.filter`${uid}` + // ).replace(replacerMail, ldapEscape.filter`${mail}`) + // let adminEntry = await client.search(ldap_base, { + // scope: "sub", + // filter: adminfilter, + // }) + // await adminEntry + // //console.log('Admin Search response:' + JSON.stringify(adminEntry.searchEntries)) + // if (adminEntry.searchEntries[0]) { + // console.log("is Admin") + // isAdmin = true + // } + // } + // } catch (ex) { + // console.log( + // "An Error occured while checking for admin rights - setting admin rights to false: " + + // String(ex) + // ) + // isAdmin = false + // } finally { + // await client.unbind() + // } + // if (mail == "" || userDn == "") { + // console.log( + // "Mail / userDn not set - exit. This should not happen - please set mail-entry in ldap." + // ) + // return { user: null, match: null } + // } + + // if (!process.env.BINDDN) { + // //since we used a fixed bind user to obtain the correct userDn we need to bind again to authenticate + // try { + // await client.bind(userDn, password) + // } catch (ex) { + // console.log("Could not bind User: " + userDn + " err: " + String(ex)) + // return { user: null, match: null } + // } finally { + // await client.unbind() + // } + // } + // //console.log('Logging in user: ' + mail + ' Name: ' + firstname + ' ' + lastname + ' isAdmin: ' + String(isAdmin)) + // // we are authenticated now let's set the query to the correct mail from ldap + // query.email = mail + // const createdUser = await User.findOne(query).exec() + + // if (createdUser && createdUser.hashedPassword) { + // //console.log('******************** LOGIN ******************') + // return { user: createdUser, match: true } + // } else { + // return await onSuccessCreateUserIfNotExistent( + // query, + // createdUser, + // uid, + // firstname, + // lastname, + // mail, + // isAdmin + // ) + // } + + // }, + // <<<<<<<<<<<<<<<<<<<<<<<<<<<<<< + validateEmail(email) { + const parsed = EmailHelper.parseEmail(email) + if (!parsed) { + return new InvalidEmailError({ message: 'email not valid' }) + } + return null + }, + + // validates a password based on a similar set of rules previously used by `passfield.js` on the frontend + // note that `passfield.js` enforced more rules than this, but these are the most commonly set. + // returns null on success, or an error object. + validatePassword(password, email) { + if (password == null) { + return new InvalidPasswordError({ + message: 'password not set', + info: { code: 'not_set' }, + }) + } + + Metrics.inc('try-validate-password') + + let allowAnyChars, min, max + if (Settings.passwordStrengthOptions) { + allowAnyChars = Settings.passwordStrengthOptions.allowAnyChars === true + if (Settings.passwordStrengthOptions.length) { + min = Settings.passwordStrengthOptions.length.min + max = Settings.passwordStrengthOptions.length.max + } + } + allowAnyChars = !!allowAnyChars + min = min || 8 + max = max || 72 + + // we don't support passwords > 72 characters in length, because bcrypt truncates them + if (max > 72) { + max = 72 + } + + if (password.length < min) { + return new InvalidPasswordError({ + message: 'password is too short', + info: { code: 'too_short' }, + }) + } + if (password.length > max) { + return new InvalidPasswordError({ + message: 'password is too long', + info: { code: 'too_long' }, + }) + } + const passwordLengthError = _validatePasswordNotTooLong(password) + if (passwordLengthError) { + return passwordLengthError + } + if ( + !allowAnyChars && + !AuthenticationManager._passwordCharactersAreValid(password) + ) { + return new InvalidPasswordError({ + message: 'password contains an invalid character', + info: { code: 'invalid_character' }, + }) + } + if (typeof email === 'string' && email !== '') { + const startOfEmail = email.split('@')[0] + if ( + password.includes(email) || + password.includes(startOfEmail) || + email.includes(password) + ) { + return new InvalidPasswordError({ + message: 'password contains part of email address', + info: { code: 'contains_email' }, + }) + } + try { + const passwordTooSimilarError = + AuthenticationManager._validatePasswordNotTooSimilar(password, email) + if (passwordTooSimilarError) { + Metrics.inc('password-too-similar-to-email') + return new InvalidPasswordError({ + message: 'password is too similar to email address', + info: { code: 'too_similar' }, + }) + } + } catch (error) { + logger.error( + { error }, + 'error while checking password similarity to email' + ) + } + // TODO: remove this check once the password-too-similar checks are active? + } + return null + }, + + async setUserPassword(user, password) { + return await AuthenticationManager.setUserPasswordInV2(user, password) + }, + + async checkRounds(user, hashedPassword, password) { + // Temporarily disable this function, TODO: re-enable this + if (Settings.security.disableBcryptRoundsUpgrades) { + Metrics.inc('bcrypt_check_rounds', 1, { status: 'disabled' }) + return + } + // check current number of rounds and rehash if necessary + const currentRounds = bcrypt.getRounds(hashedPassword) + if (currentRounds < BCRYPT_ROUNDS) { + Metrics.inc('bcrypt_check_rounds', 1, { status: 'upgrade' }) + return await AuthenticationManager._setUserPasswordInMongo(user, password) + } else { + Metrics.inc('bcrypt_check_rounds', 1, { status: 'success' }) + } + }, + + async hashPassword(password) { + // Double-check the size to avoid truncating in bcrypt. + const error = _validatePasswordNotTooLong(password) + if (error) { + throw error + } + + const salt = await bcrypt.genSalt(BCRYPT_ROUNDS, BCRYPT_MINOR_VERSION) + + Metrics.inc('bcrypt', 1, { + method: 'hash', + path: BCRYPT_ROUNDS, + }) + return await bcrypt.hash(password, salt) + }, + + async setUserPasswordInV2(user, password) { + if (!user || !user.email || !user._id) { + throw new Error('invalid user object') + } + const validationError = this.validatePassword(password, user.email) + if (validationError) { + throw validationError + } + // check if we can log in with this password. In which case we should reject it, + // because it is the same as the existing password. + const { match } = await AuthenticationManager._checkUserPassword( + { _id: user._id }, + password + ) + + if (match) { + throw new PasswordMustBeDifferentError() + } + + let isPasswordReused + try { + isPasswordReused = + await HaveIBeenPwned.promises.checkPasswordForReuse(password) + } catch (error) { + logger.err({ error }, 'cannot check password for re-use') + } + + if (isPasswordReused) { + throw new PasswordReusedError() + } + + // password is strong enough or the validation with the service did not happen + return await this._setUserPasswordInMongo(user, password) + }, + + async _setUserPasswordInMongo(user, password) { + const hash = await this.hashPassword(password) + const result = await db.users.updateOne( + { _id: new ObjectId(user._id.toString()) }, + { + $set: { + hashedPassword: hash, + }, + $unset: { + password: true, + }, + } + ) + + return _checkWriteResult(result) + }, + + _passwordCharactersAreValid(password) { + let digits, letters, lettersUp, symbols + if ( + Settings.passwordStrengthOptions && + Settings.passwordStrengthOptions.chars + ) { + digits = Settings.passwordStrengthOptions.chars.digits + letters = Settings.passwordStrengthOptions.chars.letters + lettersUp = Settings.passwordStrengthOptions.chars.letters_up + symbols = Settings.passwordStrengthOptions.chars.symbols + } + digits = digits || '1234567890' + letters = letters || 'abcdefghijklmnopqrstuvwxyz' + lettersUp = lettersUp || 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' + symbols = symbols || '@#$%^&*()-_=+[]{};:<>/?!£€.,' + + for (let charIndex = 0; charIndex <= password.length - 1; charIndex++) { + if ( + digits.indexOf(password[charIndex]) === -1 && + letters.indexOf(password[charIndex]) === -1 && + lettersUp.indexOf(password[charIndex]) === -1 && + symbols.indexOf(password[charIndex]) === -1 + ) { + return false + } + } + return true + }, + + /** + * Check if the password is similar to (parts of) the email address. + * For now, this merely sends a metric when the password and + * email address are deemed to be too similar to each other. + * Later we will reject passwords that fail this check. + * + * This logic was borrowed from the django project: + * https://github.com/django/django/blob/fa3afc5d86f1f040922cca2029d6a34301597a70/django/contrib/auth/password_validation.py#L159-L214 + */ + _validatePasswordNotTooSimilar(password, email) { + password = password.toLowerCase() + email = email.toLowerCase() + const stringsToCheck = [email] + .concat(email.split(/\W+/)) + .concat(email.split(/@/)) + for (const emailPart of stringsToCheck) { + if (!_exceedsMaximumLengthRatio(password, MAX_SIMILARITY, emailPart)) { + const similarity = DiffHelper.stringSimilarity(password, emailPart) + if (similarity > MAX_SIMILARITY) { + logger.warn( + { email, emailPart, similarity, maxSimilarity: MAX_SIMILARITY }, + 'Password too similar to email' + ) + return new Error('password is too similar to email') + } + } + } + }, + + getMessageForInvalidPasswordError(error, req) { + const errorCode = error?.info?.code + const message = { + type: 'error', + } + switch (errorCode) { + case 'not_set': + message.key = 'password-not-set' + message.text = req.i18n.translate('invalid_password_not_set') + break + case 'invalid_character': + message.key = 'password-invalid-character' + message.text = req.i18n.translate('invalid_password_invalid_character') + break + case 'contains_email': + message.key = 'password-contains-email' + message.text = req.i18n.translate('invalid_password_contains_email') + break + case 'too_similar': + message.key = 'password-too-similar' + message.text = req.i18n.translate('invalid_password_too_similar') + break + case 'too_short': + message.key = 'password-too-short' + message.text = req.i18n.translate('invalid_password_too_short', { + minLength: Settings.passwordStrengthOptions?.length?.min || 8, + }) + break + case 'too_long': + message.key = 'password-too-long' + message.text = req.i18n.translate('invalid_password_too_long', { + maxLength: Settings.passwordStrengthOptions?.length?.max || 72, + }) + break + default: + logger.error({ err: error }, 'Unknown password validation error code') + message.text = req.i18n.translate('invalid_password') + break + } + return message + }, +} + +module.exports = { + _validatePasswordNotTooSimilar: + AuthenticationManager._validatePasswordNotTooSimilar, // Private function exported for tests + validateEmail: AuthenticationManager.validateEmail, + validatePassword: AuthenticationManager.validatePassword, + getMessageForInvalidPasswordError: + AuthenticationManager.getMessageForInvalidPasswordError, + authenticate: callbackifyMultiResult(AuthenticationManager.authenticate, [ + 'user', + 'isPasswordReused', + ]), + createIfNotFoundAndLogin: AuthenticationManager.createIfNotFoundAndLogin, + setUserPassword: callbackify(AuthenticationManager.setUserPassword), + checkRounds: callbackify(AuthenticationManager.checkRounds), + hashPassword: callbackify(AuthenticationManager.hashPassword), + setUserPasswordInV2: callbackify(AuthenticationManager.setUserPasswordInV2), + promises: AuthenticationManager, +} diff --git a/ldap-overleaf-sl/sharelatex/ContactController.js b/ldap-overleaf-sl/sharelatex/ContactController.js new file mode 100644 index 0000000..5ff578b --- /dev/null +++ b/ldap-overleaf-sl/sharelatex/ContactController.js @@ -0,0 +1,127 @@ +const SessionManager = require('../Authentication/SessionManager') +const ContactManager = require('./ContactManager') +const UserGetter = require('../User/UserGetter') +const Modules = require('../../infrastructure/Modules') +const { expressify } = require('@overleaf/promise-utils') + +// >>>>>>>>>>>>>>>>>>>>>>>>>>>>>> +// TODO(ldap) +//const { Client } = require('ldapts') +// <<<<<<<<<<<<<<<<<<<<<<<<<<<<<< + +function _formatContact(contact) { + return { + id: contact._id?.toString(), + email: contact.email || '', + first_name: contact.first_name || '', + last_name: contact.last_name || '', + type: 'user', + } +} + +async function getContacts(req, res) { + const userId = SessionManager.getLoggedInUserId(req.session) + + const contactIds = await ContactManager.promises.getContactIds(userId, { + limit: 50, + }) + + let contacts = await UserGetter.promises.getUsers(contactIds, { + email: 1, + first_name: 1, + last_name: 1, + holdingAccount: 1, + }) + + // UserGetter.getUsers may not preserve order so put them back in order + const positions = {} + for (let i = 0; i < contactIds.length; i++) { + const contactId = contactIds[i] + positions[contactId] = i + } + contacts.sort( + (a, b) => positions[a._id?.toString()] - positions[b._id?.toString()] + ) + + // Don't count holding accounts to discourage users from repeating mistakes (mistyped or wrong emails, etc) + contacts = contacts.filter(c => !c.holdingAccount) + +// >>>>>>>>>>>>>>>>>>>>>>>>>>>>>> + // TODO(ldap) + //const ldapcontacts = getLdapContacts(contacts) + //contacts.push(ldapcontacts) +// <<<<<<<<<<<<<<<<<<<<<<<<<<<<<< + + contacts = contacts.map(_formatContact) + + const additionalContacts = await Modules.promises.hooks.fire( + 'getContacts', + userId, + contacts + ) + + contacts = contacts.concat(...(additionalContacts || [])) + return res.json({ + contacts, + }) +} + +// >>>>>>>>>>>>>>>>>>>>>>>>>>>>>> +// TODO(ldap) +// async function getLdapContacts(contacts) { +// if ( +// process.env.LDAP_CONTACTS === undefined || +// !(process.env.LDAP_CONTACTS.toLowerCase() === 'true') +// ) { +// return contacts +// } +// const client = new Client({ +// url: process.env.LDAP_SERVER, +// }) + +// // if we need a ldap user try to bind +// if (process.env.LDAP_BIND_USER) { +// try { +// await client.bind(process.env.LDAP_BIND_USER, process.env.LDAP_BIND_PW) +// } catch (ex) { +// console.log('Could not bind LDAP reader user: ' + String(ex)) +// } +// } + +// const ldap_base = process.env.LDAP_BASE +// // get user data +// try { +// // if you need an client.bind do it here. +// const { searchEntries, searchReferences } = await client.search(ldap_base, { +// scope: 'sub', +// filter: process.env.LDAP_CONTACT_FILTER, +// }) +// await searchEntries +// for (var i = 0; i < searchEntries.length; i++) { +// var entry = new Map() +// var obj = searchEntries[i] +// entry['_id'] = undefined +// entry['email'] = obj['mail'] +// entry['first_name'] = obj['givenName'] +// entry['last_name'] = obj['sn'] +// entry['type'] = 'user' +// // Only add to contacts if entry is not there. +// if (contacts.indexOf(entry) === -1) { +// contacts.push(entry) +// } +// } +// } catch (ex) { +// console.log(String(ex)) +// } finally { +// // console.log(JSON.stringify(contacts)) +// // even if we did not use bind - the constructor of +// // new Client() opens a socket to the ldap server +// client.unbind() +// return contacts +// } +// } +// <<<<<<<<<<<<<<<<<<<<<<<<<<<<<< + +module.exports = { + getContacts: expressify(getContacts), +} diff --git a/ldap-overleaf-sl/sharelatex/admin-index.pug b/ldap-overleaf-sl/sharelatex/admin-index.pug new file mode 100644 index 0000000..51155de --- /dev/null +++ b/ldap-overleaf-sl/sharelatex/admin-index.pug @@ -0,0 +1,43 @@ +extends ../layout-marketing +include ../_mixins/bookmarkable_tabset + +block content + .content.content-alt#main-content + .container + .row + .col-xs-12 + .card + .page-header + h1 Admin Panel + div(data-ol-bookmarkable-tabset) + ul.nav.nav-tabs(role="tablist") + +bookmarkable-tabset-header('system-messages', 'System Messages', true) + +bookmarkable-tabset-header('register-user', 'Register User') + + .tab-content + .tab-pane.active( + role="tabpanel" + id='system-messages' + ) + each message in systemMessages + .alert.alert-info.row-spaced #{message.content} + hr + form(method='post', action='/admin/messages') + input(name="_csrf", type="hidden", value=csrfToken) + .form-group + label(for="content") + input.form-control(name="content", type="text", placeholder="Message…", required) + button.btn.btn-primary(type="submit") Post Message + hr + form(method='post', action='/admin/messages/clear') + input(name="_csrf", type="hidden", value=csrfToken) + button.btn.btn-danger(type="submit") Clear all messages + + + .tab-pane( + role="tabpanel" + id='register-user' + ) + hr + a(href="/admin/register") Register User + diff --git a/ldap-overleaf-sl/sharelatex/admin-sysadmin.pug b/ldap-overleaf-sl/sharelatex/admin-sysadmin.pug new file mode 100644 index 0000000..d41b501 --- /dev/null +++ b/ldap-overleaf-sl/sharelatex/admin-sysadmin.pug @@ -0,0 +1,78 @@ +extends ../layout + +block content + .content.content-alt#main-content + .container + .row + .col-xs-12 + .card(ng-controller="RegisterUsersController") + .page-header + h1 Admin Panel + tabset(ng-cloak) + tab(heading="System Messages") + each message in systemMessages + .alert.alert-info.row-spaced(ng-non-bindable) #{message.content} + hr + form(method='post', action='/admin/messages') + input(name="_csrf", type="hidden", value=csrfToken) + .form-group + label(for="content") + input.form-control(name="content", type="text", placeholder="Message...", required) + button.btn.btn-primary(type="submit") Post Message + hr + form(method='post', action='/admin/messages/clear') + input(name="_csrf", type="hidden", value=csrfToken) + button.btn.btn-danger(type="submit") Clear all messages + + + tab(heading="Register non LDAP User") + form.form + .row + .col-md-4.col-xs-8 + input.form-control( + name="email", + type="text", + placeholder="jane@example.com, joe@example.com", + ng-model="inputs.emails", + on-enter="registerUsers()" + ) + .col-md-8.col-xs-4 + button.btn.btn-primary(ng-click="registerUsers()") #{translate("register")} + + .row-spaced(ng-show="error").ng-cloak.text-danger + p Sorry, an error occured + + .row-spaced(ng-show="users.length > 0").ng-cloak.text-success + p We've sent out welcome emails to the registered users. + p You can also manually send them URLs below to allow them to reset their password and log in for the first time. + p (Password reset tokens will expire after one week and the user will need registering again). + + hr(ng-show="users.length > 0").ng-cloak + table(ng-show="users.length > 0").table.table-striped.ng-cloak + tr + th #{translate("email")} + th Set Password Url + tr(ng-repeat="user in users") + td {{ user.email }} + td(style="word-break: break-all;") {{ user.setNewPasswordUrl }} + tab(heading="Open/Close Editor" bookmarkable-tab="open-close-editor") + if hasFeature('saas') + | The "Open/Close Editor" feature is not available in SAAS. + else + .row-spaced + form(method='post',action='/admin/closeEditor') + input(name="_csrf", type="hidden", value=csrfToken) + button.btn.btn-danger(type="submit") Close Editor + p.small Will stop anyone opening the editor. Will NOT disconnect already connected users. + + .row-spaced + form(method='post',action='/admin/disconnectAllUsers') + input(name="_csrf", type="hidden", value=csrfToken) + button.btn.btn-danger(type="submit") Disconnect all users + p.small Will force disconnect all users with the editor open. Make sure to close the editor first to avoid them reconnecting. + + .row-spaced + form(method='post',action='/admin/openEditor') + input(name="_csrf", type="hidden", value=csrfToken) + button.btn.btn-danger(type="submit") Reopen Editor + p.small Will reopen the editor after closing. \ No newline at end of file diff --git a/ldap-overleaf-sl/sharelatex/login.pug b/ldap-overleaf-sl/sharelatex/login.pug new file mode 100644 index 0000000..e8e215d --- /dev/null +++ b/ldap-overleaf-sl/sharelatex/login.pug @@ -0,0 +1,49 @@ +extends ../layout-marketing + +block content + main.content.content-alt#main-content + .container + .row + .col-md-6.col-md-offset-3.col-lg-4.col-lg-offset-4 + .card + .page-header + h1 #{translate("log_in")} + form(data-ol-async-form, name="loginForm", action='/login', method="POST") + input(name='_csrf', type='hidden', value=csrfToken) + +formMessages() + +customFormMessage('invalid-password-retry-or-reset', 'danger') + | !{translate('email_or_password_wrong_try_again_or_reset', {}, [{ name: 'a', attrs: { href: '/user/password/reset', 'aria-describedby': 'resetPasswordDescription' } }])} + span.sr-only(id='resetPasswordDescription') + | #{translate('reset_password_link')} + +customValidationMessage('password-compromised') + | !{translate('password_compromised_try_again_or_use_known_device_or_reset', {}, [{name: 'a', attrs: {href: 'https://haveibeenpwned.com/passwords', rel: 'noopener noreferrer', target: '_blank'}}, {name: 'a', attrs: {href: '/user/password/reset', target: '_blank'}}])}. + //- >>>>>>>>>>>>>>>>>>>>>>>>>>>>>> + if process.env.ALLOW_EMAIL_LOGIN !== 'false' && (process.env.LDAP_SERVER !== undefined || process.env.LDAP_SERVER !== "") + .form-group + input.form-control( + name='email', + required, + placeholder='email@example.com', + autofocus="true" + ) + + .form-group + input.form-control( + type='password', + name='password', + required, + placeholder='********', + ) + .actions + button.btn-primary.btn( + type='submit', + data-ol-disabled-inflight + ) + span(data-ol-inflight="idle") #{translate("login")} + span(hidden data-ol-inflight="pending") #{translate("logging_in")}… + a.pull-right(href='/user/password/reset') #{translate("forgot_your_password")}? + if process.env.OAUTH2_ENABLED === 'true' + .form-group.text-center(style="padding-top: 10px") + a.btn-block.login-btn(href="/oauth/redirect" style='padding-left: 0px') + | Log in via #{process.env.OAUTH2_PROVIDER || 'OAuth'} + //- <<<<<<<<<<<<<<<<<<<<<<<<<<<<<< diff --git a/ldap-overleaf-sl/sharelatex/navbar-marketing.pug b/ldap-overleaf-sl/sharelatex/navbar-marketing.pug new file mode 100644 index 0000000..b12df76 --- /dev/null +++ b/ldap-overleaf-sl/sharelatex/navbar-marketing.pug @@ -0,0 +1,176 @@ +nav.navbar.navbar-default.navbar-main + .container-fluid + .navbar-header + if (typeof(suppressNavbarRight) == "undefined") + button.navbar-toggle.collapsed( + type="button", + data-toggle="collapse", + data-target="#navbar-main-collapse" + aria-label="Toggle " + translate('navigation') + ) + i.fa.fa-bars(aria-hidden="true") + - var enableUpgradeButton = projectDashboardReact && usersBestSubscription && usersBestSubscription.type === 'free' + if (enableUpgradeButton) + a.btn.btn-primary.pull-right.me-2.visible-xs( + href="/user/subscription/plans" + event-tracking="upgrade-button-click" + event-tracking-mb="true" + event-tracking-label="upgrade" + event-tracking-trigger="click" + event-segmentation='{"source": "dashboard-top", "project-dashboard-react": "enabled", "is-dashboard-sidebar-hidden": "true", "is-screen-width-less-than-768px": "true"}' + ) #{translate("upgrade")} + if settings.nav.custom_logo + a(href='/', aria-label=settings.appName, style='background-image:url("'+settings.nav.custom_logo+'")').navbar-brand + else if (nav.title) + a(href='/', aria-label=settings.appName).navbar-title #{nav.title} + else + a(href='/', aria-label=settings.appName).navbar-brand + + - var canDisplayAdminMenu = hasAdminAccess() + - var canDisplayAdminRedirect = canRedirectToAdminDomain() + - var canDisplaySplitTestMenu = hasFeature('saas') && (canDisplayAdminMenu || (getSessionUser() && getSessionUser().staffAccess && (getSessionUser().staffAccess.splitTestMetrics || getSessionUser().staffAccess.splitTestManagement))) + - var canDisplaySurveyMenu = hasFeature('saas') && canDisplayAdminMenu + + if (typeof(suppressNavbarRight) == "undefined") + .navbar-collapse.collapse#navbar-main-collapse + ul.nav.navbar-nav.navbar-right + if (canDisplayAdminMenu || canDisplayAdminRedirect || canDisplaySplitTestMenu) + li.dropdown.subdued + a.dropdown-toggle( + href="#", + role="button", + aria-haspopup="true", + aria-expanded="false", + data-toggle="dropdown" + ) + | Admin + span.caret + ul.dropdown-menu + if canDisplayAdminMenu + li + a(href="/admin") Manage Site + li + a(href="/admin/user") Manage Users + if canDisplayAdminRedirect + li + a(href=settings.adminUrl) Switch to Admin + if canDisplaySplitTestMenu + li + a(href="/admin/split-test") Manage Feature Flags + if canDisplaySurveyMenu + li + a(href="/admin/survey") Manage Surveys + + // loop over header_extras + each item in nav.header_extras + - + if ((item.only_when_logged_in && getSessionUser()) + || (item.only_when_logged_out && (!getSessionUser())) + || (!item.only_when_logged_out && !item.only_when_logged_in && !item.only_content_pages) + || (item.only_content_pages && (typeof(suppressNavContentLinks) == "undefined" || !suppressNavContentLinks)) + ){ + var showNavItem = true + } else { + var showNavItem = false + } + + if showNavItem + if item.dropdown + li.dropdown(class=item.class) + a.dropdown-toggle( + href="#", + role="button", + aria-haspopup="true", + aria-expanded="false", + data-toggle="dropdown" + ) + | !{translate(item.text)} + span.caret + ul.dropdown-menu + each child in item.dropdown + if child.divider + li.divider + else if child.isContactUs + li + a(data-ol-open-contact-form-modal="contact-us" href) + span(event-tracking="menu-clicked-contact" event-tracking-mb="true" event-tracking-trigger="click") + | #{translate("contact_us")} + else + li + if child.url + a( + href=child.url, + class=child.class, + event-tracking=child.event + event-tracking-mb="true" + event-tracking-trigger="click" + event-segmentation=child.eventSegmentation + ) !{translate(child.text)} + else + | !{translate(child.text)} + else + li(class=item.class) + if item.url + a( + href=item.url, + class=item.class, + event-tracking=item.event + event-tracking-mb="true" + event-tracking-trigger="click" + ) !{translate(item.text)} + else + | !{translate(item.text)} + + // logged out + if !getSessionUser() + // register link + if hasFeature('registration-page') + li.primary + a( + href="/register" + event-tracking="menu-clicked-register" + event-tracking-action="clicked" + event-tracking-trigger="click" + event-tracking-mb="true" + event-segmentation={ page: currentUrl } + ) #{translate('sign_up')} + + // login link + li + a( + href="/login" + event-tracking="menu-clicked-login" + event-tracking-action="clicked" + event-tracking-trigger="click" + event-tracking-mb="true" + event-segmentation={ page: currentUrl } + ) #{translate('log_in')} + + // projects link and account menu + if getSessionUser() + li + a(href="/project") #{translate('Projects')} + li.dropdown + a.dropdown-toggle( + href="#", + role="button", + aria-haspopup="true", + aria-expanded="false", + data-toggle="dropdown" + ) + | #{translate('Account')} + span.caret + ul.dropdown-menu + li + div.subdued #{getSessionUser().email} + li.divider.hidden-xs.hidden-sm + li + a(href="/user/settings") #{translate('Account Settings')} + if nav.showSubscriptionLink + li + a(href="/user/subscription") #{translate('subscription')} + li.divider.hidden-xs.hidden-sm + li + form(method="POST" action="/logout") + input(name='_csrf', type='hidden', value=csrfToken) + button.btn-link.text-left.dropdown-menu-button #{translate('log_out')} diff --git a/ldap-overleaf-sl/sharelatex/navbar-website-redesign.pug b/ldap-overleaf-sl/sharelatex/navbar-website-redesign.pug new file mode 100644 index 0000000..5235b11 --- /dev/null +++ b/ldap-overleaf-sl/sharelatex/navbar-website-redesign.pug @@ -0,0 +1,114 @@ +nav.navbar.navbar-default.navbar-main.website-redesign-navbar + .container-fluid + .navbar-header + if (typeof(suppressNavbarRight) == "undefined") + button.navbar-toggle.collapsed( + type="button", + data-toggle="collapse", + data-target="#navbar-main-collapse" + aria-label="Toggle " + translate('navigation') + ) + i.fa.fa-bars(aria-hidden="true") + - var enableUpgradeButton = projectDashboardReact && usersBestSubscription && usersBestSubscription.type === 'free' + if (enableUpgradeButton) + a.btn.btn-primary.pull-right.me-2.visible-xs( + href="/user/subscription/plans" + event-tracking="upgrade-button-click" + event-tracking-mb="true" + event-tracking-label="upgrade" + event-tracking-trigger="click" + event-segmentation='{"source": "dashboard-top", "project-dashboard-react": "enabled", "is-dashboard-sidebar-hidden": "true", "is-screen-width-less-than-768px": "true"}' + ) #{translate("upgrade")} + if settings.nav.custom_logo + a(href='/', aria-label=settings.appName, style='background-image:url("'+settings.nav.custom_logo+'")').navbar-brand + else if (nav.title) + a(href='/', aria-label=settings.appName).navbar-title #{nav.title} + else + a(href='/', aria-label=settings.appName).navbar-brand + + .navbar-collapse.collapse#navbar-main-collapse + ul.nav.navbar-nav.navbar-right + if (getSessionUser() && getSessionUser().isAdmin) + li.dropdown.subdued + a.dropdown-toggle( + href="#", + role="button", + aria-haspopup="true", + aria-expanded="false", + data-toggle="dropdown" + ) + | Admin + span.caret + + // loop over header_extras + each item in nav.header_extras + - + if ((item.only_when_logged_in && getSessionUser()) + || (item.only_when_logged_out && (!getSessionUser())) + || (!item.only_when_logged_out && !item.only_when_logged_in && !item.only_content_pages) + || (item.only_content_pages && (typeof(suppressNavContentLinks) == "undefined" || !suppressNavContentLinks)) + ){ + var showNavItem = true + } else { + var showNavItem = false + } + + if showNavItem + if item.dropdown + li.dropdown(class=item.class) + a.dropdown-toggle( + href="#", + role="button", + aria-haspopup="true", + aria-expanded="false", + data-toggle="dropdown" + ) + | !{translate(item.text)} + span.caret + ul.dropdown-menu + each child in item.dropdown + if child.divider + li.divider + else if child.isContactUs + li + a(data-ol-open-contact-form-modal="contact-us" href) + span(event-tracking="menu-clicked-contact" event-tracking-mb="true" event-tracking-trigger="click") + | #{translate("contact_us")} + else + li + if child.url + a( + href=child.url, + class=child.class, + event-tracking=child.event + event-tracking-mb="true" + event-tracking-trigger="click" + event-segmentation=child.eventSegmentation + ) !{translate(child.text)} + else + | !{translate(child.text)} + + // logged out + if !getSessionUser() + // login link + li + a( + href="/login" + event-tracking="menu-clicked-login" + event-tracking-action="clicked" + event-tracking-trigger="click" + event-tracking-mb="true" + event-segmentation={ page: currentUrl } + ) #{translate('log_in')} + + + li + a(href="/user/settings") #{translate('Account Settings')} + if nav.showSubscriptionLink + li + a(href="/user/subscription") #{translate('subscription')} + li.divider.hidden-xs.hidden-sm + li + form(method="POST" action="/logout") + input(name='_csrf', type='hidden', value=csrfToken) + button.btn-link.text-left.dropdown-menu-button #{translate('log_out')} diff --git a/ldap-overleaf-sl/sharelatex/register.pug b/ldap-overleaf-sl/sharelatex/register.pug new file mode 100644 index 0000000..41174da --- /dev/null +++ b/ldap-overleaf-sl/sharelatex/register.pug @@ -0,0 +1,35 @@ +extends ../layout-marketing + +block content + main.content.content-alt#main-content + .container + .row + .registration_message + if sharedProjectData.user_first_name !== undefined + h1 #{translate("user_wants_you_to_see_project", {username:sharedProjectData.user_first_name, projectname:""})} + em #{sharedProjectData.project_name} + div + | #{translate("join_sl_to_view_project")}. + div + | #{translate("already_have_sl_account")} + a(href="/login") #{translate("login_here")} + else if newTemplateData.templateName !== undefined + h1 #{translate("register_to_edit_template", {templateName:newTemplateData.templateName})} + + div #{translate("already_have_sl_account")} + a(href="/login") #{translate("login_here")} + + .row + .col-md-8.col-md-offset-2.col-lg-6.col-lg-offset-3 + .card + .page-header + h1 #{translate("register")} + p + if process.env.REGISTER_TEXT !== undefined && process.env.REGISTER_TEXT !== "" + | #{process.env.REGISTER_TEXT} + else + | Please contact + | + strong #{settings.adminEmail} + | + | to create an account. diff --git a/ldap-overleaf-sl/sharelatex/router.js b/ldap-overleaf-sl/sharelatex/router.js new file mode 100644 index 0000000..361fb8b --- /dev/null +++ b/ldap-overleaf-sl/sharelatex/router.js @@ -0,0 +1,1386 @@ +const AdminController = require('./Features/ServerAdmin/AdminController') +const ErrorController = require('./Features/Errors/ErrorController') +const ProjectController = require('./Features/Project/ProjectController') +const ProjectApiController = require('./Features/Project/ProjectApiController') +const ProjectListController = require('./Features/Project/ProjectListController') +const SpellingController = require('./Features/Spelling/SpellingController') +const EditorRouter = require('./Features/Editor/EditorRouter') +const Settings = require('@overleaf/settings') +const TpdsController = require('./Features/ThirdPartyDataStore/TpdsController') +const SubscriptionRouter = require('./Features/Subscription/SubscriptionRouter') +const UploadsRouter = require('./Features/Uploads/UploadsRouter') +const metrics = require('@overleaf/metrics') +const ReferalController = require('./Features/Referal/ReferalController') +const AuthenticationController = require('./Features/Authentication/AuthenticationController') +const PermissionsController = require('./Features/Authorization/PermissionsController') +const SessionManager = require('./Features/Authentication/SessionManager') +const TagsController = require('./Features/Tags/TagsController') +const NotificationsController = require('./Features/Notifications/NotificationsController') +const CollaboratorsRouter = require('./Features/Collaborators/CollaboratorsRouter') +const UserInfoController = require('./Features/User/UserInfoController') +const UserController = require('./Features/User/UserController') +const UserEmailsController = require('./Features/User/UserEmailsController') +const UserPagesController = require('./Features/User/UserPagesController') +const TutorialController = require('./Features/Tutorial/TutorialController') +const DocumentController = require('./Features/Documents/DocumentController') +const CompileManager = require('./Features/Compile/CompileManager') +const CompileController = require('./Features/Compile/CompileController') +const ClsiCookieManager = require('./Features/Compile/ClsiCookieManager')( + Settings.apis.clsi != null ? Settings.apis.clsi.backendGroupName : undefined +) +const HealthCheckController = require('./Features/HealthCheck/HealthCheckController') +const ProjectDownloadsController = require('./Features/Downloads/ProjectDownloadsController') +const FileStoreController = require('./Features/FileStore/FileStoreController') +const DocumentUpdaterController = require('./Features/DocumentUpdater/DocumentUpdaterController') +const HistoryController = require('./Features/History/HistoryController') +const ExportsController = require('./Features/Exports/ExportsController') +const PasswordResetRouter = require('./Features/PasswordReset/PasswordResetRouter') +const StaticPagesRouter = require('./Features/StaticPages/StaticPagesRouter') +const ChatController = require('./Features/Chat/ChatController') +const Modules = require('./infrastructure/Modules') +const { + RateLimiter, + openProjectRateLimiter, +} = require('./infrastructure/RateLimiter') +const RateLimiterMiddleware = require('./Features/Security/RateLimiterMiddleware') +const InactiveProjectController = require('./Features/InactiveData/InactiveProjectController') +const ContactRouter = require('./Features/Contacts/ContactRouter') +const ReferencesController = require('./Features/References/ReferencesController') +const AuthorizationMiddleware = require('./Features/Authorization/AuthorizationMiddleware') +const BetaProgramController = require('./Features/BetaProgram/BetaProgramController') +const AnalyticsRouter = require('./Features/Analytics/AnalyticsRouter') +const MetaController = require('./Features/Metadata/MetaController') +const TokenAccessController = require('./Features/TokenAccess/TokenAccessController') +const TokenAccessRouter = require('./Features/TokenAccess/TokenAccessRouter') +const Features = require('./infrastructure/Features') +const LinkedFilesRouter = require('./Features/LinkedFiles/LinkedFilesRouter') +const TemplatesRouter = require('./Features/Templates/TemplatesRouter') +const InstitutionsController = require('./Features/Institutions/InstitutionsController') +const UserMembershipRouter = require('./Features/UserMembership/UserMembershipRouter') +const SystemMessageController = require('./Features/SystemMessages/SystemMessageController') +const AnalyticsRegistrationSourceMiddleware = require('./Features/Analytics/AnalyticsRegistrationSourceMiddleware') +const AnalyticsUTMTrackingMiddleware = require('./Features/Analytics/AnalyticsUTMTrackingMiddleware') +const CaptchaMiddleware = require('./Features/Captcha/CaptchaMiddleware') +const { Joi, validate } = require('./infrastructure/Validation') +const { + renderUnsupportedBrowserPage, + unsupportedBrowserMiddleware, +} = require('./infrastructure/UnsupportedBrowserMiddleware') + +const logger = require('@overleaf/logger') +const _ = require('lodash') +const { plainTextResponse } = require('./infrastructure/Response') +const PublicAccessLevels = require('./Features/Authorization/PublicAccessLevels') + +const rateLimiters = { + addEmail: new RateLimiter('add-email', { + points: 10, + duration: 60, + }), + addProjectToTag: new RateLimiter('add-project-to-tag', { + points: 30, + duration: 60, + }), + addProjectsToTag: new RateLimiter('add-projects-to-tag', { + points: 30, + duration: 60, + }), + canSkipCaptcha: new RateLimiter('can-skip-captcha', { + points: 20, + duration: 60, + }), + changePassword: new RateLimiter('change-password', { + points: 10, + duration: 60, + }), + compileProjectHttp: new RateLimiter('compile-project-http', { + points: 800, + duration: 60 * 60, + }), + confirmEmail: new RateLimiter('confirm-email', { + points: 10, + duration: 60, + }), + createProject: new RateLimiter('create-project', { + points: 20, + duration: 60, + }), + createTag: new RateLimiter('create-tag', { + points: 30, + duration: 60, + }), + deleteEmail: new RateLimiter('delete-email', { + points: 10, + duration: 60, + }), + deleteTag: new RateLimiter('delete-tag', { + points: 30, + duration: 60, + }), + deleteUser: new RateLimiter('delete-user', { + points: 10, + duration: 60, + }), + downloadProjectRevision: new RateLimiter('download-project-revision', { + points: 30, + duration: 60 * 60, + }), + endorseEmail: new RateLimiter('endorse-email', { + points: 30, + duration: 60, + }), + getProjects: new RateLimiter('get-projects', { + points: 30, + duration: 60, + }), + grantTokenAccessReadOnly: new RateLimiter('grant-token-access-read-only', { + points: 10, + duration: 60, + }), + grantTokenAccessReadWrite: new RateLimiter('grant-token-access-read-write', { + points: 10, + duration: 60, + }), + indexAllProjectReferences: new RateLimiter('index-all-project-references', { + points: 30, + duration: 60, + }), + miscOutputDownload: new RateLimiter('misc-output-download', { + points: 1000, + duration: 60 * 60, + }), + multipleProjectsZipDownload: new RateLimiter( + 'multiple-projects-zip-download', + { + points: 10, + duration: 60, + } + ), + openDashboard: new RateLimiter('open-dashboard', { + points: 30, + duration: 60, + }), + readAndWriteToken: new RateLimiter('read-and-write-token', { + points: 15, + duration: 60, + }), + readOnlyToken: new RateLimiter('read-only-token', { + points: 15, + duration: 60, + }), + removeProjectFromTag: new RateLimiter('remove-project-from-tag', { + points: 30, + duration: 60, + }), + removeProjectsFromTag: new RateLimiter('remove-projects-from-tag', { + points: 30, + duration: 60, + }), + renameTag: new RateLimiter('rename-tag', { + points: 30, + duration: 60, + }), + resendConfirmation: new RateLimiter('resend-confirmation', { + points: 1, + duration: 60, + }), + sendChatMessage: new RateLimiter('send-chat-message', { + points: 100, + duration: 60, + }), + statusCompiler: new RateLimiter('status-compiler', { + points: 10, + duration: 60, + }), + zipDownload: new RateLimiter('zip-download', { + points: 10, + duration: 60, + }), +} + +function initialize(webRouter, privateApiRouter, publicApiRouter) { + webRouter.use(unsupportedBrowserMiddleware) + + if (!Settings.allowPublicAccess) { + webRouter.all('*', AuthenticationController.requireGlobalLogin) + } + + webRouter.get('*', AnalyticsRegistrationSourceMiddleware.setInbound()) + webRouter.get('*', AnalyticsUTMTrackingMiddleware.recordUTMTags()) + + // Mount onto /login in order to get the deviceHistory cookie. + webRouter.post( + '/login/can-skip-captcha', + // Keep in sync with the overleaf-login options. + RateLimiterMiddleware.rateLimit(rateLimiters.canSkipCaptcha), + CaptchaMiddleware.canSkipCaptcha + ) + + webRouter.get('/login', UserPagesController.loginPage) + AuthenticationController.addEndpointToLoginWhitelist('/login') + + webRouter.post( + '/login', + CaptchaMiddleware.validateCaptcha('login'), + AuthenticationController.passportLogin + ) + + webRouter.get( + '/compromised-password', + AuthenticationController.requireLogin(), + UserPagesController.compromisedPasswordPage + ) + + webRouter.get('/account-suspended', UserPagesController.accountSuspended) + + if (Settings.enableLegacyLogin) { + AuthenticationController.addEndpointToLoginWhitelist('/login/legacy') + webRouter.get('/login/legacy', UserPagesController.loginPage) + webRouter.post( + '/login/legacy', + CaptchaMiddleware.validateCaptcha('login'), + AuthenticationController.passportLogin + ) + } + + webRouter.get( + '/read-only/one-time-login', + UserPagesController.oneTimeLoginPage + ) + AuthenticationController.addEndpointToLoginWhitelist( + '/read-only/one-time-login' + ) + + webRouter.post('/logout', UserController.logout) + + webRouter.get('/restricted', AuthorizationMiddleware.restricted) + + if (Features.hasFeature('registration-page')) { + webRouter.get('/register', UserPagesController.registerPage) + AuthenticationController.addEndpointToLoginWhitelist('/register') + } + + EditorRouter.apply(webRouter, privateApiRouter) + CollaboratorsRouter.apply(webRouter, privateApiRouter) + SubscriptionRouter.apply(webRouter, privateApiRouter, publicApiRouter) + UploadsRouter.apply(webRouter, privateApiRouter) + PasswordResetRouter.apply(webRouter, privateApiRouter) + StaticPagesRouter.apply(webRouter, privateApiRouter) + ContactRouter.apply(webRouter, privateApiRouter) + // >>>>>>>>>>>>>>>>>>>>>>>>>>>>>> + if (process.env.OAUTH2_ENABLED === 'true') { + webRouter.get('/oauth/redirect', AuthenticationController.oauth2Redirect) + webRouter.get('/oauth/callback', AuthenticationController.oauth2Callback) + AuthenticationController.addEndpointToLoginWhitelist('/oauth/redirect') + AuthenticationController.addEndpointToLoginWhitelist('/oauth/callback') + } + // <<<<<<<<<<<<<<<<<<<<<<<<<<<<<< + + AnalyticsRouter.apply(webRouter, privateApiRouter, publicApiRouter) + LinkedFilesRouter.apply(webRouter, privateApiRouter, publicApiRouter) + TemplatesRouter.apply(webRouter) + UserMembershipRouter.apply(webRouter) + TokenAccessRouter.apply(webRouter) + + Modules.applyRouter(webRouter, privateApiRouter, publicApiRouter) + + if (Settings.enableSubscriptions) { + webRouter.get( + '/user/bonus', + AuthenticationController.requireLogin(), + ReferalController.bonus + ) + } + + // .getMessages will generate an empty response for anonymous users. + webRouter.get('/system/messages', SystemMessageController.getMessages) + + webRouter.get( + '/user/settings', + AuthenticationController.requireLogin(), + PermissionsController.useCapabilities(), + UserPagesController.settingsPage + ) + webRouter.post( + '/user/settings', + AuthenticationController.requireLogin(), + UserController.updateUserSettings + ) + webRouter.post( + '/user/password/update', + AuthenticationController.requireLogin(), + RateLimiterMiddleware.rateLimit(rateLimiters.changePassword), + PermissionsController.requirePermission('change-password'), + UserController.changePassword + ) + webRouter.get( + '/user/emails', + AuthenticationController.requireLogin(), + PermissionsController.useCapabilities(), + UserController.promises.ensureAffiliationMiddleware, + UserEmailsController.list + ) + webRouter.get( + '/user/emails/confirm', + AuthenticationController.requireLogin(), + UserEmailsController.showConfirm + ) + webRouter.post( + '/user/emails/confirm', + AuthenticationController.requireLogin(), + RateLimiterMiddleware.rateLimit(rateLimiters.confirmEmail), + UserEmailsController.confirm + ) + webRouter.post( + '/user/emails/resend_confirmation', + AuthenticationController.requireLogin(), + RateLimiterMiddleware.rateLimit(rateLimiters.resendConfirmation), + UserEmailsController.resendConfirmation + ) + + webRouter.get( + '/user/emails/primary-email-check', + AuthenticationController.requireLogin(), + UserEmailsController.primaryEmailCheckPage + ) + + webRouter.post( + '/user/emails/primary-email-check', + AuthenticationController.requireLogin(), + PermissionsController.useCapabilities(), + UserEmailsController.primaryEmailCheck + ) + + if (Features.hasFeature('affiliations')) { + webRouter.post( + '/user/emails', + AuthenticationController.requireLogin(), + PermissionsController.requirePermission('add-secondary-email'), + RateLimiterMiddleware.rateLimit(rateLimiters.addEmail), + CaptchaMiddleware.validateCaptcha('addEmail'), + UserEmailsController.add + ) + + webRouter.post( + '/user/emails/delete', + AuthenticationController.requireLogin(), + RateLimiterMiddleware.rateLimit(rateLimiters.deleteEmail), + UserEmailsController.remove + ) + webRouter.post( + '/user/emails/default', + AuthenticationController.requireLogin(), + UserEmailsController.setDefault + ) + webRouter.post( + '/user/emails/endorse', + AuthenticationController.requireLogin(), + PermissionsController.requirePermission('endorse-email'), + RateLimiterMiddleware.rateLimit(rateLimiters.endorseEmail), + UserEmailsController.endorse + ) + } + + if (Features.hasFeature('saas')) { + webRouter.get( + '/user/emails/add-secondary', + AuthenticationController.requireLogin(), + PermissionsController.requirePermission('add-secondary-email'), + UserEmailsController.addSecondaryEmailPage + ) + + webRouter.get( + '/user/emails/confirm-secondary', + AuthenticationController.requireLogin(), + PermissionsController.requirePermission('add-secondary-email'), + UserEmailsController.confirmSecondaryEmailPage + ) + } + + webRouter.get( + '/user/sessions', + AuthenticationController.requireLogin(), + UserPagesController.sessionsPage + ) + webRouter.post( + '/user/sessions/clear', + AuthenticationController.requireLogin(), + UserController.clearSessions + ) + + // deprecated + webRouter.delete( + '/user/newsletter/unsubscribe', + AuthenticationController.requireLogin(), + UserController.unsubscribe + ) + + webRouter.post( + '/user/newsletter/unsubscribe', + AuthenticationController.requireLogin(), + UserController.unsubscribe + ) + + webRouter.post( + '/user/newsletter/subscribe', + AuthenticationController.requireLogin(), + UserController.subscribe + ) + + webRouter.get( + '/user/email-preferences', + AuthenticationController.requireLogin(), + UserPagesController.emailPreferencesPage + ) + + webRouter.post( + '/user/delete', + RateLimiterMiddleware.rateLimit(rateLimiters.deleteUser), + AuthenticationController.requireLogin(), + PermissionsController.requirePermission('delete-own-account'), + UserController.tryDeleteUser + ) + + webRouter.get( + '/user/personal_info', + AuthenticationController.requireLogin(), + UserInfoController.getLoggedInUsersPersonalInfo + ) + privateApiRouter.get( + '/user/:user_id/personal_info', + AuthenticationController.requirePrivateApiAuth(), + UserInfoController.getPersonalInfo + ) + + webRouter.get( + '/user/reconfirm', + UserPagesController.renderReconfirmAccountPage + ) + // for /user/reconfirm POST, see password router + + webRouter.get( + '/user/tpds/queues', + AuthenticationController.requireLogin(), + TpdsController.getQueues + ) + + webRouter.post( + '/tutorial/:tutorialKey/complete', + AuthenticationController.requireLogin(), + TutorialController.completeTutorial + ) + + webRouter.post( + '/tutorial/:tutorialKey/postpone', + AuthenticationController.requireLogin(), + TutorialController.postponeTutorial + ) + + webRouter.get( + '/user/projects', + AuthenticationController.requireLogin(), + ProjectController.userProjectsJson + ) + webRouter.get( + '/project/:Project_id/entities', + AuthenticationController.requireLogin(), + AuthorizationMiddleware.ensureUserCanReadProject, + ProjectController.projectEntitiesJson + ) + + webRouter.get( + '/project', + AuthenticationController.requireLogin(), + RateLimiterMiddleware.rateLimit(rateLimiters.openDashboard), + PermissionsController.useCapabilities(), + ProjectListController.projectListPage + ) + webRouter.post( + '/project/new', + AuthenticationController.requireLogin(), + RateLimiterMiddleware.rateLimit(rateLimiters.createProject), + ProjectController.newProject + ) + webRouter.post( + '/api/project', + AuthenticationController.requireLogin(), + RateLimiterMiddleware.rateLimit(rateLimiters.getProjects), + ProjectListController.getProjectsJson + ) + + for (const route of [ + // Keep the old route for continuous metrics + '/Project/:Project_id', + // New route for pdf-detach + '/Project/:Project_id/:detachRole(detacher|detached)', + ]) { + webRouter.get( + route, + RateLimiterMiddleware.rateLimit(openProjectRateLimiter, { + params: ['Project_id'], + }), + PermissionsController.useCapabilities(), + AuthorizationMiddleware.ensureUserCanReadProject, + ProjectController.loadEditor + ) + } + webRouter.head( + '/Project/:Project_id/file/:File_id', + AuthorizationMiddleware.ensureUserCanReadProject, + FileStoreController.getFileHead + ) + webRouter.get( + '/Project/:Project_id/file/:File_id', + AuthorizationMiddleware.ensureUserCanReadProject, + FileStoreController.getFile + ) + webRouter.get( + '/Project/:Project_id/doc/:Doc_id/download', // "download" suffix to avoid conflict with private API route at doc/:doc_id + AuthorizationMiddleware.ensureUserCanReadProject, + DocumentUpdaterController.getDoc + ) + webRouter.post( + '/project/:Project_id/settings', + validate({ + body: Joi.object({ + publicAccessLevel: Joi.string() + .valid(PublicAccessLevels.PRIVATE, PublicAccessLevels.TOKEN_BASED) + .optional(), + }), + }), + AuthorizationMiddleware.ensureUserCanWriteProjectSettings, + ProjectController.updateProjectSettings + ) + webRouter.post( + '/project/:Project_id/settings/admin', + AuthenticationController.requireLogin(), + AuthorizationMiddleware.ensureUserCanAdminProject, + ProjectController.updateProjectAdminSettings + ) + + webRouter.post( + '/project/:Project_id/compile', + RateLimiterMiddleware.rateLimit(rateLimiters.compileProjectHttp, { + params: ['Project_id'], + }), + AuthorizationMiddleware.ensureUserCanReadProject, + CompileController.compile + ) + + webRouter.post( + '/project/:Project_id/compile/stop', + AuthorizationMiddleware.ensureUserCanReadProject, + CompileController.stopCompile + ) + + // LEGACY: Used by the web download buttons, adds filename header, TODO: remove at some future date + webRouter.get( + '/project/:Project_id/output/output.pdf', + AuthorizationMiddleware.ensureUserCanReadProject, + CompileController.downloadPdf + ) + + // PDF Download button + webRouter.get( + /^\/download\/project\/([^/]*)\/output\/output\.pdf$/, + function (req, res, next) { + const params = { Project_id: req.params[0] } + req.params = params + next() + }, + AuthorizationMiddleware.ensureUserCanReadProject, + CompileController.downloadPdf + ) + + // PDF Download button for specific build + webRouter.get( + /^\/download\/project\/([^/]*)\/build\/([0-9a-f-]+)\/output\/output\.pdf$/, + function (req, res, next) { + const params = { + Project_id: req.params[0], + build_id: req.params[1], + } + req.params = params + next() + }, + AuthorizationMiddleware.ensureUserCanReadProject, + CompileController.downloadPdf + ) + + // Align with limits defined in CompileController.downloadPdf + const rateLimiterMiddlewareOutputFiles = RateLimiterMiddleware.rateLimit( + rateLimiters.miscOutputDownload, + { params: ['Project_id'] } + ) + + // Used by the pdf viewers + webRouter.get( + /^\/project\/([^/]*)\/output\/(.*)$/, + function (req, res, next) { + const params = { + Project_id: req.params[0], + file: req.params[1], + } + req.params = params + next() + }, + rateLimiterMiddlewareOutputFiles, + AuthorizationMiddleware.ensureUserCanReadProject, + CompileController.getFileFromClsi + ) + // direct url access to output files for a specific build (query string not required) + webRouter.get( + /^\/project\/([^/]*)\/build\/([0-9a-f-]+)\/output\/(.*)$/, + function (req, res, next) { + const params = { + Project_id: req.params[0], + build_id: req.params[1], + file: req.params[2], + } + req.params = params + next() + }, + rateLimiterMiddlewareOutputFiles, + AuthorizationMiddleware.ensureUserCanReadProject, + CompileController.getFileFromClsi + ) + + // direct url access to output files for user but no build, to retrieve files when build fails + webRouter.get( + /^\/project\/([^/]*)\/user\/([0-9a-f-]+)\/output\/(.*)$/, + function (req, res, next) { + const params = { + Project_id: req.params[0], + user_id: req.params[1], + file: req.params[2], + } + req.params = params + next() + }, + rateLimiterMiddlewareOutputFiles, + AuthorizationMiddleware.ensureUserCanReadProject, + CompileController.getFileFromClsi + ) + + // direct url access to output files for a specific user and build (query string not required) + webRouter.get( + /^\/project\/([^/]*)\/user\/([0-9a-f]+)\/build\/([0-9a-f-]+)\/output\/(.*)$/, + function (req, res, next) { + const params = { + Project_id: req.params[0], + user_id: req.params[1], + build_id: req.params[2], + file: req.params[3], + } + req.params = params + next() + }, + rateLimiterMiddlewareOutputFiles, + AuthorizationMiddleware.ensureUserCanReadProject, + CompileController.getFileFromClsi + ) + + webRouter.delete( + '/project/:Project_id/output', + validate({ query: { clsiserverid: Joi.string() } }), + AuthorizationMiddleware.ensureUserCanReadProject, + CompileController.deleteAuxFiles + ) + webRouter.get( + '/project/:Project_id/sync/code', + AuthorizationMiddleware.ensureUserCanReadProject, + CompileController.proxySyncCode + ) + webRouter.get( + '/project/:Project_id/sync/pdf', + AuthorizationMiddleware.ensureUserCanReadProject, + CompileController.proxySyncPdf + ) + webRouter.get( + '/project/:Project_id/wordcount', + validate({ query: { clsiserverid: Joi.string() } }), + AuthorizationMiddleware.ensureUserCanReadProject, + CompileController.wordCount + ) + + webRouter.post( + '/Project/:Project_id/archive', + AuthenticationController.requireLogin(), + AuthorizationMiddleware.ensureUserCanReadProject, + ProjectController.archiveProject + ) + webRouter.delete( + '/Project/:Project_id/archive', + AuthenticationController.requireLogin(), + AuthorizationMiddleware.ensureUserCanReadProject, + ProjectController.unarchiveProject + ) + webRouter.post( + '/project/:project_id/trash', + AuthenticationController.requireLogin(), + AuthorizationMiddleware.ensureUserCanReadProject, + ProjectController.trashProject + ) + webRouter.delete( + '/project/:project_id/trash', + AuthenticationController.requireLogin(), + AuthorizationMiddleware.ensureUserCanReadProject, + ProjectController.untrashProject + ) + + webRouter.delete( + '/Project/:Project_id', + AuthenticationController.requireLogin(), + AuthorizationMiddleware.ensureUserCanAdminProject, + ProjectController.deleteProject + ) + + webRouter.post( + '/Project/:Project_id/restore', + AuthenticationController.requireLogin(), + AuthorizationMiddleware.ensureUserCanAdminProject, + ProjectController.restoreProject + ) + webRouter.post( + '/Project/:Project_id/clone', + AuthorizationMiddleware.ensureUserCanReadProject, + ProjectController.cloneProject + ) + + webRouter.post( + '/project/:Project_id/rename', + AuthenticationController.requireLogin(), + AuthorizationMiddleware.ensureUserCanAdminProject, + ProjectController.renameProject + ) + webRouter.get( + '/project/:Project_id/updates', + AuthorizationMiddleware.blockRestrictedUserFromProject, + AuthorizationMiddleware.ensureUserCanReadProject, + HistoryController.proxyToHistoryApiAndInjectUserDetails + ) + webRouter.get( + '/project/:Project_id/doc/:doc_id/diff', + AuthorizationMiddleware.blockRestrictedUserFromProject, + AuthorizationMiddleware.ensureUserCanReadProject, + HistoryController.proxyToHistoryApi + ) + webRouter.get( + '/project/:Project_id/diff', + AuthorizationMiddleware.blockRestrictedUserFromProject, + AuthorizationMiddleware.ensureUserCanReadProject, + HistoryController.proxyToHistoryApiAndInjectUserDetails + ) + webRouter.get( + '/project/:Project_id/filetree/diff', + AuthorizationMiddleware.blockRestrictedUserFromProject, + AuthorizationMiddleware.ensureUserCanReadProject, + HistoryController.proxyToHistoryApi + ) + webRouter.post( + '/project/:project_id/restore_file', + AuthorizationMiddleware.ensureUserCanWriteProjectContent, + HistoryController.restoreFileFromV2 + ) + webRouter.post( + '/project/:project_id/revert_file', + AuthorizationMiddleware.ensureUserCanWriteProjectContent, + HistoryController.revertFile + ) + webRouter.get( + '/project/:project_id/version/:version/zip', + RateLimiterMiddleware.rateLimit(rateLimiters.downloadProjectRevision), + AuthorizationMiddleware.blockRestrictedUserFromProject, + AuthorizationMiddleware.ensureUserCanReadProject, + HistoryController.downloadZipOfVersion + ) + privateApiRouter.post( + '/project/:Project_id/history/resync', + AuthenticationController.requirePrivateApiAuth(), + HistoryController.resyncProjectHistory + ) + + webRouter.get( + '/project/:Project_id/labels', + AuthorizationMiddleware.blockRestrictedUserFromProject, + AuthorizationMiddleware.ensureUserCanReadProject, + HistoryController.getLabels + ) + webRouter.post( + '/project/:Project_id/labels', + AuthorizationMiddleware.ensureUserCanWriteProjectContent, + HistoryController.createLabel + ) + webRouter.delete( + '/project/:Project_id/labels/:label_id', + AuthorizationMiddleware.ensureUserCanWriteProjectContent, + HistoryController.deleteLabel + ) + + webRouter.post( + '/project/:project_id/export/:brand_variation_id', + AuthorizationMiddleware.ensureUserCanWriteProjectContent, + ExportsController.exportProject + ) + webRouter.get( + '/project/:project_id/export/:export_id', + AuthorizationMiddleware.ensureUserCanWriteProjectContent, + ExportsController.exportStatus + ) + webRouter.get( + '/project/:project_id/export/:export_id/:type', + AuthorizationMiddleware.ensureUserCanWriteProjectContent, + ExportsController.exportDownload + ) + + webRouter.get( + '/Project/:Project_id/download/zip', + RateLimiterMiddleware.rateLimit(rateLimiters.zipDownload, { + params: ['Project_id'], + }), + AuthorizationMiddleware.ensureUserCanReadProject, + ProjectDownloadsController.downloadProject + ) + webRouter.get( + '/project/download/zip', + AuthenticationController.requireLogin(), + RateLimiterMiddleware.rateLimit(rateLimiters.multipleProjectsZipDownload), + AuthorizationMiddleware.ensureUserCanReadMultipleProjects, + ProjectDownloadsController.downloadMultipleProjects + ) + + webRouter.get( + '/project/:project_id/metadata', + AuthorizationMiddleware.ensureUserCanReadProject, + Settings.allowAnonymousReadAndWriteSharing + ? (req, res, next) => { + next() + } + : AuthenticationController.requireLogin(), + MetaController.getMetadata + ) + webRouter.post( + '/project/:project_id/doc/:doc_id/metadata', + AuthorizationMiddleware.ensureUserCanReadProject, + Settings.allowAnonymousReadAndWriteSharing + ? (req, res, next) => { + next() + } + : AuthenticationController.requireLogin(), + MetaController.broadcastMetadataForDoc + ) + privateApiRouter.post( + '/internal/expire-deleted-projects-after-duration', + AuthenticationController.requirePrivateApiAuth(), + ProjectController.expireDeletedProjectsAfterDuration + ) + privateApiRouter.post( + '/internal/expire-deleted-users-after-duration', + AuthenticationController.requirePrivateApiAuth(), + UserController.expireDeletedUsersAfterDuration + ) + privateApiRouter.post( + '/internal/project/:projectId/expire-deleted-project', + AuthenticationController.requirePrivateApiAuth(), + ProjectController.expireDeletedProject + ) + privateApiRouter.post( + '/internal/users/:userId/expire', + AuthenticationController.requirePrivateApiAuth(), + UserController.expireDeletedUser + ) + + privateApiRouter.get( + '/user/:userId/tag', + AuthenticationController.requirePrivateApiAuth(), + TagsController.apiGetAllTags + ) + webRouter.get( + '/tag', + AuthenticationController.requireLogin(), + TagsController.getAllTags + ) + webRouter.post( + '/tag', + AuthenticationController.requireLogin(), + RateLimiterMiddleware.rateLimit(rateLimiters.createTag), + validate({ + body: Joi.object({ + name: Joi.string().required(), + color: Joi.string(), + }), + }), + TagsController.createTag + ) + webRouter.post( + '/tag/:tagId/rename', + AuthenticationController.requireLogin(), + RateLimiterMiddleware.rateLimit(rateLimiters.renameTag), + validate({ + body: Joi.object({ + name: Joi.string().required(), + }), + }), + TagsController.renameTag + ) + webRouter.post( + '/tag/:tagId/edit', + AuthenticationController.requireLogin(), + RateLimiterMiddleware.rateLimit(rateLimiters.renameTag), + validate({ + body: Joi.object({ + name: Joi.string().required(), + color: Joi.string(), + }), + }), + TagsController.editTag + ) + webRouter.delete( + '/tag/:tagId', + AuthenticationController.requireLogin(), + RateLimiterMiddleware.rateLimit(rateLimiters.deleteTag), + TagsController.deleteTag + ) + webRouter.post( + '/tag/:tagId/project/:projectId', + AuthenticationController.requireLogin(), + RateLimiterMiddleware.rateLimit(rateLimiters.addProjectToTag), + TagsController.addProjectToTag + ) + webRouter.post( + '/tag/:tagId/projects', + AuthenticationController.requireLogin(), + RateLimiterMiddleware.rateLimit(rateLimiters.addProjectsToTag), + validate({ + body: Joi.object({ + projectIds: Joi.array().items(Joi.string()).required(), + }), + }), + TagsController.addProjectsToTag + ) + webRouter.delete( + '/tag/:tagId/project/:projectId', + AuthenticationController.requireLogin(), + RateLimiterMiddleware.rateLimit(rateLimiters.removeProjectFromTag), + TagsController.removeProjectFromTag + ) + webRouter.post( + '/tag/:tagId/projects/remove', + AuthenticationController.requireLogin(), + RateLimiterMiddleware.rateLimit(rateLimiters.removeProjectsFromTag), + validate({ + body: Joi.object({ + projectIds: Joi.array().items(Joi.string()).required(), + }), + }), + TagsController.removeProjectsFromTag + ) + + webRouter.get( + '/notifications', + AuthenticationController.requireLogin(), + NotificationsController.getAllUnreadNotifications + ) + webRouter.delete( + '/notifications/:notificationId', + AuthenticationController.requireLogin(), + NotificationsController.markNotificationAsRead + ) + + // Deprecated in favour of /internal/project/:project_id but still used by versioning + privateApiRouter.get( + '/project/:project_id/details', + AuthenticationController.requirePrivateApiAuth(), + ProjectApiController.getProjectDetails + ) + + // New 'stable' /internal API end points + privateApiRouter.get( + '/internal/project/:project_id', + AuthenticationController.requirePrivateApiAuth(), + ProjectApiController.getProjectDetails + ) + privateApiRouter.get( + '/internal/project/:Project_id/zip', + AuthenticationController.requirePrivateApiAuth(), + ProjectDownloadsController.downloadProject + ) + privateApiRouter.get( + '/internal/project/:project_id/compile/pdf', + AuthenticationController.requirePrivateApiAuth(), + CompileController.compileAndDownloadPdf + ) + + privateApiRouter.post( + '/internal/deactivateOldProjects', + AuthenticationController.requirePrivateApiAuth(), + InactiveProjectController.deactivateOldProjects + ) + privateApiRouter.post( + '/internal/project/:project_id/deactivate', + AuthenticationController.requirePrivateApiAuth(), + InactiveProjectController.deactivateProject + ) + + privateApiRouter.get( + /^\/internal\/project\/([^/]*)\/output\/(.*)$/, + function (req, res, next) { + const params = { + Project_id: req.params[0], + file: req.params[1], + } + req.params = params + next() + }, + AuthenticationController.requirePrivateApiAuth(), + CompileController.getFileFromClsi + ) + + privateApiRouter.get( + '/project/:Project_id/doc/:doc_id', + AuthenticationController.requirePrivateApiAuth(), + DocumentController.getDocument + ) + privateApiRouter.post( + '/project/:Project_id/doc/:doc_id', + AuthenticationController.requirePrivateApiAuth(), + DocumentController.setDocument + ) + + privateApiRouter.post( + '/user/:user_id/project/new', + AuthenticationController.requirePrivateApiAuth(), + TpdsController.createProject + ) + privateApiRouter.post( + '/tpds/folder-update', + AuthenticationController.requirePrivateApiAuth(), + TpdsController.updateFolder + ) + privateApiRouter.post( + '/user/:user_id/update/*', + AuthenticationController.requirePrivateApiAuth(), + TpdsController.mergeUpdate + ) + privateApiRouter.delete( + '/user/:user_id/update/*', + AuthenticationController.requirePrivateApiAuth(), + TpdsController.deleteUpdate + ) + privateApiRouter.post( + '/project/:project_id/user/:user_id/update/*', + AuthenticationController.requirePrivateApiAuth(), + TpdsController.mergeUpdate + ) + privateApiRouter.delete( + '/project/:project_id/user/:user_id/update/*', + AuthenticationController.requirePrivateApiAuth(), + TpdsController.deleteUpdate + ) + + privateApiRouter.post( + '/project/:project_id/contents/*', + AuthenticationController.requirePrivateApiAuth(), + TpdsController.updateProjectContents + ) + privateApiRouter.delete( + '/project/:project_id/contents/*', + AuthenticationController.requirePrivateApiAuth(), + TpdsController.deleteProjectContents + ) + + webRouter.post( + '/spelling/check', + AuthenticationController.requireLogin(), + SpellingController.proxyCheckRequestToSpellingApi + ) + webRouter.post( + '/spelling/learn', + validate({ + body: Joi.object({ + word: Joi.string().required(), + }), + }), + AuthenticationController.requireLogin(), + SpellingController.learn + ) + + webRouter.post( + '/spelling/unlearn', + validate({ + body: Joi.object({ + word: Joi.string().required(), + }), + }), + AuthenticationController.requireLogin(), + SpellingController.unlearn + ) + + webRouter.get( + '/project/:project_id/messages', + AuthorizationMiddleware.blockRestrictedUserFromProject, + AuthorizationMiddleware.ensureUserCanReadProject, + ChatController.getMessages + ) + webRouter.post( + '/project/:project_id/messages', + AuthorizationMiddleware.blockRestrictedUserFromProject, + AuthorizationMiddleware.ensureUserCanReadProject, + RateLimiterMiddleware.rateLimit(rateLimiters.sendChatMessage), + ChatController.sendMessage + ) + + webRouter.post( + '/project/:Project_id/references/indexAll', + AuthorizationMiddleware.ensureUserCanReadProject, + RateLimiterMiddleware.rateLimit(rateLimiters.indexAllProjectReferences), + ReferencesController.indexAll + ) + + // disable beta program while v2 is in beta + webRouter.get( + '/beta/participate', + AuthenticationController.requireLogin(), + BetaProgramController.optInPage + ) + webRouter.post( + '/beta/opt-in', + AuthenticationController.requireLogin(), + BetaProgramController.optIn + ) + webRouter.post( + '/beta/opt-out', + AuthenticationController.requireLogin(), + BetaProgramController.optOut + ) + + // New "api" endpoints. Started as a way for v1 to call over to v2 (for + // long-term features, as opposed to the nominally temporary ones in the + // overleaf-integration module), but may expand beyond that role. + publicApiRouter.post( + '/api/clsi/compile/:submission_id', + AuthenticationController.requirePrivateApiAuth(), + CompileController.compileSubmission + ) + publicApiRouter.get( + /^\/api\/clsi\/compile\/([^/]*)\/build\/([0-9a-f-]+)\/output\/(.*)$/, + function (req, res, next) { + const params = { + submission_id: req.params[0], + build_id: req.params[1], + file: req.params[2], + } + req.params = params + next() + }, + AuthenticationController.requirePrivateApiAuth(), + CompileController.getFileFromClsiWithoutUser + ) + publicApiRouter.post( + '/api/institutions/confirm_university_domain', + AuthenticationController.requirePrivateApiAuth(), + InstitutionsController.confirmDomain + ) + + webRouter.get('/chrome', function (req, res, next) { + // Match v1 behaviour - this is used for a Chrome web app + if (SessionManager.isUserLoggedIn(req.session)) { + res.redirect('/project') + } else { + res.redirect('/register') + } + }) + + webRouter.get( + '/admin', + AuthorizationMiddleware.ensureUserIsSiteAdmin, + AdminController.index + ) + + if (!Features.hasFeature('saas')) { + webRouter.post( + '/admin/openEditor', + AuthorizationMiddleware.ensureUserIsSiteAdmin, + AdminController.openEditor + ) + webRouter.post( + '/admin/closeEditor', + AuthorizationMiddleware.ensureUserIsSiteAdmin, + AdminController.closeEditor + ) + webRouter.post( + '/admin/disconnectAllUsers', + AuthorizationMiddleware.ensureUserIsSiteAdmin, + AdminController.disconnectAllUsers + ) + } + webRouter.post( + '/admin/flushProjectToTpds', + AuthorizationMiddleware.ensureUserIsSiteAdmin, + AdminController.flushProjectToTpds + ) + webRouter.post( + '/admin/pollDropboxForUser', + AuthorizationMiddleware.ensureUserIsSiteAdmin, + AdminController.pollDropboxForUser + ) + webRouter.post( + '/admin/messages', + AuthorizationMiddleware.ensureUserIsSiteAdmin, + AdminController.createMessage + ) + webRouter.post( + '/admin/messages/clear', + AuthorizationMiddleware.ensureUserIsSiteAdmin, + AdminController.clearMessages + ) + + privateApiRouter.get('/perfTest', (req, res) => { + plainTextResponse(res, 'hello') + }) + + publicApiRouter.get('/status', (req, res) => { + if (Settings.shuttingDown) { + res.sendStatus(503) // Service unavailable + } else if (!Settings.siteIsOpen) { + plainTextResponse(res, 'web site is closed (web)') + } else if (!Settings.editorIsOpen) { + plainTextResponse(res, 'web editor is closed (web)') + } else { + plainTextResponse(res, 'web is alive (web)') + } + }) + privateApiRouter.get('/status', (req, res) => { + plainTextResponse(res, 'web is alive (api)') + }) + + // used by kubernetes health-check and acceptance tests + webRouter.get('/dev/csrf', (req, res) => { + plainTextResponse(res, res.locals.csrfToken) + }) + + publicApiRouter.get( + '/health_check', + HealthCheckController.checkActiveHandles, + HealthCheckController.check + ) + privateApiRouter.get( + '/health_check', + HealthCheckController.checkActiveHandles, + HealthCheckController.checkApi + ) + publicApiRouter.get( + '/health_check/api', + HealthCheckController.checkActiveHandles, + HealthCheckController.checkApi + ) + privateApiRouter.get( + '/health_check/api', + HealthCheckController.checkActiveHandles, + HealthCheckController.checkApi + ) + publicApiRouter.get( + '/health_check/full', + HealthCheckController.checkActiveHandles, + HealthCheckController.check + ) + privateApiRouter.get( + '/health_check/full', + HealthCheckController.checkActiveHandles, + HealthCheckController.check + ) + + publicApiRouter.get('/health_check/redis', HealthCheckController.checkRedis) + privateApiRouter.get('/health_check/redis', HealthCheckController.checkRedis) + + publicApiRouter.get('/health_check/mongo', HealthCheckController.checkMongo) + privateApiRouter.get('/health_check/mongo', HealthCheckController.checkMongo) + + webRouter.get( + '/status/compiler/:Project_id', + RateLimiterMiddleware.rateLimit(rateLimiters.statusCompiler), + AuthorizationMiddleware.ensureUserCanReadProject, + function (req, res) { + const projectId = req.params.Project_id + // use a valid user id for testing + const testUserId = '123456789012345678901234' + const sendRes = _.once(function (statusCode, message) { + res.status(statusCode) + plainTextResponse(res, message) + ClsiCookieManager.clearServerId(projectId, testUserId, () => {}) + }) // force every compile to a new server + // set a timeout + let handler = setTimeout(function () { + sendRes(500, 'Compiler timed out') + handler = null + }, 10000) + // run the compile + CompileManager.compile( + projectId, + testUserId, + {}, + function (error, status) { + if (handler) { + clearTimeout(handler) + } + if (error) { + sendRes(500, `Compiler returned error ${error.message}`) + } else if (status === 'success') { + sendRes(200, 'Compiler returned in less than 10 seconds') + } else { + sendRes(500, `Compiler returned failure ${status}`) + } + } + ) + } + ) + + webRouter.post('/error/client', function (req, res, next) { + logger.warn( + { err: req.body.error, meta: req.body.meta }, + 'client side error' + ) + metrics.inc('client-side-error') + res.sendStatus(204) + }) + + webRouter.get( + `/read/:token(${TokenAccessController.READ_ONLY_TOKEN_PATTERN})`, + RateLimiterMiddleware.rateLimit(rateLimiters.readOnlyToken), + AnalyticsRegistrationSourceMiddleware.setSource( + 'collaboration', + 'link-sharing' + ), + TokenAccessController.tokenAccessPage, + AnalyticsRegistrationSourceMiddleware.clearSource() + ) + + webRouter.get( + `/:token(${TokenAccessController.READ_AND_WRITE_TOKEN_PATTERN})`, + RateLimiterMiddleware.rateLimit(rateLimiters.readAndWriteToken), + AnalyticsRegistrationSourceMiddleware.setSource( + 'collaboration', + 'link-sharing' + ), + TokenAccessController.tokenAccessPage, + AnalyticsRegistrationSourceMiddleware.clearSource() + ) + + webRouter.post( + `/:token(${TokenAccessController.READ_AND_WRITE_TOKEN_PATTERN})/grant`, + RateLimiterMiddleware.rateLimit(rateLimiters.grantTokenAccessReadWrite), + TokenAccessController.grantTokenAccessReadAndWrite + ) + + webRouter.post( + `/read/:token(${TokenAccessController.READ_ONLY_TOKEN_PATTERN})/grant`, + RateLimiterMiddleware.rateLimit(rateLimiters.grantTokenAccessReadOnly), + TokenAccessController.grantTokenAccessReadOnly + ) + + webRouter.get('/unsupported-browser', renderUnsupportedBrowserPage) + + webRouter.get('*', ErrorController.notFound) +} + +module.exports = { initialize, rateLimiters } diff --git a/ldap-overleaf-sl/sharelatex/settings.pug b/ldap-overleaf-sl/sharelatex/settings.pug new file mode 100644 index 0000000..8832256 --- /dev/null +++ b/ldap-overleaf-sl/sharelatex/settings.pug @@ -0,0 +1,167 @@ +extends ../layout-marketing + +block content + .content.content-alt + .container + .row + .col-md-12.col-lg-10.col-lg-offset-1 + if ssoError + .alert.alert-danger + | #{translate('sso_link_error')}: #{translate(ssoError)} + .card + .page-header + h1 #{translate("account_settings")} + .account-settings(ng-controller="AccountSettingsController", ng-cloak) + .row + .col-md-5 + h3 #{translate("update_account_info")} + form(async-form="settings", name="settingsForm", method="POST", action="/user/settings", novalidate) + input(type="hidden", name="_csrf", value=csrfToken) + if !hasFeature('affiliations') + // show the email, non-editable + .form-group + label.control-label #{translate("email")} + div.form-control( + readonly="true", + ng-non-bindable + ) #{user.email} + if shouldAllowEditingDetails + .form-group + label(for='firstName').control-label #{translate("first_name")} + input.form-control( + id="firstName" + type='text', + name='first_name', + value=user.first_name + ng-non-bindable + ) + .form-group + label(for='lastName').control-label #{translate("last_name")} + input.form-control( + id="lastName" + type='text', + name='last_name', + value=user.last_name + ng-non-bindable + ) + .form-group + form-messages(aria-live="polite" for="settingsForm") + .alert.alert-success(ng-show="settingsForm.response.success") + | #{translate("thanks_settings_updated")} + .actions + button.btn.btn-primary( + type='submit', + ng-disabled="settingsForm.$invalid" + ) #{translate("update")} + else + .form-group + label.control-label #{translate("first_name")} + div.form-control( + readonly="true", + ng-non-bindable + ) #{user.first_name} + .form-group + label.control-label #{translate("last_name")} + div.form-control( + readonly="true", + ng-non-bindable + ) #{user.last_name} + .col-md-5.col-md-offset-1 + h3 + | Set Password for Email login + p + | Note: you can not change the LDAP password from here. You can set/reset a password for + | your email login: + | #[a(href="/user/password/reset", target='_blank') Reset.] + h3 + | Contact + div + | If you need any help, please contact your sysadmins. + + p #{translate("need_to_leave")} + a(href, ng-click="deleteAccount()") #{translate("delete_your_account")} + + + script(type='text/ng-template', id='deleteAccountModalTemplate') + .modal-header + h3 #{translate("delete_account")} + div.modal-body#delete-account-modal + p !{translate("delete_account_warning_message_3")} + if settings.createV1AccountOnLogin && settings.overleaf + p + strong + | Your Overleaf v2 projects will be deleted if you delete your account. + | If you want to remove any remaining Overleaf v1 projects in your account, + | please first make sure they are imported to Overleaf v2. + + if settings.overleaf && !hasPassword + p + b + | #[a(href="/user/password/reset", target='_blank') #{translate("delete_acct_no_existing_pw")}]. + else + form(novalidate, name="deleteAccountForm") + label #{translate('email')} + input.form-control( + type="text", + autocomplete="off", + placeholder="", + ng-model="state.deleteText", + focus-on="open", + ng-keyup="checkValidation()" + ) + + label #{translate('password')} + input.form-control( + type="password", + autocomplete="off", + placeholder="", + ng-model="state.password", + ng-keyup="checkValidation()" + ) + + div.confirmation-checkbox-wrapper + input( + type="checkbox" + ng-model="state.confirmV1Purge" + ng-change="checkValidation()" + ).pull-left + label(style="display: inline")  I have left, purged or imported my projects on Overleaf v1 (if any)   + + div.confirmation-checkbox-wrapper + input( + type="checkbox" + ng-model="state.confirmSharelatexDelete" + ng-change="checkValidation()" + ).pull-left + label(style="display: inline")  I understand this will delete all projects in my Overleaf v2 account (and ShareLaTeX account, if any) with email address #[em {{ userDefaultEmail }}] + + div(ng-if="state.error") + div.alert.alert-danger(ng-switch="state.error.code") + span(ng-switch-when="InvalidCredentialsError") + | #{translate('email_or_password_wrong_try_again')} + span(ng-switch-when="SubscriptionAdminDeletionError") + | #{translate('subscription_admins_cannot_be_deleted')} + span(ng-switch-when="UserDeletionError") + | #{translate('user_deletion_error')} + span(ng-switch-default) + | #{translate('generic_something_went_wrong')} + if settings.createV1AccountOnLogin && settings.overleaf + div(ng-if="state.error && state.error.code == 'InvalidCredentialsError'") + div.alert.alert-info + | If you can't remember your password, or if you are using Single-Sign-On with another provider + | to sign in (such as Twitter or Google), please + | #[a(href="/user/password/reset", target='_blank') reset your password], + | and try again. + .modal-footer + button.btn.btn-default( + ng-click="cancel()" + ) #{translate("cancel")} + button.btn.btn-danger( + ng-disabled="!state.isValid || state.inflight" + ng-click="delete()" + ) + span(ng-hide="state.inflight") #{translate("delete")} + span(ng-show="state.inflight") #{translate("deleting")}... + + script(type='text/javascript'). + window.passwordStrengthOptions = !{StringHelper.stringifyJsonForScript(settings.passwordStrengthOptions || {})}