From a40aec76776d19d56bea4f4af16e6a875b877ce0 Mon Sep 17 00:00:00 2001 From: yzx9 Date: Wed, 22 Nov 2023 11:45:15 +0800 Subject: [PATCH 01/13] Add OAuth2 Support (WIP) --- ldap-overleaf-sl/Dockerfile | 3 + .../sharelatex/AuthenticationController.js | 733 +++++++++ ldap-overleaf-sl/sharelatex/login.pug | 45 + ldap-overleaf-sl/sharelatex/router.js | 1361 +++++++++++++++++ 4 files changed, 2142 insertions(+) create mode 100644 ldap-overleaf-sl/sharelatex/AuthenticationController.js create mode 100644 ldap-overleaf-sl/sharelatex/login.pug create mode 100644 ldap-overleaf-sl/sharelatex/router.js diff --git a/ldap-overleaf-sl/Dockerfile b/ldap-overleaf-sl/Dockerfile index 250b7f5..9322f4e 100644 --- a/ldap-overleaf-sl/Dockerfile +++ b/ldap-overleaf-sl/Dockerfile @@ -15,10 +15,13 @@ WORKDIR /overleaf/services/web # 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 # 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/navbar.pug /overleaf/services/web/app/views/layout/ # Non LDAP User Registration for Admins diff --git a/ldap-overleaf-sl/sharelatex/AuthenticationController.js b/ldap-overleaf-sl/sharelatex/AuthenticationController.js new file mode 100644 index 0000000..0133a60 --- /dev/null +++ b/ldap-overleaf-sl/sharelatex/AuthenticationController.js @@ -0,0 +1,733 @@ +/** + * >>>>>>>>>>>>>>>>>>>>>>>>>>>>>> + * Modified from 1e4dcc8 + * <<<<<<<<<<<<<<<<<<<<<<<<<<<<<< + */ + +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 SessionStoreManager = require('../../infrastructure/SessionStoreManager') +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 } = require('./AuthenticationErrors') +const { hasAdminAccess } = require('../Helpers/AdminAuthorizationHelper') +const Modules = require('../../infrastructure/Modules') + +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 +} + +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, + isAdmin: user.isAdmin, + staffAccess: user.staffAccess, + 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 + } + 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', 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) + }, + + finishLogin(user, req, res, next) { + if (user === false) { + return AsyncFormHelper.redirect(req, res, '/login') + } // OAuth2 'state' mismatch + + 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 + + Modules.hooks.fire( + 'preFinishLogin', + req, + res, + user, + function (error, results) { + if (error) { + return next(error) + } + 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 + UserAuditLogHandler.addEntry( + userId, + 'login', + userId, + req.ip, + auditInfo, + err => { + if (err) { + return next(err) + } + _afterLoginSessionSetup(req, user, function (err) { + if (err) { + return next(err) + } + AuthenticationController._clearRedirectFromSession(req) + AnalyticsRegistrationSourceHelper.clearSource(req.session) + AnalyticsRegistrationSourceHelper.clearInbound(req.session) + AsyncFormHelper.redirect(req, res, redir) + }) + } + ) + } + ) + }, + + 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', + status: 429, + }) + } + const auditLog = { + ipAddress: req.ip, + info: { method: 'Password login' }, + } + AuthenticationManager.authenticate( + { email }, + password, + auditLog, + function (error, user) { + if (error != null) { + if (error instanceof ParallelLoginError) { + return done(null, false, { status: 429 }) + } + 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) { + // async actions + done(null, user) + } else { + AuthenticationController._recordFailedLogin() + logger.debug({ email }, 'failed log in') + done(null, false, { + text: req.i18n.translate('email_or_password_wrong_try_again'), + type: 'error', + status: 401, + }) + } + } + ) + }) + } + ) + }, + + captchaRequiredForLogin(req, user) { + switch (AuthenticationController.getAuditInfo(req).captcha) { + 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) { + const redirectURI = encodeURIComponent(`${process.env.SHARELATEX_SITE_URL}/oauth/callback`) + const next = ( + process.env.OAUTH_AUTH_URL + + `?response_type=code` + + `&client_id=${process.env.OAUTH_CLIENT_ID}` + + `&redirect_uri=${redirectURI}` + + `&scope=${process.env.OAUTH_SCOPE}` // TODO: state + ) + res.redirect(next) + }, + + async oauth2Callback(req, res, next) { + try { + const redirectURI = encodeURIComponent(`${process.env.SHARELATEX_SITE_URL}/oauth/callback`); + const tokenResponse = await fetch(process.env.OAUTH_ACCESS_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + grant_type: "authorization_code", + client_id: process.env.OAUTH_CLIENT_ID, + client_secret: process.env.OAUTH_CLIENT_SECRET, + code: req.query.code, + redirect_uri: redirectURI, + }) + }) + + const tokenData = await tokenResponse.json() + console.log("OAuth2 respond", JSON.stringify(tokenData)) // TODO: remove + console.log("OAuth2 accessToken", tokenData.access_token) // TODO: remove + + const infoResponse = await fetch(process.env.OAUTH_USER_URL, { + method: 'GET', + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${tokenData.access_token}` + } + }) + const info = await infoResponse.json() + console.log("OAuth2 user info", JSON.stringify(info.data)) + + // TODO: legacy, check standard OAuth response + if (info.data.err) { + res.json({message: info.data.err}) + return + } + + // TODO: check standard OAuth response + const mail = info.mail + const uid = info.uid + const firstname = info.givenName + const lastname = info.sn + + const isAdmin = false // TODO: how to determine? + + const query = { email: mail } + User.findOne(query, (error, user) => { + if (error) { + console.log(error) + } + + const callback = (error, user) => { + if (error) { + res.json({message: error}); + } else { + // console.log("real_user: ", user); + AuthenticationController.finishLogin(user, req, res, next); + } + } + AuthenticationManager.createIfNotExistAndLogin( + query, + user, + callback, + uid, + firstname, + lastname, + mail, + isAdmin + ) + }) + } catch(e) { + console.log("Fails to access by OAuth2: " + String(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 + }, + + requireOauth() { + // require this here because module may not be included in some versions + const Oauth2Server = require('../../../../modules/oauth2-server/app/src/Oauth2Server') + return function (req, res, next) { + if (next == null) { + next = function () {} + } + const request = new Oauth2Server.Request(req) + const response = new Oauth2Server.Response(res) + return Oauth2Server.server.authenticate( + request, + response, + {}, + function (err, token) { + if (err) { + // use a 401 status code for malformed header for git-bridge + if ( + err.code === 400 && + err.message === 'Invalid request: malformed authorization header' + ) { + err.code = 401 + } + // send all other errors + return res + .status(err.code) + .json({ error: err.name, error_description: err.message }) + } + req.oauth = { access_token: token.accessToken } + req.oauth_token = token + req.oauth_user = token.user + return next() + } + ) + } + }, + + validateUserSession: function () { + // Middleware to check that the user's session is still good on key actions, + // such as opening a a project. Could be used to check that session has not + // exceeded a maximum lifetime (req.session.session_created), or for session + // hijacking checks (e.g. change of ip address, req.session.ip_address). For + // now, just check that the session has been loaded from the session store + // correctly. + return function (req, res, next) { + // check that the session store is returning valid results + if (req.session && !SessionStoreManager.hasValidationToken(req)) { + // force user to update session + req.session.regenerate(() => { + // need to destroy the existing session and generate a new one + // otherwise they will already be logged in when they are redirected + // to the login page + if (acceptsJson(req)) return send401WithChallenge(res) + AuthenticationController._redirectToLoginOrRegisterPage(req, res) + }) + } else { + next() + } + } + }, + + _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.query.project_name != null || + 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) { + if (callback == null) { + callback = function () {} + } + req.login(user, function (err) { + if (err) { + OError.tag(err, 'error from req.login', { + user_id: user._id, + }) + return callback(err) + } + // Regenerate the session to get a new sessionID (cookie value) to + // protect against session fixation attacks + const oldSession = req.session + req.session.destroy(function (err) { + if (err) { + OError.tag(err, 'error when trying to destroy old session', { + user_id: user._id, + }) + return callback(err) + } + req.sessionStore.generate(req) + // Note: the validation token is not writable, so it does not get + // transferred to the new session below. + for (const key in oldSession) { + const value = oldSession[key] + if (key !== '__tmp' && key !== 'csrfSecret') { + req.session[key] = value + } + } + 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()) + }) + }) + }) +} + +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.recordEventForUser(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) +} + +module.exports = AuthenticationController \ 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..6705e17 --- /dev/null +++ b/ldap-overleaf-sl/sharelatex/login.pug @@ -0,0 +1,45 @@ +//- >>>>>>>>>>>>>>>>>>>>>>>>>>>>>> +//- Modified from ee00ff3 +//- <<<<<<<<<<<<<<<<<<<<<<<<<<<<<< + +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() + .form-group + input.form-control( + type='email', + 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")}? + //- >>>>>>>>>>>>>>>>>>>>>>>>>>>>>> + .form-group.text-center(style="padding-top: 10px") + a.btn-block.login-btn(href="/oauth/redirect" style='padding-left: 0px') + | Log in via OAuth + //- <<<<<<<<<<<<<<<<<<<<<<<<<<<<<< diff --git a/ldap-overleaf-sl/sharelatex/router.js b/ldap-overleaf-sl/sharelatex/router.js new file mode 100644 index 0000000..a424756 --- /dev/null +++ b/ldap-overleaf-sl/sharelatex/router.js @@ -0,0 +1,1361 @@ +/** + * >>>>>>>>>>>>>>>>>>>>>>>>>>>>>> + * Modified from 6408d15 + * <<<<<<<<<<<<<<<<<<<<<<<<<<<<<< + */ + +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 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 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('underscore') +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 + ) + + 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') + } + +// >>>>>>>>>>>>>>>>>>>>>>>>>>>>>> +webRouter.get('/oauth/redirect', AuthenticationController.oauth2Redirect) +webRouter.get('/oauth/callback', AuthenticationController.oauth2Callback) +AuthenticationController.addEndpointToLoginWhitelist('/oauth/redirect') +AuthenticationController.addEndpointToLoginWhitelist('/oauth/callback') +// <<<<<<<<<<<<<<<<<<<<<<<<<<<<<< + + 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) + AnalyticsRouter.apply(webRouter, privateApiRouter, publicApiRouter) + LinkedFilesRouter.apply(webRouter, privateApiRouter, publicApiRouter) + TemplatesRouter.apply(webRouter) + UserMembershipRouter.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), + UserController.changePassword + ) + webRouter.get( + '/user/emails', + AuthenticationController.requireLogin(), + PermissionsController.useCapabilities(), + UserController.promises.ensureAffiliationMiddleware, + UserEmailsController.list + ) + webRouter.get('/user/emails/confirm', UserEmailsController.showConfirm) + webRouter.post( + '/user/emails/confirm', + 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(), + 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 + ) + } + + 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.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), + 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'], + }), + AuthenticationController.validateUserSession(), + 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.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.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.delete( + '/tag/:tagId/projects', + 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.proxyRequestToSpellingApi + ) + 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 sharelatex is alive (web)') + } + }) + privateApiRouter.get('/status', (req, res) => { + plainTextResponse(res, 'web sharelatex 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.get('/no-cache', function (req, res, next) { + res.header('Cache-Control', 'max-age=0') + res.sendStatus(404) + }) + + webRouter.get('/oops-express', (req, res, next) => + next(new Error('Test error')) + ) + webRouter.get('/oops-internal', function (req, res, next) { + throw new Error('Test error') + }) + webRouter.get('/oops-mongo', (req, res, next) => + require('./models/Project').Project.findOne({}, function () { + throw new Error('Test error') + }) + ) + + privateApiRouter.get('/opps-small', function (req, res, next) { + logger.err('test error occured') + res.sendStatus(200) + }) + + 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 } \ No newline at end of file From b225d6a8cea371c8c600611395e679b58f394a87 Mon Sep 17 00:00:00 2001 From: yzx9 Date: Wed, 22 Nov 2023 15:32:01 +0800 Subject: [PATCH 02/13] Update OAuth2 Configuration --- README.md | 17 ++++++++ .../sharelatex/AuthenticationController.js | 39 ++++++++----------- 2 files changed, 33 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 9dc66da..f64bc5a 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,23 @@ LDAP_CONTACT_FILTER: (objectClass=person) LDAP_CONTACTS: 'true' ``` +### OAuth2 Configuration + +GitHub: + +``` +OAUTH2_CLIENT_ID: YOUR_CLIENT_ID +OAUTH2_CLIENT_SECRET: YOUR_CLIENT_SECRET +OAUTH2_SCOPE: YOUR_SCOPE +OAUTH2_AUTHORIZATION_URL: https://github.com/login/oauth/authorize +OAUTH2_TOKEN_URL: https://github.com/login/oauth/access_token +OAUTH2_PROFILE_URL: https://api.github.com/user +OAUTH2_USER_ATTR_EMAIL: email +OAUTH2_USER_ATTR_UID: id +OAUTH2_USER_ATTR_FIRSTNAME: name +OAUTH2_USER_ATTR_LASTNAME: +``` + ### Sharelatex Configuration Edit SHARELATEX_ environment variables in [docker-compose.traefik.yml](docker-compose.traefik.yml) or [docker-compose.certbot.yml](docker-compose.certbot.yml) to fit your local setup diff --git a/ldap-overleaf-sl/sharelatex/AuthenticationController.js b/ldap-overleaf-sl/sharelatex/AuthenticationController.js index 0133a60..19534ee 100644 --- a/ldap-overleaf-sl/sharelatex/AuthenticationController.js +++ b/ldap-overleaf-sl/sharelatex/AuthenticationController.js @@ -276,11 +276,11 @@ const AuthenticationController = { oauth2Redirect(req, res, next) { const redirectURI = encodeURIComponent(`${process.env.SHARELATEX_SITE_URL}/oauth/callback`) const next = ( - process.env.OAUTH_AUTH_URL + process.env.OAUTH2_AUTHORIZATION_URL + `?response_type=code` - + `&client_id=${process.env.OAUTH_CLIENT_ID}` + + `&client_id=${process.env.OAUTH2_CLIENT_ID}` + `&redirect_uri=${redirectURI}` - + `&scope=${process.env.OAUTH_SCOPE}` // TODO: state + + `&scope=${process.env.OAUTH2_SCOPE ?? ""}` // TODO: state ) res.redirect(next) }, @@ -288,49 +288,42 @@ const AuthenticationController = { async oauth2Callback(req, res, next) { try { const redirectURI = encodeURIComponent(`${process.env.SHARELATEX_SITE_URL}/oauth/callback`); - const tokenResponse = await fetch(process.env.OAUTH_ACCESS_URL, { + const tokenResponse = await fetch(process.env.OAUTH2_TOKEN_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ grant_type: "authorization_code", - client_id: process.env.OAUTH_CLIENT_ID, - client_secret: process.env.OAUTH_CLIENT_SECRET, + client_id: process.env.OAUTH2_CLIENT_ID, + client_secret: process.env.OAUTH2_CLIENT_SECRET, code: req.query.code, redirect_uri: redirectURI, }) }) - + const tokenData = await tokenResponse.json() console.log("OAuth2 respond", JSON.stringify(tokenData)) // TODO: remove console.log("OAuth2 accessToken", tokenData.access_token) // TODO: remove - const infoResponse = await fetch(process.env.OAUTH_USER_URL, { + const profileResponse = await fetch(process.env.OAUTH2_PROFILE_URL, { method: 'GET', headers: { "Content-Type": "application/json", "Authorization": `Bearer ${tokenData.access_token}` } }) - const info = await infoResponse.json() - console.log("OAuth2 user info", JSON.stringify(info.data)) + const profile = await profileResponse.json() + console.log("OAuth2 user info", JSON.stringify(profile.data)) - // TODO: legacy, check standard OAuth response - if (info.data.err) { - res.json({message: info.data.err}) - return - } - - // TODO: check standard OAuth response - const mail = info.mail - const uid = info.uid - const firstname = info.givenName - const lastname = info.sn + 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 = profile?.[process.env.OAUTH2_USER_ATTR_LASTNAME] ?? "" const isAdmin = false // TODO: how to determine? - const query = { email: mail } + const query = { email } User.findOne(query, (error, user) => { if (error) { console.log(error) @@ -351,7 +344,7 @@ const AuthenticationController = { uid, firstname, lastname, - mail, + email, isAdmin ) }) From 1f7c65aa6d6df493b5f18a2c7f90d13a043c95f4 Mon Sep 17 00:00:00 2001 From: yzx9 Date: Thu, 23 Nov 2023 12:18:25 +0800 Subject: [PATCH 03/13] Cache docker build --- ldap-overleaf-sl/Dockerfile | 36 +++++++++++++++++++----------------- 1 file changed, 19 insertions(+), 17 deletions(-) diff --git a/ldap-overleaf-sl/Dockerfile b/ldap-overleaf-sl/Dockerfile index 9322f4e..eb134b2 100644 --- a/ldap-overleaf-sl/Dockerfile +++ b/ldap-overleaf-sl/Dockerfile @@ -13,6 +13,21 @@ 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 && \ + ## This variant of updateing texlive does not work + # bash -c tlmgr install scheme-full && \ + ## try this one: + apt-get update && \ + apt-get -y install python-pygments && \ + apt-get -y install texlive texlive-lang-german texlive-latex-extra texlive-full texlive-science && \ + apt-get clean && \ + rm -rf /var/lib/apt/lists/* + # 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/ @@ -27,22 +42,10 @@ COPY sharelatex/navbar.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 - -# 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 && \ - ## This variant of updateing texlive does not work - # bash -c tlmgr install scheme-full && \ - ## try this one: - apt-get update && \ - apt-get -y install python-pygments && \ - apt-get -y install texlive texlive-lang-german texlive-latex-extra texlive-full texlive-science && \ + ## instead of copying the login.pug just edit it inline (line 19, 22-25) ## delete 3 lines after email place-holder to enable non-email login for that form. - sed -iE '/type=.*email.*/d' /overleaf/services/web/app/views/user/login.pug && \ +RUN sed -iE '/type=.*email.*/d' /overleaf/services/web/app/views/user/login.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 && \ sed -iE "s/email@example.com/${login_text:-user}/g" /overleaf/services/web/app/views/user/login.pug && \ @@ -60,9 +63,8 @@ RUN npm install -g npm && \ rm /overleaf/services/web/modules/user-activate/app/views/user/register.pug && \ ### To remove comments entirly (bug https://github.com/overleaf/overleaf/issues/678) rm /overleaf/services/web/app/views/project/editor/review-panel.pug && \ - touch /overleaf/services/web/app/views/project/editor/review-panel.pug && \ - apt-get clean && \ - rm -rf /var/lib/apt/lists/* + touch /overleaf/services/web/app/views/project/editor/review-panel.pug + ### Nginx and Certificates # enable https via letsencrypt From 242183d60193cd23a27b0e59179fd857522c8d60 Mon Sep 17 00:00:00 2001 From: yzx9 Date: Thu, 23 Nov 2023 12:27:53 +0800 Subject: [PATCH 04/13] Add docker-compose.yml --- README.md | 18 ++++- docker-compose.certbot.yml | 11 +++ docker-compose.traefik.yml | 11 +++ docker-compose.yml | 147 +++++++++++++++++++++++++++++++++++++ 4 files changed, 184 insertions(+), 3 deletions(-) create mode 100644 docker-compose.yml diff --git a/README.md b/README.md index f64bc5a..be5c048 100644 --- a/README.md +++ b/README.md @@ -174,11 +174,23 @@ docker network create web to create a network for the docker instances. -## Startup +## Startup + +### Using without proxy + +In most cases, you should use a gateway reverse proxy for your requests (see the next section), as they can offer many benefits such as enhanced security and easier SSL certificate updates. This simple startup method is used for 1. Development 2. When you know what you're doing, for example, when there is an additional gateway layer outside your server. + +Start docker containers: + +``` +docker-compose up -d +``` + +### Using proxy There are 2 different ways of starting either using Traefik or using Certbot. Adapt the one you want to use. -### Using Traefik +#### Using Traefik Then start docker containers (with loadbalancer): ``` @@ -186,7 +198,7 @@ export NUMINSTANCES=1 docker-compose -f docker-compose.traefik.yml up -d --scale sharelatex=$NUMINSTANCES ``` -### Using Certbot +#### Using Certbot Enable line 65/66 and 69/70 in ldapoverleaf-sl/Dockerfile and ``make`` again. ``` diff --git a/docker-compose.certbot.yml b/docker-compose.certbot.yml index 3bf1245..4563d76 100644 --- a/docker-compose.certbot.yml +++ b/docker-compose.certbot.yml @@ -81,6 +81,17 @@ services: LDAP_CONTACT_FILTER: "(memberof=cn=GROUPNAME,ou=groups,dc=DOMAIN,dc=TLD)" LDAP_CONTACTS: "false" + # OAUTH2_CLIENT_ID: YOUR_OAUTH2_CLIENT_ID + # OAUTH2_CLIENT_SECRET: YOUR_OAUTH2_CLIENT_SECRET + # OAUTH2_SCOPE: YOUR_OAUTH2_SCOPE + # OAUTH2_AUTHORIZATION_URL: YOUR_OAUTH2_AUTHORIZATION_URL + # OAUTH2_TOKEN_URL: YOUR_OAUTH2_TOKEN_URL + # OAUTH2_PROFILE_URL: YOUR_OAUTH2_PROFILE_URL + # OAUTH2_USER_ATTR_EMAIL: email + # OAUTH2_USER_ATTR_UID: id + # OAUTH2_USER_ATTR_FIRSTNAME: name + # OAUTH2_USER_ATTR_LASTNAME: + # Same property, unfortunately with different names in # different locations SHARELATEX_REDIS_HOST: redis diff --git a/docker-compose.traefik.yml b/docker-compose.traefik.yml index f5e7895..1719396 100644 --- a/docker-compose.traefik.yml +++ b/docker-compose.traefik.yml @@ -162,6 +162,17 @@ services: LDAP_CONTACT_FILTER: "(memberof=cn=GROUPNAME,ou=groups,dc=DOMAIN,dc=TLD)" LDAP_CONTACTS: "false" + # OAUTH2_CLIENT_ID: YOUR_OAUTH2_CLIENT_ID + # OAUTH2_CLIENT_SECRET: YOUR_OAUTH2_CLIENT_SECRET + # OAUTH2_SCOPE: YOUR_OAUTH2_SCOPE + # OAUTH2_AUTHORIZATION_URL: YOUR_OAUTH2_AUTHORIZATION_URL + # OAUTH2_TOKEN_URL: YOUR_OAUTH2_TOKEN_URL + # OAUTH2_PROFILE_URL: YOUR_OAUTH2_PROFILE_URL + # OAUTH2_USER_ATTR_EMAIL: email + # OAUTH2_USER_ATTR_UID: id + # OAUTH2_USER_ATTR_FIRSTNAME: name + # OAUTH2_USER_ATTR_LASTNAME: + # Same property, unfortunately with different names in # different locations SHARELATEX_REDIS_HOST: redis diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..073f9b6 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,147 @@ +version: "2.2" +services: + sharelatex: + restart: always + image: ldap-overleaf-sl + container_name: ldap-overleaf-sl + depends_on: + mongo: + condition: service_healthy + redis: + condition: service_healthy + privileged: false + ports: + - 80:80 + links: + - mongo + - redis + volumes: + - ${MYDATA}/sharelatex:/var/lib/sharelatex + - ${MYDATA}/letsencrypt:/etc/letsencrypt + - ${MYDATA}/letsencrypt/live/${MYDOMAIN}/:/etc/letsencrypt/certs/domain + environment: + SHARELATEX_APP_NAME: Overleaf + SHARELATEX_MONGO_URL: mongodb://mongo/sharelatex + SHARELATEX_SITE_URL: https://${MYDOMAIN} + SHARELATEX_NAV_TITLE: Overleaf - run by ${MYDOMAIN} + #SHARELATEX_HEADER_IMAGE_URL: https://${MYDOMAIN}/logo.svg + SHARELATEX_ADMIN_EMAIL: ${MYMAIL} + SHARELATEX_LEFT_FOOTER: '[{"text": "Powered by ShareLaTeX 2016"} ]' + SHARELATEX_RIGHT_FOOTER: '[{"text": "LDAP Overleaf (beta)"} ]' + SHARELATEX_EMAIL_FROM_ADDRESS: "noreply@${MYDOMAIN}" + # SHARELATEX_EMAIL_AWS_SES_ACCESS_KEY_ID: + # SHARELATEX_EMAIL_AWS_SES_SECRET_KEY: + SHARELATEX_EMAIL_SMTP_HOST: smtp.${MYDOMAIN} + SHARELATEX_EMAIL_SMTP_PORT: 587 + SHARELATEX_EMAIL_SMTP_SECURE: "false" + # SHARELATEX_EMAIL_SMTP_USER: + # SHARELATEX_EMAIL_SMTP_PASS: + # SHARELATEX_EMAIL_SMTP_TLS_REJECT_UNAUTH: true + # SHARELATEX_EMAIL_SMTP_IGNORE_TLS: false + SHARELATEX_CUSTOM_EMAIL_FOOTER: "This system is run by ${MYDOMAIN} - please contact ${MYMAIL} if you experience any issues." + + # make public links accessible w/o login (link sharing issue) + # https://github.com/overleaf/docker-image/issues/66 + # https://github.com/overleaf/overleaf/issues/628 + # https://github.com/overleaf/web/issues/367 + # Fixed in 2.0.2 (Release date: 2019-11-26) + SHARELATEX_ALLOW_PUBLIC_ACCESS: "true" + SHARELATEX_ALLOW_ANONYMOUS_READ_AND_WRITE_SHARING: "true" + + SHARELATEX_SECURE_COOKIE: "true" + SHARELATEX_BEHIND_PROXY: "true" + + LDAP_SERVER: ldaps://LDAPSERVER:636 + LDAP_BASE: ou=people,dc=DOMAIN,dc=TLD + + ### There are to ways get users from the ldap server + + ## NO LDAP BIND USER: + # Tries directly to bind with the login user (as uid) + # LDAP_BINDDN: uid=%u,ou=someunit,ou=people,dc=DOMAIN,dc=TLD + + ## Or you can use ai global LDAP_BIND_USER + # LDAP_BIND_USER: + # LDAP_BIND_PW: + + # Only allow users matching LDAP_USER_FILTER + LDAP_USER_FILTER: "(memberof=cn=GROUPNAME,ou=groups,dc=DOMAIN,dc=TLD)" + + # If user is in ADMIN_GROUP on user creation (first login) isAdmin is set to true. + # Admin Users can invite external (non ldap) users. This feature makes only sense + # when ALLOW_EMAIL_LOGIN is set to 'true'. Additionally admins can send + # system wide messages. + LDAP_ADMIN_GROUP_FILTER: "(memberof=cn=ADMINGROUPNAME,ou=groups,dc=DOMAIN,dc=TLD)" + ALLOW_EMAIL_LOGIN: "true" + + # All users in the LDAP_CONTACT_FILTER are loaded from the ldap server into contacts. + LDAP_CONTACT_FILTER: "(memberof=cn=GROUPNAME,ou=groups,dc=DOMAIN,dc=TLD)" + LDAP_CONTACTS: "false" + + # OAUTH2_CLIENT_ID: YOUR_OAUTH2_CLIENT_ID + # OAUTH2_CLIENT_SECRET: YOUR_OAUTH2_CLIENT_SECRET + # OAUTH2_SCOPE: YOUR_OAUTH2_SCOPE + # OAUTH2_AUTHORIZATION_URL: YOUR_OAUTH2_AUTHORIZATION_URL + # OAUTH2_TOKEN_URL: YOUR_OAUTH2_TOKEN_URL + # OAUTH2_PROFILE_URL: YOUR_OAUTH2_PROFILE_URL + # OAUTH2_USER_ATTR_EMAIL: email + # OAUTH2_USER_ATTR_UID: id + # OAUTH2_USER_ATTR_FIRSTNAME: name + # OAUTH2_USER_ATTR_LASTNAME: + + # Same property, unfortunately with different names in + # different locations + SHARELATEX_REDIS_HOST: redis + REDIS_HOST: redis + REDIS_PORT: 6379 + + ENABLED_LINKED_FILE_TYPES: "url,project_file" + + # Enables Thumbnail generation using ImageMagick + ENABLE_CONVERSIONS: "true" + + mongo: + restart: always + image: mongo:4.4 + container_name: mongo + expose: + - 27017 + volumes: + - ${MYDATA}/mongo_data:/data/db + healthcheck: + test: echo 'db.stats().ok' | mongo localhost:27017/test --quiet + interval: 10s + timeout: 10s + retries: 5 + command: "--replSet overleaf" + + # See also: https://github.com/overleaf/overleaf/issues/1120 + mongoinit: + image: mongo:4.4 + # this container will exit after executing the command + restart: "no" + depends_on: + mongo: + condition: service_healthy + entrypoint: + [ + "mongo", + "--host", + "mongo:27017", + "--eval", + 'rs.initiate({ _id: "overleaf", members: [ { _id: 0, host: "mongo:27017" } ] })', + ] + + redis: + restart: always + image: redis:6.2 + container_name: redis + expose: + - 6379 + volumes: + - ${MYDATA}/redis_data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 From f53790c452d7050aee039f342619d4db5dbabb5b Mon Sep 17 00:00:00 2001 From: yzx9 Date: Thu, 23 Nov 2023 17:06:44 +0800 Subject: [PATCH 05/13] Fix bugs --- ldap-overleaf-sl/Dockerfile | 7 +- .../sharelatex/AuthenticationController.js | 68 +++++++++---------- .../sharelatex/AuthenticationManager.js | 33 ++++++++- ldap-overleaf-sl/sharelatex/login.pug | 14 +++- ldap-overleaf-sl/sharelatex/router.js | 51 +++++++++----- 5 files changed, 110 insertions(+), 63 deletions(-) diff --git a/ldap-overleaf-sl/Dockerfile b/ldap-overleaf-sl/Dockerfile index eb134b2..620887b 100644 --- a/ldap-overleaf-sl/Dockerfile +++ b/ldap-overleaf-sl/Dockerfile @@ -42,13 +42,10 @@ COPY sharelatex/navbar.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 - - ## instead of copying the login.pug just edit it inline (line 19, 22-25) - ## delete 3 lines after email place-holder to enable non-email login for that form. -RUN sed -iE '/type=.*email.*/d' /overleaf/services/web/app/views/user/login.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 && \ - sed -iE "s/email@example.com/${login_text:-user}/g" /overleaf/services/web/app/views/user/login.pug && \ +RUN sed -iE "s/email@example.com/${login_text:-user}/g" /overleaf/services/web/app/views/user/login.pug && \ ## Collaboration settings display (share project placeholder) | edit line 146 ## share.pug file was removed in later versions # sed -iE "s%placeholder=.*$%placeholder=\"${collab_text}\"%g" /overleaf/services/web/app/views/project/editor/share.pug && \ diff --git a/ldap-overleaf-sl/sharelatex/AuthenticationController.js b/ldap-overleaf-sl/sharelatex/AuthenticationController.js index 19534ee..36f21b5 100644 --- a/ldap-overleaf-sl/sharelatex/AuthenticationController.js +++ b/ldap-overleaf-sl/sharelatex/AuthenticationController.js @@ -275,81 +275,79 @@ const AuthenticationController = { // >>>>>>>>>>>>>>>>>>>>>>>>>>>>>> oauth2Redirect(req, res, next) { const redirectURI = encodeURIComponent(`${process.env.SHARELATEX_SITE_URL}/oauth/callback`) - const next = ( + 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 ?? ""}` // TODO: state ) - res.redirect(next) + res.redirect(authURL) }, async oauth2Callback(req, res, next) { try { - const redirectURI = encodeURIComponent(`${process.env.SHARELATEX_SITE_URL}/oauth/callback`); + console.log("OAuth2 code", req.query.code) const tokenResponse = await fetch(process.env.OAUTH2_TOKEN_URL, { method: 'POST', headers: { - 'Content-Type': 'application/json' + "Accept": "application/json", + "Content-Type": "application/json", }, body: JSON.stringify({ grant_type: "authorization_code", client_id: process.env.OAUTH2_CLIENT_ID, client_secret: process.env.OAUTH2_CLIENT_SECRET, code: req.query.code, - redirect_uri: redirectURI, + redirect_uri: `${process.env.SHARELATEX_SITE_URL}/oauth/callback`, }) }) const tokenData = await tokenResponse.json() - console.log("OAuth2 respond", JSON.stringify(tokenData)) // TODO: remove - console.log("OAuth2 accessToken", tokenData.access_token) // TODO: remove + 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}`, "Content-Type": "application/json", - "Authorization": `Bearer ${tokenData.access_token}` } }) const profile = await profileResponse.json() - console.log("OAuth2 user info", JSON.stringify(profile.data)) + 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 = profile?.[process.env.OAUTH2_USER_ATTR_LASTNAME] ?? "" - - const isAdmin = false // TODO: how to determine? + 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 } - User.findOne(query, (error, user) => { + const callback = (error, user) => { if (error) { - console.log(error) + res.json({message: error}); + } else { + console.log("OAuth user", JSON.stringify(user)); + AuthenticationController.finishLogin(user, req, res, next); } - - const callback = (error, user) => { - if (error) { - res.json({message: error}); - } else { - // console.log("real_user: ", user); - AuthenticationController.finishLogin(user, req, res, next); - } - } - AuthenticationManager.createIfNotExistAndLogin( - query, - user, - callback, - uid, - firstname, - lastname, - email, - isAdmin - ) - }) + } + AuthenticationManager.createIfNotFoundAndLogin( + query, + callback, + uid, + firstname, + lastname, + email, + isAdmin + ) } catch(e) { - console.log("Fails to access by OAuth2: " + String(e)) + res.redirect("/login") + console.error("Fails to access by OAuth2: " + String(e)) } }, // <<<<<<<<<<<<<<<<<<<<<<<<<<<<<< diff --git a/ldap-overleaf-sl/sharelatex/AuthenticationManager.js b/ldap-overleaf-sl/sharelatex/AuthenticationManager.js index 5f474c6..bd2741e 100644 --- a/ldap-overleaf-sl/sharelatex/AuthenticationManager.js +++ b/ldap-overleaf-sl/sharelatex/AuthenticationManager.js @@ -184,6 +184,33 @@ const AuthenticationManager = { callback(null, user, true) }, + createIfNotFoundAndLogin( + query, + callback, + uid, + firstname, + lastname, + mail, + isAdmin + ) { + User.findOne(query, (error, user) => { + if (error) { + console.log(error) + } + + AuthenticationManager.createIfNotExistAndLogin( + query, + user, + callback, + uid, + firstname, + lastname, + mail, + isAdmin + ) + }) + }, + createIfNotExistAndLogin( query, user, @@ -195,10 +222,9 @@ const AuthenticationManager = { isAdmin ) { if (!user) { - //console.log('Creating User:' + JSON.stringify(query)) //create random pass for local userdb, does not get checked for ldap users during login - let pass = require("crypto").randomBytes(32).toString("hex") - //console.log('Creating User:' + JSON.stringify(query) + 'Random Pass' + pass) + const pass = require("crypto").randomBytes(32).toString("hex") + console.log('Creating User', { mail, uid, firstname, lastname, isAdmin, pass }) const userRegHand = require("../User/UserRegistrationHandler.js") userRegHand.registerNewUser( @@ -228,6 +254,7 @@ const AuthenticationManager = { } ) // end register user } else { + console.log('User exists', { mail }) AuthenticationManager.login(user, "randomPass", callback) } }, diff --git a/ldap-overleaf-sl/sharelatex/login.pug b/ldap-overleaf-sl/sharelatex/login.pug index 6705e17..80f8010 100644 --- a/ldap-overleaf-sl/sharelatex/login.pug +++ b/ldap-overleaf-sl/sharelatex/login.pug @@ -16,13 +16,21 @@ block content input(name='_csrf', type='hidden', value=csrfToken) +formMessages() .form-group + //- >>>>>>>>>>>>>>>>>>>>>>>>>>>>>> + //- input.form-control( + //- type='email', + //- name='email', + //- required, + //- placeholder='email@example.com', + //- autofocus="true" + //- ) input.form-control( - type='email', name='email', required, placeholder='email@example.com', autofocus="true" ) + //- <<<<<<<<<<<<<<<<<<<<<<<<<<<<<< .form-group input.form-control( type='password', @@ -38,8 +46,8 @@ block content 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")}? - //- >>>>>>>>>>>>>>>>>>>>>>>>>>>>>> + //- >>>>>>>>>>>>>>>>>>>>>>>>>>>>>> .form-group.text-center(style="padding-top: 10px") a.btn-block.login-btn(href="/oauth/redirect" style='padding-left: 0px') | Log in via OAuth - //- <<<<<<<<<<<<<<<<<<<<<<<<<<<<<< + //- <<<<<<<<<<<<<<<<<<<<<<<<<<<<<< diff --git a/ldap-overleaf-sl/sharelatex/router.js b/ldap-overleaf-sl/sharelatex/router.js index a424756..71c91f8 100644 --- a/ldap-overleaf-sl/sharelatex/router.js +++ b/ldap-overleaf-sl/sharelatex/router.js @@ -1,6 +1,6 @@ /** * >>>>>>>>>>>>>>>>>>>>>>>>>>>>>> - * Modified from 6408d15 + * Modified from bf92436 * <<<<<<<<<<<<<<<<<<<<<<<<<<<<<< */ @@ -27,7 +27,6 @@ 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') @@ -105,6 +104,10 @@ const rateLimiters = { points: 10, duration: 60, }), + confirmUniversityDomain: new RateLimiter('confirm-university-domain', { + points: 1, + duration: 60, + }), createProject: new RateLimiter('create-project', { points: 20, duration: 60, @@ -149,6 +152,10 @@ const rateLimiters = { points: 30, duration: 60, }), + indexProjectReferences: new RateLimiter('index-project-references', { + points: 30, + duration: 60, + }), miscOutputDownload: new RateLimiter('misc-output-download', { points: 1000, duration: 60 * 60, @@ -185,7 +192,7 @@ const rateLimiters = { duration: 60, }), resendConfirmation: new RateLimiter('resend-confirmation', { - points: 1, + points: 10, duration: 60, }), sendChatMessage: new RateLimiter('send-chat-message', { @@ -256,12 +263,12 @@ function initialize(webRouter, privateApiRouter, publicApiRouter) { AuthenticationController.addEndpointToLoginWhitelist('/register') } -// >>>>>>>>>>>>>>>>>>>>>>>>>>>>>> -webRouter.get('/oauth/redirect', AuthenticationController.oauth2Redirect) -webRouter.get('/oauth/callback', AuthenticationController.oauth2Callback) -AuthenticationController.addEndpointToLoginWhitelist('/oauth/redirect') -AuthenticationController.addEndpointToLoginWhitelist('/oauth/callback') -// <<<<<<<<<<<<<<<<<<<<<<<<<<<<<< + // >>>>>>>>>>>>>>>>>>>>>>>>>>>>>> + webRouter.get('/oauth/redirect', AuthenticationController.oauth2Redirect) + webRouter.get('/oauth/callback', AuthenticationController.oauth2Callback) + AuthenticationController.addEndpointToLoginWhitelist('/oauth/redirect') + AuthenticationController.addEndpointToLoginWhitelist('/oauth/callback') + // <<<<<<<<<<<<<<<<<<<<<<<<<<<<<< EditorRouter.apply(webRouter, privateApiRouter) CollaboratorsRouter.apply(webRouter, privateApiRouter) @@ -433,12 +440,6 @@ AuthenticationController.addEndpointToLoginWhitelist('/oauth/callback') TpdsController.getQueues ) - webRouter.post( - '/tutorial/:tutorialKey/complete', - AuthenticationController.requireLogin(), - TutorialController.completeTutorial - ) - webRouter.get( '/user/projects', AuthenticationController.requireLogin(), @@ -734,6 +735,16 @@ AuthenticationController.addEndpointToLoginWhitelist('/oauth/callback') AuthorizationMiddleware.ensureUserCanReadProject, HistoryController.proxyToHistoryApi ) + webRouter.post( + '/project/:Project_id/doc/:doc_id/version/:version_id/restore', + AuthorizationMiddleware.ensureUserCanWriteProjectContent, + HistoryController.proxyToHistoryApi + ) + webRouter.post( + '/project/:project_id/doc/:doc_id/restore', + AuthorizationMiddleware.ensureUserCanWriteProjectContent, + HistoryController.restoreDocFromDeletedDoc + ) webRouter.post( '/project/:project_id/restore_file', AuthorizationMiddleware.ensureUserCanWriteProjectContent, @@ -1082,6 +1093,12 @@ AuthenticationController.addEndpointToLoginWhitelist('/oauth/callback') ChatController.sendMessage ) + webRouter.post( + '/project/:Project_id/references/index', + AuthorizationMiddleware.ensureUserCanReadProject, + RateLimiterMiddleware.rateLimit(rateLimiters.indexProjectReferences), + ReferencesController.index + ) webRouter.post( '/project/:Project_id/references/indexAll', AuthorizationMiddleware.ensureUserCanReadProject, @@ -1130,6 +1147,7 @@ AuthenticationController.addEndpointToLoginWhitelist('/oauth/callback') ) publicApiRouter.post( '/api/institutions/confirm_university_domain', + RateLimiterMiddleware.rateLimit(rateLimiters.confirmUniversityDomain), AuthenticationController.requirePrivateApiAuth(), InstitutionsController.confirmDomain ) @@ -1357,5 +1375,4 @@ AuthenticationController.addEndpointToLoginWhitelist('/oauth/callback') webRouter.get('*', ErrorController.notFound) } - -module.exports = { initialize, rateLimiters } \ No newline at end of file +module.exports = { initialize, rateLimiters } From 78652946ee2841781f40e9012d556a3dafdee0c2 Mon Sep 17 00:00:00 2001 From: yzx9 Date: Thu, 23 Nov 2023 17:20:11 +0800 Subject: [PATCH 06/13] Update README.md --- README.md | 45 ++++++++++++++++++++++++++++----------------- 1 file changed, 28 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index be5c048..c8921e7 100644 --- a/README.md +++ b/README.md @@ -8,29 +8,32 @@ The inital idea for this implementation was taken from [worksasintended](https://github.com/worksasintended). ## BREAKING CHANGE -be careful if you try to migrate from 3.3.2! Backup your machines and data. -The migration paths hould be: -- Backup Your machines and Data -- run latest 3.5 sharelatex image and run the migration scripts -- run this sharelatex image (4.1.1) and run the migrations scripts +Be careful if you try to migrate from 3.3.2! Backup your machines and data. The migration paths should be: + +- Backup Your machines and data +- Run latest 3.5 sharelatex image and run the migration scripts +- Run this sharelatex image (4.1.1) and run the migrations scripts + +## Limitations -## Limitations: NEW: This version provides the possibility to use a separate ldap bind user. It does this just to find the proper BIND DN and record for the provided email, so it is possible that users from different groups / OUs can login. Afterwards it tries to bind to the ldap (using ldapts) with the user DN and credentials of the user which tries to login. No hassle of password hashing for LDAP pwds! If you upgrade from an older commit: -**Note**: + +**Note**: + - you have to add: uid=%u to your BIND_DN - LDAP_GROUP_FILTER is now named LDAP_USER_FILTER - Import of contacts from LDAP is now controlled by LDAP_CONTACT_FILTER - Only valid LDAP users or email users registered by an admin can login. This module authenticates against the local DB if `ALLOW_EMAIL_LOGIN` is set to `true` if this fails it tries to authenticate against the specified LDAP server. -*Note:* +**Note**: + - LDAP Users can not change their password for the ldap username login. They have to change it at the ldap server. - LDAP Users can reset their local db password. Then they can decide if they login with either their ldap user and password or with their email and local db password. - Users can not change their email. The email address is taken from the ldap server (mail) field. (or by invitation through an admin). @@ -81,12 +84,9 @@ ADMIN_IS_SYSADMIN=false *COLLAB_TEXT* : displayed for email invitation (share.pug)
*ADMIN_IS_SYSADMIN* : false or true (if ``false`` isAdmin group is allowed to add users to sharelatex and post messages. if ``true`` isAdmin group is allowed to logout other users / set maintenance mode) - ### LDAP Configuration -Edit [docker-compose.treafik.yml](docker-compose.traefik.yml) or [docker-compose.certbot.yml](docker-compose.certbot.yml) to fit your local setup. - - +Edit [docker-compose.treafik.yml](docker-compose.traefik.yml) or [docker-compose.certbot.yml](docker-compose.certbot.yml) to fit your local setup. ``` LDAP_SERVER: ldaps://LDAPSERVER:636 @@ -121,6 +121,7 @@ LDAP_CONTACTS: 'false' If you enable LDAP_CONTACTS, then all users in LDAP_CONTACT_FILTER are loaded from the ldap server into the contacts. At the moment this happens every time you click on "Share" within a project. if you want to enable this function set: + ``` LDAP_CONTACT_FILTER: (objectClass=person) LDAP_CONTACTS: 'true' @@ -140,7 +141,7 @@ OAUTH2_PROFILE_URL: https://api.github.com/user OAUTH2_USER_ATTR_EMAIL: email OAUTH2_USER_ATTR_UID: id OAUTH2_USER_ATTR_FIRSTNAME: name -OAUTH2_USER_ATTR_LASTNAME: +OAUTH2_USER_ATTR_LASTNAME: site_admin ``` ### Sharelatex Configuration @@ -160,19 +161,21 @@ Install docker-compose: pip install docker-compose ``` +use the command: -use the command ``` make ``` + to generate the ldap-overleaf-sl docker image. -use the command +use the command: + ``` docker network create web ``` -to create a network for the docker instances. +to create a network for the docker instances. ## Startup @@ -193,19 +196,27 @@ There are 2 different ways of starting either using Traefik or using Certbot. Ad #### Using Traefik Then start docker containers (with loadbalancer): + ``` export NUMINSTANCES=1 docker-compose -f docker-compose.traefik.yml up -d --scale sharelatex=$NUMINSTANCES ``` #### Using Certbot + Enable line 65/66 and 69/70 in ldapoverleaf-sl/Dockerfile and ``make`` again. ``` docker-compose -f docker-compose.certbot.yml up -d ``` +## Debug + +1. Set the env variable `LOG_LEVEL` to debug (default is info - you can do this in the docker-compose file) +2. Look in the logs of sharelatex (e.g. `/var/log/sharelatex/web.log`) + ## Upgrading + *Be aware:* if you upgrade from a previous installation check your docker image version E.g.: Mongodb: You cannot upgrade directly from mongo 4.2 to 5.0. You must first upgrade from 4.2 to 4.4. From a30419ea5aefe7836ac162918cc0a1aaca24f960 Mon Sep 17 00:00:00 2001 From: yzx9 Date: Fri, 24 Nov 2023 13:58:33 +0800 Subject: [PATCH 07/13] Disable secure cookie in no proxy settings --- README.md | 19 ++++++++++--------- docker-compose.certbot.yml | 2 ++ docker-compose.traefik.yml | 2 ++ docker-compose.yml | 7 +++++-- 4 files changed, 19 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index c8921e7..913dfa3 100644 --- a/README.md +++ b/README.md @@ -141,7 +141,8 @@ OAUTH2_PROFILE_URL: https://api.github.com/user OAUTH2_USER_ATTR_EMAIL: email OAUTH2_USER_ATTR_UID: id OAUTH2_USER_ATTR_FIRSTNAME: name -OAUTH2_USER_ATTR_LASTNAME: site_admin +OAUTH2_USER_ATTR_LASTNAME: +OAUTH2_USER_ATTR_IS_ADMIN: site_admin ``` ### Sharelatex Configuration @@ -149,7 +150,7 @@ OAUTH2_USER_ATTR_LASTNAME: site_admin Edit SHARELATEX_ environment variables in [docker-compose.traefik.yml](docker-compose.traefik.yml) or [docker-compose.certbot.yml](docker-compose.certbot.yml) to fit your local setup (e.g. proper SMTP server, Header, Footer, App Name,...). See https://github.com/overleaf/overleaf/wiki/Quick-Start-Guide for more details. -## Installation, Usage and Inital startup +## Installation, Usage and Initial startup Install the docker engine: https://docs.docker.com/engine/install/ @@ -177,9 +178,9 @@ docker network create web to create a network for the docker instances. -## Startup +### Startup -### Using without proxy +#### Using without proxy In most cases, you should use a gateway reverse proxy for your requests (see the next section), as they can offer many benefits such as enhanced security and easier SSL certificate updates. This simple startup method is used for 1. Development 2. When you know what you're doing, for example, when there is an additional gateway layer outside your server. @@ -189,11 +190,11 @@ Start docker containers: docker-compose up -d ``` -### Using proxy +#### Using proxy There are 2 different ways of starting either using Traefik or using Certbot. Adapt the one you want to use. -#### Using Traefik +##### Using Traefik Then start docker containers (with loadbalancer): @@ -202,7 +203,7 @@ export NUMINSTANCES=1 docker-compose -f docker-compose.traefik.yml up -d --scale sharelatex=$NUMINSTANCES ``` -#### Using Certbot +##### Using Certbot Enable line 65/66 and 69/70 in ldapoverleaf-sl/Dockerfile and ``make`` again. @@ -212,8 +213,8 @@ docker-compose -f docker-compose.certbot.yml up -d ## Debug -1. Set the env variable `LOG_LEVEL` to debug (default is info - you can do this in the docker-compose file) -2. Look in the logs of sharelatex (e.g. `/var/log/sharelatex/web.log`) +1. Set the env variable `LOG_LEVEL` to `debug` (default is info - you can do this in the docker-compose file) +2. Check the logs in ShareLaTeX, particularly at `/var/log/sharelatex/web.log`. You can do this by using the command: `docker exec ldap-overleaf-sl cat /var/log/sharelatex/web.log`. ## Upgrading diff --git a/docker-compose.certbot.yml b/docker-compose.certbot.yml index 4563d76..d3317de 100644 --- a/docker-compose.certbot.yml +++ b/docker-compose.certbot.yml @@ -81,6 +81,7 @@ services: LDAP_CONTACT_FILTER: "(memberof=cn=GROUPNAME,ou=groups,dc=DOMAIN,dc=TLD)" LDAP_CONTACTS: "false" + ## OAuth2 Settings # OAUTH2_CLIENT_ID: YOUR_OAUTH2_CLIENT_ID # OAUTH2_CLIENT_SECRET: YOUR_OAUTH2_CLIENT_SECRET # OAUTH2_SCOPE: YOUR_OAUTH2_SCOPE @@ -91,6 +92,7 @@ services: # OAUTH2_USER_ATTR_UID: id # OAUTH2_USER_ATTR_FIRSTNAME: name # OAUTH2_USER_ATTR_LASTNAME: + # OAUTH2_USER_ATTR_IS_ADMIN: site_admin # Same property, unfortunately with different names in # different locations diff --git a/docker-compose.traefik.yml b/docker-compose.traefik.yml index 1719396..c9b81e2 100644 --- a/docker-compose.traefik.yml +++ b/docker-compose.traefik.yml @@ -162,6 +162,7 @@ services: LDAP_CONTACT_FILTER: "(memberof=cn=GROUPNAME,ou=groups,dc=DOMAIN,dc=TLD)" LDAP_CONTACTS: "false" + ## OAuth2 Settings # OAUTH2_CLIENT_ID: YOUR_OAUTH2_CLIENT_ID # OAUTH2_CLIENT_SECRET: YOUR_OAUTH2_CLIENT_SECRET # OAUTH2_SCOPE: YOUR_OAUTH2_SCOPE @@ -172,6 +173,7 @@ services: # OAUTH2_USER_ATTR_UID: id # OAUTH2_USER_ATTR_FIRSTNAME: name # OAUTH2_USER_ATTR_LASTNAME: + # OAUTH2_USER_ATTR_IS_ADMIN: site_admin # Same property, unfortunately with different names in # different locations diff --git a/docker-compose.yml b/docker-compose.yml index 073f9b6..54d6e34 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -48,8 +48,9 @@ services: SHARELATEX_ALLOW_PUBLIC_ACCESS: "true" SHARELATEX_ALLOW_ANONYMOUS_READ_AND_WRITE_SHARING: "true" - SHARELATEX_SECURE_COOKIE: "true" - SHARELATEX_BEHIND_PROXY: "true" + # Uncomment the following line to enable secure cookies if you are using SSL + # SHARELATEX_SECURE_COOKIE: "true" + # SHARELATEX_BEHIND_PROXY: "true" LDAP_SERVER: ldaps://LDAPSERVER:636 LDAP_BASE: ou=people,dc=DOMAIN,dc=TLD @@ -78,6 +79,7 @@ services: LDAP_CONTACT_FILTER: "(memberof=cn=GROUPNAME,ou=groups,dc=DOMAIN,dc=TLD)" LDAP_CONTACTS: "false" + ## OAuth2 Settings # OAUTH2_CLIENT_ID: YOUR_OAUTH2_CLIENT_ID # OAUTH2_CLIENT_SECRET: YOUR_OAUTH2_CLIENT_SECRET # OAUTH2_SCOPE: YOUR_OAUTH2_SCOPE @@ -88,6 +90,7 @@ services: # OAUTH2_USER_ATTR_UID: id # OAUTH2_USER_ATTR_FIRSTNAME: name # OAUTH2_USER_ATTR_LASTNAME: + # OAUTH2_USER_ATTR_IS_ADMIN: site_admin # Same property, unfortunately with different names in # different locations From b1d9cedddb9ec6aa39fb93c51284934cdbf94cbb Mon Sep 17 00:00:00 2001 From: yzx9 Date: Fri, 24 Nov 2023 14:41:49 +0800 Subject: [PATCH 08/13] Add oauth enable flag --- README.md | 1 + docker-compose.certbot.yml | 1 + docker-compose.traefik.yml | 1 + docker-compose.yml | 1 + ldap-overleaf-sl/sharelatex/login.pug | 7 ++++--- ldap-overleaf-sl/sharelatex/router.js | 10 ++++++---- 6 files changed, 14 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 913dfa3..6a5f80b 100644 --- a/README.md +++ b/README.md @@ -132,6 +132,7 @@ LDAP_CONTACTS: 'true' GitHub: ``` +OAUTH2_ENABLED: "true" OAUTH2_CLIENT_ID: YOUR_CLIENT_ID OAUTH2_CLIENT_SECRET: YOUR_CLIENT_SECRET OAUTH2_SCOPE: YOUR_SCOPE diff --git a/docker-compose.certbot.yml b/docker-compose.certbot.yml index d3317de..2737543 100644 --- a/docker-compose.certbot.yml +++ b/docker-compose.certbot.yml @@ -82,6 +82,7 @@ services: LDAP_CONTACTS: "false" ## OAuth2 Settings + # OAUTH2_ENABLED: "true" # OAUTH2_CLIENT_ID: YOUR_OAUTH2_CLIENT_ID # OAUTH2_CLIENT_SECRET: YOUR_OAUTH2_CLIENT_SECRET # OAUTH2_SCOPE: YOUR_OAUTH2_SCOPE diff --git a/docker-compose.traefik.yml b/docker-compose.traefik.yml index c9b81e2..62095ee 100644 --- a/docker-compose.traefik.yml +++ b/docker-compose.traefik.yml @@ -163,6 +163,7 @@ services: LDAP_CONTACTS: "false" ## OAuth2 Settings + # OAUTH2_ENABLED: "true" # OAUTH2_CLIENT_ID: YOUR_OAUTH2_CLIENT_ID # OAUTH2_CLIENT_SECRET: YOUR_OAUTH2_CLIENT_SECRET # OAUTH2_SCOPE: YOUR_OAUTH2_SCOPE diff --git a/docker-compose.yml b/docker-compose.yml index 54d6e34..9b809a1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -80,6 +80,7 @@ services: LDAP_CONTACTS: "false" ## OAuth2 Settings + # OAUTH2_ENABLED: "true" # OAUTH2_CLIENT_ID: YOUR_OAUTH2_CLIENT_ID # OAUTH2_CLIENT_SECRET: YOUR_OAUTH2_CLIENT_SECRET # OAUTH2_SCOPE: YOUR_OAUTH2_SCOPE diff --git a/ldap-overleaf-sl/sharelatex/login.pug b/ldap-overleaf-sl/sharelatex/login.pug index 80f8010..f165d44 100644 --- a/ldap-overleaf-sl/sharelatex/login.pug +++ b/ldap-overleaf-sl/sharelatex/login.pug @@ -47,7 +47,8 @@ block content span(hidden data-ol-inflight="pending") #{translate("logging_in")}… a.pull-right(href='/user/password/reset') #{translate("forgot_your_password")}? //- >>>>>>>>>>>>>>>>>>>>>>>>>>>>>> - .form-group.text-center(style="padding-top: 10px") - a.btn-block.login-btn(href="/oauth/redirect" style='padding-left: 0px') - | Log in via OAuth + 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 OAuth2 //- <<<<<<<<<<<<<<<<<<<<<<<<<<<<<< diff --git a/ldap-overleaf-sl/sharelatex/router.js b/ldap-overleaf-sl/sharelatex/router.js index 71c91f8..83033f4 100644 --- a/ldap-overleaf-sl/sharelatex/router.js +++ b/ldap-overleaf-sl/sharelatex/router.js @@ -264,10 +264,12 @@ function initialize(webRouter, privateApiRouter, publicApiRouter) { } // >>>>>>>>>>>>>>>>>>>>>>>>>>>>>> - webRouter.get('/oauth/redirect', AuthenticationController.oauth2Redirect) - webRouter.get('/oauth/callback', AuthenticationController.oauth2Callback) - AuthenticationController.addEndpointToLoginWhitelist('/oauth/redirect') - AuthenticationController.addEndpointToLoginWhitelist('/oauth/callback') + 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') + } // <<<<<<<<<<<<<<<<<<<<<<<<<<<<<< EditorRouter.apply(webRouter, privateApiRouter) From d94aa2fdf9bf7bf7b347f1ea0395a7b959a15c2d Mon Sep 17 00:00:00 2001 From: yzx9 Date: Sat, 25 Nov 2023 01:05:40 +0800 Subject: [PATCH 09/13] Custom provider name --- README.md | 3 ++- docker-compose.certbot.yml | 1 + docker-compose.traefik.yml | 1 + docker-compose.yml | 1 + ldap-overleaf-sl/sharelatex/login.pug | 2 +- 5 files changed, 6 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 6a5f80b..ad1a8f4 100644 --- a/README.md +++ b/README.md @@ -133,9 +133,10 @@ GitHub: ``` OAUTH2_ENABLED: "true" +OAUTH2_PROVIDER: GitHub OAUTH2_CLIENT_ID: YOUR_CLIENT_ID OAUTH2_CLIENT_SECRET: YOUR_CLIENT_SECRET -OAUTH2_SCOPE: YOUR_SCOPE +OAUTH2_SCOPE: # the 'public' scope is sufficient for our needs, so we do not request any more OAUTH2_AUTHORIZATION_URL: https://github.com/login/oauth/authorize OAUTH2_TOKEN_URL: https://github.com/login/oauth/access_token OAUTH2_PROFILE_URL: https://api.github.com/user diff --git a/docker-compose.certbot.yml b/docker-compose.certbot.yml index 2737543..aa7f3f6 100644 --- a/docker-compose.certbot.yml +++ b/docker-compose.certbot.yml @@ -83,6 +83,7 @@ services: ## OAuth2 Settings # OAUTH2_ENABLED: "true" + # OAUTH2_PROVIDER: YOUR_OAUTH2_PROVIDER # OAUTH2_CLIENT_ID: YOUR_OAUTH2_CLIENT_ID # OAUTH2_CLIENT_SECRET: YOUR_OAUTH2_CLIENT_SECRET # OAUTH2_SCOPE: YOUR_OAUTH2_SCOPE diff --git a/docker-compose.traefik.yml b/docker-compose.traefik.yml index 62095ee..ef20da0 100644 --- a/docker-compose.traefik.yml +++ b/docker-compose.traefik.yml @@ -164,6 +164,7 @@ services: ## OAuth2 Settings # OAUTH2_ENABLED: "true" + # OAUTH2_PROVIDER: YOUR_OAUTH2_PROVIDER # OAUTH2_CLIENT_ID: YOUR_OAUTH2_CLIENT_ID # OAUTH2_CLIENT_SECRET: YOUR_OAUTH2_CLIENT_SECRET # OAUTH2_SCOPE: YOUR_OAUTH2_SCOPE diff --git a/docker-compose.yml b/docker-compose.yml index 9b809a1..a972113 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -81,6 +81,7 @@ services: ## OAuth2 Settings # OAUTH2_ENABLED: "true" + # OAUTH2_PROVIDER: YOUR_OAUTH2_PROVIDER # OAUTH2_CLIENT_ID: YOUR_OAUTH2_CLIENT_ID # OAUTH2_CLIENT_SECRET: YOUR_OAUTH2_CLIENT_SECRET # OAUTH2_SCOPE: YOUR_OAUTH2_SCOPE diff --git a/ldap-overleaf-sl/sharelatex/login.pug b/ldap-overleaf-sl/sharelatex/login.pug index f165d44..aedad20 100644 --- a/ldap-overleaf-sl/sharelatex/login.pug +++ b/ldap-overleaf-sl/sharelatex/login.pug @@ -50,5 +50,5 @@ block content 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 OAuth2 + | Log in via #{process.env.OAUTH2_PROVIDER || 'OAuth'} //- <<<<<<<<<<<<<<<<<<<<<<<<<<<<<< From 40eb01cce41395107beac6b4a2701ca319fa894b Mon Sep 17 00:00:00 2001 From: yzx9 Date: Sat, 25 Nov 2023 01:26:00 +0800 Subject: [PATCH 10/13] Add OAuth2 state validation --- .../sharelatex/AuthenticationController.js | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/ldap-overleaf-sl/sharelatex/AuthenticationController.js b/ldap-overleaf-sl/sharelatex/AuthenticationController.js index 36f21b5..549981b 100644 --- a/ldap-overleaf-sl/sharelatex/AuthenticationController.js +++ b/ldap-overleaf-sl/sharelatex/AuthenticationController.js @@ -274,18 +274,31 @@ const AuthenticationController = { // >>>>>>>>>>>>>>>>>>>>>>>>>>>>>> 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.SHARELATEX_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 ?? ""}` // TODO: state + + `&scope=${process.env.OAUTH2_SCOPE ?? ""} ` + + `&state=${state}` ) res.redirect(authURL) }, async oauth2Callback(req, res, next) { + const saveState = req.session.oauth2State + delete req.session.oauth2State + if (saveState !== req.query.state) { + console.log("OAuth ", JSON.stringify(user)) + return AuthenticationController.finishLogin(false, req, res, next) + } + try { console.log("OAuth2 code", req.query.code) const tokenResponse = await fetch(process.env.OAUTH2_TOKEN_URL, { From 94fa8fb192be837ec941b574ed9d044960fa0029 Mon Sep 17 00:00:00 2001 From: yzx9 Date: Sat, 25 Nov 2023 01:41:55 +0800 Subject: [PATCH 11/13] Add OAuth2 authorization content type configuration --- docker-compose.certbot.yml | 1 + docker-compose.traefik.yml | 1 + docker-compose.yml | 1 + .../sharelatex/AuthenticationController.js | 30 +++++++++++-------- 4 files changed, 20 insertions(+), 13 deletions(-) diff --git a/docker-compose.certbot.yml b/docker-compose.certbot.yml index aa7f3f6..b08ee8f 100644 --- a/docker-compose.certbot.yml +++ b/docker-compose.certbot.yml @@ -88,6 +88,7 @@ services: # OAUTH2_CLIENT_SECRET: YOUR_OAUTH2_CLIENT_SECRET # OAUTH2_SCOPE: YOUR_OAUTH2_SCOPE # OAUTH2_AUTHORIZATION_URL: YOUR_OAUTH2_AUTHORIZATION_URL + # OAUTH2_AUTHORIZATION_CONTENT_TYPE: # One of ['application/x-www-form-urlencoded', 'application/json'] # OAUTH2_TOKEN_URL: YOUR_OAUTH2_TOKEN_URL # OAUTH2_PROFILE_URL: YOUR_OAUTH2_PROFILE_URL # OAUTH2_USER_ATTR_EMAIL: email diff --git a/docker-compose.traefik.yml b/docker-compose.traefik.yml index ef20da0..22a96f0 100644 --- a/docker-compose.traefik.yml +++ b/docker-compose.traefik.yml @@ -169,6 +169,7 @@ services: # OAUTH2_CLIENT_SECRET: YOUR_OAUTH2_CLIENT_SECRET # OAUTH2_SCOPE: YOUR_OAUTH2_SCOPE # OAUTH2_AUTHORIZATION_URL: YOUR_OAUTH2_AUTHORIZATION_URL + # OAUTH2_AUTHORIZATION_CONTENT_TYPE: # One of ['application/x-www-form-urlencoded', 'application/json'] # OAUTH2_TOKEN_URL: YOUR_OAUTH2_TOKEN_URL # OAUTH2_PROFILE_URL: YOUR_OAUTH2_PROFILE_URL # OAUTH2_USER_ATTR_EMAIL: email diff --git a/docker-compose.yml b/docker-compose.yml index a972113..d7a6b5c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -86,6 +86,7 @@ services: # OAUTH2_CLIENT_SECRET: YOUR_OAUTH2_CLIENT_SECRET # OAUTH2_SCOPE: YOUR_OAUTH2_SCOPE # OAUTH2_AUTHORIZATION_URL: YOUR_OAUTH2_AUTHORIZATION_URL + # OAUTH2_AUTHORIZATION_CONTENT_TYPE: # One of ['application/x-www-form-urlencoded', 'application/json'] # OAUTH2_TOKEN_URL: YOUR_OAUTH2_TOKEN_URL # OAUTH2_PROFILE_URL: YOUR_OAUTH2_PROFILE_URL # OAUTH2_USER_ATTR_EMAIL: email diff --git a/ldap-overleaf-sl/sharelatex/AuthenticationController.js b/ldap-overleaf-sl/sharelatex/AuthenticationController.js index 549981b..28b6960 100644 --- a/ldap-overleaf-sl/sharelatex/AuthenticationController.js +++ b/ldap-overleaf-sl/sharelatex/AuthenticationController.js @@ -292,30 +292,35 @@ const AuthenticationController = { }, 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) { - console.log("OAuth ", JSON.stringify(user)) return AuthenticationController.finishLogin(false, req, res, next) } try { - console.log("OAuth2 code", req.query.code) + const contentType = process.env.OAUTH2_AUTHORIZATION_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.SHARELATEX_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": "application/json", + "Content-Type": contentType, }, - body: JSON.stringify({ - 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.SHARELATEX_SITE_URL}/oauth/callback`, - }) + body }) - + const tokenData = await tokenResponse.json() console.log("OAuth2 respond", JSON.stringify(tokenData)) @@ -324,9 +329,8 @@ const AuthenticationController = { headers: { "Accept": "application/json", "Authorization": `Bearer ${tokenData.access_token}`, - "Content-Type": "application/json", } - }) + }); const profile = await profileResponse.json() console.log("OAuth2 user profile", JSON.stringify(profile)) From d5bf3e5d1c8111319d6c6271dad82b9358ba6bd2 Mon Sep 17 00:00:00 2001 From: yzx9 Date: Sat, 25 Nov 2023 12:41:01 +0800 Subject: [PATCH 12/13] Add OAuth2 docs --- README.md | 59 ++++++++++++++++++- docker-compose.certbot.yml | 2 +- docker-compose.traefik.yml | 2 +- docker-compose.yml | 2 +- .../sharelatex/AuthenticationController.js | 2 +- 5 files changed, 60 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 8cc92a8..6e83d49 100644 --- a/README.md +++ b/README.md @@ -54,8 +54,7 @@ it tries to authenticate against the specified LDAP server. - Admins can invite non ldap users directly (via email). Additionally (``link sharing`` of projects is possible). *Important:* -Sharelatex/Overleaf uses the email address to identify users: If you change the email in the LDAP you have to update the corresponding field -in the mongo db. +Sharelatex/Overleaf uses the email address to identify users: If you change the email in the LDAP/OAuth you have to update the corresponding field in the mongo db. ``` docker exec -it mongo /bin/bash @@ -141,11 +140,47 @@ LDAP_CONTACTS: 'true' ### OAuth2 Configuration -GitHub: +``` +# Enable OAuth2 +OAUTH2_ENABLED: "true" + +# Provider name, optional +OAUTH2_PROVIDER: YOUR_OAUTH2_PROVIDER + +# OAuth2 client configuration, +OAUTH2_CLIENT_ID: YOUR_OAUTH2_CLIENT_ID +OAUTH2_CLIENT_SECRET: YOUR_OAUTH2_CLIENT_SECRET +# Scope should at least include email +OAUTH2_SCOPE: YOUR_OAUTH2_SCOPE + +# OAuth2 APIs +# Redirect to OAuth 2.0 url +OAUTH2_AUTHORIZATION_URL: YOUR_OAUTH2_AUTHORIZATION_URL +# Content type of authorization request +# One of ["application/x-www-form-urlencoded", "application/json"] +# Default "application/x-www-form-urlencoded" +OAUTH2_AUTHORIZATION_CONTENT_TYPE: "application/x-www-form-urlencoded" +# Fetch access token api endpoint +OAUTH2_TOKEN_URL: YOUR_OAUTH2_TOKEN_URL +# Fetch user profile api endpoint +OAUTH2_PROFILE_URL: YOUR_OAUTH2_PROFILE_URL + +# OAuth2 user attributes +# User identity +OAUTH2_USER_ATTR_EMAIL: email +# User attributes, only used on the first login +OAUTH2_USER_ATTR_UID: id +OAUTH2_USER_ATTR_FIRSTNAME: name +OAUTH2_USER_ATTR_LASTNAME: +OAUTH2_USER_ATTR_IS_ADMIN: site_admin +``` + +Example configuration for GitHub: ``` OAUTH2_ENABLED: "true" OAUTH2_PROVIDER: GitHub +OAUTH2_AUTHORIZATION_CONTENT_TYPE: "application/x-www-form-urlencoded" OAUTH2_CLIENT_ID: YOUR_CLIENT_ID OAUTH2_CLIENT_SECRET: YOUR_CLIENT_SECRET OAUTH2_SCOPE: # the 'public' scope is sufficient for our needs, so we do not request any more @@ -159,6 +194,24 @@ OAUTH2_USER_ATTR_LASTNAME: OAUTH2_USER_ATTR_IS_ADMIN: site_admin ``` +Example configuration for Authentik: + +``` +OAUTH2_ENABLED: "true" +OAUTH2_PROVIDER: GitHub +OAUTH2_AUTHORIZATION_CONTENT_TYPE: "application/x-www-form-urlencoded" +OAUTH2_CLIENT_ID: "redacted" +OAUTH2_CLIENT_SECRET: "redacted" +OAUTH2_AUTHORIZATION_URL: "https://auth.redacted.domain/application/o/authorize/" +OAUTH2_TOKEN_URL: "https://auth.redacted.domain/application/o/token/" +OAUTH2_PROFILE_URL: "https://auth.redacted.domain/application/o/userinfo/" +OAUTH2_USER_ATTR_EMAIL: "email" +OAUTH2_USER_ATTR_UID: "email" +OAUTH2_USER_ATTR_FIRSTNAME: "name" +#to make it work one should create a custom scope first +OAUTH2_USER_ATTR_IS_ADMIN: "is_admin" +``` + ### Sharelatex Configuration Edit SHARELATEX_ environment variables in [docker-compose.traefik.yml](docker-compose.traefik.yml) or [docker-compose.certbot.yml](docker-compose.certbot.yml) to fit your local setup diff --git a/docker-compose.certbot.yml b/docker-compose.certbot.yml index b08ee8f..4fc6365 100644 --- a/docker-compose.certbot.yml +++ b/docker-compose.certbot.yml @@ -88,8 +88,8 @@ services: # OAUTH2_CLIENT_SECRET: YOUR_OAUTH2_CLIENT_SECRET # OAUTH2_SCOPE: YOUR_OAUTH2_SCOPE # OAUTH2_AUTHORIZATION_URL: YOUR_OAUTH2_AUTHORIZATION_URL - # OAUTH2_AUTHORIZATION_CONTENT_TYPE: # One of ['application/x-www-form-urlencoded', 'application/json'] # OAUTH2_TOKEN_URL: YOUR_OAUTH2_TOKEN_URL + # OAUTH2_TOKEN_CONTENT_TYPE: # One of ['application/x-www-form-urlencoded', 'application/json'] # OAUTH2_PROFILE_URL: YOUR_OAUTH2_PROFILE_URL # OAUTH2_USER_ATTR_EMAIL: email # OAUTH2_USER_ATTR_UID: id diff --git a/docker-compose.traefik.yml b/docker-compose.traefik.yml index 22a96f0..7ec5fb6 100644 --- a/docker-compose.traefik.yml +++ b/docker-compose.traefik.yml @@ -169,8 +169,8 @@ services: # OAUTH2_CLIENT_SECRET: YOUR_OAUTH2_CLIENT_SECRET # OAUTH2_SCOPE: YOUR_OAUTH2_SCOPE # OAUTH2_AUTHORIZATION_URL: YOUR_OAUTH2_AUTHORIZATION_URL - # OAUTH2_AUTHORIZATION_CONTENT_TYPE: # One of ['application/x-www-form-urlencoded', 'application/json'] # OAUTH2_TOKEN_URL: YOUR_OAUTH2_TOKEN_URL + # OAUTH2_TOKEN_CONTENT_TYPE: # One of ['application/x-www-form-urlencoded', 'application/json'] # OAUTH2_PROFILE_URL: YOUR_OAUTH2_PROFILE_URL # OAUTH2_USER_ATTR_EMAIL: email # OAUTH2_USER_ATTR_UID: id diff --git a/docker-compose.yml b/docker-compose.yml index d7a6b5c..123f84a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -86,8 +86,8 @@ services: # OAUTH2_CLIENT_SECRET: YOUR_OAUTH2_CLIENT_SECRET # OAUTH2_SCOPE: YOUR_OAUTH2_SCOPE # OAUTH2_AUTHORIZATION_URL: YOUR_OAUTH2_AUTHORIZATION_URL - # OAUTH2_AUTHORIZATION_CONTENT_TYPE: # One of ['application/x-www-form-urlencoded', 'application/json'] # OAUTH2_TOKEN_URL: YOUR_OAUTH2_TOKEN_URL + # OAUTH2_TOKEN_CONTENT_TYPE: # One of ['application/x-www-form-urlencoded', 'application/json'] # OAUTH2_PROFILE_URL: YOUR_OAUTH2_PROFILE_URL # OAUTH2_USER_ATTR_EMAIL: email # OAUTH2_USER_ATTR_UID: id diff --git a/ldap-overleaf-sl/sharelatex/AuthenticationController.js b/ldap-overleaf-sl/sharelatex/AuthenticationController.js index 28b6960..96c878a 100644 --- a/ldap-overleaf-sl/sharelatex/AuthenticationController.js +++ b/ldap-overleaf-sl/sharelatex/AuthenticationController.js @@ -300,7 +300,7 @@ const AuthenticationController = { } try { - const contentType = process.env.OAUTH2_AUTHORIZATION_CONTENT_TYPE || 'application/x-www-form-urlencoded' + 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, From 0dafa57314aaf00d77fc3855f2a8d85527ff943c Mon Sep 17 00:00:00 2001 From: yzx9 Date: Sat, 25 Nov 2023 21:58:11 +0800 Subject: [PATCH 13/13] Update OAuth2 configuration example --- README.md | 24 +++++++++++------------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 6e83d49..114e792 100644 --- a/README.md +++ b/README.md @@ -156,12 +156,12 @@ OAUTH2_SCOPE: YOUR_OAUTH2_SCOPE # OAuth2 APIs # Redirect to OAuth 2.0 url OAUTH2_AUTHORIZATION_URL: YOUR_OAUTH2_AUTHORIZATION_URL -# Content type of authorization request -# One of ["application/x-www-form-urlencoded", "application/json"] -# Default "application/x-www-form-urlencoded" -OAUTH2_AUTHORIZATION_CONTENT_TYPE: "application/x-www-form-urlencoded" # Fetch access token api endpoint OAUTH2_TOKEN_URL: YOUR_OAUTH2_TOKEN_URL +# Content type of token request +# One of ["application/x-www-form-urlencoded", "application/json"] +# Default "application/x-www-form-urlencoded" +OAUTH2_TOKEN_CONTENT_TYPE: "application/x-www-form-urlencoded" # Fetch user profile api endpoint OAUTH2_PROFILE_URL: YOUR_OAUTH2_PROFILE_URL @@ -180,7 +180,6 @@ Example configuration for GitHub: ``` OAUTH2_ENABLED: "true" OAUTH2_PROVIDER: GitHub -OAUTH2_AUTHORIZATION_CONTENT_TYPE: "application/x-www-form-urlencoded" OAUTH2_CLIENT_ID: YOUR_CLIENT_ID OAUTH2_CLIENT_SECRET: YOUR_CLIENT_SECRET OAUTH2_SCOPE: # the 'public' scope is sufficient for our needs, so we do not request any more @@ -199,17 +198,16 @@ Example configuration for Authentik: ``` OAUTH2_ENABLED: "true" OAUTH2_PROVIDER: GitHub -OAUTH2_AUTHORIZATION_CONTENT_TYPE: "application/x-www-form-urlencoded" OAUTH2_CLIENT_ID: "redacted" OAUTH2_CLIENT_SECRET: "redacted" -OAUTH2_AUTHORIZATION_URL: "https://auth.redacted.domain/application/o/authorize/" -OAUTH2_TOKEN_URL: "https://auth.redacted.domain/application/o/token/" -OAUTH2_PROFILE_URL: "https://auth.redacted.domain/application/o/userinfo/" -OAUTH2_USER_ATTR_EMAIL: "email" +OAUTH2_AUTHORIZATION_URL: https://auth.redacted.domain/application/o/authorize/ +OAUTH2_TOKEN_URL: https://auth.redacted.domain/application/o/token/ +OAUTH2_PROFILE_URL: https://auth.redacted.domain/application/o/userinfo/ +OAUTH2_USER_ATTR_EMAIL: email OAUTH2_USER_ATTR_UID: "email" -OAUTH2_USER_ATTR_FIRSTNAME: "name" -#to make it work one should create a custom scope first -OAUTH2_USER_ATTR_IS_ADMIN: "is_admin" +OAUTH2_USER_ATTR_FIRSTNAME: name +# To make it work one should create a custom scope first +OAUTH2_USER_ATTR_IS_ADMIN: is_admin ``` ### Sharelatex Configuration