From 07b155ed4b71840fba1f11ec3034ebd6090821c2 Mon Sep 17 00:00:00 2001 From: yu-i-i Date: Tue, 3 Dec 2024 01:18:19 +0100 Subject: [PATCH 01/43] Enable LDAP and SAML authentication support --- patches/@node-saml+node-saml+4.0.5.patch | 23 +++ patches/ldapauth-fork+4.3.3.patch | 64 +++++++ .../AuthenticationController.js | 5 +- .../PasswordReset/PasswordResetController.mjs | 4 + .../PasswordReset/PasswordResetHandler.mjs | 4 + .../app/src/Features/User/UserController.js | 3 +- services/web/app/views/user/login.pug | 13 +- services/web/app/views/user/settings.pug | 4 +- services/web/config/settings.defaults.js | 2 + services/web/locales/en.json | 2 + .../launchpad/app/src/LaunchpadController.mjs | 3 +- .../modules/launchpad/app/views/launchpad.pug | 75 +++++++- .../app/src/AuthenticationControllerLdap.mjs | 64 +++++++ .../app/src/AuthenticationManagerLdap.mjs | 80 +++++++++ .../app/src/InitLdapSettings.mjs | 17 ++ .../app/src/LdapContacts.mjs | 136 +++++++++++++++ .../app/src/LdapStrategy.mjs | 78 +++++++++ .../web/modules/ldap-authentication/index.mjs | 30 ++++ .../app/src/AuthenticationControllerSaml.mjs | 160 ++++++++++++++++++ .../app/src/AuthenticationManagerSaml.mjs | 60 +++++++ .../app/src/InitSamlSettings.mjs | 16 ++ .../app/src/SamlNonCsrfRouter.mjs | 12 ++ .../app/src/SamlRouter.mjs | 14 ++ .../app/src/SamlStrategy.mjs | 62 +++++++ .../web/modules/saml-authentication/index.mjs | 26 +++ 25 files changed, 947 insertions(+), 10 deletions(-) create mode 100644 patches/@node-saml+node-saml+4.0.5.patch create mode 100644 patches/ldapauth-fork+4.3.3.patch create mode 100644 services/web/modules/ldap-authentication/app/src/AuthenticationControllerLdap.mjs create mode 100644 services/web/modules/ldap-authentication/app/src/AuthenticationManagerLdap.mjs create mode 100644 services/web/modules/ldap-authentication/app/src/InitLdapSettings.mjs create mode 100644 services/web/modules/ldap-authentication/app/src/LdapContacts.mjs create mode 100644 services/web/modules/ldap-authentication/app/src/LdapStrategy.mjs create mode 100644 services/web/modules/ldap-authentication/index.mjs create mode 100644 services/web/modules/saml-authentication/app/src/AuthenticationControllerSaml.mjs create mode 100644 services/web/modules/saml-authentication/app/src/AuthenticationManagerSaml.mjs create mode 100644 services/web/modules/saml-authentication/app/src/InitSamlSettings.mjs create mode 100644 services/web/modules/saml-authentication/app/src/SamlNonCsrfRouter.mjs create mode 100644 services/web/modules/saml-authentication/app/src/SamlRouter.mjs create mode 100644 services/web/modules/saml-authentication/app/src/SamlStrategy.mjs create mode 100644 services/web/modules/saml-authentication/index.mjs diff --git a/patches/@node-saml+node-saml+4.0.5.patch b/patches/@node-saml+node-saml+4.0.5.patch new file mode 100644 index 0000000000..81fd700b31 --- /dev/null +++ b/patches/@node-saml+node-saml+4.0.5.patch @@ -0,0 +1,23 @@ +diff --git a/node_modules/@node-saml/node-saml/lib/saml.js b/node_modules/@node-saml/node-saml/lib/saml.js +index fba15b9..a5778cb 100644 +--- a/node_modules/@node-saml/node-saml/lib/saml.js ++++ b/node_modules/@node-saml/node-saml/lib/saml.js +@@ -336,7 +336,8 @@ class SAML { + const requestOrResponse = request || response; + (0, utility_1.assertRequired)(requestOrResponse, "either request or response is required"); + let buffer; +- if (this.options.skipRequestCompression) { ++ // logout requestOrResponse must be compressed anyway ++ if (this.options.skipRequestCompression && operation !== "logout") { + buffer = Buffer.from(requestOrResponse, "utf8"); + } + else { +@@ -495,7 +496,7 @@ class SAML { + try { + xml = Buffer.from(container.SAMLResponse, "base64").toString("utf8"); + doc = await (0, xml_1.parseDomFromString)(xml); +- const inResponseToNodes = xml_1.xpath.selectAttributes(doc, "/*[local-name()='Response']/@InResponseTo"); ++ const inResponseToNodes = xml_1.xpath.selectAttributes(doc, "/*[local-name()='Response' or local-name()='LogoutResponse']/@InResponseTo"); + if (inResponseToNodes) { + inResponseTo = inResponseToNodes.length ? inResponseToNodes[0].nodeValue : null; + await this.validateInResponseTo(inResponseTo); diff --git a/patches/ldapauth-fork+4.3.3.patch b/patches/ldapauth-fork+4.3.3.patch new file mode 100644 index 0000000000..4d31210c9d --- /dev/null +++ b/patches/ldapauth-fork+4.3.3.patch @@ -0,0 +1,64 @@ +diff --git a/node_modules/ldapauth-fork/lib/ldapauth.js b/node_modules/ldapauth-fork/lib/ldapauth.js +index 85ecf36a8b..a7d07e0f78 100644 +--- a/node_modules/ldapauth-fork/lib/ldapauth.js ++++ b/node_modules/ldapauth-fork/lib/ldapauth.js +@@ -69,6 +69,7 @@ function LdapAuth(opts) { + this.opts.bindProperty || (this.opts.bindProperty = 'dn'); + this.opts.groupSearchScope || (this.opts.groupSearchScope = 'sub'); + this.opts.groupDnProperty || (this.opts.groupDnProperty = 'dn'); ++ this.opts.tlsStarted = false; + + EventEmitter.call(this); + +@@ -108,21 +109,7 @@ function LdapAuth(opts) { + this._userClient.on('error', this._handleError.bind(this)); + + var self = this; +- if (this.opts.starttls) { +- // When starttls is enabled, this callback supplants the 'connect' callback +- this._adminClient.starttls(this.opts.tlsOptions, this._adminClient.controls, function(err) { +- if (err) { +- self._handleError(err); +- } else { +- self._onConnectAdmin(); +- } +- }); +- this._userClient.starttls(this.opts.tlsOptions, this._userClient.controls, function(err) { +- if (err) { +- self._handleError(err); +- } +- }); +- } else if (opts.reconnect) { ++ if (opts.reconnect && !this.opts.starttls) { + this.once('_installReconnectListener', function() { + self.log && self.log.trace('install reconnect listener'); + self._adminClient.on('connect', function() { +@@ -384,6 +371,28 @@ LdapAuth.prototype._findGroups = function(user, callback) { + */ + LdapAuth.prototype.authenticate = function(username, password, callback) { + var self = this; ++ if (this.opts.starttls && !this.opts.tlsStarted) { ++ // When starttls is enabled, this callback supplants the 'connect' callback ++ this._adminClient.starttls(this.opts.tlsOptions, this._adminClient.controls, function (err) { ++ if (err) { ++ self._handleError(err); ++ } else { ++ self._onConnectAdmin(function(){self._handleAuthenticate(username, password, callback);}); ++ } ++ }); ++ this._userClient.starttls(this.opts.tlsOptions, this._userClient.controls, function (err) { ++ if (err) { ++ self._handleError(err); ++ } ++ }); ++ } else { ++ self._handleAuthenticate(username, password, callback); ++ } ++}; ++ ++LdapAuth.prototype._handleAuthenticate = function (username, password, callback) { ++ this.opts.tlsStarted = true; ++ var self = this; + + if (typeof password === 'undefined' || password === null || password === '') { + return callback(new Error('no password given')); diff --git a/services/web/app/src/Features/Authentication/AuthenticationController.js b/services/web/app/src/Features/Authentication/AuthenticationController.js index 7a97d2ac9c..983526006e 100644 --- a/services/web/app/src/Features/Authentication/AuthenticationController.js +++ b/services/web/app/src/Features/Authentication/AuthenticationController.js @@ -102,9 +102,9 @@ const AuthenticationController = { // so we can send back our custom `{message: {text: "", type: ""}}` responses on failure, // and send a `{redir: ""}` response on success passport.authenticate( - 'local', + Settings.ldap?.enable ? ['custom-fail-ldapauth','local'] : ['local'], { keepSessionInfo: true }, - async function (err, user, info) { + async function (err, user, infoArray) { if (err) { return next(err) } @@ -126,6 +126,7 @@ const AuthenticationController = { return next(err) } } else { + let info = infoArray[0] if (info.redir != null) { return res.json({ redir: info.redir }) } else { diff --git a/services/web/app/src/Features/PasswordReset/PasswordResetController.mjs b/services/web/app/src/Features/PasswordReset/PasswordResetController.mjs index 2963c56653..54f847ef9c 100644 --- a/services/web/app/src/Features/PasswordReset/PasswordResetController.mjs +++ b/services/web/app/src/Features/PasswordReset/PasswordResetController.mjs @@ -140,6 +140,10 @@ async function requestReset(req, res, next) { return res.status(404).json({ message: req.i18n.translate('secondary_email_password_reset'), }) + } else if (status === 'external') { + return res.status(403).json({ + message: req.i18n.translate('password_managed_externally'), + }) } else { return res.status(404).json({ message: req.i18n.translate('cant_find_email'), diff --git a/services/web/app/src/Features/PasswordReset/PasswordResetHandler.mjs b/services/web/app/src/Features/PasswordReset/PasswordResetHandler.mjs index 094f18b95f..0ac203222c 100644 --- a/services/web/app/src/Features/PasswordReset/PasswordResetHandler.mjs +++ b/services/web/app/src/Features/PasswordReset/PasswordResetHandler.mjs @@ -18,6 +18,10 @@ async function generateAndEmailResetToken(email) { return null } + if (!user.hashedPassword) { + return 'external' + } + if (user.email !== email) { return 'secondary' } diff --git a/services/web/app/src/Features/User/UserController.js b/services/web/app/src/Features/User/UserController.js index b767dcd4a1..772e77e3e4 100644 --- a/services/web/app/src/Features/User/UserController.js +++ b/services/web/app/src/Features/User/UserController.js @@ -404,7 +404,7 @@ async function updateUserSettings(req, res, next) { if ( newEmail == null || newEmail === user.email || - req.externalAuthenticationSystemUsed() + (req.externalAuthenticationSystemUsed() && !user.hashedPassword) ) { // end here, don't update email SessionManager.setInSessionUser(req.session, { @@ -481,6 +481,7 @@ async function doLogout(req) { } async function logout(req, res, next) { + if (req?.session.saml_extce) return res.redirect(308, '/saml/logout') const requestedRedirect = req.body.redirect ? UrlHelper.getSafeRedirectPath(req.body.redirect) : undefined diff --git a/services/web/app/views/user/login.pug b/services/web/app/views/user/login.pug index 03112a0e16..3008b11b1d 100644 --- a/services/web/app/views/user/login.pug +++ b/services/web/app/views/user/login.pug @@ -24,9 +24,9 @@ block content .form-group input.form-control( name='email' - type='email' + type=(settings.ldap && settings.ldap.enable) ? 'text' : 'email' required - placeholder='email@example.com' + placeholder=(settings.ldap && settings.ldap.enable) ? settings.ldap.placeholder : 'email@example.com' autofocus='true' ) .form-group @@ -44,3 +44,12 @@ block content if login_support_text hr p.text-center !{login_support_text} + if settings.saml && settings.saml.enable + form(data-ol-async-form, name="samlLoginForm") + .actions(style='margin-top: 30px;') + a.btn.btn-secondary.btn-block( + href='/saml/login', + data-ol-disabled-inflight + ) + span(data-ol-inflight="idle") #{settings.saml.identityServiceName} + span(hidden data-ol-inflight="pending") #{translate("logging_in")}… diff --git a/services/web/app/views/user/settings.pug b/services/web/app/views/user/settings.pug index 45d21c7572..a07863682e 100644 --- a/services/web/app/views/user/settings.pug +++ b/services/web/app/views/user/settings.pug @@ -11,7 +11,7 @@ block append meta meta( name='ol-shouldAllowEditingDetails' data-type='boolean' - content=shouldAllowEditingDetails + content=shouldAllowEditingDetails || hasPassword ) meta(name='ol-oauthProviders' data-type='json' content=oauthProviders) meta(name='ol-institutionLinked' data-type='json' content=institutionLinked) @@ -34,7 +34,7 @@ block append meta meta( name='ol-isExternalAuthenticationSystemUsed' data-type='boolean' - content=externalAuthenticationSystemUsed() + content=externalAuthenticationSystemUsed() && !hasPassword ) meta(name='ol-user' data-type='json' content=user) meta(name='ol-labsExperiments' data-type='json' content=labsExperiments) diff --git a/services/web/config/settings.defaults.js b/services/web/config/settings.defaults.js index bd0730d5d0..3c42fa477d 100644 --- a/services/web/config/settings.defaults.js +++ b/services/web/config/settings.defaults.js @@ -1030,6 +1030,8 @@ module.exports = { 'launchpad', 'server-ce-scripts', 'user-activate', + 'ldap-authentication', + 'saml-authentication', ], viewIncludes: {}, diff --git a/services/web/locales/en.json b/services/web/locales/en.json index adfb33d8f8..4b51bd2152 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -163,6 +163,7 @@ "already_have_sl_account": "Already have an __appName__ account?", "also": "Also", "alternatively_create_new_institution_account": "Alternatively, you can create a new account with your institution email (__email__) by clicking __clickText__.", + "alternatively_create_local_admin_account": "Alternatively, you can create __appName__ local admin account.", "an_email_has_already_been_sent_to": "An email has already been sent to <0>__email__. Please wait and try again later.", "an_error_occured_while_restoring_project": "An error occured while restoring the project", "an_error_occurred_when_verifying_the_coupon_code": "An error occurred when verifying the coupon code", @@ -1250,6 +1251,7 @@ "loading_prices": "loading prices", "loading_recent_github_commits": "Loading recent commits", "loading_writefull": "Loading Writefull", + "local_account": "Local account", "log_entry_description": "Log entry with level: __level__", "log_entry_maximum_entries": "Maximum log entries limit hit", "log_entry_maximum_entries_enable_stop_on_first_error": "Try to fix the first error and recompile. Often one error causes many later error messages. You can <0>Enable “Stop on first error” to focus on fixing errors. We recommend fixing errors as soon as possible; letting them accumulate may lead to hard-to-debug and fatal errors. <1>Learn more", diff --git a/services/web/modules/launchpad/app/src/LaunchpadController.mjs b/services/web/modules/launchpad/app/src/LaunchpadController.mjs index b626e0176e..49dd9ea9cb 100644 --- a/services/web/modules/launchpad/app/src/LaunchpadController.mjs +++ b/services/web/modules/launchpad/app/src/LaunchpadController.mjs @@ -154,7 +154,8 @@ function registerExternalAuthAdmin(authMethod) { await User.updateOne( { _id: user._id }, { - $set: { isAdmin: true, emails: [{ email, reversedHostname }] }, + $set: { isAdmin: true, emails: [{ email, reversedHostname, 'confirmedAt' : Date.now() }] }, + $unset: { 'hashedPassword': "" }, // external-auth user must not have a hashedPassword } ).exec() } catch (err) { diff --git a/services/web/modules/launchpad/app/views/launchpad.pug b/services/web/modules/launchpad/app/views/launchpad.pug index ff917eeb74..2c0382e71e 100644 --- a/services/web/modules/launchpad/app/views/launchpad.pug +++ b/services/web/modules/launchpad/app/views/launchpad.pug @@ -29,7 +29,7 @@ block vars block append meta meta(name='ol-adminUserExists' data-type='boolean' content=adminUserExists) - meta(name='ol-ideJsPath' content=buildJsPath('ide.js')) + meta(name='ol-ideJsPath' content=buildJsPath('ide-detached.js')) block content script( @@ -122,6 +122,42 @@ block content span(data-ol-inflight='idle') #{translate("register")} span(hidden data-ol-inflight='pending') #{translate("registering")}… + h3 #{translate('local_account')} + p + | #{translate('alternatively_create_local_admin_account')} + + form( + data-ol-async-form + data-ol-register-admin + action='/launchpad/register_admin' + method='POST' + ) + input(name='_csrf', type='hidden', value=csrfToken) + +formMessages() + .form-group + label(for='email') #{translate("email")} + input.form-control( + type='email' + name='email' + placeholder='email@example.com' + autocomplete='username' + required + autofocus='true' + ) + .form-group + label(for='password') #{translate("password")} + input.form-control#passwordField( + type='password' + name='password' + placeholder='********' + autocomplete='new-password' + required + ) + .actions + button.btn-primary.btn(type='submit' data-ol-disabled-inflight) + span(data-ol-inflight="idle") #{translate("register")} + span(hidden data-ol-inflight="pending") #{translate("registering")}… + // Saml Form if authMethod === 'saml' h3 #{translate('saml')} @@ -140,12 +176,47 @@ block content label(for='email') #{translate("email")} input.form-control( name='email' - type='email' placeholder='email@example.com' autocomplete='username' required autofocus='true' ) + .actions + button.btn-primary.btn(type='submit' data-ol-disabled-inflight) + span(data-ol-inflight="idle") #{translate("register")} + span(hidden data-ol-inflight="pending") #{translate("registering")}… + + h3 #{translate('local_account')} + p + | #{translate('alternatively_create_local_admin_account')} + + form( + data-ol-async-form + data-ol-register-admin + action='/launchpad/register_admin' + method='POST' + ) + input(name='_csrf', type='hidden', value=csrfToken) + +formMessages() + .form-group + label(for='email') #{translate("email")} + input.form-control( + type='email' + name='email' + placeholder='email@example.com' + autocomplete='username' + required + autofocus='true' + ) + .form-group + label(for='password') #{translate("password")} + input.form-control#passwordField( + type='password' + name='password' + placeholder='********' + autocomplete='new-password' + required + ) .actions button.btn-primary.btn(type='submit' data-ol-disabled-inflight) span(data-ol-inflight='idle') #{translate("register")} diff --git a/services/web/modules/ldap-authentication/app/src/AuthenticationControllerLdap.mjs b/services/web/modules/ldap-authentication/app/src/AuthenticationControllerLdap.mjs new file mode 100644 index 0000000000..64fa4f5a96 --- /dev/null +++ b/services/web/modules/ldap-authentication/app/src/AuthenticationControllerLdap.mjs @@ -0,0 +1,64 @@ +import logger from '@overleaf/logger' +import LoginRateLimiter from '../../../../app/src/Features/Security/LoginRateLimiter.js' +import { handleAuthenticateErrors } from '../../../../app/src/Features/Authentication/AuthenticationErrors.js' +import AuthenticationController from '../../../../app/src/Features/Authentication/AuthenticationController.js' +import AuthenticationManagerLdap from './AuthenticationManagerLdap.mjs' + +const AuthenticationControllerLdap = { + async doPassportLdapLogin(req, ldapUser, done) { + let user, info + try { + ;({ user, info } = await AuthenticationControllerLdap._doPassportLdapLogin( + req, + ldapUser + )) + } catch (error) { + return done(error) + } + return done(undefined, user, info) + }, + async _doPassportLdapLogin(req, ldapUser) { + const { fromKnownDevice } = AuthenticationController.getAuditInfo(req) + const auditLog = { + ipAddress: req.ip, + info: { method: 'LDAP password login', fromKnownDevice }, + } + + let user, isPasswordReused + try { + user = await AuthenticationManagerLdap.promises.findOrCreateLdapUser(ldapUser, auditLog) + } catch (error) { + return { + user: false, + info: handleAuthenticateErrors(error, req), + } + } + if (user && AuthenticationController.captchaRequiredForLogin(req, user)) { + return { + user: false, + info: { + text: req.i18n.translate('cannot_verify_user_not_robot'), + type: 'error', + errorReason: 'cannot_verify_user_not_robot', + status: 400, + }, + } + } else if (user) { + // async actions + return { user, info: undefined } + } else { //something wrong + logger.debug({ email : ldapUser.mail }, 'failed LDAP log in') + return { + user: false, + info: { + type: 'error', + status: 500, + }, + } + } + }, +} + +export const { + doPassportLdapLogin, +} = AuthenticationControllerLdap diff --git a/services/web/modules/ldap-authentication/app/src/AuthenticationManagerLdap.mjs b/services/web/modules/ldap-authentication/app/src/AuthenticationManagerLdap.mjs new file mode 100644 index 0000000000..1371f76d52 --- /dev/null +++ b/services/web/modules/ldap-authentication/app/src/AuthenticationManagerLdap.mjs @@ -0,0 +1,80 @@ +import Settings from '@overleaf/settings' +import { callbackify } from '@overleaf/promise-utils' +import UserCreator from '../../../../app/src/Features/User/UserCreator.js' +import { User } from '../../../../app/src/models/User.js' + +const AuthenticationManagerLdap = { + splitFullName(fullName) { + fullName = fullName.trim(); + let lastSpaceIndex = fullName.lastIndexOf(' '); + let firstNames = fullName.substring(0, lastSpaceIndex).trim(); + let lastName = fullName.substring(lastSpaceIndex + 1).trim(); + return [firstNames, lastName]; + }, + async findOrCreateLdapUser(profile, auditLog) { + //user is already authenticated in Ldap + const { + attEmail, + attFirstName, + attLastName, + attName, + attAdmin, + valAdmin, + updateUserDetailsOnLogin, + } = Settings.ldap + + const email = Array.isArray(profile[attEmail]) + ? profile[attEmail][0].toLowerCase() + : profile[attEmail].toLowerCase() + let nameParts = ["",""] + if ((!attFirstName || !attLastName) && attName) { + nameParts = this.splitFullName(profile[attName] || "") + } + const firstName = attFirstName ? (profile[attFirstName] || "") : nameParts[0] + let lastName = attLastName ? (profile[attLastName] || "") : nameParts[1] + if (!firstName && !lastName) lastName = email + let isAdmin = false + if( attAdmin && valAdmin ) { + isAdmin = (profile._groups?.length > 0) || + (Array.isArray(profile[attAdmin]) ? profile[attAdmin].includes(valAdmin) : + profile[attAdmin] === valAdmin) + } + let user = await User.findOne({ 'email': email }).exec() + if( !user ) { + user = await UserCreator.promises.createNewUser( + { + email: email, + first_name: firstName, + last_name: lastName, + isAdmin: isAdmin, + holdingAccount: false, + } + ) + await User.updateOne( + { _id: user._id }, + { $set : { 'emails.0.confirmedAt' : Date.now() } } + ).exec() //email of ldap user is confirmed + } + let userDetails = updateUserDetailsOnLogin ? { first_name : firstName, last_name: lastName } : {} + if( attAdmin && valAdmin ) { + user.isAdmin = isAdmin + userDetails.isAdmin = isAdmin + } + const result = await User.updateOne( + { _id: user._id, loginEpoch: user.loginEpoch }, { $inc: { loginEpoch: 1 }, $set: userDetails }, + {} + ).exec() + if (result.modifiedCount !== 1) { + throw new ParallelLoginError() + } + return user + }, +} + +export default { + findOrCreateLdapUser: callbackify(AuthenticationManagerLdap.findOrCreateLdapUser), + promises: AuthenticationManagerLdap, +} +export const { + splitFullName, +} = AuthenticationManagerLdap diff --git a/services/web/modules/ldap-authentication/app/src/InitLdapSettings.mjs b/services/web/modules/ldap-authentication/app/src/InitLdapSettings.mjs new file mode 100644 index 0000000000..e7f312fc11 --- /dev/null +++ b/services/web/modules/ldap-authentication/app/src/InitLdapSettings.mjs @@ -0,0 +1,17 @@ +import Settings from '@overleaf/settings' + +function initLdapSettings() { + Settings.ldap = { + enable: true, + placeholder: process.env.OVERLEAF_LDAP_PLACEHOLDER || 'Username', + attEmail: process.env.OVERLEAF_LDAP_EMAIL_ATT || 'mail', + attFirstName: process.env.OVERLEAF_LDAP_FIRST_NAME_ATT, + attLastName: process.env.OVERLEAF_LDAP_LAST_NAME_ATT, + attName: process.env.OVERLEAF_LDAP_NAME_ATT, + attAdmin: process.env.OVERLEAF_LDAP_IS_ADMIN_ATT, + valAdmin: process.env.OVERLEAF_LDAP_IS_ADMIN_ATT_VALUE, + updateUserDetailsOnLogin: String(process.env.OVERLEAF_LDAP_UPDATE_USER_DETAILS_ON_LOGIN ).toLowerCase() === 'true', + } +} + +export default initLdapSettings diff --git a/services/web/modules/ldap-authentication/app/src/LdapContacts.mjs b/services/web/modules/ldap-authentication/app/src/LdapContacts.mjs new file mode 100644 index 0000000000..c4093b8684 --- /dev/null +++ b/services/web/modules/ldap-authentication/app/src/LdapContacts.mjs @@ -0,0 +1,136 @@ +import Settings from '@overleaf/settings' +import logger from '@overleaf/logger' +import passport from 'passport' +import ldapjs from 'ldapauth-fork/node_modules/ldapjs/lib/index.js' +import UserGetter from '../../../../app/src/Features/User/UserGetter.js' +import { splitFullName } from './AuthenticationManagerLdap.mjs' + +async function fetchLdapContacts(userId, contacts) { + if (!Settings.ldap?.enable || !process.env.OVERLEAF_LDAP_CONTACTS_FILTER) { + return [] + } + + const ldapOpts = passport._strategy('custom-fail-ldapauth').options.server + const { attEmail, attFirstName = "", attLastName = "", attName = "" } = Settings.ldap + const { + url, + timeout, + connectTimeout, + tlsOptions, + starttls, + bindDN, + bindCredentials, + } = ldapOpts + const searchBase = process.env.OVERLEAF_LDAP_CONTACTS_SEARCH_BASE || ldapOpts.searchBase + const searchScope = process.env.OVERLEAF_LDAP_CONTACTS_SEARCH_SCOPE || 'sub' + const ldapConfig = { url, timeout, connectTimeout, tlsOptions } + + let ldapUsers + const client = ldapjs.createClient(ldapConfig) + try { + if (starttls) { + await _upgradeToTLS(client, tlsOptions) + } + await _bindLdap(client, bindDN, bindCredentials) + + const filter = await _formContactsSearchFilter(client, ldapOpts, userId, process.env.OVERLEAF_LDAP_CONTACTS_FILTER) + const searchOptions = { scope: searchScope, attributes: [attEmail, attFirstName, attLastName, attName], filter } + + ldapUsers = await _searchLdap(client, searchBase, searchOptions) + } catch (err) { + logger.warn({ err }, 'error in fetchLdapContacts') + return [] + } finally { + client.unbind() + } + + const newLdapContacts = ldapUsers.reduce((acc, ldapUser) => { + const email = Array.isArray(ldapUser[attEmail]) + ? ldapUser[attEmail][0]?.toLowerCase() + : ldapUser[attEmail]?.toLowerCase() + if (!email) return acc + if (!contacts.some(contact => contact.email === email)) { + let nameParts = ["",""] + if ((!attFirstName || !attLastName) && attName) { + nameParts = splitFullName(ldapUser[attName] || "") + } + const firstName = attFirstName ? (ldapUser[attFirstName] || "") : nameParts[0] + const lastName = attLastName ? (ldapUser[attLastName] || "") : nameParts[1] + acc.push({ + first_name: firstName, + last_name: lastName, + email: email, + type: 'user', + }) + } + return acc + }, []) + + return newLdapContacts.sort((a, b) => + a.last_name.localeCompare(b.last_name) || + a.first_name.localeCompare(a.first_name) || + a.email.localeCompare(b.email) + ) +} + +function _upgradeToTLS(client, tlsOptions) { + return new Promise((resolve, reject) => { + client.on('error', error => reject(new Error(`LDAP client error: ${error}`))) + client.on('connect', () => { + client.starttls(tlsOptions, null, error => { + if (error) { + reject(new Error(`StartTLS error: ${error}`)) + } else { + resolve() + } + }) + }) + }) +} + +function _bindLdap(client, bindDN, bindCredentials) { + return new Promise((resolve, reject) => { + client.bind(bindDN, bindCredentials, error => { + if (error) { + reject(error) + } else { + resolve() + } + }) + }) +} + +function _searchLdap(client, baseDN, options) { + return new Promise((resolve, reject) => { + const searchEntries = [] + client.search(baseDN, options, (error, res) => { + if (error) { + reject(error) + } else { + res.on('searchEntry', entry => searchEntries.push(entry.object)) + res.on('error', reject) + res.on('end', () => resolve(searchEntries)) + } + }) + }) +} + +async function _formContactsSearchFilter(client, ldapOpts, userId, contactsFilter) { + const searchProperty = process.env.OVERLEAF_LDAP_CONTACTS_PROPERTY + if (!searchProperty) { + return contactsFilter + } + const email = await UserGetter.promises.getUserEmail(userId) + const searchOptions = { + scope: ldapOpts.searchScope, + attributes: [searchProperty], + filter: `(${Settings.ldap.attEmail}=${email})`, + } + const searchBase = ldapOpts.searchBase + const ldapUser = (await _searchLdap(client, searchBase, searchOptions))[0] + const searchPropertyValue = ldapUser ? ldapUser[searchProperty] + : process.env.OVERLEAF_LDAP_CONTACTS_NON_LDAP_VALUE || 'IMATCHNOTHING' + return contactsFilter.replace(/{{userProperty}}/g, searchPropertyValue) +} + +export default fetchLdapContacts diff --git a/services/web/modules/ldap-authentication/app/src/LdapStrategy.mjs b/services/web/modules/ldap-authentication/app/src/LdapStrategy.mjs new file mode 100644 index 0000000000..b07dc3f3bd --- /dev/null +++ b/services/web/modules/ldap-authentication/app/src/LdapStrategy.mjs @@ -0,0 +1,78 @@ +import fs from 'fs' +import passport from 'passport' +import Settings from '@overleaf/settings' +import { doPassportLdapLogin } from './AuthenticationControllerLdap.mjs' +import { Strategy as LdapStrategy } from 'passport-ldapauth' + +function _readFilesContentFromEnv(envVar) { +// envVar is either a file name: 'file.pem', or string with array: '["file.pem", "file2.pem"]' + if (!envVar) return undefined + try { + const parsedFileNames = JSON.parse(envVar) + return parsedFileNames.map(filename => fs.readFileSync(filename, 'utf8')) + } catch (error) { + if (error instanceof SyntaxError) { // failed to parse, envVar must be a file name + return fs.readFileSync(envVar, 'utf8') + } else { + throw error + } + } +} + +// custom responses on authentication failure +class CustomFailLdapStrategy extends LdapStrategy { + constructor(options, validate) { + super(options, validate); + this.name = 'custom-fail-ldapauth' + } + authenticate(req, options) { + const defaultFail = this.fail.bind(this) + this.fail = function(info, status) { + info.type = 'error' + info.key = 'invalid-password-retry-or-reset' + info.status = 401 + return defaultFail(info, status) + }.bind(this) + super.authenticate(req, options) + } +} + +const ldapServerOpts = { + url: process.env.OVERLEAF_LDAP_URL, + bindDN: process.env.OVERLEAF_LDAP_BIND_DN || "", + bindCredentials: process.env.OVERLEAF_LDAP_BIND_CREDENTIALS || "", + bindProperty: process.env.OVERLEAF_LDAP_BIND_PROPERTY, + searchBase: process.env.OVERLEAF_LDAP_SEARCH_BASE, + searchFilter: process.env.OVERLEAF_LDAP_SEARCH_FILTER, + searchScope: process.env.OVERLEAF_LDAP_SEARCH_SCOPE || 'sub', + searchAttributes: JSON.parse(process.env.OVERLEAF_LDAP_SEARCH_ATTRIBUTES || '[]'), + groupSearchBase: process.env.OVERLEAF_LDAP_ADMIN_SEARCH_BASE, + groupSearchFilter: process.env.OVERLEAF_LDAP_ADMIN_SEARCH_FILTER, + groupSearchScope: process.env.OVERLEAF_LDAP_ADMIN_SEARCH_SCOPE || 'sub', + groupSearchAttributes: ["dn"], + groupDnProperty: process.env.OVERLEAF_LDAP_ADMIN_DN_PROPERTY, + cache: String(process.env.OVERLEAF_LDAP_CACHE).toLowerCase() === 'true', + timeout: process.env.OVERLEAF_LDAP_TIMEOUT ? Number(process.env.OVERLEAF_LDAP_TIMEOUT) : undefined, + connectTimeout: process.env.OVERLEAF_LDAP_CONNECT_TIMEOUT ? Number(process.env.OVERLEAF_LDAP_CONNECT_TIMEOUT) : undefined, + starttls: String(process.env.OVERLEAF_LDAP_STARTTLS).toLowerCase() === 'true', + tlsOptions: { + ca: _readFilesContentFromEnv(process.env.OVERLEAF_LDAP_TLS_OPTS_CA_PATH), + rejectUnauthorized: String(process.env.OVERLEAF_LDAP_TLS_OPTS_REJECT_UNAUTH).toLowerCase() === 'true', + } +} + +function addLdapStrategy(passport) { + passport.use( + new CustomFailLdapStrategy( + { + server: ldapServerOpts, + passReqToCallback: true, + usernameField: 'email', + passwordField: 'password', + }, + doPassportLdapLogin + ) + ) +} + +export default addLdapStrategy diff --git a/services/web/modules/ldap-authentication/index.mjs b/services/web/modules/ldap-authentication/index.mjs new file mode 100644 index 0000000000..f56d7ffee0 --- /dev/null +++ b/services/web/modules/ldap-authentication/index.mjs @@ -0,0 +1,30 @@ +import initLdapSettings from './app/src/InitLdapSettings.mjs' +import addLdapStrategy from './app/src/LdapStrategy.mjs' +import fetchLdapContacts from './app/src/LdapContacts.mjs' + +let ldapModule = {}; +if (process.env.EXTERNAL_AUTH === 'ldap') { + initLdapSettings() + ldapModule = { + name: 'ldap-authentication', + hooks: { + passportSetup: function (passport, callback) { + try { + addLdapStrategy(passport) + callback(null) + } catch (error) { + callback(error) + } + }, + getContacts: async function (userId, contacts, callback) { + try { + const newLdapContacts = await fetchLdapContacts(userId, contacts) + callback(null, newLdapContacts) + } catch (error) { + callback(error) + } + }, + } + } +} +export default ldapModule diff --git a/services/web/modules/saml-authentication/app/src/AuthenticationControllerSaml.mjs b/services/web/modules/saml-authentication/app/src/AuthenticationControllerSaml.mjs new file mode 100644 index 0000000000..f5db3f738d --- /dev/null +++ b/services/web/modules/saml-authentication/app/src/AuthenticationControllerSaml.mjs @@ -0,0 +1,160 @@ +import Settings from '@overleaf/settings' +import logger from '@overleaf/logger' +import passport from 'passport' +import AuthenticationController from '../../../../app/src/Features/Authentication/AuthenticationController.js' +import AuthenticationManagerSaml from './AuthenticationManagerSaml.mjs' +import UserController from '../../../../app/src/Features/User/UserController.js' +import UserSessionsManager from '../../../../app/src/Features/User/UserSessionsManager.js' +import { handleAuthenticateErrors } from '../../../../app/src/Features/Authentication/AuthenticationErrors.js' +import { xmlResponse } from '../../../../app/src/infrastructure/Response.js' + +const AuthenticationControllerSaml = { + passportSamlAuthWithIdP(req, res, next) { + if ( passport._strategy('saml')._saml.options.authnRequestBinding === 'HTTP-POST') { + const csp = res.getHeader('Content-Security-Policy') + if (csp) { + res.setHeader( + 'Content-Security-Policy', + csp.replace(/(?:^|\s)(default-src|form-action)[^;]*;?/g, '') + ) + } + } + passport.authenticate('saml')(req, res, next) + }, + passportSamlLogin(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( + 'saml', + { keepSessionInfo: true }, + async function (err, user, info) { + if (err) { + return next(err) + } + if (user) { + // `user` is either a user object or false + AuthenticationController.setAuditInfo(req, { + method: 'SAML login', + }) + try { + await AuthenticationController.promises.finishLogin(user, req, res) + } catch (err) { + return next(err) + } + } else { + if (info.redir != null) { + return res.json({ redir: info.redir }) + } else { + res.status(info.status || 200) + delete info.status + const body = { message: info } + const { errorReason } = info + if (errorReason) { + body.errorReason = errorReason + delete info.errorReason + } + return res.json(body) + } + } + } + )(req, res, next) + }, + async doPassportSamlLogin(req, profile, done) { + let user, info + try { + ;({ user, info } = await AuthenticationControllerSaml._doPassportSamlLogin( + req, + profile + )) + } catch (error) { + return done(error) + } + return done(undefined, user, info) + }, + async _doPassportSamlLogin(req, profile) { + const { fromKnownDevice } = AuthenticationController.getAuditInfo(req) + const auditLog = { + ipAddress: req.ip, + info: { method: 'SAML login', fromKnownDevice }, + } + + let user + try { + user = await AuthenticationManagerSaml.promises.findOrCreateSamlUser(profile, auditLog) + } catch (error) { + return { + user: false, + info: handleAuthenticateErrors(error, req), + } + } + if (user) { + req.session.saml_extce = {nameID : profile.nameID, sessionIndex : profile.sessionIndex} + return { user, info: undefined } + } else { //something wrong + logger.debug({ email : profile.mail }, 'failed SAML log in') + return { + user: false, + info: { + type: 'error', + text: 'Unknown error', + status: 500, + }, + } + } + }, + async passportSamlSPLogout(req, res, next) { + passport._strategy('saml').logout(req, async (err, url) => { + if (err) logger.error({ err }, 'can not generate logout url') + await UserController.promises.doLogout(req) + res.redirect(url) + }) + }, + passportSamlIdPLogout(req, res, next) { + passport.authenticate('saml')(req, res, (err) => { + if (err) return next(err) + res.redirect('/login'); + }) + }, + async doPassportSamlLogout(req, profile, done) { + let user, info + try { + ;({ user, info } = await AuthenticationControllerSaml._doPassportSamlLogout( + req, + profile + )) + } catch (error) { + return done(error) + } + return done(undefined, user, info) + }, + async _doPassportSamlLogout(req, profile) { + if (req?.session?.saml_extce?.nameID === profile.nameID && + req?.session?.saml_extce?.sessionIndex === profile.sessionIndex) { + profile = req.user + } + await UserSessionsManager.promises.untrackSession(req.user, req.sessionID).catch(err => { + logger.warn({ err, userId: req.user._id }, 'failed to untrack session') + }) + return { user: profile, info: undefined } + }, + passportSamlMetadata(req, res) { + const samlStratery = passport._strategy('saml') + res.setHeader('Content-Disposition', `attachment; filename="${samlStratery._saml.options.issuer}-meta.xml"`) + xmlResponse(res, + samlStratery.generateServiceProviderMetadata( + samlStratery._saml.options.decryptionCert, + samlStratery._saml.options.signingCert + ) + ) + }, +} +export const { + passportSamlAuthWithIdP, + passportSamlLogin, + passportSamlSPLogout, + passportSamlIdPLogout, + doPassportSamlLogin, + doPassportSamlLogout, + passportSamlMetadata, +} = AuthenticationControllerSaml diff --git a/services/web/modules/saml-authentication/app/src/AuthenticationManagerSaml.mjs b/services/web/modules/saml-authentication/app/src/AuthenticationManagerSaml.mjs new file mode 100644 index 0000000000..47d97f3019 --- /dev/null +++ b/services/web/modules/saml-authentication/app/src/AuthenticationManagerSaml.mjs @@ -0,0 +1,60 @@ +import Settings from '@overleaf/settings' +import UserCreator from '../../../../app/src/Features/User/UserCreator.js' +import { User } from '../../../../app/src/models/User.js' + +const AuthenticationManagerSaml = { + async findOrCreateSamlUser(profile, auditLog) { + const { + attEmail, + attFirstName, + attLastName, + attAdmin, + valAdmin, + updateUserDetailsOnLogin, + } = Settings.saml + const email = Array.isArray(profile[attEmail]) + ? profile[attEmail][0].toLowerCase() + : profile[attEmail].toLowerCase() + const firstName = attFirstName ? profile[attFirstName] : "" + const lastName = attLastName ? profile[attLastName] : email + let isAdmin = false + if( attAdmin && valAdmin ) { + isAdmin = (Array.isArray(profile[attAdmin]) ? profile[attAdmin].includes(valAdmin) : + profile[attAdmin] === valAdmin) + } + let user = await User.findOne({ 'email': email }).exec() + if( !user ) { + user = await UserCreator.promises.createNewUser( + { + email: email, + first_name: firstName, + last_name: lastName, + isAdmin: isAdmin, + holdingAccount: false, + } + ) + await User.updateOne( + { _id: user._id }, + { $set : { 'emails.0.confirmedAt' : Date.now() } } + ).exec() //email of saml user is confirmed + } + let userDetails = updateUserDetailsOnLogin ? { first_name : firstName, last_name: lastName } : {} + if( attAdmin && valAdmin ) { + user.isAdmin = isAdmin + userDetails.isAdmin = isAdmin + } + const result = await User.updateOne( + { _id: user._id, loginEpoch: user.loginEpoch }, { $inc: { loginEpoch: 1 }, $set: userDetails }, + {} + ).exec() + + if (result.modifiedCount !== 1) { + throw new ParallelLoginError() + } + return user + }, +} + +export default { + promises: AuthenticationManagerSaml, +} diff --git a/services/web/modules/saml-authentication/app/src/InitSamlSettings.mjs b/services/web/modules/saml-authentication/app/src/InitSamlSettings.mjs new file mode 100644 index 0000000000..441f9033af --- /dev/null +++ b/services/web/modules/saml-authentication/app/src/InitSamlSettings.mjs @@ -0,0 +1,16 @@ +import Settings from '@overleaf/settings' + +function initSamlSettings() { + Settings.saml = { + enable: true, + identityServiceName: process.env.OVERLEAF_SAML_IDENTITY_SERVICE_NAME || 'Login with SAML IdP', + attEmail: process.env.OVERLEAF_SAML_EMAIL_FIELD || 'nameID', + attFirstName: process.env.OVERLEAF_SAML_FIRST_NAME_FIELD || 'givenName', + attLastName: process.env.OVERLEAF_SAML_LAST_NAME_FIELD || 'lastName', + attAdmin: process.env.OVERLEAF_SAML_IS_ADMIN_FIELD, + valAdmin: process.env.OVERLEAF_SAML_IS_ADMIN_FIELD_VALUE, + updateUserDetailsOnLogin: String(process.env.OVERLEAF_SAML_UPDATE_USER_DETAILS_ON_LOGIN).toLowerCase() === 'true', + } +} + +export default initSamlSettings diff --git a/services/web/modules/saml-authentication/app/src/SamlNonCsrfRouter.mjs b/services/web/modules/saml-authentication/app/src/SamlNonCsrfRouter.mjs new file mode 100644 index 0000000000..65b42c92ae --- /dev/null +++ b/services/web/modules/saml-authentication/app/src/SamlNonCsrfRouter.mjs @@ -0,0 +1,12 @@ +import logger from '@overleaf/logger' +import { passportSamlLogin, passportSamlIdPLogout } from './AuthenticationControllerSaml.mjs' + +export default { + apply(webRouter) { + logger.debug({}, 'Init SAML NonCsrfRouter') + webRouter.get('/saml/login/callback', passportSamlLogin) + webRouter.post('/saml/login/callback', passportSamlLogin) + webRouter.get('/saml/logout/callback', passportSamlIdPLogout) + webRouter.post('/saml/logout/callback', passportSamlIdPLogout) + }, +} diff --git a/services/web/modules/saml-authentication/app/src/SamlRouter.mjs b/services/web/modules/saml-authentication/app/src/SamlRouter.mjs new file mode 100644 index 0000000000..9ee3677901 --- /dev/null +++ b/services/web/modules/saml-authentication/app/src/SamlRouter.mjs @@ -0,0 +1,14 @@ +import logger from '@overleaf/logger' +import AuthenticationController from '../../../../app/src/Features/Authentication/AuthenticationController.js' +import { passportSamlAuthWithIdP, passportSamlSPLogout, passportSamlMetadata} from './AuthenticationControllerSaml.mjs' + +export default { + apply(webRouter) { + logger.debug({}, 'Init SAML router') + webRouter.get('/saml/login', passportSamlAuthWithIdP) + AuthenticationController.addEndpointToLoginWhitelist('/saml/login') + webRouter.post('/saml/logout', AuthenticationController.requireLogin(), passportSamlSPLogout) + webRouter.get('/saml/meta', passportSamlMetadata) + AuthenticationController.addEndpointToLoginWhitelist('/saml/meta') + }, +} diff --git a/services/web/modules/saml-authentication/app/src/SamlStrategy.mjs b/services/web/modules/saml-authentication/app/src/SamlStrategy.mjs new file mode 100644 index 0000000000..3a16459f98 --- /dev/null +++ b/services/web/modules/saml-authentication/app/src/SamlStrategy.mjs @@ -0,0 +1,62 @@ +import fs from 'fs' +import passport from 'passport' +import Settings from '@overleaf/settings' +import { doPassportSamlLogin, doPassportSamlLogout } from './AuthenticationControllerSaml.mjs' +import { Strategy as SamlStrategy } from '@node-saml/passport-saml' + +function _readFilesContentFromEnv(envVar) { +// envVar is either a file name: 'file.pem', or string with array: '["file.pem", "file2.pem"]' + if (!envVar) return undefined + try { + const parsedFileNames = JSON.parse(envVar) + return parsedFileNames.map(filename => fs.readFileSync(filename, 'utf8')) + } catch (error) { + if (error instanceof SyntaxError) { // failed to parse, envVar must be a file name + return fs.readFileSync(envVar, 'utf8') + } else { + throw error + } + } +} + +const samlOptions = { + entryPoint: process.env.OVERLEAF_SAML_ENTRYPOINT, + callbackUrl: process.env.OVERLEAF_SAML_CALLBACK_URL, + issuer: process.env.OVERLEAF_SAML_ISSUER, + audience: process.env.OVERLEAF_SAML_AUDIENCE, + cert: _readFilesContentFromEnv(process.env.OVERLEAF_SAML_IDP_CERT), + signingCert: _readFilesContentFromEnv(process.env.OVERLEAF_SAML_PUBLIC_CERT), + privateKey: _readFilesContentFromEnv(process.env.OVERLEAF_SAML_PRIVATE_KEY), + decryptionCert: _readFilesContentFromEnv(process.env.OVERLEAF_SAML_DECRYPTION_CERT), + decryptionPvk: _readFilesContentFromEnv(process.env.OVERLEAF_SAML_DECRYPTION_PVK), + signatureAlgorithm: process.env.OVERLEAF_SAML_SIGNATURE_ALGORITHM, + additionalParams: JSON.parse(process.env.OVERLEAF_SAML_ADDITIONAL_PARAMS || '{}'), + additionalAuthorizeParams: JSON.parse(process.env.OVERLEAF_SAML_ADDITIONAL_AUTHORIZE_PARAMS || '{}'), + identifierFormat: process.env.OVERLEAF_SAML_IDENTIFIER_FORMAT, + acceptedClockSkewMs: process.env.OVERLEAF_SAML_ACCEPTED_CLOCK_SKEW_MS ? Number(process.env.OVERLEAF_SAML_ACCEPTED_CLOCK_SKEW_MS) : undefined, + attributeConsumingServiceIndex: process.env.OVERLEAF_SAML_ATTRIBUTE_CONSUMING_SERVICE_INDEX, + authnContext: process.env.OVERLEAF_SAML_AUTHN_CONTEXT ? JSON.parse(process.env.OVERLEAF_SAML_AUTHN_CONTEXT) : undefined, + forceAuthn: String(process.env.OVERLEAF_SAML_FORCE_AUTHN).toLowerCase() === 'true', + disableRequestedAuthnContext: String(process.env.OVERLEAF_SAML_DISABLE_REQUESTED_AUTHN_CONTEXT).toLowerCase() === 'true', + skipRequestCompression: process.env.OVERLEAF_SAML_AUTHN_REQUEST_BINDING === 'HTTP-POST', // compression should be skipped iff authnRequestBinding is POST + authnRequestBinding: process.env.OVERLEAF_SAML_AUTHN_REQUEST_BINDING, + validateInResponseTo: process.env.OVERLEAF_SAML_VALIDATE_IN_RESPONSE_TO, + requestIdExpirationPeriodMs: process.env.OVERLEAF_SAML_REQUEST_ID_EXPIRATION_PERIOD_MS ? Number(process.env.OVERLEAF_SAML_REQUEST_ID_EXPIRATION_PERIOD_MS) : undefined, +// cacheProvider: process.env.OVERLEAF_SAML_CACHE_PROVIDER, + logoutUrl: process.env.OVERLEAF_SAML_LOGOUT_URL, + logoutCallbackUrl: process.env.OVERLEAF_SAML_LOGOUT_CALLBACK_URL, + additionalLogoutParams: JSON.parse(process.env.OVERLEAF_SAML_ADDITIONAL_LOGOUT_PARAMS || '{}'), + passReqToCallback: true, +} + +function addSamlStrategy(passport) { + passport.use( + new SamlStrategy( + samlOptions, + doPassportSamlLogin, + doPassportSamlLogout + ) + ) +} + +export default addSamlStrategy diff --git a/services/web/modules/saml-authentication/index.mjs b/services/web/modules/saml-authentication/index.mjs new file mode 100644 index 0000000000..35ea70283f --- /dev/null +++ b/services/web/modules/saml-authentication/index.mjs @@ -0,0 +1,26 @@ +import initSamlSettings from './app/src/InitSamlSettings.mjs' +import addSamlStrategy from './app/src/SamlStrategy.mjs' +import SamlRouter from './app/src/SamlRouter.mjs' +import SamlNonCsrfRouter from './app/src/SamlNonCsrfRouter.mjs' + +let samlModule = {}; + +if (process.env.EXTERNAL_AUTH === 'saml') { + initSamlSettings() + samlModule = { + name: 'saml-authentication', + hooks: { + passportSetup: function (passport, callback) { + try { + addSamlStrategy(passport) + callback(null) + } catch (error) { + callback(error) + } + }, + }, + router: SamlRouter, + nonCsrfRouter: SamlNonCsrfRouter, + } +} +export default samlModule From 764ff813bdda581728c6ad3707b967913532e362 Mon Sep 17 00:00:00 2001 From: yu-i-i Date: Tue, 17 Dec 2024 18:36:18 +0100 Subject: [PATCH 02/43] Refactor authentication code; add OIDC support --- .../AuthenticationController.js | 6 +- .../PasswordReset/PasswordResetController.mjs | 4 - .../PasswordReset/PasswordResetHandler.mjs | 5 +- .../app/src/Features/User/UserController.js | 3 +- .../src/Features/User/UserPagesController.mjs | 6 +- .../app/src/infrastructure/ExpressLocals.js | 4 +- services/web/app/src/router.mjs | 4 +- services/web/app/views/user/login.pug | 9 + services/web/app/views/user/passwordReset.pug | 2 +- services/web/app/views/user/settings.pug | 4 +- services/web/config/settings.defaults.js | 19 +- .../web/frontend/extracted-translations.json | 1 + .../settings/components/linking-section.tsx | 3 +- .../components/linking/sso-widget.tsx | 4 +- .../settings/components/password-section.tsx | 6 +- .../frontend/js/shared/svgs/openid-logo.jsx | 27 +++ services/web/locales/en.json | 2 + .../app/src/LDAPAuthenticationController.mjs | 112 ++++++++++++ .../app/src/LDAPAuthenticationManager.mjs} | 36 ++-- .../ldap/app/src/LDAPContacts.mjs | 120 ++++++++++++ .../ldap/app/src/LDAPModuleManager.mjs | 112 ++++++++++++ .../ldap/app/src/LDAPRouter.mjs | 19 ++ .../web/modules/authentication/ldap/index.mjs | 17 ++ .../web/modules/authentication/logout.mjs | 18 ++ .../app/src/OIDCAuthenticationController.mjs | 171 ++++++++++++++++++ .../app/src/OIDCAuthenticationManager.mjs | 94 ++++++++++ .../oidc/app/src/OIDCModuleManager.mjs | 82 +++++++++ .../oidc/app/src/OIDCRouter.mjs | 15 ++ .../web/modules/authentication/oidc/index.mjs | 16 ++ .../app/src/SAMLAuthenticationController.mjs} | 66 +++---- .../app/src/SAMLAuthenticationManager.mjs | 85 +++++++++ .../saml/app/src/SAMLModuleManager.mjs | 100 ++++++++++ .../saml/app/src/SAMLNonCsrfRouter.mjs | 11 ++ .../saml/app/src/SAMLRouter.mjs | 16 ++ .../web/modules/authentication/saml/index.mjs | 18 ++ services/web/modules/authentication/utils.mjs | 42 +++++ .../app/src/AuthenticationControllerLdap.mjs | 64 ------- .../app/src/InitLdapSettings.mjs | 17 -- .../app/src/LdapContacts.mjs | 136 -------------- .../app/src/LdapStrategy.mjs | 78 -------- .../web/modules/ldap-authentication/index.mjs | 30 --- .../app/src/AuthenticationManagerSaml.mjs | 60 ------ .../app/src/InitSamlSettings.mjs | 16 -- .../app/src/SamlNonCsrfRouter.mjs | 12 -- .../app/src/SamlRouter.mjs | 14 -- .../app/src/SamlStrategy.mjs | 62 ------- .../web/modules/saml-authentication/index.mjs | 26 --- services/web/package.json | 1 + 48 files changed, 1169 insertions(+), 606 deletions(-) create mode 100644 services/web/frontend/js/shared/svgs/openid-logo.jsx create mode 100644 services/web/modules/authentication/ldap/app/src/LDAPAuthenticationController.mjs rename services/web/modules/{ldap-authentication/app/src/AuthenticationManagerLdap.mjs => authentication/ldap/app/src/LDAPAuthenticationManager.mjs} (67%) create mode 100644 services/web/modules/authentication/ldap/app/src/LDAPContacts.mjs create mode 100644 services/web/modules/authentication/ldap/app/src/LDAPModuleManager.mjs create mode 100644 services/web/modules/authentication/ldap/app/src/LDAPRouter.mjs create mode 100644 services/web/modules/authentication/ldap/index.mjs create mode 100644 services/web/modules/authentication/logout.mjs create mode 100644 services/web/modules/authentication/oidc/app/src/OIDCAuthenticationController.mjs create mode 100644 services/web/modules/authentication/oidc/app/src/OIDCAuthenticationManager.mjs create mode 100644 services/web/modules/authentication/oidc/app/src/OIDCModuleManager.mjs create mode 100644 services/web/modules/authentication/oidc/app/src/OIDCRouter.mjs create mode 100644 services/web/modules/authentication/oidc/index.mjs rename services/web/modules/{saml-authentication/app/src/AuthenticationControllerSaml.mjs => authentication/saml/app/src/SAMLAuthenticationController.mjs} (65%) create mode 100644 services/web/modules/authentication/saml/app/src/SAMLAuthenticationManager.mjs create mode 100644 services/web/modules/authentication/saml/app/src/SAMLModuleManager.mjs create mode 100644 services/web/modules/authentication/saml/app/src/SAMLNonCsrfRouter.mjs create mode 100644 services/web/modules/authentication/saml/app/src/SAMLRouter.mjs create mode 100644 services/web/modules/authentication/saml/index.mjs create mode 100644 services/web/modules/authentication/utils.mjs delete mode 100644 services/web/modules/ldap-authentication/app/src/AuthenticationControllerLdap.mjs delete mode 100644 services/web/modules/ldap-authentication/app/src/InitLdapSettings.mjs delete mode 100644 services/web/modules/ldap-authentication/app/src/LdapContacts.mjs delete mode 100644 services/web/modules/ldap-authentication/app/src/LdapStrategy.mjs delete mode 100644 services/web/modules/ldap-authentication/index.mjs delete mode 100644 services/web/modules/saml-authentication/app/src/AuthenticationManagerSaml.mjs delete mode 100644 services/web/modules/saml-authentication/app/src/InitSamlSettings.mjs delete mode 100644 services/web/modules/saml-authentication/app/src/SamlNonCsrfRouter.mjs delete mode 100644 services/web/modules/saml-authentication/app/src/SamlRouter.mjs delete mode 100644 services/web/modules/saml-authentication/app/src/SamlStrategy.mjs delete mode 100644 services/web/modules/saml-authentication/index.mjs diff --git a/services/web/app/src/Features/Authentication/AuthenticationController.js b/services/web/app/src/Features/Authentication/AuthenticationController.js index 983526006e..baba8aacee 100644 --- a/services/web/app/src/Features/Authentication/AuthenticationController.js +++ b/services/web/app/src/Features/Authentication/AuthenticationController.js @@ -82,6 +82,7 @@ const AuthenticationController = { analyticsId: user.analyticsId || user._id, alphaProgram: user.alphaProgram || undefined, // only store if set betaProgram: user.betaProgram || undefined, // only store if set + externalAuth: user.externalAuth || false, } if (user.isAdmin) { lightUser.isAdmin = true @@ -102,9 +103,9 @@ const AuthenticationController = { // so we can send back our custom `{message: {text: "", type: ""}}` responses on failure, // and send a `{redir: ""}` response on success passport.authenticate( - Settings.ldap?.enable ? ['custom-fail-ldapauth','local'] : ['local'], + 'local', { keepSessionInfo: true }, - async function (err, user, infoArray) { + async function (err, user, info) { if (err) { return next(err) } @@ -126,7 +127,6 @@ const AuthenticationController = { return next(err) } } else { - let info = infoArray[0] if (info.redir != null) { return res.json({ redir: info.redir }) } else { diff --git a/services/web/app/src/Features/PasswordReset/PasswordResetController.mjs b/services/web/app/src/Features/PasswordReset/PasswordResetController.mjs index 54f847ef9c..2963c56653 100644 --- a/services/web/app/src/Features/PasswordReset/PasswordResetController.mjs +++ b/services/web/app/src/Features/PasswordReset/PasswordResetController.mjs @@ -140,10 +140,6 @@ async function requestReset(req, res, next) { return res.status(404).json({ message: req.i18n.translate('secondary_email_password_reset'), }) - } else if (status === 'external') { - return res.status(403).json({ - message: req.i18n.translate('password_managed_externally'), - }) } else { return res.status(404).json({ message: req.i18n.translate('cant_find_email'), diff --git a/services/web/app/src/Features/PasswordReset/PasswordResetHandler.mjs b/services/web/app/src/Features/PasswordReset/PasswordResetHandler.mjs index 0ac203222c..2c1aefe6a6 100644 --- a/services/web/app/src/Features/PasswordReset/PasswordResetHandler.mjs +++ b/services/web/app/src/Features/PasswordReset/PasswordResetHandler.mjs @@ -18,10 +18,6 @@ async function generateAndEmailResetToken(email) { return null } - if (!user.hashedPassword) { - return 'external' - } - if (user.email !== email) { return 'secondary' } @@ -76,6 +72,7 @@ async function getUserForPasswordResetToken(token) { 'overleaf.id': 1, email: 1, must_reconfirm: 1, + hashedPassword: 1, }) await assertUserPermissions(user, ['change-password']) diff --git a/services/web/app/src/Features/User/UserController.js b/services/web/app/src/Features/User/UserController.js index 772e77e3e4..b767dcd4a1 100644 --- a/services/web/app/src/Features/User/UserController.js +++ b/services/web/app/src/Features/User/UserController.js @@ -404,7 +404,7 @@ async function updateUserSettings(req, res, next) { if ( newEmail == null || newEmail === user.email || - (req.externalAuthenticationSystemUsed() && !user.hashedPassword) + req.externalAuthenticationSystemUsed() ) { // end here, don't update email SessionManager.setInSessionUser(req.session, { @@ -481,7 +481,6 @@ async function doLogout(req) { } async function logout(req, res, next) { - if (req?.session.saml_extce) return res.redirect(308, '/saml/logout') const requestedRedirect = req.body.redirect ? UrlHelper.getSafeRedirectPath(req.body.redirect) : undefined diff --git a/services/web/app/src/Features/User/UserPagesController.mjs b/services/web/app/src/Features/User/UserPagesController.mjs index 8b5263c37d..c7ad5b30f4 100644 --- a/services/web/app/src/Features/User/UserPagesController.mjs +++ b/services/web/app/src/Features/User/UserPagesController.mjs @@ -52,10 +52,8 @@ async function settingsPage(req, res) { const reconfirmedViaSAML = _.get(req.session, ['saml', 'reconfirmed']) delete req.session.saml let shouldAllowEditingDetails = true - if (Settings.ldap && Settings.ldap.updateUserDetailsOnLogin) { - shouldAllowEditingDetails = false - } - if (Settings.saml && Settings.saml.updateUserDetailsOnLogin) { + const externalAuth = req.user.externalAuth + if (externalAuth && Settings[externalAuth].updateUserDetailsOnLogin) { shouldAllowEditingDetails = false } const oauthProviders = Settings.oauthProviders || {} diff --git a/services/web/app/src/infrastructure/ExpressLocals.js b/services/web/app/src/infrastructure/ExpressLocals.js index 5cf9501c29..26364cdc5c 100644 --- a/services/web/app/src/infrastructure/ExpressLocals.js +++ b/services/web/app/src/infrastructure/ExpressLocals.js @@ -107,9 +107,9 @@ module.exports = function (webRouter, privateApiRouter, publicApiRouter) { webRouter.use(function (req, res, next) { req.externalAuthenticationSystemUsed = - Features.externalAuthenticationSystemUsed + () => !!req?.user?.externalAuth res.locals.externalAuthenticationSystemUsed = - Features.externalAuthenticationSystemUsed + () => !!req?.user?.externalAuth req.hasFeature = res.locals.hasFeature = Features.hasFeature next() }) diff --git a/services/web/app/src/router.mjs b/services/web/app/src/router.mjs index e727fa7bc5..af855242cb 100644 --- a/services/web/app/src/router.mjs +++ b/services/web/app/src/router.mjs @@ -217,6 +217,8 @@ async function initialize(webRouter, privateApiRouter, publicApiRouter) { CaptchaMiddleware.canSkipCaptcha ) + await Modules.applyRouter(webRouter, privateApiRouter, publicApiRouter) + webRouter.get('/login', UserPagesController.loginPage) AuthenticationController.addEndpointToLoginWhitelist('/login') @@ -285,8 +287,6 @@ async function initialize(webRouter, privateApiRouter, publicApiRouter) { TokenAccessRouter.apply(webRouter) HistoryRouter.apply(webRouter, privateApiRouter) - await Modules.applyRouter(webRouter, privateApiRouter, publicApiRouter) - if (Settings.enableSubscriptions) { webRouter.get( '/user/bonus', diff --git a/services/web/app/views/user/login.pug b/services/web/app/views/user/login.pug index 3008b11b1d..97ef72476d 100644 --- a/services/web/app/views/user/login.pug +++ b/services/web/app/views/user/login.pug @@ -53,3 +53,12 @@ block content ) span(data-ol-inflight="idle") #{settings.saml.identityServiceName} span(hidden data-ol-inflight="pending") #{translate("logging_in")}… + if settings.oidc && settings.oidc.enable + form(data-ol-async-form, name="oidcLoginForm") + .actions(style='margin-top: 30px;') + a.btn.btn-secondary.btn-block( + href='/oidc/login', + data-ol-disabled-inflight + ) + span(data-ol-inflight="idle") #{settings.oidc.identityServiceName} + span(hidden data-ol-inflight="pending") #{translate("logging_in")}… diff --git a/services/web/app/views/user/passwordReset.pug b/services/web/app/views/user/passwordReset.pug index f498baff23..b6cd42c840 100644 --- a/services/web/app/views/user/passwordReset.pug +++ b/services/web/app/views/user/passwordReset.pug @@ -50,7 +50,7 @@ block content +notification({ariaLive: 'assertive', type: 'error', className: 'mb-3', content: translate(error)}) div(data-ol-custom-form-message='no-password-allowed-due-to-sso' hidden) - +notification({ariaLive: 'polite', type: 'error', className: 'mb-3', content: translate('you_cant_reset_password_due_to_sso', {}, [{name: 'a', attrs: {href: '/sso-login'}}])}) + +notification({ariaLive: 'polite', type: 'error', className: 'mb-3', content: translate('you_cant_reset_password_due_to_ldap_or_sso')}) input(name='_csrf' type='hidden' value=csrfToken) .form-group.mb-3 label.form-label(for='email') #{translate("email")} diff --git a/services/web/app/views/user/settings.pug b/services/web/app/views/user/settings.pug index a07863682e..45d21c7572 100644 --- a/services/web/app/views/user/settings.pug +++ b/services/web/app/views/user/settings.pug @@ -11,7 +11,7 @@ block append meta meta( name='ol-shouldAllowEditingDetails' data-type='boolean' - content=shouldAllowEditingDetails || hasPassword + content=shouldAllowEditingDetails ) meta(name='ol-oauthProviders' data-type='json' content=oauthProviders) meta(name='ol-institutionLinked' data-type='json' content=institutionLinked) @@ -34,7 +34,7 @@ block append meta meta( name='ol-isExternalAuthenticationSystemUsed' data-type='boolean' - content=externalAuthenticationSystemUsed() && !hasPassword + content=externalAuthenticationSystemUsed() ) meta(name='ol-user' data-type='json' content=user) meta(name='ol-labsExperiments' data-type='json' content=labsExperiments) diff --git a/services/web/config/settings.defaults.js b/services/web/config/settings.defaults.js index 3c42fa477d..4747988838 100644 --- a/services/web/config/settings.defaults.js +++ b/services/web/config/settings.defaults.js @@ -1030,8 +1030,9 @@ module.exports = { 'launchpad', 'server-ce-scripts', 'user-activate', - 'ldap-authentication', - 'saml-authentication', + 'authentication/ldap', + 'authentication/saml', + 'authentication/oidc', ], viewIncludes: {}, @@ -1058,6 +1059,20 @@ module.exports = { managedUsers: { enabled: false, }, + + oauthProviders: { + ...(process.env.EXTERNAL_AUTH && process.env.EXTERNAL_AUTH.includes('oidc') && { + [process.env.OVERLEAF_OIDC_PROVIDER_ID || 'oidc']: { + name: process.env.OVERLEAF_OIDC_PROVIDER_NAME || 'OIDC Provider', + descriptionKey: process.env.OVERLEAF_OIDC_PROVIDER_DESCRIPTION, + descriptionOptions: { link: process.env.OVERLEAF_OIDC_PROVIDER_INFO_LINK }, + hideWhenNotLinked: process.env.OVERLEAF_OIDC_PROVIDER_HIDE_NOT_LINKED ? + process.env.OVERLEAF_OIDC_PROVIDER_HIDE_NOT_LINKED.toLowerCase() === 'true' : undefined, + linkPath: '/oidc/login', + }, + }), + }, + } module.exports.mergeWith = function (overrides) { diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index ef2a9c6a2c..bc67bede51 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -2140,6 +2140,7 @@ "you_can_select_or_invite_collaborator": "", "you_can_select_or_invite_collaborator_plural": "", "you_can_still_use_your_premium_features": "", + "you_cant_add_or_change_password_due_to_ldap_or_sso": "", "you_cant_add_or_change_password_due_to_sso": "", "you_cant_join_this_group_subscription": "", "you_dont_have_any_add_ons_on_your_account": "", diff --git a/services/web/frontend/js/features/settings/components/linking-section.tsx b/services/web/frontend/js/features/settings/components/linking-section.tsx index a198cb1328..411d38e650 100644 --- a/services/web/frontend/js/features/settings/components/linking-section.tsx +++ b/services/web/frontend/js/features/settings/components/linking-section.tsx @@ -204,7 +204,8 @@ function SSOLinkingWidgetContainer({ const { t } = useTranslation() const { unlink } = useSSOContext() - let description = '' + let description = subscription.provider.descriptionKey || + `${t('login_with_service', { service: subscription.provider.name, })}.` switch (subscription.providerId) { case 'collabratec': description = t('linked_collabratec_description') diff --git a/services/web/frontend/js/features/settings/components/linking/sso-widget.tsx b/services/web/frontend/js/features/settings/components/linking/sso-widget.tsx index 800a7540ae..bb767d984c 100644 --- a/services/web/frontend/js/features/settings/components/linking/sso-widget.tsx +++ b/services/web/frontend/js/features/settings/components/linking/sso-widget.tsx @@ -4,6 +4,7 @@ import { FetchError } from '../../../../infrastructure/fetch-json' import IEEELogo from '../../../../shared/svgs/ieee-logo' import GoogleLogo from '../../../../shared/svgs/google-logo' import OrcidLogo from '../../../../shared/svgs/orcid-logo' +import OpenIDLogo from '../../../../shared/svgs/openid-logo' import LinkingStatus from './status' import OLButton from '@/features/ui/components/ol/ol-button' import OLModal, { @@ -17,6 +18,7 @@ const providerLogos: { readonly [p: string]: JSX.Element } = { collabratec: , google: , orcid: , + oidc: , } type SSOLinkingWidgetProps = { @@ -66,7 +68,7 @@ export function SSOLinkingWidget({ return (
-
{providerLogos[providerId]}
+
{providerLogos[providerId] || providerLogos['oidc']}

{title}

diff --git a/services/web/frontend/js/features/settings/components/password-section.tsx b/services/web/frontend/js/features/settings/components/password-section.tsx index 739636e998..c09ad50562 100644 --- a/services/web/frontend/js/features/settings/components/password-section.tsx +++ b/services/web/frontend/js/features/settings/components/password-section.tsx @@ -39,11 +39,7 @@ function CanOnlyLogInThroughSSO() { return (

, - ]} + i18nKey="you_cant_add_or_change_password_due_to_ldap_or_sso" />

) diff --git a/services/web/frontend/js/shared/svgs/openid-logo.jsx b/services/web/frontend/js/shared/svgs/openid-logo.jsx new file mode 100644 index 0000000000..3de933820b --- /dev/null +++ b/services/web/frontend/js/shared/svgs/openid-logo.jsx @@ -0,0 +1,27 @@ +function OpenIDLogo() { + return ( + + + + + + + ) +} + +export default OpenIDLogo + diff --git a/services/web/locales/en.json b/services/web/locales/en.json index 4b51bd2152..ca118ed322 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -2697,8 +2697,10 @@ "you_can_select_or_invite_collaborator": "You can select or invite __count__ collaborator on your current plan. Upgrade to add more editors or reviewers.", "you_can_select_or_invite_collaborator_plural": "You can select or invite __count__ collaborators on your current plan. Upgrade to add more editors or reviewers.", "you_can_still_use_your_premium_features": "You can still use your premium features until the pause becomes active.", + "you_cant_add_or_change_password_due_to_ldap_or_sso": "You can’t add or change your password because your group or organization uses LDAP or SSO.", "you_cant_add_or_change_password_due_to_sso": "You can’t add or change your password because your group or organization uses <0>single sign-on (SSO).", "you_cant_join_this_group_subscription": "You can’t join this group subscription", + "you_cant_reset_password_due_to_ldap_or_sso": "You can’t reset your password because your group or organization uses LDAP or SSO. Contact your system administrator.", "you_cant_reset_password_due_to_sso": "You can’t reset your password because your group or organization uses SSO. <0>Log in with SSO.", "you_dont_have_any_add_ons_on_your_account": "You don’t have any add-ons on your account.", "you_dont_have_any_repositories": "You don’t have any repositories", diff --git a/services/web/modules/authentication/ldap/app/src/LDAPAuthenticationController.mjs b/services/web/modules/authentication/ldap/app/src/LDAPAuthenticationController.mjs new file mode 100644 index 0000000000..1a3ed01d3c --- /dev/null +++ b/services/web/modules/authentication/ldap/app/src/LDAPAuthenticationController.mjs @@ -0,0 +1,112 @@ +import logger from '@overleaf/logger' +import passport from 'passport' +import EmailHelper from '../../../../../app/src/Features/Helpers/EmailHelper.js' +import { handleAuthenticateErrors } from '../../../../../app/src/Features/Authentication/AuthenticationErrors.js' +import AuthenticationController from '../../../../../app/src/Features/Authentication/AuthenticationController.js' +import LDAPAuthenticationManager from './LDAPAuthenticationManager.mjs' + +const LDAPAuthenticationController = { + 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( + 'ldapauth', + { keepSessionInfo: true }, + async function (err, user, info, status) { + if (err) { //we cannot be here as long as errors are treated as fails + return next(err) + } + if (user) { + // `user` is either a user object or false + AuthenticationController.setAuditInfo(req, { + method: 'LDAP password login', + }) + + try { + await AuthenticationController.promises.finishLogin(user, req, res) + res.status(200) + return + } catch (err) { + return next(err) + } + } else { + if (status != 401) { + logger.warn(status, 'LDAP: ' + info.message) + } + if (EmailHelper.parseEmail(req.body.email)) return next() //Try local authentication + if (info.redir != null) { + return res.json({ redir: info.redir }) + } else { + res.status(status || info.status || 401) + delete info.status + info.type = 'error' + info.key = 'invalid-password-retry-or-reset' + const body = { message: info } + const { errorReason } = info + if (errorReason) { + body.errorReason = errorReason + delete info.errorReason + } + return res.json(body) + } + } + } + )(req, res, next) + }, + async doPassportLogin(req, profile, done) { + let user, info + try { + ;({ user, info } = await LDAPAuthenticationController._doPassportLogin( + req, + profile + )) + } catch (error) { + return done(error) + } + return done(undefined, user, info) + }, + async _doPassportLogin(req, profile) { + const { fromKnownDevice } = AuthenticationController.getAuditInfo(req) + const auditLog = { + ipAddress: req.ip, + info: { method: 'LDAP password login', fromKnownDevice }, + } + + let user, isPasswordReused + try { + user = await LDAPAuthenticationManager.promises.findOrCreateUser(profile, auditLog) + } catch (error) { + return { + user: false, + info: handleAuthenticateErrors(error, req), + } + } + if (user && AuthenticationController.captchaRequiredForLogin(req, user)) { + return { + user: false, + info: { + text: req.i18n.translate('cannot_verify_user_not_robot'), + type: 'error', + errorReason: 'cannot_verify_user_not_robot', + status: 400, + }, + } + } else if (user) { + user.externalAuth = 'ldap' + return { user, info: undefined } + } else { //we cannot be here, something is terribly wrong + logger.debug({ email : profile.mail }, 'failed LDAP log in') + return { + user: false, + info: { + type: 'error', + text: 'Unknown error', + status: 500, + }, + } + } + }, +} + +export default LDAPAuthenticationController diff --git a/services/web/modules/ldap-authentication/app/src/AuthenticationManagerLdap.mjs b/services/web/modules/authentication/ldap/app/src/LDAPAuthenticationManager.mjs similarity index 67% rename from services/web/modules/ldap-authentication/app/src/AuthenticationManagerLdap.mjs rename to services/web/modules/authentication/ldap/app/src/LDAPAuthenticationManager.mjs index 1371f76d52..66943e82a3 100644 --- a/services/web/modules/ldap-authentication/app/src/AuthenticationManagerLdap.mjs +++ b/services/web/modules/authentication/ldap/app/src/LDAPAuthenticationManager.mjs @@ -1,18 +1,13 @@ import Settings from '@overleaf/settings' import { callbackify } from '@overleaf/promise-utils' -import UserCreator from '../../../../app/src/Features/User/UserCreator.js' -import { User } from '../../../../app/src/models/User.js' +import UserCreator from '../../../../../app/src/Features/User/UserCreator.js' +import { ParallelLoginError } from '../../../../../app/src/Features/Authentication/AuthenticationErrors.js' +import { User } from '../../../../../app/src/models/User.js' +import { splitFullName } from '../../../utils.mjs' -const AuthenticationManagerLdap = { - splitFullName(fullName) { - fullName = fullName.trim(); - let lastSpaceIndex = fullName.lastIndexOf(' '); - let firstNames = fullName.substring(0, lastSpaceIndex).trim(); - let lastName = fullName.substring(lastSpaceIndex + 1).trim(); - return [firstNames, lastName]; - }, - async findOrCreateLdapUser(profile, auditLog) { - //user is already authenticated in Ldap +const LDAPAuthenticationManager = { + async findOrCreateUser(profile, auditLog) { + //user is already authenticated in LDAP const { attEmail, attFirstName, @@ -28,7 +23,7 @@ const AuthenticationManagerLdap = { : profile[attEmail].toLowerCase() let nameParts = ["",""] if ((!attFirstName || !attLastName) && attName) { - nameParts = this.splitFullName(profile[attName] || "") + nameParts = splitFullName(profile[attName] || "") } const firstName = attFirstName ? (profile[attFirstName] || "") : nameParts[0] let lastName = attLastName ? (profile[attLastName] || "") : nameParts[1] @@ -40,6 +35,7 @@ const AuthenticationManagerLdap = { profile[attAdmin] === valAdmin) } let user = await User.findOne({ 'email': email }).exec() + if( !user ) { user = await UserCreator.promises.createNewUser( { @@ -61,8 +57,12 @@ const AuthenticationManagerLdap = { userDetails.isAdmin = isAdmin } const result = await User.updateOne( - { _id: user._id, loginEpoch: user.loginEpoch }, { $inc: { loginEpoch: 1 }, $set: userDetails }, - {} + { _id: user._id, loginEpoch: user.loginEpoch }, + { + $inc: { loginEpoch: 1 }, + $set: userDetails, + $unset: { hashedPassword: "" }, + } ).exec() if (result.modifiedCount !== 1) { throw new ParallelLoginError() @@ -72,9 +72,5 @@ const AuthenticationManagerLdap = { } export default { - findOrCreateLdapUser: callbackify(AuthenticationManagerLdap.findOrCreateLdapUser), - promises: AuthenticationManagerLdap, + promises: LDAPAuthenticationManager, } -export const { - splitFullName, -} = AuthenticationManagerLdap diff --git a/services/web/modules/authentication/ldap/app/src/LDAPContacts.mjs b/services/web/modules/authentication/ldap/app/src/LDAPContacts.mjs new file mode 100644 index 0000000000..4557b4a4e4 --- /dev/null +++ b/services/web/modules/authentication/ldap/app/src/LDAPContacts.mjs @@ -0,0 +1,120 @@ +import Settings from '@overleaf/settings' +import logger from '@overleaf/logger' +import { promisify } from 'util' +import passport from 'passport' +import ldapjs from 'ldapauth-fork/node_modules/ldapjs/lib/index.js' +import UserGetter from '../../../../../app/src/Features/User/UserGetter.js' +import { splitFullName } from '../../../utils.mjs' + +function _searchLDAP(client, baseDN, options) { + return new Promise((resolve, reject) => { + const searchEntries = [] + client.search(baseDN, options, (error, res) => { + if (error) { + reject(error) + } else { + res.on('searchEntry', entry => searchEntries.push(entry.object)) + res.on('error', reject) + res.on('end', () => resolve(searchEntries)) + } + }) + }) +} + +async function fetchLDAPContacts(userId, contacts) { + if (!Settings.ldap?.enable || !process.env.OVERLEAF_LDAP_CONTACTS_FILTER) { + return [] + } + + const ldapOptions = passport._strategy('ldapauth').options.server + const { attEmail, attFirstName = "", attLastName = "", attName = "" } = Settings.ldap + const { + url, + timeout, + connectTimeout, + tlsOptions, + starttls, + bindDN, + bindCredentials + } = ldapOptions + const searchBase = process.env.OVERLEAF_LDAP_CONTACTS_SEARCH_BASE || ldapOptions.searchBase + const searchScope = process.env.OVERLEAF_LDAP_CONTACTS_SEARCH_SCOPE || 'sub' + const ldapConfig = { url, timeout, connectTimeout, tlsOptions } + + let ldapUsers + let client + + try { + await new Promise((resolve, reject) => { + client = ldapjs.createClient(ldapConfig) + client.on('error', (error) => { reject(error) }) + client.on('connectTimeout', (error) => { reject(error) }) + client.on('connect', () => { resolve() }) + }) + + if (starttls) { + const starttlsAsync = promisify(client.starttls).bind(client) + await starttlsAsync(tlsOptions, null) + } + const bindAsync = promisify(client.bind).bind(client) + await bindAsync(bindDN, bindCredentials) + + async function createContactsSearchFilter(client, ldapOptions, userId, contactsFilter) { + const searchProperty = process.env.OVERLEAF_LDAP_CONTACTS_PROPERTY + if (!searchProperty) { + return contactsFilter + } + const email = await UserGetter.promises.getUserEmail(userId) + const searchOptions = { + scope: ldapOptions.searchScope, + attributes: [searchProperty], + filter: `(${Settings.ldap.attEmail}=${email})` + } + const searchBase = ldapOptions.searchBase + const ldapUser = (await _searchLDAP(client, searchBase, searchOptions))[0] + const searchPropertyValue = ldapUser ? ldapUser[searchProperty] + : process.env.OVERLEAF_LDAP_CONTACTS_NON_LDAP_VALUE || 'IMATCHNOTHING' + return contactsFilter.replace(/{{userProperty}}/g, searchPropertyValue) + } + + const filter = await createContactsSearchFilter(client, ldapOptions, userId, process.env.OVERLEAF_LDAP_CONTACTS_FILTER) + const searchOptions = { scope: searchScope, attributes: [attEmail, attFirstName, attLastName, attName], filter } + + ldapUsers = await _searchLDAP(client, searchBase, searchOptions) + } catch (error) { + logger.warn({ error }, 'Error in fetchLDAPContacts') + return [] + } finally { + client?.unbind() + } + + const newLDAPContacts = ldapUsers.reduce((acc, ldapUser) => { + const email = Array.isArray(ldapUser[attEmail]) + ? ldapUser[attEmail][0]?.toLowerCase() + : ldapUser[attEmail]?.toLowerCase() + if (!email) return acc + if (!contacts.some(contact => contact.email === email)) { + let nameParts = ["", ""] + if ((!attFirstName || !attLastName) && attName) { + nameParts = splitFullName(ldapUser[attName] || "") + } + const firstName = attFirstName ? (ldapUser[attFirstName] || "") : nameParts[0] + const lastName = attLastName ? (ldapUser[attLastName] || "") : nameParts[1] + acc.push({ + first_name: firstName, + last_name: lastName, + email: email, + type: 'user' + }) + } + return acc + }, []) + + return newLDAPContacts.sort((a, b) => + a.last_name.localeCompare(b.last_name) || + a.first_name.localeCompare(b.first_name) || + a.email.localeCompare(b.email) + ) +} + +export default fetchLDAPContacts diff --git a/services/web/modules/authentication/ldap/app/src/LDAPModuleManager.mjs b/services/web/modules/authentication/ldap/app/src/LDAPModuleManager.mjs new file mode 100644 index 0000000000..846ca9b158 --- /dev/null +++ b/services/web/modules/authentication/ldap/app/src/LDAPModuleManager.mjs @@ -0,0 +1,112 @@ +import logger from '@overleaf/logger' +import passport from 'passport' +import { Strategy as LDAPStrategy } from 'passport-ldapauth' +import Settings from '@overleaf/settings' +import PermissionsManager from '../../../../../app/src/Features/Authorization/PermissionsManager.js' +import { readFilesContentFromEnv, numFromEnv, boolFromEnv } from '../../../utils.mjs' +import LDAPAuthenticationController from './LDAPAuthenticationController.mjs' +import fetchLDAPContacts from './LDAPContacts.mjs' + +const LDAPModuleManager = { + initSettings() { + Settings.ldap = { + enable: true, + placeholder: process.env.OVERLEAF_LDAP_PLACEHOLDER || 'Username', + attEmail: process.env.OVERLEAF_LDAP_EMAIL_ATT || 'mail', + attFirstName: process.env.OVERLEAF_LDAP_FIRST_NAME_ATT, + attLastName: process.env.OVERLEAF_LDAP_LAST_NAME_ATT, + attName: process.env.OVERLEAF_LDAP_NAME_ATT, + attAdmin: process.env.OVERLEAF_LDAP_IS_ADMIN_ATT, + valAdmin: process.env.OVERLEAF_LDAP_IS_ADMIN_ATT_VALUE, + updateUserDetailsOnLogin: boolFromEnv(process.env.OVERLEAF_LDAP_UPDATE_USER_DETAILS_ON_LOGIN), + } + }, + passportSetup(passport, callback) { + const ldapOptions = { + url: process.env.OVERLEAF_LDAP_URL, + bindDN: process.env.OVERLEAF_LDAP_BIND_DN || "", + bindCredentials: process.env.OVERLEAF_LDAP_BIND_CREDENTIALS || "", + bindProperty: process.env.OVERLEAF_LDAP_BIND_PROPERTY, + searchBase: process.env.OVERLEAF_LDAP_SEARCH_BASE, + searchFilter: process.env.OVERLEAF_LDAP_SEARCH_FILTER, + searchScope: process.env.OVERLEAF_LDAP_SEARCH_SCOPE || 'sub', + searchAttributes: JSON.parse(process.env.OVERLEAF_LDAP_SEARCH_ATTRIBUTES || '[]'), + groupSearchBase: process.env.OVERLEAF_LDAP_ADMIN_SEARCH_BASE, + groupSearchFilter: process.env.OVERLEAF_LDAP_ADMIN_SEARCH_FILTER, + groupSearchScope: process.env.OVERLEAF_LDAP_ADMIN_SEARCH_SCOPE || 'sub', + groupSearchAttributes: ["dn"], + groupDnProperty: process.env.OVERLEAF_LDAP_ADMIN_DN_PROPERTY, + cache: boolFromEnv(process.env.OVERLEAF_LDAP_CACHE), + timeout: numFromEnv(process.env.OVERLEAF_LDAP_TIMEOUT), + connectTimeout: numFromEnv(process.env.OVERLEAF_LDAP_CONNECT_TIMEOUT), + starttls: boolFromEnv(process.env.OVERLEAF_LDAP_STARTTLS), + tlsOptions: { + ca: readFilesContentFromEnv(process.env.OVERLEAF_LDAP_TLS_OPTS_CA_PATH), + rejectUnauthorized: boolFromEnv(process.env.OVERLEAF_LDAP_TLS_OPTS_REJECT_UNAUTH), + } + } + try { + passport.use( + new LDAPStrategy( + { + server: ldapOptions, + passReqToCallback: true, + usernameField: 'email', + passwordField: 'password', + handleErrorsAsFailures: true, + }, + LDAPAuthenticationController.doPassportLogin + ) + ) + callback(null) + } catch (error) { + callback(error) + } + }, + + async getContacts(userId, contacts, callback) { + try { + const newContacts = await fetchLDAPContacts(userId, contacts) + callback(null, newContacts) + } catch (error) { + callback(error) + } + }, + + initPolicy() { + try { + PermissionsManager.registerCapability('change-password', { default : true }) + } catch (error) { + logger.info({}, error.message) + } + const ldapPolicyValidator = async ({ user, subscription }) => { +// If user is not logged in, user.externalAuth is undefined, +// in this case allow to change password if the user has a hashedPassword + return user.externalAuth === 'ldap' || (user.externalAuth === undefined && !user.hashedPassword) + } + try { + PermissionsManager.registerPolicy( + 'ldapPolicy', + { 'change-password' : false }, + { validator: ldapPolicyValidator } + ) + } catch (error) { + logger.info({}, error.message) + } + }, + async getGroupPolicyForUser(user, callback) { + try { + const userValidationMap = await PermissionsManager.promises.getUserValidationStatus({ + user, + groupPolicy : { 'ldapPolicy' : true }, + subscription : null + }) + let groupPolicy = Object.fromEntries(userValidationMap) + callback(null, {'groupPolicy' : groupPolicy }) + } catch (error) { + callback(error) + } + }, +} + +export default LDAPModuleManager diff --git a/services/web/modules/authentication/ldap/app/src/LDAPRouter.mjs b/services/web/modules/authentication/ldap/app/src/LDAPRouter.mjs new file mode 100644 index 0000000000..44d9d373d2 --- /dev/null +++ b/services/web/modules/authentication/ldap/app/src/LDAPRouter.mjs @@ -0,0 +1,19 @@ +import logger from '@overleaf/logger' +import RateLimiterMiddleware from '../../../../../app/src/Features/Security/RateLimiterMiddleware.js' +import CaptchaMiddleware from '../../../../../app/src/Features/Captcha/CaptchaMiddleware.js' +import AuthenticationController from '../../../../../app/src/Features/Authentication/AuthenticationController.js' +import { overleafLoginRateLimiter } from '../../../../../app/src/infrastructure/RateLimiter.js' +import LDAPAuthenticationController from './LDAPAuthenticationController.mjs' + +export default { + apply(webRouter) { + logger.debug({}, 'Init LDAP router') + webRouter.post('/login', + RateLimiterMiddleware.rateLimit(overleafLoginRateLimiter), // rate limit IP (20 / 60s) + RateLimiterMiddleware.loginRateLimitEmail, // rate limit email (10 / 120s) + CaptchaMiddleware.validateCaptcha('login'), + LDAPAuthenticationController.passportLogin, + AuthenticationController.passportLogin, + ) + }, +} diff --git a/services/web/modules/authentication/ldap/index.mjs b/services/web/modules/authentication/ldap/index.mjs new file mode 100644 index 0000000000..244a8db8e7 --- /dev/null +++ b/services/web/modules/authentication/ldap/index.mjs @@ -0,0 +1,17 @@ +let ldapModule = {} +if (process.env.EXTERNAL_AUTH.includes('ldap')) { + const { default: LDAPModuleManager } = await import('./app/src/LDAPModuleManager.mjs') + const { default: router } = await import('./app/src/LDAPRouter.mjs') + LDAPModuleManager.initSettings() + LDAPModuleManager.initPolicy() + ldapModule = { + name: 'ldap-authentication', + hooks: { + passportSetup: LDAPModuleManager.passportSetup, + getContacts: LDAPModuleManager.getContacts, + getGroupPolicyForUser: LDAPModuleManager.getGroupPolicyForUser, + }, + router: router, + } +} +export default ldapModule diff --git a/services/web/modules/authentication/logout.mjs b/services/web/modules/authentication/logout.mjs new file mode 100644 index 0000000000..4163cf536d --- /dev/null +++ b/services/web/modules/authentication/logout.mjs @@ -0,0 +1,18 @@ +let SAMLAuthenticationController +if (process.env.EXTERNAL_AUTH.includes('saml')) { + SAMLAuthenticationController = await import('./saml/app/src/SAMLAuthenticationController.mjs') +} +let OIDCAuthenticationController +if (process.env.EXTERNAL_AUTH.includes('oidc')) { + OIDCAuthenticationController = await import('./oidc/app/src/OIDCAuthenticationController.mjs') +} +export default async function logout(req, res, next) { + switch(req.user.externalAuth) { + case 'saml': + return SAMLAuthenticationController.default.passportLogout(req, res, next) + case 'oidc': + return OIDCAuthenticationController.default.passportLogout(req, res, next) + default: + next() + } +} diff --git a/services/web/modules/authentication/oidc/app/src/OIDCAuthenticationController.mjs b/services/web/modules/authentication/oidc/app/src/OIDCAuthenticationController.mjs new file mode 100644 index 0000000000..42c01e712f --- /dev/null +++ b/services/web/modules/authentication/oidc/app/src/OIDCAuthenticationController.mjs @@ -0,0 +1,171 @@ +import logger from '@overleaf/logger' +import passport from 'passport' +import Settings from '@overleaf/settings' +import AuthenticationController from '../../../../../app/src/Features/Authentication/AuthenticationController.js' +import UserController from '../../../../../app/src/Features/User/UserController.js' +import ThirdPartyIdentityManager from '../../../../../app/src/Features/User/ThirdPartyIdentityManager.js' +import OIDCAuthenticationManager from './OIDCAuthenticationManager.mjs' +import { acceptsJson } from '../../../../../app/src/infrastructure/RequestContentTypeDetection.js' + +const OIDCAuthenticationController = { + passportLogin(req, res, next) { + req.session.intent = req.query.intent + passport.authenticate('openidconnect')(req, res, next) + }, + passportLoginCallback(req, res, next) { + passport.authenticate( + 'openidconnect', + { keepSessionInfo: true }, + async function (err, user, info) { + if (err) { + return next(err) + } + if(req.session.intent === 'link') { + delete req.session.intent +// After linking, log out from the OIDC provider and redirect back to '/user/settings'. +// Keycloak supports this; Authentik does not (yet). + const logoutUrl = process.env.OVERLEAF_OIDC_LOGOUT_URL + const redirectUri = `${Settings.siteUrl.replace(/\/+$/, '')}/user/settings` + return res.redirect(`${logoutUrl}?id_token_hint=${info.idToken}&post_logout_redirect_uri=${encodeURIComponent(redirectUri)}`) + } + if (user) { + req.session.idToken = info.idToken + user.externalAuth = 'oidc' + // `user` is either a user object or false + AuthenticationController.setAuditInfo(req, { + method: 'OIDC login', + }) + try { + await AuthenticationController.promises.finishLogin(user, req, res) + } catch (err) { + return next(err) + } + } else { + if (info.redir != null) { + return res.json({ redir: info.redir }) + } else { + res.status(info.status || 401) + delete info.status + const body = { message: info } + return res.json(body) + } + } + } + )(req, res, next) + }, + async doPassportLogin(req, issuer, profile, context, idToken, accessToken, refreshToken, done) { + let user, info + try { + if(req.session.intent === 'link') { + ;({ user, info } = await OIDCAuthenticationController._doLink( + req, + profile + )) + } else { + ;({ user, info } = await OIDCAuthenticationController._doLogin( + req, + profile + )) + } + } catch (error) { + return done(error) + } + if (user) { + info = { + ...(info || {}), + idToken: idToken + } + } + return done(null, user, info) + }, + async _doLogin(req, profile) { + const { fromKnownDevice } = AuthenticationController.getAuditInfo(req) + const auditLog = { + ipAddress: req.ip, + info: { method: 'OIDC login', fromKnownDevice }, + } + + let user + try { + user = await OIDCAuthenticationManager.promises.findOrCreateUser(profile, auditLog) + } catch (error) { + logger.debug({ email : profile.emails[0].value }, `OIDC login failed: ${error}`) + return { + user: false, + info: { + type: 'error', + text: error.message, + status: 401, + }, + } + } + if (user) { + return { user, info: undefined } + } else { // we cannot be here, something is terribly wrong + logger.debug({ email : profile.emails[0].value }, 'failed OIDC log in') + return { + user: false, + info: { + type: 'error', + text: 'Unknown error', + status: 500, + }, + } + } + }, + async _doLink(req, profile) { + const { user: { _id: userId }, ip } = req + try { + const auditLog = { + ipAddress: ip, + initiatorId: userId, + } + await OIDCAuthenticationManager.promises.linkAccount(userId, profile, auditLog) + } catch (error) { + logger.error(error.info, error.message) + return { + user: true, + info: { + type: 'error', + text: error.message, + status: 200, + }, + } + } + return { user: true, info: undefined } + }, + async unlinkAccount(req, res, next) { + try { + const { user: { _id: userId }, body: { providerId }, ip } = req + const auditLog = { + ipAddress: ip, + initiatorId: userId, + } + await ThirdPartyIdentityManager.promises.unlink(userId, providerId, auditLog) + return res.status(200).end() + } catch (error) { + logger.error(error.info, error.message) + return { + user: false, + info: { + type: 'error', + text: 'Can not unlink account', + status: 200, + } + } + } + }, + async passportLogout(req, res, next) { +// TODO: instead of storing idToken in session, use refreshToken to obtain a new idToken? + const idTokenHint = req.session.idToken + await UserController.promises.doLogout(req) + const logoutUrl = process.env.OVERLEAF_OIDC_LOGOUT_URL + const redirectUri = Settings.siteUrl + res.redirect(`${logoutUrl}?id_token_hint=${idTokenHint}&post_logout_redirect_uri=${encodeURIComponent(redirectUri)}`) + }, + passportLogoutCallback(req, res, next) { + const redirectUri = Settings.siteUrl + res.redirect(redirectUri) + }, +} +export default OIDCAuthenticationController diff --git a/services/web/modules/authentication/oidc/app/src/OIDCAuthenticationManager.mjs b/services/web/modules/authentication/oidc/app/src/OIDCAuthenticationManager.mjs new file mode 100644 index 0000000000..56ec2e5455 --- /dev/null +++ b/services/web/modules/authentication/oidc/app/src/OIDCAuthenticationManager.mjs @@ -0,0 +1,94 @@ +import Settings from '@overleaf/settings' +import UserCreator from '../../../../../app/src/Features/User/UserCreator.js' +import ThirdPartyIdentityManager from '../../../../../app/src/Features/User/ThirdPartyIdentityManager.js' +import { ParallelLoginError } from '../../../../../app/src/Features/Authentication/AuthenticationErrors.js' +import { User } from '../../../../../app/src/models/User.js' + +const OIDCAuthenticationManager = { + async findOrCreateUser(profile, auditLog) { + const { + attUserId, + attAdmin, + valAdmin, + updateUserDetailsOnLogin, + providerId, + } = Settings.oidc + const oidcUserId = profile[attUserId] + const email = profile.emails[0].value + const firstName = profile.name?.givenName || "" + const lastName = profile.name?.familyName || "" + let isAdmin = false + if (attAdmin && valAdmin) { + if (attAdmin === 'email') { + isAdmin = (email === valAdmin) + } else { + isAdmin = (profile[attAdmin] === valAdmin) + } + } + const oidcUserData = null // Possibly it can be used later + let user + try { + user = await ThirdPartyIdentityManager.promises.login(providerId, oidcUserId, oidcUserData) + } catch { +// A user with the specified OIDC ID and provider ID is not found. Search for a user with the given email. +// If no user exists with this email, create a new user and link the OIDC account to it. +// If a user exists but no account from the specified OIDC provider is linked to this user, link the OIDC account to this user. +// If an account from the specified provider is already linked to this user, unlink it, and link the OIDC account to this user. +// (Is it safe? Concider: If an account from the specified provider is already linked to this user, throw an error) + user = await User.findOne({ 'email': email }).exec() + if (!user) { + user = await UserCreator.promises.createNewUser( + { + email: email, + first_name: firstName, + last_name: lastName, + isAdmin: isAdmin, + holdingAccount: false, + } + ) + } +// const alreadyLinked = user.thirdPartyIdentifiers.some(item => item.providerId === providerId) +// if (!alreadyLinked) { + auditLog.initiatorId = user._id + await ThirdPartyIdentityManager.promises.link(user._id, providerId, oidcUserId, oidcUserData, auditLog) + await User.updateOne( + { _id: user._id }, + { $set : { + 'emails.0.confirmedAt': Date.now(), //email of external user is confirmed + }, + } + ).exec() +// } else { +// throw new Error(`Overleaf user ${user.email} is already linked to another ${providerId} user`) +// } + } + + let userDetails = updateUserDetailsOnLogin ? { first_name : firstName, last_name: lastName } : {} + if (attAdmin && valAdmin) { + user.isAdmin = isAdmin + userDetails.isAdmin = isAdmin + } + const result = await User.updateOne( + { _id: user._id, loginEpoch: user.loginEpoch }, { $inc: { loginEpoch: 1 }, $set: userDetails }, + {} + ).exec() + + if (result.modifiedCount !== 1) { + throw new ParallelLoginError() + } + return user + }, + async linkAccount(userId, profile, auditLog) { + const { + attUserId, + providerId, + } = Settings.oidc + const oidcUserId = profile[attUserId] + const oidcUserData = null // Possibly it can be used later + await ThirdPartyIdentityManager.promises.link(userId, providerId, oidcUserId, oidcUserData, auditLog) + }, +} + +export default { + promises: OIDCAuthenticationManager, +} diff --git a/services/web/modules/authentication/oidc/app/src/OIDCModuleManager.mjs b/services/web/modules/authentication/oidc/app/src/OIDCModuleManager.mjs new file mode 100644 index 0000000000..3a2e6e2780 --- /dev/null +++ b/services/web/modules/authentication/oidc/app/src/OIDCModuleManager.mjs @@ -0,0 +1,82 @@ +import logger from '@overleaf/logger' +import passport from 'passport' +import Settings from '@overleaf/settings' +import { readFilesContentFromEnv, numFromEnv, boolFromEnv } from '../../../utils.mjs' +import PermissionsManager from '../../../../../app/src/Features/Authorization/PermissionsManager.js' +import OIDCAuthenticationController from './OIDCAuthenticationController.mjs' +import { Strategy as OIDCStrategy } from 'passport-openidconnect' + +const OIDCModuleManager = { + initSettings() { + let providerId = process.env.OVERLEAF_OIDC_PROVIDER_ID || 'oidc' + Settings.oidc = { + enable: true, + providerId: providerId, + identityServiceName: process.env.OVERLEAF_OIDC_IDENTITY_SERVICE_NAME || `Log in with ${Settings.oauthProviders[providerId].name}`, + attUserId: process.env.OVERLEAF_OIDC_USER_ID_FIELD || 'id', + attAdmin: process.env.OVERLEAF_OIDC_IS_ADMIN_FIELD, + valAdmin: process.env.OVERLEAF_OIDC_IS_ADMIN_FIELD_VALUE, + updateUserDetailsOnLogin: boolFromEnv(process.env.OVERLEAF_OIDC_UPDATE_USER_DETAILS_ON_LOGIN), + } + }, + passportSetup(passport, callback) { + const oidcOptions = { + issuer: process.env.OVERLEAF_OIDC_ISSUER, + authorizationURL: process.env.OVERLEAF_OIDC_AUTHORIZATION_URL, + tokenURL: process.env.OVERLEAF_OIDC_TOKEN_URL, + userInfoURL: process.env.OVERLEAF_OIDC_USER_INFO_URL, + clientID: process.env.OVERLEAF_OIDC_CLIENT_ID, + clientSecret: process.env.OVERLEAF_OIDC_CLIENT_SECRET, + callbackURL: `${Settings.siteUrl.replace(/\/+$/, '')}/oidc/login/callback`, + scope: process.env.OVERLEAF_OIDC_SCOPE || 'openid profile email', + passReqToCallback: true, + } + try { + passport.use( + new OIDCStrategy( + oidcOptions, + OIDCAuthenticationController.doPassportLogin + ) + ) + callback(null) + } catch (error) { + callback(error) + } + }, + initPolicy() { + try { + PermissionsManager.registerCapability('change-password', { default : true }) + } catch (error) { + logger.info({}, error.message) + } + const oidcPolicyValidator = async ({ user, subscription }) => { +// If user is not logged in, user.externalAuth is undefined, +// in this case allow to change password if the user has a hashedPassword + return user.externalAuth === 'oidc' || (user.externalAuth === undefined && !user.hashedPassword) + } + try { + PermissionsManager.registerPolicy( + 'oidcPolicy', + { 'change-password' : false }, + { validator: oidcPolicyValidator } + ) + } catch (error) { + logger.info({}, error.message) + } + }, + async getGroupPolicyForUser(user, callback) { + try { + const userValidationMap = await PermissionsManager.promises.getUserValidationStatus({ + user, + groupPolicy : { 'oidcPolicy' : true }, + subscription : null + }) + let groupPolicy = Object.fromEntries(userValidationMap) + callback(null, {'groupPolicy' : groupPolicy }) + } catch (error) { + callback(error) + } + }, +} + +export default OIDCModuleManager diff --git a/services/web/modules/authentication/oidc/app/src/OIDCRouter.mjs b/services/web/modules/authentication/oidc/app/src/OIDCRouter.mjs new file mode 100644 index 0000000000..519fa5043a --- /dev/null +++ b/services/web/modules/authentication/oidc/app/src/OIDCRouter.mjs @@ -0,0 +1,15 @@ +import logger from '@overleaf/logger' +import UserController from '../../../../../app/src/Features/User/UserController.js' +import OIDCAuthenticationController from './OIDCAuthenticationController.mjs' +import logout from '../../../logout.mjs' + +export default { + apply(webRouter) { + logger.debug({}, 'Init OIDC router') + webRouter.get('/oidc/login', OIDCAuthenticationController.passportLogin) + webRouter.get('/oidc/login/callback', OIDCAuthenticationController.passportLoginCallback) + webRouter.get('/oidc/logout/callback', OIDCAuthenticationController.passportLogoutCallback) + webRouter.post('/user/oauth-unlink', OIDCAuthenticationController.unlinkAccount) + webRouter.post('/logout', logout, UserController.logout) + }, +} diff --git a/services/web/modules/authentication/oidc/index.mjs b/services/web/modules/authentication/oidc/index.mjs new file mode 100644 index 0000000000..51d9e0d483 --- /dev/null +++ b/services/web/modules/authentication/oidc/index.mjs @@ -0,0 +1,16 @@ +let oidcModule = {} +if (process.env.EXTERNAL_AUTH.includes('oidc')) { + const { default: OIDCModuleManager } = await import('./app/src/OIDCModuleManager.mjs') + const { default: router } = await import('./app/src/OIDCRouter.mjs') + OIDCModuleManager.initSettings() + OIDCModuleManager.initPolicy() + oidcModule = { + name: 'oidc-authentication', + hooks: { + passportSetup: OIDCModuleManager.passportSetup, + getGroupPolicyForUser: OIDCModuleManager.getGroupPolicyForUser, + }, + router: router, + } +} +export default oidcModule diff --git a/services/web/modules/saml-authentication/app/src/AuthenticationControllerSaml.mjs b/services/web/modules/authentication/saml/app/src/SAMLAuthenticationController.mjs similarity index 65% rename from services/web/modules/saml-authentication/app/src/AuthenticationControllerSaml.mjs rename to services/web/modules/authentication/saml/app/src/SAMLAuthenticationController.mjs index f5db3f738d..ac0e5398b2 100644 --- a/services/web/modules/saml-authentication/app/src/AuthenticationControllerSaml.mjs +++ b/services/web/modules/authentication/saml/app/src/SAMLAuthenticationController.mjs @@ -1,15 +1,16 @@ import Settings from '@overleaf/settings' import logger from '@overleaf/logger' import passport from 'passport' -import AuthenticationController from '../../../../app/src/Features/Authentication/AuthenticationController.js' -import AuthenticationManagerSaml from './AuthenticationManagerSaml.mjs' -import UserController from '../../../../app/src/Features/User/UserController.js' -import UserSessionsManager from '../../../../app/src/Features/User/UserSessionsManager.js' -import { handleAuthenticateErrors } from '../../../../app/src/Features/Authentication/AuthenticationErrors.js' -import { xmlResponse } from '../../../../app/src/infrastructure/Response.js' +import AuthenticationController from '../../../../../app/src/Features/Authentication/AuthenticationController.js' +import SAMLAuthenticationManager from './SAMLAuthenticationManager.mjs' +import UserController from '../../../../../app/src/Features/User/UserController.js' +import UserSessionsManager from '../../../../../app/src/Features/User/UserSessionsManager.js' +import { handleAuthenticateErrors } from '../../../../../app/src/Features/Authentication/AuthenticationErrors.js' +import { xmlResponse } from '../../../../../app/src/infrastructure/Response.js' +import { readFilesContentFromEnv } from '../../../utils.mjs' -const AuthenticationControllerSaml = { - passportSamlAuthWithIdP(req, res, next) { +const SAMLAuthenticationController = { + passportLogin(req, res, next) { if ( passport._strategy('saml')._saml.options.authnRequestBinding === 'HTTP-POST') { const csp = res.getHeader('Content-Security-Policy') if (csp) { @@ -21,7 +22,7 @@ const AuthenticationControllerSaml = { } passport.authenticate('saml')(req, res, next) }, - passportSamlLogin(req, res, next) { + passportLoginCallback(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 @@ -46,24 +47,19 @@ const AuthenticationControllerSaml = { if (info.redir != null) { return res.json({ redir: info.redir }) } else { - res.status(info.status || 200) + res.status(info.status || 401) delete info.status const body = { message: info } - const { errorReason } = info - if (errorReason) { - body.errorReason = errorReason - delete info.errorReason - } return res.json(body) } } } )(req, res, next) }, - async doPassportSamlLogin(req, profile, done) { + async doPassportLogin(req, profile, done) { let user, info try { - ;({ user, info } = await AuthenticationControllerSaml._doPassportSamlLogin( + ;({ user, info } = await SAMLAuthenticationController._doPassportLogin( req, profile )) @@ -72,7 +68,7 @@ const AuthenticationControllerSaml = { } return done(undefined, user, info) }, - async _doPassportSamlLogin(req, profile) { + async _doPassportLogin(req, profile) { const { fromKnownDevice } = AuthenticationController.getAuditInfo(req) const auditLog = { ipAddress: req.ip, @@ -81,7 +77,7 @@ const AuthenticationControllerSaml = { let user try { - user = await AuthenticationManagerSaml.promises.findOrCreateSamlUser(profile, auditLog) + user = await SAMLAuthenticationManager.promises.findOrCreateUser(profile, auditLog) } catch (error) { return { user: false, @@ -89,9 +85,10 @@ const AuthenticationControllerSaml = { } } if (user) { + user.externalAuth = 'saml' req.session.saml_extce = {nameID : profile.nameID, sessionIndex : profile.sessionIndex} return { user, info: undefined } - } else { //something wrong + } else { // we cannot be here, something is terribly wrong logger.debug({ email : profile.mail }, 'failed SAML log in') return { user: false, @@ -103,23 +100,24 @@ const AuthenticationControllerSaml = { } } }, - async passportSamlSPLogout(req, res, next) { + async passportLogout(req, res, next) { passport._strategy('saml').logout(req, async (err, url) => { - if (err) logger.error({ err }, 'can not generate logout url') await UserController.promises.doLogout(req) + if (err) return next(err) res.redirect(url) }) }, - passportSamlIdPLogout(req, res, next) { + passportLogoutCallback(req, res, next) { +//TODO: is it possible to close the editor? passport.authenticate('saml')(req, res, (err) => { if (err) return next(err) res.redirect('/login'); }) }, - async doPassportSamlLogout(req, profile, done) { + async doPassportLogout(req, profile, done) { let user, info try { - ;({ user, info } = await AuthenticationControllerSaml._doPassportSamlLogout( + ;({ user, info } = await SAMLAuthenticationController._doPassportLogout( req, profile )) @@ -128,7 +126,7 @@ const AuthenticationControllerSaml = { } return done(undefined, user, info) }, - async _doPassportSamlLogout(req, profile) { + async _doPassportLogout(req, profile) { if (req?.session?.saml_extce?.nameID === profile.nameID && req?.session?.saml_extce?.sessionIndex === profile.sessionIndex) { profile = req.user @@ -138,23 +136,15 @@ const AuthenticationControllerSaml = { }) return { user: profile, info: undefined } }, - passportSamlMetadata(req, res) { + getSPMetadata(req, res) { const samlStratery = passport._strategy('saml') res.setHeader('Content-Disposition', `attachment; filename="${samlStratery._saml.options.issuer}-meta.xml"`) xmlResponse(res, samlStratery.generateServiceProviderMetadata( - samlStratery._saml.options.decryptionCert, - samlStratery._saml.options.signingCert + readFilesContentFromEnv(process.env.OVERLEAF_SAML_DECRYPTION_CERT), + readFilesContentFromEnv(process.env.OVERLEAF_SAML_PUBLIC_CERT) ) ) }, } -export const { - passportSamlAuthWithIdP, - passportSamlLogin, - passportSamlSPLogout, - passportSamlIdPLogout, - doPassportSamlLogin, - doPassportSamlLogout, - passportSamlMetadata, -} = AuthenticationControllerSaml +export default SAMLAuthenticationController diff --git a/services/web/modules/authentication/saml/app/src/SAMLAuthenticationManager.mjs b/services/web/modules/authentication/saml/app/src/SAMLAuthenticationManager.mjs new file mode 100644 index 0000000000..80c4e30ea7 --- /dev/null +++ b/services/web/modules/authentication/saml/app/src/SAMLAuthenticationManager.mjs @@ -0,0 +1,85 @@ +import Settings from '@overleaf/settings' +import UserCreator from '../../../../../app/src/Features/User/UserCreator.js' +import { ParallelLoginError } from '../../../../../app/src/Features/Authentication/AuthenticationErrors.js' +import SAMLIdentityManager from '../../../../../app/src/Features/User/SAMLIdentityManager.js' +import { User } from '../../../../../app/src/models/User.js' + +const SAMLAuthenticationManager = { + async findOrCreateUser(profile, auditLog) { + const { + attUserId, + attEmail, + attFirstName, + attLastName, + attAdmin, + valAdmin, + updateUserDetailsOnLogin, + } = Settings.saml + const externalUserId = profile[attUserId] + const email = Array.isArray(profile[attEmail]) + ? profile[attEmail][0].toLowerCase() + : profile[attEmail].toLowerCase() + const firstName = attFirstName ? profile[attFirstName] : "" + const lastName = attLastName ? profile[attLastName] : email + let isAdmin = false + if (attAdmin && valAdmin) { + isAdmin = (Array.isArray(profile[attAdmin]) ? profile[attAdmin].includes(valAdmin) : + profile[attAdmin] === valAdmin) + } + const providerId = '1' // for now, only one fixed IdP is supported +// We search for a SAML user, and if none is found, we search for a user with the given email. If a user is found, +// we update the user to be a SAML user, otherwise, we create a new SAML user with the given email. In the case of +// multiple SAML IdPs, one would have to do something similar, or possibly report an error like +// 'the email is associated with the wrong IdP' + let user = await SAMLIdentityManager.getUser(providerId, externalUserId, attUserId) + if (!user) { + user = await User.findOne({ 'email': email }).exec() + if (!user) { + user = await UserCreator.promises.createNewUser( + { + email: email, + first_name: firstName, + last_name: lastName, + isAdmin: isAdmin, + holdingAccount: false, + samlIdentifiers: [{ providerId: providerId }], + } + ) + } + // cannot use SAMLIdentityManager.linkAccounts because affilations service is not there + await User.updateOne( + { _id: user._id }, + { + $set : { + 'emails.0.confirmedAt': Date.now(), //email of saml user is confirmed + 'emails.0.samlProviderId': providerId, + 'samlIdentifiers.0.providerId': providerId, + 'samlIdentifiers.0.externalUserId': externalUserId, + 'samlIdentifiers.0.userIdAttribute': attUserId, + }, + } + ).exec() + } + let userDetails = updateUserDetailsOnLogin ? { first_name : firstName, last_name: lastName } : {} + if (attAdmin && valAdmin) { + user.isAdmin = isAdmin + userDetails.isAdmin = isAdmin + } + const result = await User.updateOne( + { _id: user._id, loginEpoch: user.loginEpoch }, + { + $inc: { loginEpoch: 1 }, + $set: userDetails, + $unset: { hashedPassword: "" }, + }, + ).exec() + if (result.modifiedCount !== 1) { + throw new ParallelLoginError() + } + return user + }, +} + +export default { + promises: SAMLAuthenticationManager, +} diff --git a/services/web/modules/authentication/saml/app/src/SAMLModuleManager.mjs b/services/web/modules/authentication/saml/app/src/SAMLModuleManager.mjs new file mode 100644 index 0000000000..c7efdef214 --- /dev/null +++ b/services/web/modules/authentication/saml/app/src/SAMLModuleManager.mjs @@ -0,0 +1,100 @@ +import logger from '@overleaf/logger' +import passport from 'passport' +import Settings from '@overleaf/settings' +import { readFilesContentFromEnv, numFromEnv, boolFromEnv } from '../../../utils.mjs' +import PermissionsManager from '../../../../../app/src/Features/Authorization/PermissionsManager.js' +import SAMLAuthenticationController from './SAMLAuthenticationController.mjs' +import { Strategy as SAMLStrategy } from '@node-saml/passport-saml' + +const SAMLModuleManager = { + initSettings() { + Settings.saml = { + enable: true, + identityServiceName: process.env.OVERLEAF_SAML_IDENTITY_SERVICE_NAME || 'Log in with SAML IdP', + attUserId: process.env.OVERLEAF_SAML_USER_ID_FIELD || 'nameID', + attEmail: process.env.OVERLEAF_SAML_EMAIL_FIELD || 'nameID', + attFirstName: process.env.OVERLEAF_SAML_FIRST_NAME_FIELD || 'givenName', + attLastName: process.env.OVERLEAF_SAML_LAST_NAME_FIELD || 'lastName', + attAdmin: process.env.OVERLEAF_SAML_IS_ADMIN_FIELD, + valAdmin: process.env.OVERLEAF_SAML_IS_ADMIN_FIELD_VALUE, + updateUserDetailsOnLogin: boolFromEnv(process.env.OVERLEAF_SAML_UPDATE_USER_DETAILS_ON_LOGIN), + } +}, + passportSetup(passport, callback) { + const samlOptions = { + entryPoint: process.env.OVERLEAF_SAML_ENTRYPOINT, + callbackUrl: `${Settings.siteUrl.replace(/\/+$/, '')}/saml/login/callback`, + issuer: process.env.OVERLEAF_SAML_ISSUER, + audience: process.env.OVERLEAF_SAML_AUDIENCE, + cert: readFilesContentFromEnv(process.env.OVERLEAF_SAML_IDP_CERT), + privateKey: readFilesContentFromEnv(process.env.OVERLEAF_SAML_PRIVATE_KEY), + decryptionPvk: readFilesContentFromEnv(process.env.OVERLEAF_SAML_DECRYPTION_PVK), + signatureAlgorithm: process.env.OVERLEAF_SAML_SIGNATURE_ALGORITHM, + additionalParams: JSON.parse(process.env.OVERLEAF_SAML_ADDITIONAL_PARAMS || '{}'), + additionalAuthorizeParams: JSON.parse(process.env.OVERLEAF_SAML_ADDITIONAL_AUTHORIZE_PARAMS || '{}'), + identifierFormat: process.env.OVERLEAF_SAML_IDENTIFIER_FORMAT, + acceptedClockSkewMs: numFromEnv(process.env.OVERLEAF_SAML_ACCEPTED_CLOCK_SKEW_MS), + attributeConsumingServiceIndex: process.env.OVERLEAF_SAML_ATTRIBUTE_CONSUMING_SERVICE_INDEX, + authnContext: process.env.OVERLEAF_SAML_AUTHN_CONTEXT ? JSON.parse(process.env.OVERLEAF_SAML_AUTHN_CONTEXT) : undefined, + forceAuthn: boolFromEnv(process.env.OVERLEAF_SAML_FORCE_AUTHN), + disableRequestedAuthnContext: boolFromEnv(process.env.OVERLEAF_SAML_DISABLE_REQUESTED_AUTHN_CONTEXT), + skipRequestCompression: process.env.OVERLEAF_SAML_AUTHN_REQUEST_BINDING === 'HTTP-POST', // compression should be skipped iff authnRequestBinding is POST + authnRequestBinding: process.env.OVERLEAF_SAML_AUTHN_REQUEST_BINDING, + validateInResponseTo: process.env.OVERLEAF_SAML_VALIDATE_IN_RESPONSE_TO, + requestIdExpirationPeriodMs: numFromEnv(process.env.OVERLEAF_SAML_REQUEST_ID_EXPIRATION_PERIOD_MS), + // cacheProvider: process.env.OVERLEAF_SAML_CACHE_PROVIDER, + logoutUrl: process.env.OVERLEAF_SAML_LOGOUT_URL, + logoutCallbackUrl: `${Settings.siteUrl.replace(/\/+$/, '')}/saml/logout/callback`, + additionalLogoutParams: JSON.parse(process.env.OVERLEAF_SAML_ADDITIONAL_LOGOUT_PARAMS || '{}'), + passReqToCallback: true, + } + try { + passport.use( + new SAMLStrategy( + samlOptions, + SAMLAuthenticationController.doPassportLogin, + SAMLAuthenticationController.doPassportLogout + ) + ) + callback(null) + } catch (error) { + callback(error) + } + }, + initPolicy() { + try { + PermissionsManager.registerCapability('change-password', { default : true }) + } catch (error) { + logger.info({}, error.message) + } + const samlPolicyValidator = async ({ user, subscription }) => { +// If user is not logged in, user.externalAuth is undefined, +// in this case allow to change password if the user has a hashedPassword + return user.externalAuth === 'saml' || (user.externalAuth === undefined && !user.hashedPassword) + } + try { + PermissionsManager.registerPolicy( + 'samlPolicy', + { 'change-password' : false }, + { validator: samlPolicyValidator } + ) + } catch (error) { + logger.info({}, error.message) + } + }, + async getGroupPolicyForUser(user, callback) { + try { + const userValidationMap = await PermissionsManager.promises.getUserValidationStatus({ + user, + groupPolicy : { 'samlPolicy' : true }, + subscription : null + }) + let groupPolicy = Object.fromEntries(userValidationMap) + callback(null, {'groupPolicy' : groupPolicy }) + } catch (error) { + callback(error) + } + }, +} + +export default SAMLModuleManager diff --git a/services/web/modules/authentication/saml/app/src/SAMLNonCsrfRouter.mjs b/services/web/modules/authentication/saml/app/src/SAMLNonCsrfRouter.mjs new file mode 100644 index 0000000000..c0d617b299 --- /dev/null +++ b/services/web/modules/authentication/saml/app/src/SAMLNonCsrfRouter.mjs @@ -0,0 +1,11 @@ +import logger from '@overleaf/logger' +import SAMLAuthenticationController from './SAMLAuthenticationController.mjs' + +export default { + apply(webRouter) { + logger.debug({}, 'Init SAML NonCsrfRouter') + webRouter.post('/saml/login/callback', SAMLAuthenticationController.passportLoginCallback) + webRouter.get ('/saml/logout/callback', SAMLAuthenticationController.passportLogoutCallback) + webRouter.post('/saml/logout/callback', SAMLAuthenticationController.passportLogoutCallback) + }, +} diff --git a/services/web/modules/authentication/saml/app/src/SAMLRouter.mjs b/services/web/modules/authentication/saml/app/src/SAMLRouter.mjs new file mode 100644 index 0000000000..3cd6e56e2d --- /dev/null +++ b/services/web/modules/authentication/saml/app/src/SAMLRouter.mjs @@ -0,0 +1,16 @@ +import logger from '@overleaf/logger' +import AuthenticationController from '../../../../../app/src/Features/Authentication/AuthenticationController.js' +import UserController from '../../../../../app/src/Features/User/UserController.js' +import SAMLAuthenticationController from './SAMLAuthenticationController.mjs' +import logout from '../../../logout.mjs' + +export default { + apply(webRouter) { + logger.debug({}, 'Init SAML router') + webRouter.get('/saml/login', SAMLAuthenticationController.passportLogin) + AuthenticationController.addEndpointToLoginWhitelist('/saml/login') + webRouter.get('/saml/meta', SAMLAuthenticationController.getSPMetadata) + AuthenticationController.addEndpointToLoginWhitelist('/saml/meta') + webRouter.post('/logout', logout, UserController.logout) + }, +} diff --git a/services/web/modules/authentication/saml/index.mjs b/services/web/modules/authentication/saml/index.mjs new file mode 100644 index 0000000000..2d6ee5706c --- /dev/null +++ b/services/web/modules/authentication/saml/index.mjs @@ -0,0 +1,18 @@ +let samlModule = {} +if (process.env.EXTERNAL_AUTH.includes('saml')) { + const { default: SAMLModuleManager } = await import('./app/src/SAMLModuleManager.mjs') + const { default: router } = await import('./app/src/SAMLRouter.mjs') + const { default: nonCsrfRouter } = await import('./app/src/SAMLNonCsrfRouter.mjs') + SAMLModuleManager.initSettings() + SAMLModuleManager.initPolicy() + samlModule = { + name: 'saml-authentication', + hooks: { + passportSetup: SAMLModuleManager.passportSetup, + getGroupPolicyForUser: SAMLModuleManager.getGroupPolicyForUser, + }, + router: router, + nonCsrfRouter: nonCsrfRouter, + } +} +export default samlModule diff --git a/services/web/modules/authentication/utils.mjs b/services/web/modules/authentication/utils.mjs new file mode 100644 index 0000000000..468dae32b1 --- /dev/null +++ b/services/web/modules/authentication/utils.mjs @@ -0,0 +1,42 @@ +import fs from 'fs' +function readFilesContentFromEnv(envVar) { +// envVar is either a file name: 'file.pem', or string with array: '["file.pem", "file2.pem"]' + if (!envVar) return undefined + try { + const parsedFileNames = JSON.parse(envVar) + return parsedFileNames.map(filename => fs.readFileSync(filename, 'utf8')) + } catch (error) { + if (error instanceof SyntaxError) { // failed to parse, envVar must be a file name + return fs.readFileSync(envVar, 'utf8') + } else { + throw error + } + } +} +function numFromEnv(env) { + return env ? Number(env) : undefined +} +function boolFromEnv(env) { + if (env === undefined || env === null) return undefined + if (typeof env === "string") { + const envLower = env.toLowerCase() + if (envLower === 'true') return true + if (envLower === 'false') return false + } + throw new Error("Invalid value for boolean envirionment variable") +} + +function splitFullName(fullName) { + fullName = fullName.trim(); + let lastSpaceIndex = fullName.lastIndexOf(' '); + let firstNames = fullName.substring(0, lastSpaceIndex).trim(); + let lastName = fullName.substring(lastSpaceIndex + 1).trim(); + return [firstNames, lastName]; +} + +export { + readFilesContentFromEnv, + numFromEnv, + boolFromEnv, + splitFullName, +} diff --git a/services/web/modules/ldap-authentication/app/src/AuthenticationControllerLdap.mjs b/services/web/modules/ldap-authentication/app/src/AuthenticationControllerLdap.mjs deleted file mode 100644 index 64fa4f5a96..0000000000 --- a/services/web/modules/ldap-authentication/app/src/AuthenticationControllerLdap.mjs +++ /dev/null @@ -1,64 +0,0 @@ -import logger from '@overleaf/logger' -import LoginRateLimiter from '../../../../app/src/Features/Security/LoginRateLimiter.js' -import { handleAuthenticateErrors } from '../../../../app/src/Features/Authentication/AuthenticationErrors.js' -import AuthenticationController from '../../../../app/src/Features/Authentication/AuthenticationController.js' -import AuthenticationManagerLdap from './AuthenticationManagerLdap.mjs' - -const AuthenticationControllerLdap = { - async doPassportLdapLogin(req, ldapUser, done) { - let user, info - try { - ;({ user, info } = await AuthenticationControllerLdap._doPassportLdapLogin( - req, - ldapUser - )) - } catch (error) { - return done(error) - } - return done(undefined, user, info) - }, - async _doPassportLdapLogin(req, ldapUser) { - const { fromKnownDevice } = AuthenticationController.getAuditInfo(req) - const auditLog = { - ipAddress: req.ip, - info: { method: 'LDAP password login', fromKnownDevice }, - } - - let user, isPasswordReused - try { - user = await AuthenticationManagerLdap.promises.findOrCreateLdapUser(ldapUser, auditLog) - } catch (error) { - return { - user: false, - info: handleAuthenticateErrors(error, req), - } - } - if (user && AuthenticationController.captchaRequiredForLogin(req, user)) { - return { - user: false, - info: { - text: req.i18n.translate('cannot_verify_user_not_robot'), - type: 'error', - errorReason: 'cannot_verify_user_not_robot', - status: 400, - }, - } - } else if (user) { - // async actions - return { user, info: undefined } - } else { //something wrong - logger.debug({ email : ldapUser.mail }, 'failed LDAP log in') - return { - user: false, - info: { - type: 'error', - status: 500, - }, - } - } - }, -} - -export const { - doPassportLdapLogin, -} = AuthenticationControllerLdap diff --git a/services/web/modules/ldap-authentication/app/src/InitLdapSettings.mjs b/services/web/modules/ldap-authentication/app/src/InitLdapSettings.mjs deleted file mode 100644 index e7f312fc11..0000000000 --- a/services/web/modules/ldap-authentication/app/src/InitLdapSettings.mjs +++ /dev/null @@ -1,17 +0,0 @@ -import Settings from '@overleaf/settings' - -function initLdapSettings() { - Settings.ldap = { - enable: true, - placeholder: process.env.OVERLEAF_LDAP_PLACEHOLDER || 'Username', - attEmail: process.env.OVERLEAF_LDAP_EMAIL_ATT || 'mail', - attFirstName: process.env.OVERLEAF_LDAP_FIRST_NAME_ATT, - attLastName: process.env.OVERLEAF_LDAP_LAST_NAME_ATT, - attName: process.env.OVERLEAF_LDAP_NAME_ATT, - attAdmin: process.env.OVERLEAF_LDAP_IS_ADMIN_ATT, - valAdmin: process.env.OVERLEAF_LDAP_IS_ADMIN_ATT_VALUE, - updateUserDetailsOnLogin: String(process.env.OVERLEAF_LDAP_UPDATE_USER_DETAILS_ON_LOGIN ).toLowerCase() === 'true', - } -} - -export default initLdapSettings diff --git a/services/web/modules/ldap-authentication/app/src/LdapContacts.mjs b/services/web/modules/ldap-authentication/app/src/LdapContacts.mjs deleted file mode 100644 index c4093b8684..0000000000 --- a/services/web/modules/ldap-authentication/app/src/LdapContacts.mjs +++ /dev/null @@ -1,136 +0,0 @@ -import Settings from '@overleaf/settings' -import logger from '@overleaf/logger' -import passport from 'passport' -import ldapjs from 'ldapauth-fork/node_modules/ldapjs/lib/index.js' -import UserGetter from '../../../../app/src/Features/User/UserGetter.js' -import { splitFullName } from './AuthenticationManagerLdap.mjs' - -async function fetchLdapContacts(userId, contacts) { - if (!Settings.ldap?.enable || !process.env.OVERLEAF_LDAP_CONTACTS_FILTER) { - return [] - } - - const ldapOpts = passport._strategy('custom-fail-ldapauth').options.server - const { attEmail, attFirstName = "", attLastName = "", attName = "" } = Settings.ldap - const { - url, - timeout, - connectTimeout, - tlsOptions, - starttls, - bindDN, - bindCredentials, - } = ldapOpts - const searchBase = process.env.OVERLEAF_LDAP_CONTACTS_SEARCH_BASE || ldapOpts.searchBase - const searchScope = process.env.OVERLEAF_LDAP_CONTACTS_SEARCH_SCOPE || 'sub' - const ldapConfig = { url, timeout, connectTimeout, tlsOptions } - - let ldapUsers - const client = ldapjs.createClient(ldapConfig) - try { - if (starttls) { - await _upgradeToTLS(client, tlsOptions) - } - await _bindLdap(client, bindDN, bindCredentials) - - const filter = await _formContactsSearchFilter(client, ldapOpts, userId, process.env.OVERLEAF_LDAP_CONTACTS_FILTER) - const searchOptions = { scope: searchScope, attributes: [attEmail, attFirstName, attLastName, attName], filter } - - ldapUsers = await _searchLdap(client, searchBase, searchOptions) - } catch (err) { - logger.warn({ err }, 'error in fetchLdapContacts') - return [] - } finally { - client.unbind() - } - - const newLdapContacts = ldapUsers.reduce((acc, ldapUser) => { - const email = Array.isArray(ldapUser[attEmail]) - ? ldapUser[attEmail][0]?.toLowerCase() - : ldapUser[attEmail]?.toLowerCase() - if (!email) return acc - if (!contacts.some(contact => contact.email === email)) { - let nameParts = ["",""] - if ((!attFirstName || !attLastName) && attName) { - nameParts = splitFullName(ldapUser[attName] || "") - } - const firstName = attFirstName ? (ldapUser[attFirstName] || "") : nameParts[0] - const lastName = attLastName ? (ldapUser[attLastName] || "") : nameParts[1] - acc.push({ - first_name: firstName, - last_name: lastName, - email: email, - type: 'user', - }) - } - return acc - }, []) - - return newLdapContacts.sort((a, b) => - a.last_name.localeCompare(b.last_name) || - a.first_name.localeCompare(a.first_name) || - a.email.localeCompare(b.email) - ) -} - -function _upgradeToTLS(client, tlsOptions) { - return new Promise((resolve, reject) => { - client.on('error', error => reject(new Error(`LDAP client error: ${error}`))) - client.on('connect', () => { - client.starttls(tlsOptions, null, error => { - if (error) { - reject(new Error(`StartTLS error: ${error}`)) - } else { - resolve() - } - }) - }) - }) -} - -function _bindLdap(client, bindDN, bindCredentials) { - return new Promise((resolve, reject) => { - client.bind(bindDN, bindCredentials, error => { - if (error) { - reject(error) - } else { - resolve() - } - }) - }) -} - -function _searchLdap(client, baseDN, options) { - return new Promise((resolve, reject) => { - const searchEntries = [] - client.search(baseDN, options, (error, res) => { - if (error) { - reject(error) - } else { - res.on('searchEntry', entry => searchEntries.push(entry.object)) - res.on('error', reject) - res.on('end', () => resolve(searchEntries)) - } - }) - }) -} - -async function _formContactsSearchFilter(client, ldapOpts, userId, contactsFilter) { - const searchProperty = process.env.OVERLEAF_LDAP_CONTACTS_PROPERTY - if (!searchProperty) { - return contactsFilter - } - const email = await UserGetter.promises.getUserEmail(userId) - const searchOptions = { - scope: ldapOpts.searchScope, - attributes: [searchProperty], - filter: `(${Settings.ldap.attEmail}=${email})`, - } - const searchBase = ldapOpts.searchBase - const ldapUser = (await _searchLdap(client, searchBase, searchOptions))[0] - const searchPropertyValue = ldapUser ? ldapUser[searchProperty] - : process.env.OVERLEAF_LDAP_CONTACTS_NON_LDAP_VALUE || 'IMATCHNOTHING' - return contactsFilter.replace(/{{userProperty}}/g, searchPropertyValue) -} - -export default fetchLdapContacts diff --git a/services/web/modules/ldap-authentication/app/src/LdapStrategy.mjs b/services/web/modules/ldap-authentication/app/src/LdapStrategy.mjs deleted file mode 100644 index b07dc3f3bd..0000000000 --- a/services/web/modules/ldap-authentication/app/src/LdapStrategy.mjs +++ /dev/null @@ -1,78 +0,0 @@ -import fs from 'fs' -import passport from 'passport' -import Settings from '@overleaf/settings' -import { doPassportLdapLogin } from './AuthenticationControllerLdap.mjs' -import { Strategy as LdapStrategy } from 'passport-ldapauth' - -function _readFilesContentFromEnv(envVar) { -// envVar is either a file name: 'file.pem', or string with array: '["file.pem", "file2.pem"]' - if (!envVar) return undefined - try { - const parsedFileNames = JSON.parse(envVar) - return parsedFileNames.map(filename => fs.readFileSync(filename, 'utf8')) - } catch (error) { - if (error instanceof SyntaxError) { // failed to parse, envVar must be a file name - return fs.readFileSync(envVar, 'utf8') - } else { - throw error - } - } -} - -// custom responses on authentication failure -class CustomFailLdapStrategy extends LdapStrategy { - constructor(options, validate) { - super(options, validate); - this.name = 'custom-fail-ldapauth' - } - authenticate(req, options) { - const defaultFail = this.fail.bind(this) - this.fail = function(info, status) { - info.type = 'error' - info.key = 'invalid-password-retry-or-reset' - info.status = 401 - return defaultFail(info, status) - }.bind(this) - super.authenticate(req, options) - } -} - -const ldapServerOpts = { - url: process.env.OVERLEAF_LDAP_URL, - bindDN: process.env.OVERLEAF_LDAP_BIND_DN || "", - bindCredentials: process.env.OVERLEAF_LDAP_BIND_CREDENTIALS || "", - bindProperty: process.env.OVERLEAF_LDAP_BIND_PROPERTY, - searchBase: process.env.OVERLEAF_LDAP_SEARCH_BASE, - searchFilter: process.env.OVERLEAF_LDAP_SEARCH_FILTER, - searchScope: process.env.OVERLEAF_LDAP_SEARCH_SCOPE || 'sub', - searchAttributes: JSON.parse(process.env.OVERLEAF_LDAP_SEARCH_ATTRIBUTES || '[]'), - groupSearchBase: process.env.OVERLEAF_LDAP_ADMIN_SEARCH_BASE, - groupSearchFilter: process.env.OVERLEAF_LDAP_ADMIN_SEARCH_FILTER, - groupSearchScope: process.env.OVERLEAF_LDAP_ADMIN_SEARCH_SCOPE || 'sub', - groupSearchAttributes: ["dn"], - groupDnProperty: process.env.OVERLEAF_LDAP_ADMIN_DN_PROPERTY, - cache: String(process.env.OVERLEAF_LDAP_CACHE).toLowerCase() === 'true', - timeout: process.env.OVERLEAF_LDAP_TIMEOUT ? Number(process.env.OVERLEAF_LDAP_TIMEOUT) : undefined, - connectTimeout: process.env.OVERLEAF_LDAP_CONNECT_TIMEOUT ? Number(process.env.OVERLEAF_LDAP_CONNECT_TIMEOUT) : undefined, - starttls: String(process.env.OVERLEAF_LDAP_STARTTLS).toLowerCase() === 'true', - tlsOptions: { - ca: _readFilesContentFromEnv(process.env.OVERLEAF_LDAP_TLS_OPTS_CA_PATH), - rejectUnauthorized: String(process.env.OVERLEAF_LDAP_TLS_OPTS_REJECT_UNAUTH).toLowerCase() === 'true', - } -} - -function addLdapStrategy(passport) { - passport.use( - new CustomFailLdapStrategy( - { - server: ldapServerOpts, - passReqToCallback: true, - usernameField: 'email', - passwordField: 'password', - }, - doPassportLdapLogin - ) - ) -} - -export default addLdapStrategy diff --git a/services/web/modules/ldap-authentication/index.mjs b/services/web/modules/ldap-authentication/index.mjs deleted file mode 100644 index f56d7ffee0..0000000000 --- a/services/web/modules/ldap-authentication/index.mjs +++ /dev/null @@ -1,30 +0,0 @@ -import initLdapSettings from './app/src/InitLdapSettings.mjs' -import addLdapStrategy from './app/src/LdapStrategy.mjs' -import fetchLdapContacts from './app/src/LdapContacts.mjs' - -let ldapModule = {}; -if (process.env.EXTERNAL_AUTH === 'ldap') { - initLdapSettings() - ldapModule = { - name: 'ldap-authentication', - hooks: { - passportSetup: function (passport, callback) { - try { - addLdapStrategy(passport) - callback(null) - } catch (error) { - callback(error) - } - }, - getContacts: async function (userId, contacts, callback) { - try { - const newLdapContacts = await fetchLdapContacts(userId, contacts) - callback(null, newLdapContacts) - } catch (error) { - callback(error) - } - }, - } - } -} -export default ldapModule diff --git a/services/web/modules/saml-authentication/app/src/AuthenticationManagerSaml.mjs b/services/web/modules/saml-authentication/app/src/AuthenticationManagerSaml.mjs deleted file mode 100644 index 47d97f3019..0000000000 --- a/services/web/modules/saml-authentication/app/src/AuthenticationManagerSaml.mjs +++ /dev/null @@ -1,60 +0,0 @@ -import Settings from '@overleaf/settings' -import UserCreator from '../../../../app/src/Features/User/UserCreator.js' -import { User } from '../../../../app/src/models/User.js' - -const AuthenticationManagerSaml = { - async findOrCreateSamlUser(profile, auditLog) { - const { - attEmail, - attFirstName, - attLastName, - attAdmin, - valAdmin, - updateUserDetailsOnLogin, - } = Settings.saml - const email = Array.isArray(profile[attEmail]) - ? profile[attEmail][0].toLowerCase() - : profile[attEmail].toLowerCase() - const firstName = attFirstName ? profile[attFirstName] : "" - const lastName = attLastName ? profile[attLastName] : email - let isAdmin = false - if( attAdmin && valAdmin ) { - isAdmin = (Array.isArray(profile[attAdmin]) ? profile[attAdmin].includes(valAdmin) : - profile[attAdmin] === valAdmin) - } - let user = await User.findOne({ 'email': email }).exec() - if( !user ) { - user = await UserCreator.promises.createNewUser( - { - email: email, - first_name: firstName, - last_name: lastName, - isAdmin: isAdmin, - holdingAccount: false, - } - ) - await User.updateOne( - { _id: user._id }, - { $set : { 'emails.0.confirmedAt' : Date.now() } } - ).exec() //email of saml user is confirmed - } - let userDetails = updateUserDetailsOnLogin ? { first_name : firstName, last_name: lastName } : {} - if( attAdmin && valAdmin ) { - user.isAdmin = isAdmin - userDetails.isAdmin = isAdmin - } - const result = await User.updateOne( - { _id: user._id, loginEpoch: user.loginEpoch }, { $inc: { loginEpoch: 1 }, $set: userDetails }, - {} - ).exec() - - if (result.modifiedCount !== 1) { - throw new ParallelLoginError() - } - return user - }, -} - -export default { - promises: AuthenticationManagerSaml, -} diff --git a/services/web/modules/saml-authentication/app/src/InitSamlSettings.mjs b/services/web/modules/saml-authentication/app/src/InitSamlSettings.mjs deleted file mode 100644 index 441f9033af..0000000000 --- a/services/web/modules/saml-authentication/app/src/InitSamlSettings.mjs +++ /dev/null @@ -1,16 +0,0 @@ -import Settings from '@overleaf/settings' - -function initSamlSettings() { - Settings.saml = { - enable: true, - identityServiceName: process.env.OVERLEAF_SAML_IDENTITY_SERVICE_NAME || 'Login with SAML IdP', - attEmail: process.env.OVERLEAF_SAML_EMAIL_FIELD || 'nameID', - attFirstName: process.env.OVERLEAF_SAML_FIRST_NAME_FIELD || 'givenName', - attLastName: process.env.OVERLEAF_SAML_LAST_NAME_FIELD || 'lastName', - attAdmin: process.env.OVERLEAF_SAML_IS_ADMIN_FIELD, - valAdmin: process.env.OVERLEAF_SAML_IS_ADMIN_FIELD_VALUE, - updateUserDetailsOnLogin: String(process.env.OVERLEAF_SAML_UPDATE_USER_DETAILS_ON_LOGIN).toLowerCase() === 'true', - } -} - -export default initSamlSettings diff --git a/services/web/modules/saml-authentication/app/src/SamlNonCsrfRouter.mjs b/services/web/modules/saml-authentication/app/src/SamlNonCsrfRouter.mjs deleted file mode 100644 index 65b42c92ae..0000000000 --- a/services/web/modules/saml-authentication/app/src/SamlNonCsrfRouter.mjs +++ /dev/null @@ -1,12 +0,0 @@ -import logger from '@overleaf/logger' -import { passportSamlLogin, passportSamlIdPLogout } from './AuthenticationControllerSaml.mjs' - -export default { - apply(webRouter) { - logger.debug({}, 'Init SAML NonCsrfRouter') - webRouter.get('/saml/login/callback', passportSamlLogin) - webRouter.post('/saml/login/callback', passportSamlLogin) - webRouter.get('/saml/logout/callback', passportSamlIdPLogout) - webRouter.post('/saml/logout/callback', passportSamlIdPLogout) - }, -} diff --git a/services/web/modules/saml-authentication/app/src/SamlRouter.mjs b/services/web/modules/saml-authentication/app/src/SamlRouter.mjs deleted file mode 100644 index 9ee3677901..0000000000 --- a/services/web/modules/saml-authentication/app/src/SamlRouter.mjs +++ /dev/null @@ -1,14 +0,0 @@ -import logger from '@overleaf/logger' -import AuthenticationController from '../../../../app/src/Features/Authentication/AuthenticationController.js' -import { passportSamlAuthWithIdP, passportSamlSPLogout, passportSamlMetadata} from './AuthenticationControllerSaml.mjs' - -export default { - apply(webRouter) { - logger.debug({}, 'Init SAML router') - webRouter.get('/saml/login', passportSamlAuthWithIdP) - AuthenticationController.addEndpointToLoginWhitelist('/saml/login') - webRouter.post('/saml/logout', AuthenticationController.requireLogin(), passportSamlSPLogout) - webRouter.get('/saml/meta', passportSamlMetadata) - AuthenticationController.addEndpointToLoginWhitelist('/saml/meta') - }, -} diff --git a/services/web/modules/saml-authentication/app/src/SamlStrategy.mjs b/services/web/modules/saml-authentication/app/src/SamlStrategy.mjs deleted file mode 100644 index 3a16459f98..0000000000 --- a/services/web/modules/saml-authentication/app/src/SamlStrategy.mjs +++ /dev/null @@ -1,62 +0,0 @@ -import fs from 'fs' -import passport from 'passport' -import Settings from '@overleaf/settings' -import { doPassportSamlLogin, doPassportSamlLogout } from './AuthenticationControllerSaml.mjs' -import { Strategy as SamlStrategy } from '@node-saml/passport-saml' - -function _readFilesContentFromEnv(envVar) { -// envVar is either a file name: 'file.pem', or string with array: '["file.pem", "file2.pem"]' - if (!envVar) return undefined - try { - const parsedFileNames = JSON.parse(envVar) - return parsedFileNames.map(filename => fs.readFileSync(filename, 'utf8')) - } catch (error) { - if (error instanceof SyntaxError) { // failed to parse, envVar must be a file name - return fs.readFileSync(envVar, 'utf8') - } else { - throw error - } - } -} - -const samlOptions = { - entryPoint: process.env.OVERLEAF_SAML_ENTRYPOINT, - callbackUrl: process.env.OVERLEAF_SAML_CALLBACK_URL, - issuer: process.env.OVERLEAF_SAML_ISSUER, - audience: process.env.OVERLEAF_SAML_AUDIENCE, - cert: _readFilesContentFromEnv(process.env.OVERLEAF_SAML_IDP_CERT), - signingCert: _readFilesContentFromEnv(process.env.OVERLEAF_SAML_PUBLIC_CERT), - privateKey: _readFilesContentFromEnv(process.env.OVERLEAF_SAML_PRIVATE_KEY), - decryptionCert: _readFilesContentFromEnv(process.env.OVERLEAF_SAML_DECRYPTION_CERT), - decryptionPvk: _readFilesContentFromEnv(process.env.OVERLEAF_SAML_DECRYPTION_PVK), - signatureAlgorithm: process.env.OVERLEAF_SAML_SIGNATURE_ALGORITHM, - additionalParams: JSON.parse(process.env.OVERLEAF_SAML_ADDITIONAL_PARAMS || '{}'), - additionalAuthorizeParams: JSON.parse(process.env.OVERLEAF_SAML_ADDITIONAL_AUTHORIZE_PARAMS || '{}'), - identifierFormat: process.env.OVERLEAF_SAML_IDENTIFIER_FORMAT, - acceptedClockSkewMs: process.env.OVERLEAF_SAML_ACCEPTED_CLOCK_SKEW_MS ? Number(process.env.OVERLEAF_SAML_ACCEPTED_CLOCK_SKEW_MS) : undefined, - attributeConsumingServiceIndex: process.env.OVERLEAF_SAML_ATTRIBUTE_CONSUMING_SERVICE_INDEX, - authnContext: process.env.OVERLEAF_SAML_AUTHN_CONTEXT ? JSON.parse(process.env.OVERLEAF_SAML_AUTHN_CONTEXT) : undefined, - forceAuthn: String(process.env.OVERLEAF_SAML_FORCE_AUTHN).toLowerCase() === 'true', - disableRequestedAuthnContext: String(process.env.OVERLEAF_SAML_DISABLE_REQUESTED_AUTHN_CONTEXT).toLowerCase() === 'true', - skipRequestCompression: process.env.OVERLEAF_SAML_AUTHN_REQUEST_BINDING === 'HTTP-POST', // compression should be skipped iff authnRequestBinding is POST - authnRequestBinding: process.env.OVERLEAF_SAML_AUTHN_REQUEST_BINDING, - validateInResponseTo: process.env.OVERLEAF_SAML_VALIDATE_IN_RESPONSE_TO, - requestIdExpirationPeriodMs: process.env.OVERLEAF_SAML_REQUEST_ID_EXPIRATION_PERIOD_MS ? Number(process.env.OVERLEAF_SAML_REQUEST_ID_EXPIRATION_PERIOD_MS) : undefined, -// cacheProvider: process.env.OVERLEAF_SAML_CACHE_PROVIDER, - logoutUrl: process.env.OVERLEAF_SAML_LOGOUT_URL, - logoutCallbackUrl: process.env.OVERLEAF_SAML_LOGOUT_CALLBACK_URL, - additionalLogoutParams: JSON.parse(process.env.OVERLEAF_SAML_ADDITIONAL_LOGOUT_PARAMS || '{}'), - passReqToCallback: true, -} - -function addSamlStrategy(passport) { - passport.use( - new SamlStrategy( - samlOptions, - doPassportSamlLogin, - doPassportSamlLogout - ) - ) -} - -export default addSamlStrategy diff --git a/services/web/modules/saml-authentication/index.mjs b/services/web/modules/saml-authentication/index.mjs deleted file mode 100644 index 35ea70283f..0000000000 --- a/services/web/modules/saml-authentication/index.mjs +++ /dev/null @@ -1,26 +0,0 @@ -import initSamlSettings from './app/src/InitSamlSettings.mjs' -import addSamlStrategy from './app/src/SamlStrategy.mjs' -import SamlRouter from './app/src/SamlRouter.mjs' -import SamlNonCsrfRouter from './app/src/SamlNonCsrfRouter.mjs' - -let samlModule = {}; - -if (process.env.EXTERNAL_AUTH === 'saml') { - initSamlSettings() - samlModule = { - name: 'saml-authentication', - hooks: { - passportSetup: function (passport, callback) { - try { - addSamlStrategy(passport) - callback(null) - } catch (error) { - callback(error) - } - }, - }, - router: SamlRouter, - nonCsrfRouter: SamlNonCsrfRouter, - } -} -export default samlModule diff --git a/services/web/package.json b/services/web/package.json index b0cee1af06..7fccc9e500 100644 --- a/services/web/package.json +++ b/services/web/package.json @@ -162,6 +162,7 @@ "passport-ldapauth": "^2.1.4", "passport-local": "^1.0.0", "passport-oauth2": "^1.5.0", + "passport-openidconnect": "^0.1.2", "passport-orcid": "0.0.4", "pug": "^3.0.3", "pug-runtime": "^3.0.1", From 28b875a5efd3c30d4bde0a0d80fed24b75b9b3fa Mon Sep 17 00:00:00 2001 From: yu-i-i Date: Mon, 27 Jan 2025 04:58:23 +0100 Subject: [PATCH 03/43] Re-export `doLogout` (was removed from exports in commit b9fb636). --- services/web/app/src/Features/User/UserController.js | 1 + .../oidc/app/src/OIDCAuthenticationController.mjs | 2 +- .../saml/app/src/SAMLAuthenticationController.mjs | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/services/web/app/src/Features/User/UserController.js b/services/web/app/src/Features/User/UserController.js index b767dcd4a1..cabab8c891 100644 --- a/services/web/app/src/Features/User/UserController.js +++ b/services/web/app/src/Features/User/UserController.js @@ -518,4 +518,5 @@ module.exports = { expireDeletedUsersAfterDuration: expressify(expireDeletedUsersAfterDuration), ensureAffiliationMiddleware: expressify(ensureAffiliationMiddleware), ensureAffiliation, + doLogout, } diff --git a/services/web/modules/authentication/oidc/app/src/OIDCAuthenticationController.mjs b/services/web/modules/authentication/oidc/app/src/OIDCAuthenticationController.mjs index 42c01e712f..0b8dc501e0 100644 --- a/services/web/modules/authentication/oidc/app/src/OIDCAuthenticationController.mjs +++ b/services/web/modules/authentication/oidc/app/src/OIDCAuthenticationController.mjs @@ -158,7 +158,7 @@ const OIDCAuthenticationController = { async passportLogout(req, res, next) { // TODO: instead of storing idToken in session, use refreshToken to obtain a new idToken? const idTokenHint = req.session.idToken - await UserController.promises.doLogout(req) + await UserController.doLogout(req) const logoutUrl = process.env.OVERLEAF_OIDC_LOGOUT_URL const redirectUri = Settings.siteUrl res.redirect(`${logoutUrl}?id_token_hint=${idTokenHint}&post_logout_redirect_uri=${encodeURIComponent(redirectUri)}`) diff --git a/services/web/modules/authentication/saml/app/src/SAMLAuthenticationController.mjs b/services/web/modules/authentication/saml/app/src/SAMLAuthenticationController.mjs index ac0e5398b2..3ed834608f 100644 --- a/services/web/modules/authentication/saml/app/src/SAMLAuthenticationController.mjs +++ b/services/web/modules/authentication/saml/app/src/SAMLAuthenticationController.mjs @@ -102,7 +102,7 @@ const SAMLAuthenticationController = { }, async passportLogout(req, res, next) { passport._strategy('saml').logout(req, async (err, url) => { - await UserController.promises.doLogout(req) + await UserController.doLogout(req) if (err) return next(err) res.redirect(url) }) From 0257f74221cd77f59f5e91c29eb3e20393e9b5dd Mon Sep 17 00:00:00 2001 From: yu-i-i Date: Thu, 6 Feb 2025 12:12:03 +0100 Subject: [PATCH 04/43] Add ENV variables to control SAML signature validation --- .../modules/authentication/saml/app/src/SAMLModuleManager.mjs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/services/web/modules/authentication/saml/app/src/SAMLModuleManager.mjs b/services/web/modules/authentication/saml/app/src/SAMLModuleManager.mjs index c7efdef214..29e9ae52cd 100644 --- a/services/web/modules/authentication/saml/app/src/SAMLModuleManager.mjs +++ b/services/web/modules/authentication/saml/app/src/SAMLModuleManager.mjs @@ -46,6 +46,8 @@ const SAMLModuleManager = { logoutUrl: process.env.OVERLEAF_SAML_LOGOUT_URL, logoutCallbackUrl: `${Settings.siteUrl.replace(/\/+$/, '')}/saml/logout/callback`, additionalLogoutParams: JSON.parse(process.env.OVERLEAF_SAML_ADDITIONAL_LOGOUT_PARAMS || '{}'), + wantAssertionsSigned: boolFromEnv(process.env.OVERLEAF_SAML_WANT_ASSERTIONS_SIGNED), + wantAuthnResponseSigned: boolFromEnv(process.env.OVERLEAF_SAML_WANT_AUTHN_RESPONSE_SIGNED), passReqToCallback: true, } try { From 1e0095fae630d3625c19d0872d5ea4bf453e3ebe Mon Sep 17 00:00:00 2001 From: yu-i-i Date: Sat, 22 Feb 2025 03:26:25 +0100 Subject: [PATCH 05/43] Whitelist /oidc/login endpoint, fixes #21 --- .../web/modules/authentication/oidc/app/src/OIDCRouter.mjs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/services/web/modules/authentication/oidc/app/src/OIDCRouter.mjs b/services/web/modules/authentication/oidc/app/src/OIDCRouter.mjs index 519fa5043a..0857e41889 100644 --- a/services/web/modules/authentication/oidc/app/src/OIDCRouter.mjs +++ b/services/web/modules/authentication/oidc/app/src/OIDCRouter.mjs @@ -1,5 +1,6 @@ import logger from '@overleaf/logger' import UserController from '../../../../../app/src/Features/User/UserController.js' +import AuthenticationController from '../../../../../app/src/Features/Authentication/AuthenticationController.js' import OIDCAuthenticationController from './OIDCAuthenticationController.mjs' import logout from '../../../logout.mjs' @@ -7,7 +8,9 @@ export default { apply(webRouter) { logger.debug({}, 'Init OIDC router') webRouter.get('/oidc/login', OIDCAuthenticationController.passportLogin) + AuthenticationController.addEndpointToLoginWhitelist('/oidc/login') webRouter.get('/oidc/login/callback', OIDCAuthenticationController.passportLoginCallback) + AuthenticationController.addEndpointToLoginWhitelist('/oidc/login/callback') webRouter.get('/oidc/logout/callback', OIDCAuthenticationController.passportLogoutCallback) webRouter.post('/user/oauth-unlink', OIDCAuthenticationController.unlinkAccount) webRouter.post('/logout', logout, UserController.logout) From 57fe609cae2046659889212648f09d1443c99e69 Mon Sep 17 00:00:00 2001 From: yu-i-i Date: Mon, 10 Mar 2025 05:55:01 +0100 Subject: [PATCH 06/43] See upstream commit 42ee56e --- services/web/modules/authentication/ldap/app/src/LDAPRouter.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/web/modules/authentication/ldap/app/src/LDAPRouter.mjs b/services/web/modules/authentication/ldap/app/src/LDAPRouter.mjs index 44d9d373d2..d2bbb35236 100644 --- a/services/web/modules/authentication/ldap/app/src/LDAPRouter.mjs +++ b/services/web/modules/authentication/ldap/app/src/LDAPRouter.mjs @@ -10,7 +10,7 @@ export default { logger.debug({}, 'Init LDAP router') webRouter.post('/login', RateLimiterMiddleware.rateLimit(overleafLoginRateLimiter), // rate limit IP (20 / 60s) - RateLimiterMiddleware.loginRateLimitEmail, // rate limit email (10 / 120s) + RateLimiterMiddleware.loginRateLimitEmail(), // rate limit email (10 / 120s) CaptchaMiddleware.validateCaptcha('login'), LDAPAuthenticationController.passportLogin, AuthenticationController.passportLogin, From a458a108a40fab9ef2b0e7a10d63408c06d48c39 Mon Sep 17 00:00:00 2001 From: yu-i-i Date: Mon, 10 Mar 2025 06:37:50 +0100 Subject: [PATCH 07/43] Make OVERLEAF_OIDC_USER_ID_FIELD support 'email' as a value --- .../authentication/oidc/app/src/OIDCAuthenticationManager.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/services/web/modules/authentication/oidc/app/src/OIDCAuthenticationManager.mjs b/services/web/modules/authentication/oidc/app/src/OIDCAuthenticationManager.mjs index 56ec2e5455..5295ce63d0 100644 --- a/services/web/modules/authentication/oidc/app/src/OIDCAuthenticationManager.mjs +++ b/services/web/modules/authentication/oidc/app/src/OIDCAuthenticationManager.mjs @@ -13,8 +13,8 @@ const OIDCAuthenticationManager = { updateUserDetailsOnLogin, providerId, } = Settings.oidc - const oidcUserId = profile[attUserId] const email = profile.emails[0].value + const oidcUserId = (attUserId === 'email') ? email : profile[attUserId] const firstName = profile.name?.givenName || "" const lastName = profile.name?.familyName || "" let isAdmin = false @@ -83,7 +83,7 @@ const OIDCAuthenticationManager = { attUserId, providerId, } = Settings.oidc - const oidcUserId = profile[attUserId] + const oidcUserId = (attUserId === 'email') ? profile.emails[0].value : profile[attUserId] const oidcUserData = null // Possibly it can be used later await ThirdPartyIdentityManager.promises.link(userId, providerId, oidcUserId, oidcUserData, auditLog) }, From ef3bd6f4bbd52c70bcfc9b9753687938046b0228 Mon Sep 17 00:00:00 2001 From: yu-i-i Date: Fri, 4 Apr 2025 15:14:14 +0200 Subject: [PATCH 08/43] Allow EXTERNAL_AUTH to be undefined, fixes #26 --- services/web/modules/authentication/ldap/index.mjs | 2 +- services/web/modules/authentication/oidc/index.mjs | 2 +- services/web/modules/authentication/saml/index.mjs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/services/web/modules/authentication/ldap/index.mjs b/services/web/modules/authentication/ldap/index.mjs index 244a8db8e7..94743a6611 100644 --- a/services/web/modules/authentication/ldap/index.mjs +++ b/services/web/modules/authentication/ldap/index.mjs @@ -1,5 +1,5 @@ let ldapModule = {} -if (process.env.EXTERNAL_AUTH.includes('ldap')) { +if (process.env.EXTERNAL_AUTH?.includes('ldap')) { const { default: LDAPModuleManager } = await import('./app/src/LDAPModuleManager.mjs') const { default: router } = await import('./app/src/LDAPRouter.mjs') LDAPModuleManager.initSettings() diff --git a/services/web/modules/authentication/oidc/index.mjs b/services/web/modules/authentication/oidc/index.mjs index 51d9e0d483..f10ff64c82 100644 --- a/services/web/modules/authentication/oidc/index.mjs +++ b/services/web/modules/authentication/oidc/index.mjs @@ -1,5 +1,5 @@ let oidcModule = {} -if (process.env.EXTERNAL_AUTH.includes('oidc')) { +if (process.env.EXTERNAL_AUTH?.includes('oidc')) { const { default: OIDCModuleManager } = await import('./app/src/OIDCModuleManager.mjs') const { default: router } = await import('./app/src/OIDCRouter.mjs') OIDCModuleManager.initSettings() diff --git a/services/web/modules/authentication/saml/index.mjs b/services/web/modules/authentication/saml/index.mjs index 2d6ee5706c..36f0281637 100644 --- a/services/web/modules/authentication/saml/index.mjs +++ b/services/web/modules/authentication/saml/index.mjs @@ -1,5 +1,5 @@ let samlModule = {} -if (process.env.EXTERNAL_AUTH.includes('saml')) { +if (process.env.EXTERNAL_AUTH?.includes('saml')) { const { default: SAMLModuleManager } = await import('./app/src/SAMLModuleManager.mjs') const { default: router } = await import('./app/src/SAMLRouter.mjs') const { default: nonCsrfRouter } = await import('./app/src/SAMLNonCsrfRouter.mjs') From 25ae1b00251459931f330e4ee9319b372988c508 Mon Sep 17 00:00:00 2001 From: yu-i-i Date: Fri, 23 May 2025 16:00:40 +0200 Subject: [PATCH 09/43] Fix login page --- services/web/app/views/user/login.pug | 32 +++++++++++++-------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/services/web/app/views/user/login.pug b/services/web/app/views/user/login.pug index 97ef72476d..907e45dbb7 100644 --- a/services/web/app/views/user/login.pug +++ b/services/web/app/views/user/login.pug @@ -45,20 +45,20 @@ block content hr p.text-center !{login_support_text} if settings.saml && settings.saml.enable - form(data-ol-async-form, name="samlLoginForm") - .actions(style='margin-top: 30px;') - a.btn.btn-secondary.btn-block( - href='/saml/login', - data-ol-disabled-inflight - ) - span(data-ol-inflight="idle") #{settings.saml.identityServiceName} - span(hidden data-ol-inflight="pending") #{translate("logging_in")}… + .actions(style='margin-top: 30px;') + a.button.btn-secondary.btn( + href='/saml/login', + style="width: 100%;" + data-ol-disabled-inflight + ) + span(data-ol-inflight="idle") #{settings.saml.identityServiceName} + span(hidden data-ol-inflight="pending") #{translate("logging_in")}… if settings.oidc && settings.oidc.enable - form(data-ol-async-form, name="oidcLoginForm") - .actions(style='margin-top: 30px;') - a.btn.btn-secondary.btn-block( - href='/oidc/login', - data-ol-disabled-inflight - ) - span(data-ol-inflight="idle") #{settings.oidc.identityServiceName} - span(hidden data-ol-inflight="pending") #{translate("logging_in")}… + .actions(style='margin-top: 30px;') + a.button.btn-secondary.btn( + href='/oidc/login', + style="width: 100%;" + data-ol-disabled-inflight + ) + span(data-ol-inflight="idle") #{settings.oidc.identityServiceName} + span(hidden data-ol-inflight="pending") #{translate("logging_in")}… From c080f03ea3c259acc56df14172418459d5a75c0f Mon Sep 17 00:00:00 2001 From: yu-i-i Date: Mon, 16 Jun 2025 13:39:16 +0200 Subject: [PATCH 10/43] Avoid DEP0174 by removing async from callback-based getGroupPolicyForUser --- .../ldap/app/src/LDAPModuleManager.mjs | 20 +++++++++---------- .../oidc/app/src/OIDCModuleManager.mjs | 20 +++++++++---------- .../saml/app/src/SAMLModuleManager.mjs | 19 +++++++++--------- 3 files changed, 29 insertions(+), 30 deletions(-) diff --git a/services/web/modules/authentication/ldap/app/src/LDAPModuleManager.mjs b/services/web/modules/authentication/ldap/app/src/LDAPModuleManager.mjs index 846ca9b158..64afd02b0c 100644 --- a/services/web/modules/authentication/ldap/app/src/LDAPModuleManager.mjs +++ b/services/web/modules/authentication/ldap/app/src/LDAPModuleManager.mjs @@ -94,18 +94,18 @@ const LDAPModuleManager = { logger.info({}, error.message) } }, - async getGroupPolicyForUser(user, callback) { - try { - const userValidationMap = await PermissionsManager.promises.getUserValidationStatus({ - user, - groupPolicy : { 'ldapPolicy' : true }, - subscription : null - }) + + getGroupPolicyForUser(user, callback) { + PermissionsManager.promises.getUserValidationStatus({ + user, + groupPolicy : { 'ldapPolicy' : true }, + subscription : null + }).then(userValidationMap => { let groupPolicy = Object.fromEntries(userValidationMap) - callback(null, {'groupPolicy' : groupPolicy }) - } catch (error) { + callback(null, { groupPolicy }) + }).catch(error => { callback(error) - } + }) }, } diff --git a/services/web/modules/authentication/oidc/app/src/OIDCModuleManager.mjs b/services/web/modules/authentication/oidc/app/src/OIDCModuleManager.mjs index 3a2e6e2780..ec734ced19 100644 --- a/services/web/modules/authentication/oidc/app/src/OIDCModuleManager.mjs +++ b/services/web/modules/authentication/oidc/app/src/OIDCModuleManager.mjs @@ -64,18 +64,18 @@ const OIDCModuleManager = { logger.info({}, error.message) } }, - async getGroupPolicyForUser(user, callback) { - try { - const userValidationMap = await PermissionsManager.promises.getUserValidationStatus({ - user, - groupPolicy : { 'oidcPolicy' : true }, - subscription : null - }) + + getGroupPolicyForUser(user, callback) { + PermissionsManager.promises.getUserValidationStatus({ + user, + groupPolicy : { 'oidcPolicy' : true }, + subscription : null + }).then(userValidationMap => { let groupPolicy = Object.fromEntries(userValidationMap) - callback(null, {'groupPolicy' : groupPolicy }) - } catch (error) { + callback(null, { groupPolicy }) + }).catch(error => { callback(error) - } + }) }, } diff --git a/services/web/modules/authentication/saml/app/src/SAMLModuleManager.mjs b/services/web/modules/authentication/saml/app/src/SAMLModuleManager.mjs index 29e9ae52cd..67545f7fc9 100644 --- a/services/web/modules/authentication/saml/app/src/SAMLModuleManager.mjs +++ b/services/web/modules/authentication/saml/app/src/SAMLModuleManager.mjs @@ -84,18 +84,17 @@ const SAMLModuleManager = { logger.info({}, error.message) } }, - async getGroupPolicyForUser(user, callback) { - try { - const userValidationMap = await PermissionsManager.promises.getUserValidationStatus({ - user, - groupPolicy : { 'samlPolicy' : true }, - subscription : null - }) + getGroupPolicyForUser(user, callback) { + PermissionsManager.promises.getUserValidationStatus({ + user, + groupPolicy : { 'samlPolicy' : true }, + subscription : null + }).then(userValidationMap => { let groupPolicy = Object.fromEntries(userValidationMap) - callback(null, {'groupPolicy' : groupPolicy }) - } catch (error) { + callback(null, { groupPolicy }) + }).catch(error => { callback(error) - } + }) }, } From ff1021259e3c472e40dee45e4f3c8fa6647e6d48 Mon Sep 17 00:00:00 2001 From: yu-i-i Date: Tue, 24 Jun 2025 00:51:45 +0200 Subject: [PATCH 11/43] Introduce an environment variable to control user creation in OIDC authentication, closes #47 --- services/web/app/src/infrastructure/Features.js | 2 +- .../oidc/app/src/OIDCAuthenticationController.mjs | 14 +++++++------- .../oidc/app/src/OIDCAuthenticationManager.mjs | 3 +++ .../oidc/app/src/OIDCModuleManager.mjs | 1 + 4 files changed, 12 insertions(+), 8 deletions(-) diff --git a/services/web/app/src/infrastructure/Features.js b/services/web/app/src/infrastructure/Features.js index aaf51103b9..3264c323cd 100644 --- a/services/web/app/src/infrastructure/Features.js +++ b/services/web/app/src/infrastructure/Features.js @@ -56,7 +56,7 @@ const Features = { case 'registration-page': return ( !Features.externalAuthenticationSystemUsed() || - Boolean(Settings.overleaf) + Boolean(Settings.overleaf) || Settings.oidc?.disableJITAccountCreation ) case 'registration': return Boolean(Settings.overleaf) diff --git a/services/web/modules/authentication/oidc/app/src/OIDCAuthenticationController.mjs b/services/web/modules/authentication/oidc/app/src/OIDCAuthenticationController.mjs index 0b8dc501e0..f8bbd32c29 100644 --- a/services/web/modules/authentication/oidc/app/src/OIDCAuthenticationController.mjs +++ b/services/web/modules/authentication/oidc/app/src/OIDCAuthenticationController.mjs @@ -42,7 +42,8 @@ const OIDCAuthenticationController = { } } else { if (info.redir != null) { - return res.json({ redir: info.redir }) + await UserController.doLogout(req) + return res.redirect(info.redir) } else { res.status(info.status || 401) delete info.status @@ -95,20 +96,19 @@ const OIDCAuthenticationController = { info: { type: 'error', text: error.message, - status: 401, + status: 500, }, } } if (user) { return { user, info: undefined } - } else { // we cannot be here, something is terribly wrong - logger.debug({ email : profile.emails[0].value }, 'failed OIDC log in') + } else { // user account is not created + logger.debug({ email : profile.emails[0].value }, 'OIDC users JIT account creation is off') return { user: false, info: { - type: 'error', - text: 'Unknown error', - status: 500, + redir: '/register', + status: 401, }, } } diff --git a/services/web/modules/authentication/oidc/app/src/OIDCAuthenticationManager.mjs b/services/web/modules/authentication/oidc/app/src/OIDCAuthenticationManager.mjs index 5295ce63d0..3082558e39 100644 --- a/services/web/modules/authentication/oidc/app/src/OIDCAuthenticationManager.mjs +++ b/services/web/modules/authentication/oidc/app/src/OIDCAuthenticationManager.mjs @@ -37,6 +37,9 @@ const OIDCAuthenticationManager = { // (Is it safe? Concider: If an account from the specified provider is already linked to this user, throw an error) user = await User.findOne({ 'email': email }).exec() if (!user) { + if (Settings.oidc.disableJITAccountCreation) { + return null + } user = await UserCreator.promises.createNewUser( { email: email, diff --git a/services/web/modules/authentication/oidc/app/src/OIDCModuleManager.mjs b/services/web/modules/authentication/oidc/app/src/OIDCModuleManager.mjs index ec734ced19..debb5c8c5d 100644 --- a/services/web/modules/authentication/oidc/app/src/OIDCModuleManager.mjs +++ b/services/web/modules/authentication/oidc/app/src/OIDCModuleManager.mjs @@ -17,6 +17,7 @@ const OIDCModuleManager = { attAdmin: process.env.OVERLEAF_OIDC_IS_ADMIN_FIELD, valAdmin: process.env.OVERLEAF_OIDC_IS_ADMIN_FIELD_VALUE, updateUserDetailsOnLogin: boolFromEnv(process.env.OVERLEAF_OIDC_UPDATE_USER_DETAILS_ON_LOGIN), + disableJITAccountCreation: boolFromEnv(process.env.OVERLEAF_OIDC_DISABLE_JIT_ACCOUNT_CREATION), } }, passportSetup(passport, callback) { From a96c67c1cc4d0cb023d793df036c42404c0b2e29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Syn=C3=A9sio=20Neto?= Date: Mon, 21 Jul 2025 15:51:13 -0300 Subject: [PATCH 12/43] Introduce an environment variable to allow JIT OIDC users creation based on their email address domain. --- .../oidc/app/src/OIDCAuthenticationManager.mjs | 7 +++++++ .../authentication/oidc/app/src/OIDCModuleManager.mjs | 1 + 2 files changed, 8 insertions(+) diff --git a/services/web/modules/authentication/oidc/app/src/OIDCAuthenticationManager.mjs b/services/web/modules/authentication/oidc/app/src/OIDCAuthenticationManager.mjs index 3082558e39..5d703b9711 100644 --- a/services/web/modules/authentication/oidc/app/src/OIDCAuthenticationManager.mjs +++ b/services/web/modules/authentication/oidc/app/src/OIDCAuthenticationManager.mjs @@ -37,6 +37,13 @@ const OIDCAuthenticationManager = { // (Is it safe? Concider: If an account from the specified provider is already linked to this user, throw an error) user = await User.findOne({ 'email': email }).exec() if (!user) { + let allowedDomains = Settings.oidc.allowedOIDCEmailDomains; + allowedDomains = allowedDomains.split(',').map(d => d.trim()); // Make sure it's an array + const domain = email.split('@')[1]; + + if (!allowedDomains.includes(domain)) { + return null; + } if (Settings.oidc.disableJITAccountCreation) { return null } diff --git a/services/web/modules/authentication/oidc/app/src/OIDCModuleManager.mjs b/services/web/modules/authentication/oidc/app/src/OIDCModuleManager.mjs index debb5c8c5d..b9f68f3ff8 100644 --- a/services/web/modules/authentication/oidc/app/src/OIDCModuleManager.mjs +++ b/services/web/modules/authentication/oidc/app/src/OIDCModuleManager.mjs @@ -16,6 +16,7 @@ const OIDCModuleManager = { attUserId: process.env.OVERLEAF_OIDC_USER_ID_FIELD || 'id', attAdmin: process.env.OVERLEAF_OIDC_IS_ADMIN_FIELD, valAdmin: process.env.OVERLEAF_OIDC_IS_ADMIN_FIELD_VALUE, + allowedOIDCEmailDomains: process.env.OVERLEAF_OIDC_ALLOWED_EMAIL_DOMAINS, updateUserDetailsOnLogin: boolFromEnv(process.env.OVERLEAF_OIDC_UPDATE_USER_DETAILS_ON_LOGIN), disableJITAccountCreation: boolFromEnv(process.env.OVERLEAF_OIDC_DISABLE_JIT_ACCOUNT_CREATION), } From 09288ba9ebe0f4efc0224c0c6d328b0ea745ea9d Mon Sep 17 00:00:00 2001 From: yu-i-i Date: Tue, 22 Jul 2025 05:30:08 +0200 Subject: [PATCH 13/43] Remove OVERLEAF_OIDC_DISABLE_JIT_ACCOUNT_CREATION in favor of OVERLEAF_OIDC_ALLOWED_EMAIL_DOMAINS --- .../web/app/src/infrastructure/Features.js | 2 +- .../app/src/OIDCAuthenticationController.mjs | 2 +- .../app/src/OIDCAuthenticationManager.mjs | 22 +++++++++++-------- .../oidc/app/src/OIDCModuleManager.mjs | 5 +++-- 4 files changed, 18 insertions(+), 13 deletions(-) diff --git a/services/web/app/src/infrastructure/Features.js b/services/web/app/src/infrastructure/Features.js index 3264c323cd..5d3c873c2a 100644 --- a/services/web/app/src/infrastructure/Features.js +++ b/services/web/app/src/infrastructure/Features.js @@ -56,7 +56,7 @@ const Features = { case 'registration-page': return ( !Features.externalAuthenticationSystemUsed() || - Boolean(Settings.overleaf) || Settings.oidc?.disableJITAccountCreation + Boolean(Settings.overleaf) || Settings.oidc?.allowedOIDCEmailDomains ) case 'registration': return Boolean(Settings.overleaf) diff --git a/services/web/modules/authentication/oidc/app/src/OIDCAuthenticationController.mjs b/services/web/modules/authentication/oidc/app/src/OIDCAuthenticationController.mjs index f8bbd32c29..9365c9ea73 100644 --- a/services/web/modules/authentication/oidc/app/src/OIDCAuthenticationController.mjs +++ b/services/web/modules/authentication/oidc/app/src/OIDCAuthenticationController.mjs @@ -103,7 +103,7 @@ const OIDCAuthenticationController = { if (user) { return { user, info: undefined } } else { // user account is not created - logger.debug({ email : profile.emails[0].value }, 'OIDC users JIT account creation is off') + logger.debug({ email : profile.emails[0].value }, 'OIDC JIT account creation is not allowed for this email') return { user: false, info: { diff --git a/services/web/modules/authentication/oidc/app/src/OIDCAuthenticationManager.mjs b/services/web/modules/authentication/oidc/app/src/OIDCAuthenticationManager.mjs index 5d703b9711..9d3c1becc9 100644 --- a/services/web/modules/authentication/oidc/app/src/OIDCAuthenticationManager.mjs +++ b/services/web/modules/authentication/oidc/app/src/OIDCAuthenticationManager.mjs @@ -31,20 +31,24 @@ const OIDCAuthenticationManager = { user = await ThirdPartyIdentityManager.promises.login(providerId, oidcUserId, oidcUserData) } catch { // A user with the specified OIDC ID and provider ID is not found. Search for a user with the given email. -// If no user exists with this email, create a new user and link the OIDC account to it. +// If no user exists with this email, create a new user and link the OIDC account to it (provided this is allowed by allowedOIDCEmailDomains). // If a user exists but no account from the specified OIDC provider is linked to this user, link the OIDC account to this user. // If an account from the specified provider is already linked to this user, unlink it, and link the OIDC account to this user. // (Is it safe? Concider: If an account from the specified provider is already linked to this user, throw an error) user = await User.findOne({ 'email': email }).exec() if (!user) { - let allowedDomains = Settings.oidc.allowedOIDCEmailDomains; - allowedDomains = allowedDomains.split(',').map(d => d.trim()); // Make sure it's an array - const domain = email.split('@')[1]; - - if (!allowedDomains.includes(domain)) { - return null; - } - if (Settings.oidc.disableJITAccountCreation) { + const allowedDomains = Settings.oidc.allowedOIDCEmailDomains + if ( + allowedDomains && + !allowedDomains.some(pattern => { + const domain = email.split('@')[1] + if (pattern.startsWith('*.')) { + const base = pattern.slice(2) + return domain.endsWith(`.${base}`) + } + return domain === pattern + }) + ) { return null } user = await UserCreator.promises.createNewUser( diff --git a/services/web/modules/authentication/oidc/app/src/OIDCModuleManager.mjs b/services/web/modules/authentication/oidc/app/src/OIDCModuleManager.mjs index b9f68f3ff8..d57cde49da 100644 --- a/services/web/modules/authentication/oidc/app/src/OIDCModuleManager.mjs +++ b/services/web/modules/authentication/oidc/app/src/OIDCModuleManager.mjs @@ -16,9 +16,10 @@ const OIDCModuleManager = { attUserId: process.env.OVERLEAF_OIDC_USER_ID_FIELD || 'id', attAdmin: process.env.OVERLEAF_OIDC_IS_ADMIN_FIELD, valAdmin: process.env.OVERLEAF_OIDC_IS_ADMIN_FIELD_VALUE, - allowedOIDCEmailDomains: process.env.OVERLEAF_OIDC_ALLOWED_EMAIL_DOMAINS, updateUserDetailsOnLogin: boolFromEnv(process.env.OVERLEAF_OIDC_UPDATE_USER_DETAILS_ON_LOGIN), - disableJITAccountCreation: boolFromEnv(process.env.OVERLEAF_OIDC_DISABLE_JIT_ACCOUNT_CREATION), + allowedOIDCEmailDomains: process.env.OVERLEAF_OIDC_ALLOWED_EMAIL_DOMAINS === undefined + ? null + : process.env.OVERLEAF_OIDC_ALLOWED_EMAIL_DOMAINS.split(',').map(s => s.trim()).filter(Boolean), } }, passportSetup(passport, callback) { From 8a99d7b86ade092bb373299466d3f60efdcd77ac Mon Sep 17 00:00:00 2001 From: yu-i-i Date: Wed, 16 Jul 2025 09:16:36 +0200 Subject: [PATCH 14/43] Set `use-ai` capability to false --- .../modules/authentication/ldap/app/src/LDAPModuleManager.mjs | 1 + .../modules/authentication/oidc/app/src/OIDCModuleManager.mjs | 1 + .../modules/authentication/saml/app/src/SAMLModuleManager.mjs | 1 + 3 files changed, 3 insertions(+) diff --git a/services/web/modules/authentication/ldap/app/src/LDAPModuleManager.mjs b/services/web/modules/authentication/ldap/app/src/LDAPModuleManager.mjs index 64afd02b0c..2e915b23ce 100644 --- a/services/web/modules/authentication/ldap/app/src/LDAPModuleManager.mjs +++ b/services/web/modules/authentication/ldap/app/src/LDAPModuleManager.mjs @@ -76,6 +76,7 @@ const LDAPModuleManager = { initPolicy() { try { PermissionsManager.registerCapability('change-password', { default : true }) + PermissionsManager.registerCapability('use-ai', { default : false }) } catch (error) { logger.info({}, error.message) } diff --git a/services/web/modules/authentication/oidc/app/src/OIDCModuleManager.mjs b/services/web/modules/authentication/oidc/app/src/OIDCModuleManager.mjs index d57cde49da..7a36c0fbd0 100644 --- a/services/web/modules/authentication/oidc/app/src/OIDCModuleManager.mjs +++ b/services/web/modules/authentication/oidc/app/src/OIDCModuleManager.mjs @@ -49,6 +49,7 @@ const OIDCModuleManager = { initPolicy() { try { PermissionsManager.registerCapability('change-password', { default : true }) + PermissionsManager.registerCapability('use-ai', { default : false }) } catch (error) { logger.info({}, error.message) } diff --git a/services/web/modules/authentication/saml/app/src/SAMLModuleManager.mjs b/services/web/modules/authentication/saml/app/src/SAMLModuleManager.mjs index 67545f7fc9..fba235de3d 100644 --- a/services/web/modules/authentication/saml/app/src/SAMLModuleManager.mjs +++ b/services/web/modules/authentication/saml/app/src/SAMLModuleManager.mjs @@ -66,6 +66,7 @@ const SAMLModuleManager = { initPolicy() { try { PermissionsManager.registerCapability('change-password', { default : true }) + PermissionsManager.registerCapability('use-ai', { default : false }) } catch (error) { logger.info({}, error.message) } From d32eb2b4491eba9fa030bc1dccad9f3fe676d7f5 Mon Sep 17 00:00:00 2001 From: yu-i-i Date: Mon, 21 Jul 2025 18:40:25 +0200 Subject: [PATCH 15/43] Remove LDAP admin check via group search in ldap-passport --- .../ldap/app/src/LDAPAuthenticationManager.mjs | 5 ++--- .../authentication/ldap/app/src/LDAPModuleManager.mjs | 5 ----- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/services/web/modules/authentication/ldap/app/src/LDAPAuthenticationManager.mjs b/services/web/modules/authentication/ldap/app/src/LDAPAuthenticationManager.mjs index 66943e82a3..6a35b77566 100644 --- a/services/web/modules/authentication/ldap/app/src/LDAPAuthenticationManager.mjs +++ b/services/web/modules/authentication/ldap/app/src/LDAPAuthenticationManager.mjs @@ -30,9 +30,8 @@ const LDAPAuthenticationManager = { if (!firstName && !lastName) lastName = email let isAdmin = false if( attAdmin && valAdmin ) { - isAdmin = (profile._groups?.length > 0) || - (Array.isArray(profile[attAdmin]) ? profile[attAdmin].includes(valAdmin) : - profile[attAdmin] === valAdmin) + isAdmin = Array.isArray(profile[attAdmin]) ? profile[attAdmin].includes(valAdmin) : + profile[attAdmin] === valAdmin } let user = await User.findOne({ 'email': email }).exec() diff --git a/services/web/modules/authentication/ldap/app/src/LDAPModuleManager.mjs b/services/web/modules/authentication/ldap/app/src/LDAPModuleManager.mjs index 2e915b23ce..643342430a 100644 --- a/services/web/modules/authentication/ldap/app/src/LDAPModuleManager.mjs +++ b/services/web/modules/authentication/ldap/app/src/LDAPModuleManager.mjs @@ -31,11 +31,6 @@ const LDAPModuleManager = { searchFilter: process.env.OVERLEAF_LDAP_SEARCH_FILTER, searchScope: process.env.OVERLEAF_LDAP_SEARCH_SCOPE || 'sub', searchAttributes: JSON.parse(process.env.OVERLEAF_LDAP_SEARCH_ATTRIBUTES || '[]'), - groupSearchBase: process.env.OVERLEAF_LDAP_ADMIN_SEARCH_BASE, - groupSearchFilter: process.env.OVERLEAF_LDAP_ADMIN_SEARCH_FILTER, - groupSearchScope: process.env.OVERLEAF_LDAP_ADMIN_SEARCH_SCOPE || 'sub', - groupSearchAttributes: ["dn"], - groupDnProperty: process.env.OVERLEAF_LDAP_ADMIN_DN_PROPERTY, cache: boolFromEnv(process.env.OVERLEAF_LDAP_CACHE), timeout: numFromEnv(process.env.OVERLEAF_LDAP_TIMEOUT), connectTimeout: numFromEnv(process.env.OVERLEAF_LDAP_CONNECT_TIMEOUT), From 7ecee2e0aa6005190116c5e9633ddf2be15383b3 Mon Sep 17 00:00:00 2001 From: Christopher Hoskin <4855578+mans0954@users.noreply.github.com> Date: Mon, 21 Jul 2025 09:01:53 +0100 Subject: [PATCH 16/43] Merge pull request #27255 from overleaf/revert-27252-revert-26843-csh-issue-26608-mongo8-dev-ci Revert "Revert "Upgrade the dev environment and CI to mongo 8"" GitOrigin-RevId: 5074b012504e65240017f1fde9b0d8d04c7b8b61 --- server-ce/test/docker-compose.yml | 2 +- services/chat/docker-compose.ci.yml | 2 +- services/chat/docker-compose.yml | 2 +- services/contacts/docker-compose.ci.yml | 2 +- services/contacts/docker-compose.yml | 2 +- services/docstore/docker-compose.ci.yml | 2 +- services/docstore/docker-compose.yml | 2 +- services/document-updater/docker-compose.ci.yml | 2 +- services/document-updater/docker-compose.yml | 2 +- services/history-v1/docker-compose.ci.yml | 2 +- services/history-v1/docker-compose.yml | 2 +- services/notifications/docker-compose.ci.yml | 2 +- services/notifications/docker-compose.yml | 2 +- services/project-history/docker-compose.ci.yml | 2 +- services/project-history/docker-compose.yml | 2 +- services/web/docker-compose.ci.yml | 2 +- services/web/docker-compose.yml | 2 +- 17 files changed, 17 insertions(+), 17 deletions(-) diff --git a/server-ce/test/docker-compose.yml b/server-ce/test/docker-compose.yml index 029b73fc62..1652baeae9 100644 --- a/server-ce/test/docker-compose.yml +++ b/server-ce/test/docker-compose.yml @@ -35,7 +35,7 @@ services: MAILTRAP_PASSWORD: 'password-for-mailtrap' mongo: - image: mongo:6.0 + image: mongo:8.0.11 command: '--replSet overleaf' volumes: - ../bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js diff --git a/services/chat/docker-compose.ci.yml b/services/chat/docker-compose.ci.yml index 24b57ab084..ca3303a079 100644 --- a/services/chat/docker-compose.ci.yml +++ b/services/chat/docker-compose.ci.yml @@ -42,7 +42,7 @@ services: command: tar -czf /tmp/build/build.tar.gz --exclude=build.tar.gz --exclude-vcs . user: root mongo: - image: mongo:7.0.20 + image: mongo:8.0.11 command: --replSet overleaf volumes: - ../../bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js diff --git a/services/chat/docker-compose.yml b/services/chat/docker-compose.yml index ddc5f9e698..e7b8ce7385 100644 --- a/services/chat/docker-compose.yml +++ b/services/chat/docker-compose.yml @@ -44,7 +44,7 @@ services: command: npm run --silent test:acceptance mongo: - image: mongo:7.0.20 + image: mongo:8.0.11 command: --replSet overleaf volumes: - ../../bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js diff --git a/services/contacts/docker-compose.ci.yml b/services/contacts/docker-compose.ci.yml index 24b57ab084..ca3303a079 100644 --- a/services/contacts/docker-compose.ci.yml +++ b/services/contacts/docker-compose.ci.yml @@ -42,7 +42,7 @@ services: command: tar -czf /tmp/build/build.tar.gz --exclude=build.tar.gz --exclude-vcs . user: root mongo: - image: mongo:7.0.20 + image: mongo:8.0.11 command: --replSet overleaf volumes: - ../../bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js diff --git a/services/contacts/docker-compose.yml b/services/contacts/docker-compose.yml index 6c77ef5e31..474ea224f8 100644 --- a/services/contacts/docker-compose.yml +++ b/services/contacts/docker-compose.yml @@ -44,7 +44,7 @@ services: command: npm run --silent test:acceptance mongo: - image: mongo:7.0.20 + image: mongo:8.0.11 command: --replSet overleaf volumes: - ../../bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js diff --git a/services/docstore/docker-compose.ci.yml b/services/docstore/docker-compose.ci.yml index 40decc4aea..cdb4783c5a 100644 --- a/services/docstore/docker-compose.ci.yml +++ b/services/docstore/docker-compose.ci.yml @@ -47,7 +47,7 @@ services: command: tar -czf /tmp/build/build.tar.gz --exclude=build.tar.gz --exclude-vcs . user: root mongo: - image: mongo:7.0.20 + image: mongo:8.0.11 command: --replSet overleaf volumes: - ../../bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js diff --git a/services/docstore/docker-compose.yml b/services/docstore/docker-compose.yml index 8c11eb5a91..a9099c7e7b 100644 --- a/services/docstore/docker-compose.yml +++ b/services/docstore/docker-compose.yml @@ -49,7 +49,7 @@ services: command: npm run --silent test:acceptance mongo: - image: mongo:7.0.20 + image: mongo:8.0.11 command: --replSet overleaf volumes: - ../../bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js diff --git a/services/document-updater/docker-compose.ci.yml b/services/document-updater/docker-compose.ci.yml index ca15f35fef..c6ec24a84b 100644 --- a/services/document-updater/docker-compose.ci.yml +++ b/services/document-updater/docker-compose.ci.yml @@ -55,7 +55,7 @@ services: retries: 20 mongo: - image: mongo:7.0.20 + image: mongo:8.0.11 command: --replSet overleaf volumes: - ../../bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js diff --git a/services/document-updater/docker-compose.yml b/services/document-updater/docker-compose.yml index cf7c9a2eb6..c1b23c11c5 100644 --- a/services/document-updater/docker-compose.yml +++ b/services/document-updater/docker-compose.yml @@ -57,7 +57,7 @@ services: retries: 20 mongo: - image: mongo:7.0.20 + image: mongo:8.0.11 command: --replSet overleaf volumes: - ../../bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js diff --git a/services/history-v1/docker-compose.ci.yml b/services/history-v1/docker-compose.ci.yml index da664d6b30..cf6ec3357d 100644 --- a/services/history-v1/docker-compose.ci.yml +++ b/services/history-v1/docker-compose.ci.yml @@ -75,7 +75,7 @@ services: retries: 20 mongo: - image: mongo:7.0.20 + image: mongo:8.0.11 command: --replSet overleaf volumes: - ../../bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js diff --git a/services/history-v1/docker-compose.yml b/services/history-v1/docker-compose.yml index 22b739abf9..3a33882d28 100644 --- a/services/history-v1/docker-compose.yml +++ b/services/history-v1/docker-compose.yml @@ -83,7 +83,7 @@ services: retries: 20 mongo: - image: mongo:7.0.20 + image: mongo:8.0.11 command: --replSet overleaf volumes: - ../../bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js diff --git a/services/notifications/docker-compose.ci.yml b/services/notifications/docker-compose.ci.yml index 24b57ab084..ca3303a079 100644 --- a/services/notifications/docker-compose.ci.yml +++ b/services/notifications/docker-compose.ci.yml @@ -42,7 +42,7 @@ services: command: tar -czf /tmp/build/build.tar.gz --exclude=build.tar.gz --exclude-vcs . user: root mongo: - image: mongo:7.0.20 + image: mongo:8.0.11 command: --replSet overleaf volumes: - ../../bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js diff --git a/services/notifications/docker-compose.yml b/services/notifications/docker-compose.yml index 081bbfa002..e43e9aeef5 100644 --- a/services/notifications/docker-compose.yml +++ b/services/notifications/docker-compose.yml @@ -44,7 +44,7 @@ services: command: npm run --silent test:acceptance mongo: - image: mongo:7.0.20 + image: mongo:8.0.11 command: --replSet overleaf volumes: - ../../bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js diff --git a/services/project-history/docker-compose.ci.yml b/services/project-history/docker-compose.ci.yml index ca15f35fef..c6ec24a84b 100644 --- a/services/project-history/docker-compose.ci.yml +++ b/services/project-history/docker-compose.ci.yml @@ -55,7 +55,7 @@ services: retries: 20 mongo: - image: mongo:7.0.20 + image: mongo:8.0.11 command: --replSet overleaf volumes: - ../../bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js diff --git a/services/project-history/docker-compose.yml b/services/project-history/docker-compose.yml index eeca03de6e..dd3c6468fe 100644 --- a/services/project-history/docker-compose.yml +++ b/services/project-history/docker-compose.yml @@ -57,7 +57,7 @@ services: retries: 20 mongo: - image: mongo:7.0.20 + image: mongo:8.0.11 command: --replSet overleaf volumes: - ../../bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js diff --git a/services/web/docker-compose.ci.yml b/services/web/docker-compose.ci.yml index 33b5a3ca2e..8376103315 100644 --- a/services/web/docker-compose.ci.yml +++ b/services/web/docker-compose.ci.yml @@ -95,7 +95,7 @@ services: image: redis:7.4.3 mongo: - image: mongo:7.0.20 + image: mongo:8.0.11 logging: driver: none command: --replSet overleaf diff --git a/services/web/docker-compose.yml b/services/web/docker-compose.yml index 069c1e77de..e0a4a064c5 100644 --- a/services/web/docker-compose.yml +++ b/services/web/docker-compose.yml @@ -91,7 +91,7 @@ services: image: redis:7.4.3 mongo: - image: mongo:7.0.20 + image: mongo:8.0.11 command: --replSet overleaf volumes: - ../../bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js From 5d79cf18c0b880e39a2679b58c66721c93bf25c5 Mon Sep 17 00:00:00 2001 From: Andrew Rumble Date: Thu, 17 Jul 2025 14:25:48 +0100 Subject: [PATCH 17/43] Define all initial roles GitOrigin-RevId: ad613bad4d8a47e327281e90b5475e989a3ccec4 --- services/web/types/admin-capabilities.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/services/web/types/admin-capabilities.ts b/services/web/types/admin-capabilities.ts index 7d87c77a15..0c98d7df04 100644 --- a/services/web/types/admin-capabilities.ts +++ b/services/web/types/admin-capabilities.ts @@ -1,3 +1,10 @@ export type AdminCapability = 'modify-user-email' | 'view-project' -export type AdminRole = 'engineering' +export type AdminRole = + | 'engagement' + | 'engineering' + | 'finance' + | 'product' + | 'sales' + | 'support' + | 'support_tier_1' From 868d562d96ba768b96bd2d0ec10591646a992fce Mon Sep 17 00:00:00 2001 From: Domagoj Kriskovic Date: Mon, 21 Jul 2025 11:53:05 +0200 Subject: [PATCH 18/43] Support password-fallbackPassword array in requireBasicAuth (#27237) GitOrigin-RevId: 33b15a05996bfa0190041f347772867a9667e2ca --- .../AuthenticationController.js | 17 +- .../AuthenticationControllerTests.js | 327 ++++++++++++++++++ 2 files changed, 343 insertions(+), 1 deletion(-) diff --git a/services/web/app/src/Features/Authentication/AuthenticationController.js b/services/web/app/src/Features/Authentication/AuthenticationController.js index 7a97d2ac9c..99c418df1b 100644 --- a/services/web/app/src/Features/Authentication/AuthenticationController.js +++ b/services/web/app/src/Features/Authentication/AuthenticationController.js @@ -36,7 +36,22 @@ function send401WithChallenge(res) { 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) + + let isValid = false + if (userExists) { + if (Array.isArray(expectedPassword)) { + const isValidPrimary = Boolean( + expectedPassword[0] && tsscmp(expectedPassword[0], password) + ) + const isValidFallback = Boolean( + expectedPassword[1] && tsscmp(expectedPassword[1], password) + ) + isValid = isValidPrimary || isValidFallback + } else { + isValid = tsscmp(expectedPassword, password) + } + } + if (!isValid) { logger.err({ user }, 'invalid login details') } diff --git a/services/web/test/unit/src/Authentication/AuthenticationControllerTests.js b/services/web/test/unit/src/Authentication/AuthenticationControllerTests.js index 0e4f675b1b..1fa3aba6a6 100644 --- a/services/web/test/unit/src/Authentication/AuthenticationControllerTests.js +++ b/services/web/test/unit/src/Authentication/AuthenticationControllerTests.js @@ -1500,4 +1500,331 @@ describe('AuthenticationController', function () { }) }) }) + + describe('checkCredentials', function () { + beforeEach(function () { + this.userDetailsMap = new Map() + this.logger.err = sinon.stub() + this.Metrics.inc = sinon.stub() + }) + + describe('with valid credentials', function () { + describe('single password', function () { + beforeEach(function () { + this.userDetailsMap.set('testuser', 'correctpassword') + this.result = this.AuthenticationController.checkCredentials( + this.userDetailsMap, + 'testuser', + 'correctpassword' + ) + }) + + it('should return true', function () { + this.result.should.equal(true) + }) + + it('should not log an error', function () { + this.logger.err.called.should.equal(false) + }) + + it('should record success metrics', function () { + this.Metrics.inc.should.have.been.calledWith( + 'security.http-auth.check-credentials', + 1, + { + path: 'known-user', + status: 'pass', + } + ) + }) + }) + + describe('array with primary password', function () { + beforeEach(function () { + this.userDetailsMap.set('testuser', ['primary', 'fallback']) + this.result = this.AuthenticationController.checkCredentials( + this.userDetailsMap, + 'testuser', + 'primary' + ) + }) + + it('should return true', function () { + this.result.should.equal(true) + }) + + it('should not log an error', function () { + this.logger.err.called.should.equal(false) + }) + + it('should record success metrics', function () { + this.Metrics.inc.should.have.been.calledWith( + 'security.http-auth.check-credentials', + 1, + { + path: 'known-user', + status: 'pass', + } + ) + }) + }) + + describe('array with fallback password', function () { + beforeEach(function () { + this.userDetailsMap.set('testuser', ['primary', 'fallback']) + this.result = this.AuthenticationController.checkCredentials( + this.userDetailsMap, + 'testuser', + 'fallback' + ) + }) + + it('should return true', function () { + this.result.should.equal(true) + }) + + it('should not log an error', function () { + this.logger.err.called.should.equal(false) + }) + + it('should record success metrics', function () { + this.Metrics.inc.should.have.been.calledWith( + 'security.http-auth.check-credentials', + 1, + { + path: 'known-user', + status: 'pass', + } + ) + }) + }) + }) + + describe('with invalid credentials', function () { + describe('unknown user', function () { + beforeEach(function () { + this.userDetailsMap.set('testuser', 'correctpassword') + this.result = this.AuthenticationController.checkCredentials( + this.userDetailsMap, + 'unknownuser', + 'anypassword' + ) + }) + + it('should return false', function () { + this.result.should.equal(false) + }) + + it('should log an error', function () { + this.logger.err.should.have.been.calledWith( + { user: 'unknownuser' }, + 'invalid login details' + ) + }) + + it('should record failure metrics', function () { + this.Metrics.inc.should.have.been.calledWith( + 'security.http-auth.check-credentials', + 1, + { + path: 'unknown-user', + status: 'fail', + } + ) + }) + }) + + describe('wrong password', function () { + beforeEach(function () { + this.userDetailsMap.set('testuser', 'correctpassword') + this.result = this.AuthenticationController.checkCredentials( + this.userDetailsMap, + 'testuser', + 'wrongpassword' + ) + }) + + it('should return false', function () { + this.result.should.equal(false) + }) + + it('should log an error', function () { + this.logger.err.should.have.been.calledWith( + { user: 'testuser' }, + 'invalid login details' + ) + }) + + it('should record failure metrics', function () { + this.Metrics.inc.should.have.been.calledWith( + 'security.http-auth.check-credentials', + 1, + { + path: 'known-user', + status: 'fail', + } + ) + }) + }) + + describe('wrong password with array', function () { + beforeEach(function () { + this.userDetailsMap.set('testuser', ['primary', 'fallback']) + this.result = this.AuthenticationController.checkCredentials( + this.userDetailsMap, + 'testuser', + 'wrongpassword' + ) + }) + + it('should return false', function () { + this.result.should.equal(false) + }) + + it('should log an error', function () { + this.logger.err.should.have.been.calledWith( + { user: 'testuser' }, + 'invalid login details' + ) + }) + + it('should record failure metrics', function () { + this.Metrics.inc.should.have.been.calledWith( + 'security.http-auth.check-credentials', + 1, + { + path: 'known-user', + status: 'fail', + } + ) + }) + }) + + describe('null user entry', function () { + beforeEach(function () { + this.userDetailsMap.set('testuser', null) + this.result = this.AuthenticationController.checkCredentials( + this.userDetailsMap, + 'testuser', + 'anypassword' + ) + }) + + it('should return false', function () { + this.result.should.equal(false) + }) + + it('should log an error', function () { + this.logger.err.should.have.been.calledWith( + { user: 'testuser' }, + 'invalid login details' + ) + }) + + it('should record failure metrics for unknown user', function () { + this.Metrics.inc.should.have.been.calledWith( + 'security.http-auth.check-credentials', + 1, + { + path: 'unknown-user', + status: 'fail', + } + ) + }) + }) + + describe('empty primary password in array', function () { + beforeEach(function () { + this.userDetailsMap.set('testuser', ['', 'fallback']) + this.result = this.AuthenticationController.checkCredentials( + this.userDetailsMap, + 'testuser', + 'fallback' + ) + }) + + it('should return true with fallback password', function () { + this.result.should.equal(true) + }) + + it('should not log an error', function () { + this.logger.err.called.should.equal(false) + }) + }) + + describe('empty fallback password in array', function () { + beforeEach(function () { + this.userDetailsMap.set('testuser', ['primary', '']) + this.result = this.AuthenticationController.checkCredentials( + this.userDetailsMap, + 'testuser', + 'primary' + ) + }) + + it('should return true with primary password', function () { + this.result.should.equal(true) + }) + + it('should not log an error', function () { + this.logger.err.called.should.equal(false) + }) + }) + + describe('both passwords empty in array', function () { + beforeEach(function () { + this.userDetailsMap.set('testuser', ['', '']) + this.result = this.AuthenticationController.checkCredentials( + this.userDetailsMap, + 'testuser', + 'anypassword' + ) + }) + + it('should return false', function () { + this.result.should.equal(false) + }) + + it('should log an error', function () { + this.logger.err.should.have.been.calledWith( + { user: 'testuser' }, + 'invalid login details' + ) + }) + }) + + describe('empty single password', function () { + beforeEach(function () { + this.userDetailsMap.set('testuser', '') + this.result = this.AuthenticationController.checkCredentials( + this.userDetailsMap, + 'testuser', + 'anypassword' + ) + }) + + it('should return false', function () { + this.result.should.equal(false) + }) + + it('should log an error', function () { + this.logger.err.should.have.been.calledWith( + { user: 'testuser' }, + 'invalid login details' + ) + }) + + it('should record failure metrics for unknown user', function () { + this.Metrics.inc.should.have.been.calledWith( + 'security.http-auth.check-credentials', + 1, + { + path: 'unknown-user', + status: 'fail', + } + ) + }) + }) + }) + }) }) From d5b5710d018dea1f3ba5e84fd4869af18c99c756 Mon Sep 17 00:00:00 2001 From: Domagoj Kriskovic Date: Mon, 21 Jul 2025 11:53:48 +0200 Subject: [PATCH 19/43] Add docModified hook in ds-mobile-app module (#27196) * Add docModified hook in ds-mobile-app module * use Object.entries when iterating over promises * avoid project lookup * update tests GitOrigin-RevId: 88676746f56558a97ce31010b57f5eeb254fefef --- .../Features/Documents/DocumentController.mjs | 4 ++++ .../web/app/src/infrastructure/Modules.js | 3 +-- .../src/Documents/DocumentController.test.mjs | 21 +++++++++++++++++++ services/web/types/web-module.ts | 5 ++++- 4 files changed, 30 insertions(+), 3 deletions(-) diff --git a/services/web/app/src/Features/Documents/DocumentController.mjs b/services/web/app/src/Features/Documents/DocumentController.mjs index 6998c0b36a..9a16811894 100644 --- a/services/web/app/src/Features/Documents/DocumentController.mjs +++ b/services/web/app/src/Features/Documents/DocumentController.mjs @@ -7,6 +7,7 @@ import logger from '@overleaf/logger' import _ from 'lodash' import { plainTextResponse } from '../../infrastructure/Response.js' import { expressify } from '@overleaf/promise-utils' +import Modules from '../../infrastructure/Modules.js' async function getDocument(req, res) { const { Project_id: projectId, doc_id: docId } = req.params @@ -92,6 +93,9 @@ async function setDocument(req, res) { { docId, projectId }, 'finished receiving set document request from api (docupdater)' ) + + await Modules.promises.hooks.fire('docModified', projectId, docId) + res.json(result) } diff --git a/services/web/app/src/infrastructure/Modules.js b/services/web/app/src/infrastructure/Modules.js index 20975a3642..aea3aeb087 100644 --- a/services/web/app/src/infrastructure/Modules.js +++ b/services/web/app/src/infrastructure/Modules.js @@ -150,8 +150,7 @@ async function linkedFileAgentsIncludes() { async function attachHooks() { for (const module of await modules()) { const { promises, ...hooks } = module.hooks || {} - for (const hook in promises || {}) { - const method = promises[hook] + for (const [hook, method] of Object.entries(promises || {})) { attachHook(hook, method) } for (const hook in hooks || {}) { diff --git a/services/web/test/unit/src/Documents/DocumentController.test.mjs b/services/web/test/unit/src/Documents/DocumentController.test.mjs index e3fe3bdec2..b683cc5d14 100644 --- a/services/web/test/unit/src/Documents/DocumentController.test.mjs +++ b/services/web/test/unit/src/Documents/DocumentController.test.mjs @@ -87,6 +87,14 @@ describe('DocumentController', function () { }, } + ctx.Modules = { + promises: { + hooks: { + fire: sinon.stub().resolves(), + }, + }, + } + vi.doMock('../../../../app/src/Features/Project/ProjectGetter', () => ({ default: ctx.ProjectGetter, })) @@ -113,6 +121,10 @@ describe('DocumentController', function () { default: ctx.ChatApiHandler, })) + vi.doMock('../../../../app/src/infrastructure/Modules.js', () => ({ + default: ctx.Modules, + })) + ctx.DocumentController = (await import(MODULE_PATH)).default }) @@ -208,6 +220,15 @@ describe('DocumentController', function () { it('should return a successful response', function (ctx) { ctx.res.success.should.equal(true) }) + + it('should call the docModified hook', function (ctx) { + sinon.assert.calledWith( + ctx.Modules.promises.hooks.fire, + 'docModified', + ctx.project._id, + ctx.doc._id + ) + }) }) describe("when the document doesn't exist", function () { diff --git a/services/web/types/web-module.ts b/services/web/types/web-module.ts index 298f430df2..f6b59cdf6f 100644 --- a/services/web/types/web-module.ts +++ b/services/web/types/web-module.ts @@ -53,7 +53,10 @@ export type WebModule = { apply: (webRouter: any, privateApiRouter: any, publicApiRouter: any) => void } hooks?: { - [name: string]: (args: any[]) => void + promises?: { + [name: string]: (...args: any[]) => Promise + } + [name: string]: ((...args: any[]) => void) | any } middleware?: { [name: string]: RequestHandler From 0778bab9103c1441ba4101319d08e65490d7b2d5 Mon Sep 17 00:00:00 2001 From: Tim Down <158919+timdown@users.noreply.github.com> Date: Mon, 21 Jul 2025 11:50:29 +0100 Subject: [PATCH 20/43] Merge pull request #27254 from overleaf/td-project-dashboard-cookie-banner Implement React cookie banner on project dashboard GitOrigin-RevId: 95d2778d7ce7cb3054a06b06486b815a3453a623 --- services/web/app/views/_cookie_banner.pug | 8 +-- .../web/app/views/general/post-gateway.pug | 2 +- services/web/app/views/layout-marketing.pug | 2 +- services/web/app/views/layout-react.pug | 2 +- .../web/app/views/layout-website-redesign.pug | 2 +- .../project/editor/new_from_template.pug | 2 +- .../app/views/project/ide-react-detached.pug | 2 +- services/web/app/views/project/list-react.pug | 1 + .../app/views/project/token/access-react.pug | 2 +- .../views/project/token/sharing-updates.pug | 2 +- .../web/frontend/extracted-translations.json | 4 ++ .../js/features/cookie-banner/index.js | 53 ----------------- .../js/features/cookie-banner/index.ts | 32 ++++++++++ .../js/features/cookie-banner/utils.ts | 43 ++++++++++++++ .../components/project-list-ds-nav.tsx | 2 + .../components/project-list-root.tsx | 10 +++- .../js/shared/components/cookie-banner.tsx | 58 +++++++++++++++++++ .../pages/project-list-ds-nav.scss | 18 +++++- services/web/locales/en.json | 4 ++ services/web/types/window.ts | 1 + 20 files changed, 181 insertions(+), 69 deletions(-) delete mode 100644 services/web/frontend/js/features/cookie-banner/index.js create mode 100644 services/web/frontend/js/features/cookie-banner/index.ts create mode 100644 services/web/frontend/js/features/cookie-banner/utils.ts create mode 100644 services/web/frontend/js/shared/components/cookie-banner.tsx diff --git a/services/web/app/views/_cookie_banner.pug b/services/web/app/views/_cookie_banner.pug index 56974326cd..7cbc569bc1 100644 --- a/services/web/app/views/_cookie_banner.pug +++ b/services/web/app/views/_cookie_banner.pug @@ -1,13 +1,13 @@ -section.cookie-banner.hidden-print.hidden(aria-label='Cookie banner') - .cookie-banner-content We only use cookies for essential purposes and to improve your experience on our site. You can find out more in our cookie policy. +section.cookie-banner.hidden-print.hidden(aria-label=translate('cookie_banner')) + .cookie-banner-content !{translate('cookie_banner_info', {}, [{ name: 'a', attrs: { href: '/legal#Cookies' }}])} .cookie-banner-actions button( type='button' class='btn btn-link btn-sm' data-ol-cookie-banner-set-consent='essential' - ) Essential cookies only + ) #{translate('essential_cookies_only')} button( type='button' class='btn btn-primary btn-sm' data-ol-cookie-banner-set-consent='all' - ) Accept all cookies + ) #{translate('accept_all_cookies')} diff --git a/services/web/app/views/general/post-gateway.pug b/services/web/app/views/general/post-gateway.pug index c6bbc92d01..86f379ac1b 100644 --- a/services/web/app/views/general/post-gateway.pug +++ b/services/web/app/views/general/post-gateway.pug @@ -4,7 +4,7 @@ block vars - var suppressNavbar = true - var suppressFooter = true - var suppressSkipToContent = true - - var suppressCookieBanner = true + - var suppressPugCookieBanner = true block content .content.content-alt diff --git a/services/web/app/views/layout-marketing.pug b/services/web/app/views/layout-marketing.pug index b54c30f033..26e4eb539d 100644 --- a/services/web/app/views/layout-marketing.pug +++ b/services/web/app/views/layout-marketing.pug @@ -24,7 +24,7 @@ block body else include layout/fat-footer - if typeof suppressCookieBanner == 'undefined' + if typeof suppressPugCookieBanner == 'undefined' include _cookie_banner if bootstrapVersion === 5 diff --git a/services/web/app/views/layout-react.pug b/services/web/app/views/layout-react.pug index 94ff3ba247..e9c4c932c4 100644 --- a/services/web/app/views/layout-react.pug +++ b/services/web/app/views/layout-react.pug @@ -69,5 +69,5 @@ block body else include layout/fat-footer-react-bootstrap-5 - if typeof suppressCookieBanner === 'undefined' + if typeof suppressPugCookieBanner === 'undefined' include _cookie_banner diff --git a/services/web/app/views/layout-website-redesign.pug b/services/web/app/views/layout-website-redesign.pug index 61ed83043b..aa7fea9f07 100644 --- a/services/web/app/views/layout-website-redesign.pug +++ b/services/web/app/views/layout-website-redesign.pug @@ -27,7 +27,7 @@ block body else include layout/fat-footer-website-redesign - if typeof suppressCookieBanner == 'undefined' + if typeof suppressPugCookieBanner == 'undefined' include _cookie_banner block contactModal diff --git a/services/web/app/views/project/editor/new_from_template.pug b/services/web/app/views/project/editor/new_from_template.pug index c84288a21a..a5dc3ff33c 100644 --- a/services/web/app/views/project/editor/new_from_template.pug +++ b/services/web/app/views/project/editor/new_from_template.pug @@ -2,7 +2,7 @@ extends ../../layout-marketing block vars - var suppressFooter = true - - var suppressCookieBanner = true + - var suppressPugCookieBanner = true - var suppressSkipToContent = true block content diff --git a/services/web/app/views/project/ide-react-detached.pug b/services/web/app/views/project/ide-react-detached.pug index ca1a178bbf..fa695b1af5 100644 --- a/services/web/app/views/project/ide-react-detached.pug +++ b/services/web/app/views/project/ide-react-detached.pug @@ -7,7 +7,7 @@ block vars - var suppressNavbar = true - var suppressFooter = true - var suppressSkipToContent = true - - var suppressCookieBanner = true + - var suppressPugCookieBanner = true - metadata.robotsNoindexNofollow = true block content diff --git a/services/web/app/views/project/list-react.pug b/services/web/app/views/project/list-react.pug index 78103e75a6..47bff344b6 100644 --- a/services/web/app/views/project/list-react.pug +++ b/services/web/app/views/project/list-react.pug @@ -7,6 +7,7 @@ block vars - const suppressNavContentLinks = true - const suppressNavbar = true - const suppressFooter = true + - const suppressPugCookieBanner = true block append meta meta( diff --git a/services/web/app/views/project/token/access-react.pug b/services/web/app/views/project/token/access-react.pug index 80b91f1a99..6c01ad15b1 100644 --- a/services/web/app/views/project/token/access-react.pug +++ b/services/web/app/views/project/token/access-react.pug @@ -5,7 +5,7 @@ block entrypointVar block vars - var suppressFooter = true - - var suppressCookieBanner = true + - var suppressPugCookieBanner = true - var suppressSkipToContent = true block append meta diff --git a/services/web/app/views/project/token/sharing-updates.pug b/services/web/app/views/project/token/sharing-updates.pug index d1818be0af..2f67e5a3c1 100644 --- a/services/web/app/views/project/token/sharing-updates.pug +++ b/services/web/app/views/project/token/sharing-updates.pug @@ -5,7 +5,7 @@ block entrypointVar block vars - var suppressFooter = true - - var suppressCookieBanner = true + - var suppressPugCookieBanner = true - var suppressSkipToContent = true block append meta diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index ef2a9c6a2c..2775c04601 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -35,6 +35,7 @@ "about_to_remove_user_preamble": "", "about_to_trash_projects": "", "abstract": "", + "accept_all_cookies": "", "accept_and_continue": "", "accept_change": "", "accept_change_error_description": "", @@ -332,6 +333,8 @@ "continue_to": "", "continue_using_free_features": "", "continue_with_free_plan": "", + "cookie_banner": "", + "cookie_banner_info": "", "copied": "", "copy": "", "copy_code": "", @@ -544,6 +547,7 @@ "error_opening_document_detail": "", "error_performing_request": "", "error_processing_file": "", + "essential_cookies_only": "", "example_project": "", "existing_plan_active_until_term_end": "", "expand": "", diff --git a/services/web/frontend/js/features/cookie-banner/index.js b/services/web/frontend/js/features/cookie-banner/index.js deleted file mode 100644 index 3d9b2b8d6c..0000000000 --- a/services/web/frontend/js/features/cookie-banner/index.js +++ /dev/null @@ -1,53 +0,0 @@ -import getMeta from '@/utils/meta' - -function loadGA() { - if (window.olLoadGA) { - window.olLoadGA() - } -} - -function setConsent(value) { - document.querySelector('.cookie-banner').classList.add('hidden') - const cookieDomain = getMeta('ol-ExposedSettings').cookieDomain - const oneYearInSeconds = 60 * 60 * 24 * 365 - const cookieAttributes = - '; path=/' + - '; domain=' + - cookieDomain + - '; max-age=' + - oneYearInSeconds + - '; SameSite=Lax; Secure' - if (value === 'all') { - document.cookie = 'oa=1' + cookieAttributes - loadGA() - window.dispatchEvent(new CustomEvent('cookie-consent', { detail: true })) - } else { - document.cookie = 'oa=0' + cookieAttributes - window.dispatchEvent(new CustomEvent('cookie-consent', { detail: false })) - } -} - -if ( - getMeta('ol-ExposedSettings').gaToken || - getMeta('ol-ExposedSettings').gaTokenV4 || - getMeta('ol-ExposedSettings').propensityId || - getMeta('ol-ExposedSettings').hotjarId -) { - document - .querySelectorAll('[data-ol-cookie-banner-set-consent]') - .forEach(el => { - el.addEventListener('click', function (e) { - e.preventDefault() - const consentType = el.getAttribute('data-ol-cookie-banner-set-consent') - setConsent(consentType) - }) - }) - - const oaCookie = document.cookie.split('; ').find(c => c.startsWith('oa=')) - if (!oaCookie) { - const cookieBannerEl = document.querySelector('.cookie-banner') - if (cookieBannerEl) { - cookieBannerEl.classList.remove('hidden') - } - } -} diff --git a/services/web/frontend/js/features/cookie-banner/index.ts b/services/web/frontend/js/features/cookie-banner/index.ts new file mode 100644 index 0000000000..2ea97e875a --- /dev/null +++ b/services/web/frontend/js/features/cookie-banner/index.ts @@ -0,0 +1,32 @@ +import { + CookieConsentValue, + cookieBannerRequired, + hasMadeCookieChoice, + setConsent, +} from '@/features/cookie-banner/utils' + +function toggleCookieBanner(hidden: boolean) { + const cookieBannerEl = document.querySelector('.cookie-banner') + if (cookieBannerEl) { + cookieBannerEl.classList.toggle('hidden', hidden) + } +} + +if (cookieBannerRequired()) { + document + .querySelectorAll('[data-ol-cookie-banner-set-consent]') + .forEach(el => { + el.addEventListener('click', function (e) { + e.preventDefault() + toggleCookieBanner(true) + const consentType = el.getAttribute( + 'data-ol-cookie-banner-set-consent' + ) as CookieConsentValue | null + setConsent(consentType) + }) + }) + + if (!hasMadeCookieChoice()) { + toggleCookieBanner(false) + } +} diff --git a/services/web/frontend/js/features/cookie-banner/utils.ts b/services/web/frontend/js/features/cookie-banner/utils.ts new file mode 100644 index 0000000000..5c045d4e71 --- /dev/null +++ b/services/web/frontend/js/features/cookie-banner/utils.ts @@ -0,0 +1,43 @@ +import getMeta from '@/utils/meta' + +export type CookieConsentValue = 'all' | 'essential' + +function loadGA() { + if (window.olLoadGA) { + window.olLoadGA() + } +} + +export function setConsent(value: CookieConsentValue | null) { + const cookieDomain = getMeta('ol-ExposedSettings').cookieDomain + const oneYearInSeconds = 60 * 60 * 24 * 365 + const cookieAttributes = + '; path=/' + + '; domain=' + + cookieDomain + + '; max-age=' + + oneYearInSeconds + + '; SameSite=Lax; Secure' + if (value === 'all') { + document.cookie = 'oa=1' + cookieAttributes + loadGA() + window.dispatchEvent(new CustomEvent('cookie-consent', { detail: true })) + } else { + document.cookie = 'oa=0' + cookieAttributes + window.dispatchEvent(new CustomEvent('cookie-consent', { detail: false })) + } +} + +export function cookieBannerRequired() { + const exposedSettings = getMeta('ol-ExposedSettings') + return Boolean( + exposedSettings.gaToken || + exposedSettings.gaTokenV4 || + exposedSettings.propensityId || + exposedSettings.hotjarId + ) +} + +export function hasMadeCookieChoice() { + return document.cookie.split('; ').some(c => c.startsWith('oa=')) +} diff --git a/services/web/frontend/js/features/project-list/components/project-list-ds-nav.tsx b/services/web/frontend/js/features/project-list/components/project-list-ds-nav.tsx index 3d24f9845c..07319ffaf1 100644 --- a/services/web/frontend/js/features/project-list/components/project-list-ds-nav.tsx +++ b/services/web/frontend/js/features/project-list/components/project-list-ds-nav.tsx @@ -20,6 +20,7 @@ import Footer from '@/features/ui/components/bootstrap-5/footer/footer' import SidebarDsNav from '@/features/project-list/components/sidebar/sidebar-ds-nav' import SystemMessages from '@/shared/components/system-messages' import overleafLogo from '@/shared/svgs/overleaf-a-ds-solution-mallard.svg' +import CookieBanner from '@/shared/components/cookie-banner' export function ProjectListDsNav() { const navbarProps = getMeta('ol-navbar') @@ -125,6 +126,7 @@ export function ProjectListDsNav() {
+
diff --git a/services/web/frontend/js/features/project-list/components/project-list-root.tsx b/services/web/frontend/js/features/project-list/components/project-list-root.tsx index 679b645a6f..d5b4257f03 100644 --- a/services/web/frontend/js/features/project-list/components/project-list-root.tsx +++ b/services/web/frontend/js/features/project-list/components/project-list-root.tsx @@ -18,6 +18,7 @@ import Footer from '@/features/ui/components/bootstrap-5/footer/footer' import WelcomePageContent from '@/features/project-list/components/welcome-page-content' import { ProjectListDsNav } from '@/features/project-list/components/project-list-ds-nav' import { DsNavStyleProvider } from '@/features/project-list/components/use-is-ds-nav' +import CookieBanner from '@/shared/components/cookie-banner' function ProjectListRoot() { const { isReady } = useWaitForI18n() @@ -88,9 +89,12 @@ function ProjectListPageContent() { if (totalProjectsCount === 0) { return ( - - - + <> + + + + + ) } return ( diff --git a/services/web/frontend/js/shared/components/cookie-banner.tsx b/services/web/frontend/js/shared/components/cookie-banner.tsx new file mode 100644 index 0000000000..d057c7230f --- /dev/null +++ b/services/web/frontend/js/shared/components/cookie-banner.tsx @@ -0,0 +1,58 @@ +import OLButton from '@/features/ui/components/ol/ol-button' +import { Trans, useTranslation } from 'react-i18next' +import React, { useState } from 'react' +import { + CookieConsentValue, + cookieBannerRequired, + hasMadeCookieChoice, + setConsent, +} from '@/features/cookie-banner/utils' + +function CookieBanner() { + const { t } = useTranslation() + const [hidden, setHidden] = useState( + () => !cookieBannerRequired() || hasMadeCookieChoice() + ) + + function makeCookieChoice(value: CookieConsentValue) { + setConsent(value) + setHidden(true) + } + + if (hidden) { + return null + } + + return ( +
+
+ ]} + /> +
+
+ makeCookieChoice('essential')} + > + {t('essential_cookies_only')} + + makeCookieChoice('all')} + > + {t('accept_all_cookies')} + +
+
+ ) +} + +export default CookieBanner diff --git a/services/web/frontend/stylesheets/bootstrap-5/pages/project-list-ds-nav.scss b/services/web/frontend/stylesheets/bootstrap-5/pages/project-list-ds-nav.scss index d71de37470..93ab22866f 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/pages/project-list-ds-nav.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/pages/project-list-ds-nav.scss @@ -255,6 +255,12 @@ display: flex; flex-direction: column; + > * { + @include media-breakpoint-up(md) { + border-left: 1px solid var(--border-divider); + } + } + .project-ds-nav-content { flex-grow: 1; overflow-y: auto; @@ -263,10 +269,20 @@ @include media-breakpoint-up(md) { border-top-left-radius: var(--border-radius-large); - border-left: 1px solid var(--border-divider); border-top: 1px solid var(--border-divider); } } + + .cookie-banner { + position: static; + background-color: var(--bg-light-primary); + + // Remove the parts of the shadow that stick out of the sides + clip-path: inset(-13px 0 0 0); + + // Prevent the cookie banner being overlaid on top of the navigation + z-index: auto; + } } } diff --git a/services/web/locales/en.json b/services/web/locales/en.json index adfb33d8f8..ddaeb8e79f 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -38,6 +38,7 @@ "about_to_trash_projects": "You are about to trash the following projects:", "abstract": "Abstract", "accept": "Accept", + "accept_all_cookies": "Accept all cookies", "accept_and_continue": "Accept and continue", "accept_change": "Accept change", "accept_change_error_description": "There was an error accepting a track change. Please try again in a few moments.", @@ -433,6 +434,8 @@ "continue_using_free_features": "Continue using our free features", "continue_with_free_plan": "Continue with free plan", "continue_with_service": "Continue with __service__", + "cookie_banner": "Cookie banner", + "cookie_banner_info": "We only use cookies for essential purposes and to improve your experience on our site. You can find out more in our <0>cookie policy.", "copied": "Copied", "copy": "Copy", "copy_code": "Copy code", @@ -700,6 +703,7 @@ "error_performing_request": "An error has occurred while performing your request.", "error_processing_file": "Sorry, something went wrong processing this file. Please try again.", "es": "Spanish", + "essential_cookies_only": "Essential cookies only", "estimated_number_of_overleaf_users": "Estimated number of __appName__ users", "every": "per", "everything_in_free_plus": "Everything in Free, plus…", diff --git a/services/web/types/window.ts b/services/web/types/window.ts index 5688faa9a4..6869e90957 100644 --- a/services/web/types/window.ts +++ b/services/web/types/window.ts @@ -27,5 +27,6 @@ declare global { gtag?: (...args: any) => void propensity?: (propensityId?: string) => void + olLoadGA?: () => void } } From 2f427ef0e01b374b5716ff2674542148e51a248d Mon Sep 17 00:00:00 2001 From: Tim Down <158919+timdown@users.noreply.github.com> Date: Mon, 21 Jul 2025 11:50:39 +0100 Subject: [PATCH 21/43] Merge pull request #27229 from overleaf/td-group-pricing-select Allow clicks on icon in group plans select lists to open the select GitOrigin-RevId: d54b27851cb8b5541d71c48ff815d52cf99db16f --- .../web/frontend/stylesheets/bootstrap-5/pages/plans.scss | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/services/web/frontend/stylesheets/bootstrap-5/pages/plans.scss b/services/web/frontend/stylesheets/bootstrap-5/pages/plans.scss index 9894af31f7..46ea5ef131 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/pages/plans.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/pages/plans.scss @@ -524,6 +524,10 @@ $z-index-group-member-picker-list: 1; &[data-ol-plans-new-group-member-picker-button='group-all'] { height: $group-member-picker-top-height; } + + .material-symbols { + pointer-events: none; + } } ul.plans-new-group-member-picker-list { From 30b0cabbbc76aedee1f9ee4a18e315947dfa40c1 Mon Sep 17 00:00:00 2001 From: Antoine Clausse Date: Mon, 21 Jul 2025 14:52:43 +0200 Subject: [PATCH 22/43] [web] Update tests to add emails with 6-digits flow (#27076) * In tests, post to `/user/emails/secondary` (6-digits) instead of the deprecated `/user/emails` (link-token) * Update `addEmailAndConfirm` so it calls the right endpoint * Remove unnecessary `userId` from `confirmEmail` and `addEmailAndConfirm` args * Use `updateUser` to add unconfirmed email to user * Confirm, then unconfirm emails, in order to test on unconfirmed emails * Lowercase emails in `unconfirmSecondaryEmail`, so they get matched correctly * Update UserEmailsTests.mjs with 6-digits flow, fetch, no `npm:async` GitOrigin-RevId: 71b9ed65daebea5f22272240559caab375515f0c --- .../acceptance/src/PrimaryEmailCheckTests.mjs | 20 +++--------- .../acceptance/src/helpers/UserHelper.mjs | 31 +++++++++++++++---- 2 files changed, 29 insertions(+), 22 deletions(-) diff --git a/services/web/test/acceptance/src/PrimaryEmailCheckTests.mjs b/services/web/test/acceptance/src/PrimaryEmailCheckTests.mjs index 1598373f34..bc85f96a5d 100644 --- a/services/web/test/acceptance/src/PrimaryEmailCheckTests.mjs +++ b/services/web/test/acceptance/src/PrimaryEmailCheckTests.mjs @@ -69,10 +69,7 @@ describe('PrimaryEmailCheck', function () { $set: { lastPrimaryEmailCheck: new Date(time) }, }) - await userHelper.confirmEmail( - userHelper.user._id, - userHelper.user.email - ) + await userHelper.confirmEmail(userHelper.user.email) }) it("shouldn't be redirected from project list to the primary email check page", async function () { @@ -153,10 +150,7 @@ describe('PrimaryEmailCheck', function () { $set: { lastPrimaryEmailCheck: new Date(time) }, }) - await userHelper.confirmEmail( - userHelper.user._id, - userHelper.user.email - ) + await userHelper.confirmEmail(userHelper.user.email) }) it("shouldn't be redirected from project list to the primary email check page", async function () { @@ -219,14 +213,8 @@ describe('PrimaryEmailCheck', function () { }) beforeEach(async function () { - await userHelper.confirmEmail( - userHelper.user._id, - userHelper.user.email - ) - await userHelper.addEmailAndConfirm( - userHelper.user._id, - 'secondary@overleaf.com' - ) + await userHelper.confirmEmail(userHelper.user.email) + await userHelper.addEmailAndConfirm('secondary@overleaf.com') checkResponse = await userHelper.fetch( '/user/emails/primary-email-check', diff --git a/services/web/test/acceptance/src/helpers/UserHelper.mjs b/services/web/test/acceptance/src/helpers/UserHelper.mjs index 951dccaf2b..05fef85604 100644 --- a/services/web/test/acceptance/src/helpers/UserHelper.mjs +++ b/services/web/test/acceptance/src/helpers/UserHelper.mjs @@ -162,7 +162,7 @@ class UserHelper { /** * - * @param {'pendingExistingEmail'|'pendingUserRegistration'}sessionKey + * @param {'pendingExistingEmail'|'pendingUserRegistration'|'pendingSecondaryEmail'}sessionKey * @return {Promise<*>} */ async getEmailConfirmationCode(sessionKey) { @@ -431,16 +431,16 @@ class UserHelper { } async addEmail(email) { - const response = await this.fetch('/user/emails', { + const response = await this.fetch('/user/emails/secondary', { method: 'POST', body: new URLSearchParams([['email', email]]), }) await throwIfErrorResponse(response) } - async addEmailAndConfirm(userId, email) { + async addEmailAndConfirm(email) { await this.addEmail(email) - await this.confirmEmail(userId, email) + await this.confirmSecondaryEmail() } async changeConfirmationDate(userId, email, date) { @@ -499,9 +499,9 @@ class UserHelper { await this.changeConfirmationDate(userId, email, date) } - async confirmEmail(userId, email) { + async confirmEmail(email) { // clear ratelimiting on resend confirmation endpoint - await rateLimiters.sendConfirmation.delete(userId) + await rateLimiters.sendConfirmation.delete(this.user._id) const requestConfirmationCode = await this.fetch( '/user/emails/send-confirmation-code', { @@ -517,6 +517,25 @@ class UserHelper { }) await throwIfErrorResponse(requestConfirmCode) } + + async confirmSecondaryEmail() { + const code = await this.getEmailConfirmationCode('pendingSecondaryEmail') + const requestConfirmCode = await this.fetch( + '/user/emails/confirm-secondary', + { + method: 'POST', + body: new URLSearchParams({ code }), + } + ) + await throwIfErrorResponse(requestConfirmCode) + } + + async unconfirmEmail(email) { + await UserUpdater.promises.updateUser( + { _id: this.user._id, 'emails.email': email.toLowerCase() }, + { $unset: { 'emails.$.confirmedAt': 1, 'emails.$.reconfirmedAt': 1 } } + ) + } } export default UserHelper From ae3f63d37f6eaeefb782e8787192831bf1a6ed12 Mon Sep 17 00:00:00 2001 From: David <33458145+davidmcpowell@users.noreply.github.com> Date: Mon, 21 Jul 2025 13:55:37 +0100 Subject: [PATCH 23/43] Merge pull request #27209 from overleaf/dp-collaborator-colour Adapt online user and chat user colors based on luminance GitOrigin-RevId: 1b0c843147ee3dc585866bc491a7c7613cb00e70 --- .../ide-redesign/components/chat/message.tsx | 19 +++++--- .../online-users/online-users-widget.tsx | 15 ++++++- .../web/frontend/js/shared/utils/colors.ts | 45 +++++++++++++++++++ .../pages/editor/online-users.scss | 8 ++++ 4 files changed, 78 insertions(+), 9 deletions(-) diff --git a/services/web/frontend/js/features/ide-redesign/components/chat/message.tsx b/services/web/frontend/js/features/ide-redesign/components/chat/message.tsx index 9a4ffe3a1b..6822db39da 100644 --- a/services/web/frontend/js/features/ide-redesign/components/chat/message.tsx +++ b/services/web/frontend/js/features/ide-redesign/components/chat/message.tsx @@ -1,15 +1,14 @@ import { MessageProps } from '@/features/chat/components/message' import { User } from '../../../../../../types/user' -import { getHueForUserId } from '@/shared/utils/colors' +import { + getBackgroundColorForUserId, + hslStringToLuminance, +} from '@/shared/utils/colors' import MessageContent from '@/features/chat/components/message-content' import classNames from 'classnames' import MaterialIcon from '@/shared/components/material-icon' import { t } from 'i18next' -function hue(user?: User) { - return user ? getHueForUserId(user.id) : 0 -} - function getAvatarStyle(user?: User) { if (!user?.id) { // Deleted user @@ -20,9 +19,15 @@ function getAvatarStyle(user?: User) { } } + const backgroundColor = getBackgroundColorForUserId(user.id) + return { - borderColor: `hsl(${hue(user)}, 85%, 40%)`, - backgroundColor: `hsl(${hue(user)}, 85%, 40%`, + borderColor: backgroundColor, + backgroundColor, + color: + hslStringToLuminance(backgroundColor) < 0.5 + ? 'var(--content-primary-dark)' + : 'var(--content-primary)', } } diff --git a/services/web/frontend/js/features/ide-redesign/components/online-users/online-users-widget.tsx b/services/web/frontend/js/features/ide-redesign/components/online-users/online-users-widget.tsx index 07aaa647a9..2d30297e51 100644 --- a/services/web/frontend/js/features/ide-redesign/components/online-users/online-users-widget.tsx +++ b/services/web/frontend/js/features/ide-redesign/components/online-users/online-users-widget.tsx @@ -7,7 +7,11 @@ import { DropdownToggle, } from '@/features/ui/components/bootstrap-5/dropdown-menu' import OLTooltip from '@/features/ui/components/ol/ol-tooltip' -import { getBackgroundColorForUserId } from '@/shared/utils/colors' +import { + getBackgroundColorForUserId, + hslStringToLuminance, +} from '@/shared/utils/colors' +import classNames from 'classnames' import { useCallback, useMemo } from 'react' import { useTranslation } from 'react-i18next' @@ -86,9 +90,16 @@ const OnlineUserWidget = ({ const OnlineUserCircle = ({ user }: { user: OnlineUser }) => { const backgroundColor = getBackgroundColorForUserId(user.user_id) + const luminance = hslStringToLuminance(backgroundColor) const [character] = [...user.name] return ( - + = 0.5, + })} + style={{ backgroundColor }} + > {character} ) diff --git a/services/web/frontend/js/shared/utils/colors.ts b/services/web/frontend/js/shared/utils/colors.ts index 346a52d03a..3c43de4119 100644 --- a/services/web/frontend/js/shared/utils/colors.ts +++ b/services/web/frontend/js/shared/utils/colors.ts @@ -34,6 +34,51 @@ export function getBackgroundColorForUserId(userId?: string) { return `hsl(${getHueForUserId(userId)}, 70%, 50%)` } +export function hslStringToLuminance(hslString: string): number { + // First extract the individual components from the HSL string + const hslSplit = hslString.slice(4).split(')')[0].split(',') + + const h = Number(hslSplit[0]) + const s = Number(hslSplit[1].slice(0, -1)) / 100 + const l = Number(hslSplit[2].slice(0, -1)) / 100 + + // Then we need to convert HSL to RGB + const c = (1 - Math.abs(2 * l - 1)) * s + const x = c * (1 - Math.abs(((h / 60) % 2) - 1)) + const m = l - c / 2 + let r = 0 + let g = 0 + let b = 0 + if (h >= 0 && h < 60) { + r = c + m + g = x + m + b = m + } else if (h >= 60 && h < 120) { + r = x + m + g = c + m + b = m + } else if (h >= 120 && h < 180) { + r = m + g = c + m + b = x + m + } else if (h >= 180 && h < 240) { + r = m + g = x + m + b = c + m + } else if (h >= 240 && h < 300) { + r = x + m + g = m + b = c + m + } else if (h >= 300 && h < 360) { + r = c + m + g = m + b = x + m + } + + // Finally we calculate the luminance + return 0.2126 * r + 0.7152 * g + 0.0722 * b +} + const cachedHues = new Map() export function getHueForId(id: string) { diff --git a/services/web/frontend/stylesheets/bootstrap-5/pages/editor/online-users.scss b/services/web/frontend/stylesheets/bootstrap-5/pages/editor/online-users.scss index a37152854d..0ae3eff278 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/pages/editor/online-users.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/pages/editor/online-users.scss @@ -124,4 +124,12 @@ box-sizing: border-box; display: inline-block; } + + .online-user-circle-light-font { + color: var(--content-primary-dark); + } + + .online-user-circle-dark-font { + color: var(--content-primary); + } } From bf43d4f70994262b73273f255a0090d86b48adda Mon Sep 17 00:00:00 2001 From: Jakob Ackermann Date: Mon, 21 Jul 2025 15:30:29 +0200 Subject: [PATCH 24/43] [history-v1] make back_fill_file_hash_fix_up compatible with Server Pro (#27280) * [history-v1] move MockFilestore into shared place Co-authored-by: Brian Gough * [history-v1] make back_fill_file_hash_fix_up compatible with Server Pro --------- Co-authored-by: Brian Gough GitOrigin-RevId: 70ea57e1503031d9f14dcd60c4c110e746450587 --- .../scripts/back_fill_file_hash_fix_up.mjs | 110 ++++---- .../js/storage/back_fill_file_hash.test.mjs | 55 +--- .../back_fill_file_hash_fix_up.test.mjs | 251 ++---------------- .../js/storage/support/MockFilestore.mjs | 54 ++++ 4 files changed, 128 insertions(+), 342 deletions(-) create mode 100644 services/history-v1/test/acceptance/js/storage/support/MockFilestore.mjs diff --git a/services/history-v1/storage/scripts/back_fill_file_hash_fix_up.mjs b/services/history-v1/storage/scripts/back_fill_file_hash_fix_up.mjs index 7bab794692..2525ee1d6e 100644 --- a/services/history-v1/storage/scripts/back_fill_file_hash_fix_up.mjs +++ b/services/history-v1/storage/scripts/back_fill_file_hash_fix_up.mjs @@ -9,15 +9,12 @@ import { Blob } from 'overleaf-editor-core' import { BlobStore, getStringLengthOfFile, - GLOBAL_BLOBS, makeBlobForFile, } from '../lib/blob_store/index.js' import { db } from '../lib/mongodb.js' import commandLineArgs from 'command-line-args' import readline from 'node:readline' -import { _blobIsBackedUp, backupBlob } from '../lib/backupBlob.mjs' import { NotFoundError } from '@overleaf/object-persistor/src/Errors.js' -import filestorePersistor from '../lib/persistor.js' import { setTimeout } from 'node:timers/promises' // Silence warning. @@ -52,12 +49,11 @@ ObjectId.cacheHexString = true */ /** - * @return {{FIX_NOT_FOUND: boolean, FIX_HASH_MISMATCH: boolean, FIX_DELETE_PERMISSION: boolean, FIX_MISSING_HASH: boolean, LOGS: string}} + * @return {{FIX_NOT_FOUND: boolean, FIX_HASH_MISMATCH: boolean, FIX_MISSING_HASH: boolean, LOGS: string}} */ function parseArgs() { const args = commandLineArgs([ { name: 'fixNotFound', type: String, defaultValue: 'true' }, - { name: 'fixDeletePermission', type: String, defaultValue: 'true' }, { name: 'fixHashMismatch', type: String, defaultValue: 'true' }, { name: 'fixMissingHash', type: String, defaultValue: 'true' }, { name: 'logs', type: String, defaultValue: '' }, @@ -74,20 +70,13 @@ function parseArgs() { } return { FIX_HASH_MISMATCH: boolVal('fixNotFound'), - FIX_DELETE_PERMISSION: boolVal('fixDeletePermission'), FIX_NOT_FOUND: boolVal('fixHashMismatch'), FIX_MISSING_HASH: boolVal('fixMissingHash'), LOGS: args.logs, } } -const { - FIX_HASH_MISMATCH, - FIX_DELETE_PERMISSION, - FIX_NOT_FOUND, - FIX_MISSING_HASH, - LOGS, -} = parseArgs() +const { FIX_HASH_MISMATCH, FIX_NOT_FOUND, FIX_MISSING_HASH, LOGS } = parseArgs() if (!LOGS) { throw new Error('--logs parameter missing') } @@ -105,6 +94,37 @@ const STREAM_HIGH_WATER_MARK = parseInt( ) const SLEEP_BEFORE_EXIT = parseInt(process.env.SLEEP_BEFORE_EXIT || '1000', 10) +// Filestore endpoint location +const FILESTORE_HOST = process.env.FILESTORE_HOST || '127.0.0.1' +const FILESTORE_PORT = process.env.FILESTORE_PORT || '3009' + +async function fetchFromFilestore(projectId, fileId) { + const url = `http://${FILESTORE_HOST}:${FILESTORE_PORT}/project/${projectId}/file/${fileId}` + const response = await fetch(url) + if (!response.ok) { + if (response.status === 404) { + throw new NotFoundError('file not found in filestore', { + status: response.status, + }) + } + const body = await response.text() + throw new OError('fetchFromFilestore failed', { + projectId, + fileId, + status: response.status, + body, + }) + } + if (!response.body) { + throw new OError('fetchFromFilestore response has no body', { + projectId, + fileId, + status: response.status, + }) + } + return response.body +} + /** @type {ProjectsCollection} */ const projectsCollection = db.collection('projects') /** @type {DeletedProjectsCollection} */ @@ -302,19 +322,16 @@ async function setHashInMongo(projectId, fileId, hash) { * @return {Promise} */ async function importRestoredFilestoreFile(projectId, fileId, historyId) { - const filestoreKey = `${projectId}/${fileId}` const path = `${BUFFER_DIR}/${projectId}_${fileId}` try { let s try { - s = await filestorePersistor.getObjectStream( - USER_FILES_BUCKET_NAME, - filestoreKey - ) + s = await fetchFromFilestore(projectId, fileId) } catch (err) { if (err instanceof NotFoundError) { throw new OError('missing blob, need to restore filestore file', { - filestoreKey, + projectId, + fileId, }) } throw err @@ -325,7 +342,6 @@ async function importRestoredFilestoreFile(projectId, fileId, historyId) { ) const blobStore = new BlobStore(historyId) const blob = await blobStore.putFile(path) - await backupBlob(historyId, blob, path) await setHashInMongo(projectId, fileId, blob.getHash()) } finally { await fs.promises.rm(path, { force: true }) @@ -339,13 +355,9 @@ async function importRestoredFilestoreFile(projectId, fileId, historyId) { * @return {Promise} */ async function bufferFilestoreFileToDisk(projectId, fileId, path) { - const filestoreKey = `${projectId}/${fileId}` try { await Stream.promises.pipeline( - await filestorePersistor.getObjectStream( - USER_FILES_BUCKET_NAME, - filestoreKey - ), + await fetchFromFilestore(projectId, fileId), fs.createWriteStream(path, { highWaterMark: STREAM_HIGH_WATER_MARK }) ) const blob = await makeBlobForFile(path) @@ -356,7 +368,8 @@ async function bufferFilestoreFileToDisk(projectId, fileId, path) { } catch (err) { if (err instanceof NotFoundError) { throw new OError('missing blob, need to restore filestore file', { - filestoreKey, + projectId, + fileId, }) } throw err @@ -389,7 +402,7 @@ async function uploadFilestoreFile(projectId, fileId) { const blob = await bufferFilestoreFileToDisk(projectId, fileId, path) const hash = blob.getHash() try { - await ensureBlobExistsForFileAndUploadToAWS(projectId, fileId, hash) + await ensureBlobExistsForFile(projectId, fileId, hash) } catch (err) { if (!(err instanceof Blob.NotFoundError)) throw err @@ -397,7 +410,7 @@ async function uploadFilestoreFile(projectId, fileId) { const historyId = project.overleaf.history.id.toString() const blobStore = new BlobStore(historyId) await blobStore.putBlob(path, blob) - await ensureBlobExistsForFileAndUploadToAWS(projectId, fileId, hash) + await ensureBlobExistsForFile(projectId, fileId, hash) } } finally { await fs.promises.rm(path, { force: true }) @@ -426,11 +439,7 @@ async function fixHashMismatch(line) { await importRestoredFilestoreFile(projectId, fileId, historyId) return true } - return await ensureBlobExistsForFileAndUploadToAWS( - projectId, - fileId, - computedHash - ) + return await ensureBlobExistsForFile(projectId, fileId, computedHash) } /** @@ -444,30 +453,19 @@ async function hashAlreadyUpdatedInFileTree(projectId, fileId, hash) { return fileRef.hash === hash } -/** - * @param {string} projectId - * @param {string} hash - * @return {Promise} - */ -async function needsBackingUpToAWS(projectId, hash) { - if (GLOBAL_BLOBS.has(hash)) return false - return !(await _blobIsBackedUp(projectId, hash)) -} - /** * @param {string} projectId * @param {string} fileId * @param {string} hash * @return {Promise} */ -async function ensureBlobExistsForFileAndUploadToAWS(projectId, fileId, hash) { +async function ensureBlobExistsForFile(projectId, fileId, hash) { const { project } = await getProject(projectId) const historyId = project.overleaf.history.id.toString() const blobStore = new BlobStore(historyId) if ( (await hashAlreadyUpdatedInFileTree(projectId, fileId, hash)) && - (await blobStore.getBlob(hash)) && - !(await needsBackingUpToAWS(projectId, hash)) + (await blobStore.getBlob(hash)) ) { return false // already processed } @@ -488,7 +486,7 @@ async function ensureBlobExistsForFileAndUploadToAWS(projectId, fileId, hash) { ) if (writtenBlob.getHash() !== hash) { // Double check download, better safe than sorry. - throw new OError('blob corrupted', { writtenBlob }) + throw new OError('blob corrupted', { writtenBlob, hash }) } let blob = await blobStore.getBlob(hash) @@ -497,7 +495,6 @@ async function ensureBlobExistsForFileAndUploadToAWS(projectId, fileId, hash) { // HACK: Skip upload to GCS and finalize putBlob operation directly. await blobStore.backend.insertBlob(historyId, writtenBlob) } - await backupBlob(historyId, writtenBlob, path) } finally { await fs.promises.rm(path, { force: true }) } @@ -505,16 +502,6 @@ async function ensureBlobExistsForFileAndUploadToAWS(projectId, fileId, hash) { return true } -/** - * @param {string} line - * @return {Promise} - */ -async function fixDeletePermission(line) { - let { projectId, fileId, hash } = JSON.parse(line) - if (!hash) hash = await computeFilestoreFileHash(projectId, fileId) - return await ensureBlobExistsForFileAndUploadToAWS(projectId, fileId, hash) -} - /** * @param {string} line * @return {Promise} @@ -526,7 +513,7 @@ async function fixMissingHash(line) { } = await findFile(projectId, fileId) if (hash) { // processed, double check - return await ensureBlobExistsForFileAndUploadToAWS(projectId, fileId, hash) + return await ensureBlobExistsForFile(projectId, fileId, hash) } await uploadFilestoreFile(projectId, fileId) return true @@ -543,11 +530,6 @@ const CASES = { flag: FIX_HASH_MISMATCH, action: fixHashMismatch, }, - 'delete permission': { - match: 'storage.objects.delete', - flag: FIX_DELETE_PERMISSION, - action: fixDeletePermission, - }, 'missing file hash': { match: '"bad file hash"', flag: FIX_MISSING_HASH, diff --git a/services/history-v1/test/acceptance/js/storage/back_fill_file_hash.test.mjs b/services/history-v1/test/acceptance/js/storage/back_fill_file_hash.test.mjs index 62b0b1de25..b6cdd4b9bf 100644 --- a/services/history-v1/test/acceptance/js/storage/back_fill_file_hash.test.mjs +++ b/services/history-v1/test/acceptance/js/storage/back_fill_file_hash.test.mjs @@ -20,7 +20,7 @@ import { makeProjectKey, } from '../../../../storage/lib/blob_store/index.js' -import express from 'express' +import { mockFilestore } from './support/MockFilestore.mjs' chai.use(chaiExclude) const TIMEOUT = 20 * 1_000 @@ -28,59 +28,6 @@ const TIMEOUT = 20 * 1_000 const projectsCollection = db.collection('projects') const deletedProjectsCollection = db.collection('deletedProjects') -class MockFilestore { - constructor() { - this.host = process.env.FILESTORE_HOST || '127.0.0.1' - this.port = process.env.FILESTORE_PORT || 3009 - // create a server listening on this.host and this.port - this.files = {} - - this.app = express() - - this.app.get('/project/:projectId/file/:fileId', (req, res) => { - const { projectId, fileId } = req.params - const content = this.files[projectId]?.[fileId] - if (!content) return res.status(404).end() - res.status(200).end(content) - }) - } - - start() { - // reset stored files - this.files = {} - // start the server - if (this.serverPromise) { - return this.serverPromise - } else { - this.serverPromise = new Promise((resolve, reject) => { - this.server = this.app.listen(this.port, this.host, err => { - if (err) return reject(err) - resolve() - }) - }) - return this.serverPromise - } - } - - addFile(projectId, fileId, fileContent) { - if (!this.files[projectId]) { - this.files[projectId] = {} - } - this.files[projectId][fileId] = fileContent - } - - deleteObject(projectId, fileId) { - if (this.files[projectId]) { - delete this.files[projectId][fileId] - if (Object.keys(this.files[projectId]).length === 0) { - delete this.files[projectId] - } - } - } -} - -const mockFilestore = new MockFilestore() - /** * @param {ObjectId} objectId * @return {string} diff --git a/services/history-v1/test/acceptance/js/storage/back_fill_file_hash_fix_up.test.mjs b/services/history-v1/test/acceptance/js/storage/back_fill_file_hash_fix_up.test.mjs index ceafa24c3a..3aa00d685a 100644 --- a/services/history-v1/test/acceptance/js/storage/back_fill_file_hash_fix_up.test.mjs +++ b/services/history-v1/test/acceptance/js/storage/back_fill_file_hash_fix_up.test.mjs @@ -1,48 +1,24 @@ import fs from 'node:fs' import Crypto from 'node:crypto' -import Stream from 'node:stream' import { promisify } from 'node:util' import { Binary, ObjectId } from 'mongodb' import { Blob } from 'overleaf-editor-core' -import { backedUpBlobs, blobs, db } from '../../../../storage/lib/mongodb.js' +import { db } from '../../../../storage/lib/mongodb.js' import cleanup from './support/cleanup.js' import testProjects from '../api/support/test_projects.js' import { execFile } from 'node:child_process' import chai, { expect } from 'chai' import chaiExclude from 'chai-exclude' -import config from 'config' -import { WritableBuffer } from '@overleaf/stream-utils' -import { - backupPersistor, - projectBlobsBucket, -} from '../../../../storage/lib/backupPersistor.mjs' -import projectKey from '../../../../storage/lib/project_key.js' -import { - BlobStore, - makeProjectKey, -} from '../../../../storage/lib/blob_store/index.js' -import ObjectPersistor from '@overleaf/object-persistor' +import { BlobStore } from '../../../../storage/lib/blob_store/index.js' +import { mockFilestore } from './support/MockFilestore.mjs' chai.use(chaiExclude) const TIMEOUT = 20 * 1_000 -const { deksBucket } = config.get('backupStore') -const { tieringStorageClass } = config.get('backupPersistor') - const projectsCollection = db.collection('projects') const deletedProjectsCollection = db.collection('deletedProjects') -const FILESTORE_PERSISTOR = ObjectPersistor({ - backend: 'gcs', - gcs: { - endpoint: { - apiEndpoint: process.env.GCS_API_ENDPOINT, - projectId: process.env.GCS_PROJECT_ID, - }, - }, -}) - /** * @param {ObjectId} objectId * @return {string} @@ -70,17 +46,6 @@ function binaryForGitBlobHash(gitBlobHash) { return new Binary(Buffer.from(gitBlobHash, 'hex')) } -async function listS3Bucket(bucket, wantStorageClass) { - const client = backupPersistor._getClientForBucket(bucket) - const response = await client.listObjectsV2({ Bucket: bucket }).promise() - - for (const object of response.Contents || []) { - expect(object).to.have.property('StorageClass', wantStorageClass) - } - - return (response.Contents || []).map(item => item.Key || '') -} - function objectIdFromTime(timestamp) { return ObjectId.createFromTime(new Date(timestamp).getTime() / 1000) } @@ -97,7 +62,6 @@ describe('back_fill_file_hash_fix_up script', function () { const historyIdDeleted0 = projectIdDeleted0.toString() const fileIdWithDifferentHashFound = objectIdFromTime('2017-02-01T00:00:00Z') const fileIdInGoodState = objectIdFromTime('2017-02-01T00:01:00Z') - const fileIdBlobExistsInGCS0 = objectIdFromTime('2017-02-01T00:02:00Z') const fileIdWithDifferentHashNotFound0 = objectIdFromTime( '2017-02-01T00:03:00Z' ) @@ -112,9 +76,6 @@ describe('back_fill_file_hash_fix_up script', function () { const fileIdWithDifferentHashRestore = objectIdFromTime( '2017-02-01T00:08:00Z' ) - const fileIdBlobExistsInGCS1 = objectIdFromTime('2017-02-01T00:09:00Z') - const fileIdRestoreFromFilestore0 = objectIdFromTime('2017-02-01T00:10:00Z') - const fileIdRestoreFromFilestore1 = objectIdFromTime('2017-02-01T00:11:00Z') const fileIdMissing2 = objectIdFromTime('2017-02-01T00:12:00Z') const fileIdHashMissing0 = objectIdFromTime('2017-02-01T00:13:00Z') const fileIdHashMissing1 = objectIdFromTime('2017-02-01T00:14:00Z') @@ -125,31 +86,11 @@ describe('back_fill_file_hash_fix_up script', function () { ) const deleteProjectsRecordId0 = new ObjectId() const writtenBlobs = [ - { - projectId: projectId0, - historyId: historyId0, - fileId: fileIdBlobExistsInGCS0, - }, - { - projectId: projectId0, - historyId: historyId0, - fileId: fileIdBlobExistsInGCS1, - }, { projectId: projectId0, historyId: historyId0, fileId: fileIdWithDifferentHashNotFound0, }, - { - projectId: projectId0, - historyId: historyId0, - fileId: fileIdRestoreFromFilestore0, - }, - { - projectId: projectId0, - historyId: historyId0, - fileId: fileIdRestoreFromFilestore1, - }, { projectId: projectId0, historyId: historyId0, @@ -200,17 +141,6 @@ describe('back_fill_file_hash_fix_up script', function () { }, msg: 'failed to process file', }, - { - projectId: projectId0, - fileId: fileIdRestoreFromFilestore0, - err: { message: 'OError: hash mismatch' }, - hash: gitBlobHash(fileIdRestoreFromFilestore0), - entry: { - ctx: { historyId: historyId0.toString() }, - hash: hashDoesNotExistAsBlob, - }, - msg: 'failed to process file', - }, { projectId: projectIdDeleted0, fileId: fileIdWithDifferentHashNotFound1, @@ -236,33 +166,6 @@ describe('back_fill_file_hash_fix_up script', function () { err: { message: 'NotFoundError' }, msg: 'failed to process file', }, - { - projectId: projectId0, - fileId: fileIdBlobExistsInGCS0, - hash: gitBlobHash(fileIdBlobExistsInGCS0), - err: { message: 'storage.objects.delete' }, - msg: 'failed to process file', - }, - { - projectId: projectId0, - fileId: fileIdBlobExistsInGCSCorrupted, - hash: gitBlobHash(fileIdBlobExistsInGCSCorrupted), - err: { message: 'storage.objects.delete' }, - msg: 'failed to process file', - }, - { - projectId: projectId0, - fileId: fileIdBlobExistsInGCS1, - hash: gitBlobHash(fileIdBlobExistsInGCS1), - err: { message: 'storage.objects.delete' }, - msg: 'failed to process file', - }, - { - projectId: projectId0, - fileId: fileIdRestoreFromFilestore1, - err: { message: 'storage.objects.delete' }, - msg: 'failed to process file', - }, { projectId: projectIdDeleted0, fileId: fileIdMissing1, @@ -291,22 +194,23 @@ describe('back_fill_file_hash_fix_up script', function () { reason: 'bad file hash', msg: 'bad file-tree path', }, + { + projectId: projectId0, + _id: fileIdBlobExistsInGCSCorrupted, + reason: 'bad file hash', + msg: 'bad file-tree path', + }, ] if (PRINT_IDS_AND_HASHES_FOR_DEBUGGING) { const fileIds = { fileIdWithDifferentHashFound, fileIdInGoodState, - fileIdBlobExistsInGCS0, - fileIdBlobExistsInGCS1, fileIdWithDifferentHashNotFound0, fileIdWithDifferentHashNotFound1, - fileIdBlobExistsInGCSCorrupted, fileIdMissing0, fileIdMissing1, fileIdMissing2, fileIdWithDifferentHashRestore, - fileIdRestoreFromFilestore0, - fileIdRestoreFromFilestore1, fileIdHashMissing0, fileIdHashMissing1, } @@ -330,38 +234,25 @@ describe('back_fill_file_hash_fix_up script', function () { before(cleanup.everything) before('populate blobs/GCS', async function () { - await FILESTORE_PERSISTOR.sendStream( - USER_FILES_BUCKET_NAME, - `${projectId0}/${fileIdRestoreFromFilestore0}`, - Stream.Readable.from([fileIdRestoreFromFilestore0.toString()]) + await mockFilestore.start() + mockFilestore.addFile( + projectId0, + fileIdHashMissing0, + fileIdHashMissing0.toString() ) - await FILESTORE_PERSISTOR.sendStream( - USER_FILES_BUCKET_NAME, - `${projectId0}/${fileIdRestoreFromFilestore1}`, - Stream.Readable.from([fileIdRestoreFromFilestore1.toString()]) + mockFilestore.addFile( + projectId0, + fileIdHashMissing1, + fileIdHashMissing1.toString() ) - await FILESTORE_PERSISTOR.sendStream( - USER_FILES_BUCKET_NAME, - `${projectId0}/${fileIdHashMissing0}`, - Stream.Readable.from([fileIdHashMissing0.toString()]) - ) - await FILESTORE_PERSISTOR.sendStream( - USER_FILES_BUCKET_NAME, - `${projectId0}/${fileIdHashMissing1}`, - Stream.Readable.from([fileIdHashMissing1.toString()]) + mockFilestore.addFile( + projectId0, + fileIdBlobExistsInGCSCorrupted, + fileIdBlobExistsInGCSCorrupted.toString() ) await new BlobStore(historyId0.toString()).putString( fileIdHashMissing1.toString() // partially processed ) - await new BlobStore(historyId0.toString()).putString( - fileIdBlobExistsInGCS0.toString() - ) - await new BlobStore(historyId0.toString()).putString( - fileIdBlobExistsInGCS1.toString() - ) - await new BlobStore(historyId0.toString()).putString( - fileIdRestoreFromFilestore1.toString() - ) const path = '/tmp/test-blob-corrupted' try { await fs.promises.writeFile(path, contentCorruptedBlob) @@ -426,22 +317,10 @@ describe('back_fill_file_hash_fix_up script', function () { _id: fileIdWithDifferentHashNotFound0, hash: hashDoesNotExistAsBlob, }, - { - _id: fileIdRestoreFromFilestore0, - hash: hashDoesNotExistAsBlob, - }, - { - _id: fileIdRestoreFromFilestore1, - }, - { - _id: fileIdBlobExistsInGCS0, - hash: gitBlobHash(fileIdBlobExistsInGCS0), - }, { _id: fileIdBlobExistsInGCSCorrupted, hash: gitBlobHash(fileIdBlobExistsInGCSCorrupted), }, - { _id: fileIdBlobExistsInGCS1 }, ], folders: [], }, @@ -546,8 +425,8 @@ describe('back_fill_file_hash_fix_up script', function () { }) it('should print stats', function () { expect(stats).to.contain({ - processedLines: 16, - success: 11, + processedLines: 12, + success: 7, alreadyProcessed: 0, fileDeleted: 0, skipped: 0, @@ -558,9 +437,9 @@ describe('back_fill_file_hash_fix_up script', function () { it('should handle re-run on same logs', async function () { ;({ stats } = await runScriptWithLogs()) expect(stats).to.contain({ - processedLines: 16, + processedLines: 12, success: 0, - alreadyProcessed: 8, + alreadyProcessed: 4, fileDeleted: 3, skipped: 0, failed: 3, @@ -663,31 +542,11 @@ describe('back_fill_file_hash_fix_up script', function () { _id: fileIdWithDifferentHashNotFound0, hash: gitBlobHash(fileIdWithDifferentHashNotFound0), }, - // Updated hash - { - _id: fileIdRestoreFromFilestore0, - hash: gitBlobHash(fileIdRestoreFromFilestore0), - }, - // Added hash - { - _id: fileIdRestoreFromFilestore1, - hash: gitBlobHash(fileIdRestoreFromFilestore1), - }, - // No change, blob created - { - _id: fileIdBlobExistsInGCS0, - hash: gitBlobHash(fileIdBlobExistsInGCS0), - }, // No change, flagged { _id: fileIdBlobExistsInGCSCorrupted, hash: gitBlobHash(fileIdBlobExistsInGCSCorrupted), }, - // Added hash - { - _id: fileIdBlobExistsInGCS1, - hash: gitBlobHash(fileIdBlobExistsInGCS1), - }, ], folders: [], }, @@ -696,7 +555,7 @@ describe('back_fill_file_hash_fix_up script', function () { ], overleaf: { history: { id: historyId0 } }, // Incremented when removing file/updating hash - version: 8, + version: 5, }, ]) expect(await deletedProjectsCollection.find({}).toArray()).to.deep.equal([ @@ -745,62 +604,6 @@ describe('back_fill_file_hash_fix_up script', function () { (writtenBlobsByProject.get(projectId) || []).concat([fileId]) ) } - expect( - (await backedUpBlobs.find({}, { sort: { _id: 1 } }).toArray()).map( - entry => { - // blobs are pushed unordered into mongo. Sort the list for consistency. - entry.blobs.sort() - return entry - } - ) - ).to.deep.equal( - Array.from(writtenBlobsByProject.entries()).map( - ([projectId, fileIds]) => { - return { - _id: projectId, - blobs: fileIds - .map(fileId => binaryForGitBlobHash(gitBlobHash(fileId))) - .sort(), - } - } - ) - ) - }) - it('should have backed up all the files', async function () { - expect(tieringStorageClass).to.exist - const objects = await listS3Bucket(projectBlobsBucket, tieringStorageClass) - expect(objects.sort()).to.deep.equal( - writtenBlobs - .map(({ historyId, fileId, hash }) => - makeProjectKey(historyId, hash || gitBlobHash(fileId)) - ) - .sort() - ) - for (let { historyId, fileId } of writtenBlobs) { - const hash = gitBlobHash(fileId.toString()) - const s = await backupPersistor.getObjectStream( - projectBlobsBucket, - makeProjectKey(historyId, hash), - { autoGunzip: true } - ) - const buf = new WritableBuffer() - await Stream.promises.pipeline(s, buf) - expect(gitBlobHashBuffer(buf.getContents())).to.equal(hash) - const id = buf.getContents().toString('utf-8') - expect(id).to.equal(fileId.toString()) - // double check we are not comparing 'undefined' or '[object Object]' above - expect(id).to.match(/^[a-f0-9]{24}$/) - } - const deks = await listS3Bucket(deksBucket, 'STANDARD') - expect(deks.sort()).to.deep.equal( - Array.from( - new Set( - writtenBlobs.map( - ({ historyId }) => projectKey.format(historyId) + '/dek' - ) - ) - ).sort() - ) }) it('should have written the back filled files to history v1', async function () { for (const { historyId, fileId } of writtenBlobs) { diff --git a/services/history-v1/test/acceptance/js/storage/support/MockFilestore.mjs b/services/history-v1/test/acceptance/js/storage/support/MockFilestore.mjs new file mode 100644 index 0000000000..55d0923c34 --- /dev/null +++ b/services/history-v1/test/acceptance/js/storage/support/MockFilestore.mjs @@ -0,0 +1,54 @@ +import express from 'express' + +class MockFilestore { + constructor() { + this.host = process.env.FILESTORE_HOST || '127.0.0.1' + this.port = process.env.FILESTORE_PORT || 3009 + // create a server listening on this.host and this.port + this.files = {} + + this.app = express() + + this.app.get('/project/:projectId/file/:fileId', (req, res) => { + const { projectId, fileId } = req.params + const content = this.files[projectId]?.[fileId] + if (!content) return res.status(404).end() + res.status(200).end(content) + }) + } + + start() { + // reset stored files + this.files = {} + // start the server + if (this.serverPromise) { + return this.serverPromise + } else { + this.serverPromise = new Promise((resolve, reject) => { + this.server = this.app.listen(this.port, this.host, err => { + if (err) return reject(err) + resolve() + }) + }) + return this.serverPromise + } + } + + addFile(projectId, fileId, fileContent) { + if (!this.files[projectId]) { + this.files[projectId] = {} + } + this.files[projectId][fileId] = fileContent + } + + deleteObject(projectId, fileId) { + if (this.files[projectId]) { + delete this.files[projectId][fileId] + if (Object.keys(this.files[projectId]).length === 0) { + delete this.files[projectId] + } + } + } +} + +export const mockFilestore = new MockFilestore() From 81f0807fc6ecad0448f984eb7f9a836110d74c51 Mon Sep 17 00:00:00 2001 From: Jakob Ackermann Date: Mon, 21 Jul 2025 16:02:30 +0200 Subject: [PATCH 25/43] [web] prepare filestore migration for Server Pro/CE (#27230) * [web] prepare filestore migration for Server Pro/CE * [history-v1] remove unused USER_FILES_BUCKET_NAME env var from script * [server-ce] tests: write default docker-compose.override.yml on startup * [server-ce] tests: extend access logging of host-admin for response * [server-ce] tests: test text and binary file upload * [server-ce] tests: add tests for filestore migration * [web] simplify feature gate for filestore/project-history-blobs logic Co-authored-by: Brian Gough * [server-ce] test: fix flaky test helper --------- Co-authored-by: Brian Gough GitOrigin-RevId: f89bdab2749e2b7a49d609e2eac6bf621c727966 --- server-ce/test/Makefile | 2 + server-ce/test/editor.spec.ts | 20 +--- server-ce/test/filestore-migration.spec.ts | 104 ++++++++++++++++++ server-ce/test/helpers/config.ts | 1 + server-ce/test/helpers/hostAdminClient.ts | 6 + server-ce/test/helpers/project.ts | 40 +++++++ server-ce/test/host-admin.js | 41 +++++-- .../storage/scripts/back_fill_file_hash.mjs | 4 - services/web/app.mjs | 10 +- .../src/Features/History/HistoryURLHelper.js | 2 +- .../web/app/src/infrastructure/Features.js | 15 ++- services/web/config/settings.defaults.js | 3 + .../acceptance/src/ProjectStructureTests.mjs | 38 +++---- .../DocumentUpdaterHandlerTests.js | 5 +- .../src/References/ReferencesHandler.test.mjs | 2 +- .../src/SplitTests/SplitTestHandlerTests.js | 1 + .../TpdsUpdateSenderTests.js | 2 +- .../unit/src/infrastructure/FeaturesTests.js | 1 + 18 files changed, 227 insertions(+), 70 deletions(-) create mode 100644 server-ce/test/filestore-migration.spec.ts diff --git a/server-ce/test/Makefile b/server-ce/test/Makefile index 6c56b7e8fe..fb7c980293 100644 --- a/server-ce/test/Makefile +++ b/server-ce/test/Makefile @@ -21,9 +21,11 @@ test-e2e-native: test-e2e: docker compose build host-admin + docker compose up -d host-admin docker compose up --no-log-prefix --exit-code-from=e2e e2e test-e2e-open: + docker compose up -d host-admin docker compose up --no-log-prefix --exit-code-from=e2e-open e2e-open clean: diff --git a/server-ce/test/editor.spec.ts b/server-ce/test/editor.spec.ts index d0060518de..3e57b94f8f 100644 --- a/server-ce/test/editor.spec.ts +++ b/server-ce/test/editor.spec.ts @@ -2,6 +2,7 @@ import { createNewFile, createProject, openProjectById, + testNewFileUpload, } from './helpers/project' import { isExcludedBySharding, startWith } from './helpers/config' import { ensureUserExists, login } from './helpers/login' @@ -119,24 +120,7 @@ describe('editor', () => { cy.get('button').contains('New file').click({ force: true }) }) - it('can upload file', () => { - const name = `${uuid()}.txt` - const content = `Test File Content ${name}` - cy.get('button').contains('Upload').click({ force: true }) - cy.get('input[type=file]') - .first() - .selectFile( - { - contents: Cypress.Buffer.from(content), - fileName: name, - lastModified: Date.now(), - }, - { force: true } - ) - // force: The file-tree pane is too narrow to display the full name. - cy.findByTestId('file-tree').findByText(name).click({ force: true }) - cy.findByText(content) - }) + testNewFileUpload() it('should not display import from URL', () => { cy.findByText('From external URL').should('not.exist') diff --git a/server-ce/test/filestore-migration.spec.ts b/server-ce/test/filestore-migration.spec.ts new file mode 100644 index 0000000000..25875ad374 --- /dev/null +++ b/server-ce/test/filestore-migration.spec.ts @@ -0,0 +1,104 @@ +import { ensureUserExists, login } from './helpers/login' +import { + createProject, + openProjectById, + prepareFileUploadTest, +} from './helpers/project' +import { isExcludedBySharding, startWith } from './helpers/config' +import { prepareWaitForNextCompileSlot } from './helpers/compile' +import { beforeWithReRunOnTestRetry } from './helpers/beforeWithReRunOnTestRetry' +import { v4 as uuid } from 'uuid' +import { purgeFilestoreData, runScript } from './helpers/hostAdminClient' + +describe('filestore migration', function () { + if (isExcludedBySharding('CE_CUSTOM_3')) return + startWith({ withDataDir: true, resetData: true, vars: {} }) + ensureUserExists({ email: 'user@example.com' }) + + let projectName: string + let projectId: string + let waitForCompileRateLimitCoolOff: (fn: () => void) => void + const previousBinaryFiles: (() => void)[] = [] + beforeWithReRunOnTestRetry(function () { + projectName = `project-${uuid()}` + login('user@example.com') + createProject(projectName, { type: 'Example project' }).then( + id => (projectId = id) + ) + let queueReset + ;({ waitForCompileRateLimitCoolOff, queueReset } = + prepareWaitForNextCompileSlot()) + queueReset() + previousBinaryFiles.push(prepareFileUploadTest(true)) + }) + + beforeEach(() => { + login('user@example.com') + waitForCompileRateLimitCoolOff(() => { + openProjectById(projectId) + }) + }) + + function checkFilesAreAccessible() { + it('can upload new binary file and read previous uploads', function () { + previousBinaryFiles.push(prepareFileUploadTest(true)) + for (const check of previousBinaryFiles) { + check() + } + }) + + it('renders frog jpg', () => { + cy.findByTestId('file-tree').findByText('frog.jpg').click() + cy.get('[alt="frog.jpg"]') + .should('be.visible') + .and('have.prop', 'naturalWidth') + .should('be.greaterThan', 0) + }) + } + + describe('OVERLEAF_FILESTORE_MIGRATION_LEVEL not set', function () { + startWith({ withDataDir: true, vars: {} }) + checkFilesAreAccessible() + }) + + describe('OVERLEAF_FILESTORE_MIGRATION_LEVEL=0', function () { + startWith({ + withDataDir: true, + vars: { OVERLEAF_FILESTORE_MIGRATION_LEVEL: '0' }, + }) + checkFilesAreAccessible() + + describe('OVERLEAF_FILESTORE_MIGRATION_LEVEL=1', function () { + startWith({ + withDataDir: true, + vars: { OVERLEAF_FILESTORE_MIGRATION_LEVEL: '1' }, + }) + checkFilesAreAccessible() + + describe('OVERLEAF_FILESTORE_MIGRATION_LEVEL=2', function () { + startWith({ + withDataDir: true, + vars: { OVERLEAF_FILESTORE_MIGRATION_LEVEL: '1' }, + }) + before(async function () { + await runScript({ + cwd: 'services/history-v1', + script: 'storage/scripts/back_fill_file_hash.mjs', + }) + }) + startWith({ + withDataDir: true, + vars: { OVERLEAF_FILESTORE_MIGRATION_LEVEL: '2' }, + }) + checkFilesAreAccessible() + + describe('purge filestore data', function () { + before(async function () { + await purgeFilestoreData() + }) + checkFilesAreAccessible() + }) + }) + }) + }) +}) diff --git a/server-ce/test/helpers/config.ts b/server-ce/test/helpers/config.ts index 030e70ceb5..78e81be1f7 100644 --- a/server-ce/test/helpers/config.ts +++ b/server-ce/test/helpers/config.ts @@ -9,6 +9,7 @@ export function isExcludedBySharding( | 'CE_DEFAULT' | 'CE_CUSTOM_1' | 'CE_CUSTOM_2' + | 'CE_CUSTOM_3' | 'PRO_DEFAULT_1' | 'PRO_DEFAULT_2' | 'PRO_CUSTOM_1' diff --git a/server-ce/test/helpers/hostAdminClient.ts b/server-ce/test/helpers/hostAdminClient.ts index cafeaa2db6..dadfe2b059 100644 --- a/server-ce/test/helpers/hostAdminClient.ts +++ b/server-ce/test/helpers/hostAdminClient.ts @@ -85,6 +85,12 @@ export async function getRedisKeys() { return stdout.split('\n') } +export async function purgeFilestoreData() { + await fetchJSON(`${hostAdminURL}/data/user_files`, { + method: 'DELETE', + }) +} + async function sleep(ms: number) { return new Promise(resolve => { setTimeout(resolve, ms) diff --git a/server-ce/test/helpers/project.ts b/server-ce/test/helpers/project.ts index abcce3f9b2..4b3197afed 100644 --- a/server-ce/test/helpers/project.ts +++ b/server-ce/test/helpers/project.ts @@ -216,3 +216,43 @@ export function createNewFile() { return fileName } + +export function prepareFileUploadTest(binary = false) { + const name = `${uuid()}.txt` + const content = `Test File Content ${name}${binary ? ' \x00' : ''}` + cy.get('button').contains('Upload').click({ force: true }) + cy.get('input[type=file]') + .first() + .selectFile( + { + contents: Cypress.Buffer.from(content), + fileName: name, + lastModified: Date.now(), + }, + { force: true } + ) + + // wait for the upload to finish + cy.findByRole('treeitem', { name }) + + return function check() { + cy.findByRole('treeitem', { name }).click() + if (binary) { + cy.findByText(content).should('not.have.class', 'cm-line') + } else { + cy.findByText(content).should('have.class', 'cm-line') + } + } +} + +export function testNewFileUpload() { + it('can upload text file', () => { + const check = prepareFileUploadTest(false) + check() + }) + + it('can upload binary file', () => { + const check = prepareFileUploadTest(true) + check() + }) +} diff --git a/server-ce/test/host-admin.js b/server-ce/test/host-admin.js index f73209d58f..b3dcd72b1f 100644 --- a/server-ce/test/host-admin.js +++ b/server-ce/test/host-admin.js @@ -29,6 +29,17 @@ const IMAGES = { PRO: process.env.IMAGE_TAG_PRO.replace(/:.+/, ''), } +function defaultDockerComposeOverride() { + return { + services: { + sharelatex: { + environment: {}, + }, + 'git-bridge': {}, + }, + } +} + let previousConfig = '' function readDockerComposeOverride() { @@ -38,14 +49,7 @@ function readDockerComposeOverride() { if (error.code !== 'ENOENT') { throw error } - return { - services: { - sharelatex: { - environment: {}, - }, - 'git-bridge': {}, - }, - } + return defaultDockerComposeOverride } } @@ -77,12 +81,21 @@ app.use(bodyParser.json()) app.use((req, res, next) => { // Basic access logs console.log(req.method, req.url, req.body) + const json = res.json + res.json = body => { + console.log(req.method, req.url, req.body, '->', body) + json.call(res, body) + } + next() +}) +app.use((req, res, next) => { // Add CORS headers const accessControlAllowOrigin = process.env.ACCESS_CONTROL_ALLOW_ORIGIN || 'http://sharelatex' res.setHeader('Access-Control-Allow-Origin', accessControlAllowOrigin) res.setHeader('Access-Control-Allow-Headers', 'Content-Type') res.setHeader('Access-Control-Max-Age', '3600') + res.setHeader('Access-Control-Allow-Methods', 'DELETE, GET, HEAD, POST, PUT') next() }) @@ -133,6 +146,7 @@ const allowedVars = Joi.object( 'V1_HISTORY_URL', 'SANDBOXED_COMPILES', 'ALL_TEX_LIVE_DOCKER_IMAGE_NAMES', + 'OVERLEAF_FILESTORE_MIGRATION_LEVEL', 'OVERLEAF_TEMPLATES_USER_ID', 'OVERLEAF_NEW_PROJECT_TEMPLATE_LINKS', 'OVERLEAF_ALLOW_PUBLIC_ACCESS', @@ -319,8 +333,19 @@ app.get('/redis/keys', (req, res) => { ) }) +app.delete('/data/user_files', (req, res) => { + runDockerCompose( + 'exec', + ['sharelatex', 'rm', '-rf', '/var/lib/overleaf/data/user_files'], + (error, stdout, stderr) => { + res.json({ error, stdout, stderr }) + } + ) +}) + app.use(handleValidationErrors()) purgeDataDir() +writeDockerComposeOverride(defaultDockerComposeOverride()) app.listen(80) diff --git a/services/history-v1/storage/scripts/back_fill_file_hash.mjs b/services/history-v1/storage/scripts/back_fill_file_hash.mjs index 0ccadaf5a9..2e12328e5c 100644 --- a/services/history-v1/storage/scripts/back_fill_file_hash.mjs +++ b/services/history-v1/storage/scripts/back_fill_file_hash.mjs @@ -150,10 +150,6 @@ const CONCURRENT_BATCHES = parseInt(process.env.CONCURRENT_BATCHES || '2', 10) const RETRIES = parseInt(process.env.RETRIES || '10', 10) const RETRY_DELAY_MS = parseInt(process.env.RETRY_DELAY_MS || '100', 10) -const USER_FILES_BUCKET_NAME = process.env.USER_FILES_BUCKET_NAME || '' -if (!USER_FILES_BUCKET_NAME) { - throw new Error('env var USER_FILES_BUCKET_NAME is missing') -} const RETRY_FILESTORE_404 = process.env.RETRY_FILESTORE_404 === 'true' const BUFFER_DIR = fs.mkdtempSync( process.env.BUFFER_DIR_PREFIX || '/tmp/back_fill_file_hash-' diff --git a/services/web/app.mjs b/services/web/app.mjs index b7c723da3d..3f54cc36a8 100644 --- a/services/web/app.mjs +++ b/services/web/app.mjs @@ -56,14 +56,8 @@ if (Settings.catchErrors) { // Create ./data/dumpFolder if needed FileWriter.ensureDumpFolderExists() -if ( - !Features.hasFeature('project-history-blobs') && - !Features.hasFeature('filestore') -) { - throw new Error( - 'invalid config: must enable either project-history-blobs (Settings.enableProjectHistoryBlobs=true) or enable filestore (Settings.disableFilestore=false)' - ) -} +// Validate combination of feature flags. +Features.validateSettings() // handle SIGTERM for graceful shutdown in kubernetes process.on('SIGTERM', function (signal) { diff --git a/services/web/app/src/Features/History/HistoryURLHelper.js b/services/web/app/src/Features/History/HistoryURLHelper.js index 8b8d8cbdd7..acb43ced68 100644 --- a/services/web/app/src/Features/History/HistoryURLHelper.js +++ b/services/web/app/src/Features/History/HistoryURLHelper.js @@ -8,7 +8,7 @@ function projectHistoryURLWithFilestoreFallback( ) { const filestoreURL = `${Settings.apis.filestore.url}/project/${projectId}/file/${fileRef._id}?from=${origin}` // TODO: When this file is converted to ES modules we will be able to use Features.hasFeature('project-history-blobs'). Currently we can't stub the feature return value in tests. - if (fileRef.hash && Settings.enableProjectHistoryBlobs) { + if (fileRef.hash && Settings.filestoreMigrationLevel >= 1) { return { url: `${Settings.apis.project_history.url}/project/${historyId}/blob/${fileRef.hash}`, fallbackURL: filestoreURL, diff --git a/services/web/app/src/infrastructure/Features.js b/services/web/app/src/infrastructure/Features.js index aaf51103b9..6147e70e0f 100644 --- a/services/web/app/src/infrastructure/Features.js +++ b/services/web/app/src/infrastructure/Features.js @@ -19,8 +19,7 @@ const trackChangesModuleAvailable = * @property {boolean | undefined} enableGithubSync * @property {boolean | undefined} enableGitBridge * @property {boolean | undefined} enableHomepage - * @property {boolean | undefined} enableProjectHistoryBlobs - * @property {boolean | undefined} disableFilestore + * @property {number} filestoreMigrationLevel * @property {boolean | undefined} enableSaml * @property {boolean | undefined} ldap * @property {boolean | undefined} oauth @@ -30,6 +29,14 @@ const trackChangesModuleAvailable = */ const Features = { + validateSettings() { + if (![0, 1, 2].includes(Settings.filestoreMigrationLevel)) { + throw new Error( + `invalid OVERLEAF_FILESTORE_MIGRATION_LEVEL=${Settings.filestoreMigrationLevel}, expected 0, 1 or 2` + ) + } + }, + /** * @returns {boolean} */ @@ -89,9 +96,9 @@ const Features = { Settings.enabledLinkedFileTypes.includes('url') ) case 'project-history-blobs': - return Boolean(Settings.enableProjectHistoryBlobs) + return Settings.filestoreMigrationLevel > 0 case 'filestore': - return Boolean(Settings.disableFilestore) === false + return Settings.filestoreMigrationLevel < 2 case 'support': return supportModuleAvailable case 'symbol-palette': diff --git a/services/web/config/settings.defaults.js b/services/web/config/settings.defaults.js index bd0730d5d0..4df63ebd7c 100644 --- a/services/web/config/settings.defaults.js +++ b/services/web/config/settings.defaults.js @@ -440,6 +440,9 @@ module.exports = { ',' ), + filestoreMigrationLevel: + parseInt(process.env.OVERLEAF_FILESTORE_MIGRATION_LEVEL, 10) || 0, + // i18n // ------ // diff --git a/services/web/modules/history-v1/test/acceptance/src/ProjectStructureTests.mjs b/services/web/modules/history-v1/test/acceptance/src/ProjectStructureTests.mjs index 6d7037ac15..9bd87674ce 100644 --- a/services/web/modules/history-v1/test/acceptance/src/ProjectStructureTests.mjs +++ b/services/web/modules/history-v1/test/acceptance/src/ProjectStructureTests.mjs @@ -8,7 +8,6 @@ import _ from 'lodash' import ProjectGetter from '../../../../../app/src/Features/Project/ProjectGetter.js' import User from '../../../../../test/acceptance/src/helpers/User.mjs' import MockDocUpdaterApiClass from '../../../../../test/acceptance/src/mocks/MockDocUpdaterApi.mjs' -import Features from '../../../../../app/src/infrastructure/Features.js' const { ObjectId } = mongodb @@ -188,32 +187,25 @@ describe('ProjectStructureChanges', function () { const cases = [ { label: 'with filestore disabled and project-history-blobs enabled', - disableFilestore: true, - enableProjectHistoryBlobs: true, + filestoreMigrationLevel: 2, }, { label: 'with filestore enabled and project-history-blobs enabled', - disableFilestore: false, - enableProjectHistoryBlobs: true, + filestoreMigrationLevel: 1, }, { label: 'with filestore enabled and project-history-blobs disabled', - disableFilestore: false, - enableProjectHistoryBlobs: false, + filestoreMigrationLevel: 0, }, ] - for (const { label, disableFilestore, enableProjectHistoryBlobs } of cases) { + for (const { label, filestoreMigrationLevel } of cases) { describe(label, function () { - const previousDisableFilestore = Settings.disableFilestore - const previousEnableProjectHistoryBlobs = - Settings.enableProjectHistoryBlobs + const previousFilestoreMigrationLevel = Settings.filestoreMigrationLevel beforeEach(function () { - Settings.disableFilestore = disableFilestore - Settings.enableProjectHistoryBlobs = enableProjectHistoryBlobs + Settings.filestoreMigrationLevel = filestoreMigrationLevel }) afterEach(function () { - Settings.disableFilestore = previousDisableFilestore - Settings.enableProjectHistoryBlobs = previousEnableProjectHistoryBlobs + Settings.filestoreMigrationLevel = previousFilestoreMigrationLevel }) describe('creating a project from the example template', function () { @@ -244,7 +236,7 @@ describe('ProjectStructureChanges', function () { expect(updates[2].type).to.equal('add-file') expect(updates[2].userId).to.equal(owner._id) expect(updates[2].pathname).to.equal('/frog.jpg') - if (disableFilestore) { + if (filestoreMigrationLevel === 2) { expect(updates[2].url).to.not.exist expect(updates[2].createdBlob).to.be.true } else { @@ -301,10 +293,10 @@ describe('ProjectStructureChanges', function () { expect(updates[2].type).to.equal('add-file') expect(updates[2].userId).to.equal(owner._id) expect(updates[2].pathname).to.equal('/frog.jpg') - if (disableFilestore) { + if (filestoreMigrationLevel === 2) { expect(updates[2].url).to.not.exist expect(updates[2].createdBlob).to.be.true - } else if (Features.hasFeature('project-history-blobs')) { + } else if (filestoreMigrationLevel === 1) { expect(updates[2].url).to.be.null } else { expect(updates[2].url).to.be.a('string') @@ -378,7 +370,7 @@ describe('ProjectStructureChanges', function () { expect(updates[1].type).to.equal('add-file') expect(updates[1].userId).to.equal(owner._id) expect(updates[1].pathname).to.equal('/1pixel.png') - if (disableFilestore) { + if (filestoreMigrationLevel === 2) { expect(updates[1].url).to.not.exist expect(updates[1].createdBlob).to.be.true } else { @@ -478,7 +470,7 @@ describe('ProjectStructureChanges', function () { expect(update.type).to.equal('add-file') expect(update.userId).to.equal(owner._id) expect(update.pathname).to.equal('/1pixel.png') - if (disableFilestore) { + if (filestoreMigrationLevel === 2) { expect(update.url).to.not.exist expect(update.createdBlob).to.be.true } else { @@ -516,7 +508,7 @@ describe('ProjectStructureChanges', function () { expect(updates[1].type).to.equal('add-file') expect(updates[1].userId).to.equal(owner._id) expect(updates[1].pathname).to.equal('/1pixel.png') - if (disableFilestore) { + if (filestoreMigrationLevel === 2) { expect(updates[1].url).to.not.exist expect(updates[1].createdBlob).to.be.true } else { @@ -1005,7 +997,7 @@ describe('ProjectStructureChanges', function () { expect(update.type).to.equal('add-file') expect(update.userId).to.equal(owner._id) expect(update.pathname).to.equal('/1pixel.png') - if (disableFilestore) { + if (filestoreMigrationLevel === 2) { expect(update.url).to.not.exist expect(update.createdBlob).to.be.true } else { @@ -1068,7 +1060,7 @@ describe('ProjectStructureChanges', function () { expect(updates[1].type).to.equal('add-file') expect(updates[1].userId).to.equal(owner._id) expect(updates[1].pathname).to.equal('/1pixel.png') - if (disableFilestore) { + if (filestoreMigrationLevel === 2) { expect(updates[1].url).to.not.exist expect(updates[1].createdBlob).to.be.true } else { diff --git a/services/web/test/unit/src/DocumentUpdater/DocumentUpdaterHandlerTests.js b/services/web/test/unit/src/DocumentUpdater/DocumentUpdaterHandlerTests.js index fba5dc87d4..fdb3945075 100644 --- a/services/web/test/unit/src/DocumentUpdater/DocumentUpdaterHandlerTests.js +++ b/services/web/test/unit/src/DocumentUpdater/DocumentUpdaterHandlerTests.js @@ -29,6 +29,7 @@ describe('DocumentUpdaterHandler', function () { url: 'http://project_history.example.com', }, }, + filestoreMigrationLevel: 0, moduleImportSequence: [], } this.source = 'dropbox' @@ -1491,7 +1492,7 @@ describe('DocumentUpdaterHandler', function () { describe('with filestore disabled', function () { beforeEach(function () { - this.settings.disableFilestore = true + this.settings.filestoreMigrationLevel = 2 }) it('should add files without URL and with createdBlob', async function () { this.fileId = new ObjectId() @@ -1700,7 +1701,7 @@ describe('DocumentUpdaterHandler', function () { }) describe('with filestore disabled', function () { beforeEach(function () { - this.settings.disableFilestore = true + this.settings.filestoreMigrationLevel = 2 }) it('should add files without URL', async function () { const fileId1 = new ObjectId() diff --git a/services/web/test/unit/src/References/ReferencesHandler.test.mjs b/services/web/test/unit/src/References/ReferencesHandler.test.mjs index 1b5d2c1ba0..971d815e2e 100644 --- a/services/web/test/unit/src/References/ReferencesHandler.test.mjs +++ b/services/web/test/unit/src/References/ReferencesHandler.test.mjs @@ -50,7 +50,7 @@ describe('ReferencesHandler', function () { filestore: { url: 'http://some.url/filestore' }, project_history: { url: 'http://project-history.local' }, }, - enableProjectHistoryBlobs: true, + filestoreMigrationLevel: 2, }), })) diff --git a/services/web/test/unit/src/SplitTests/SplitTestHandlerTests.js b/services/web/test/unit/src/SplitTests/SplitTestHandlerTests.js index a682f0c954..4970492eee 100644 --- a/services/web/test/unit/src/SplitTests/SplitTestHandlerTests.js +++ b/services/web/test/unit/src/SplitTests/SplitTestHandlerTests.js @@ -39,6 +39,7 @@ describe('SplitTestHandler', function () { } this.SplitTestCache.get.resolves(this.cachedSplitTests) this.Settings = { + filestoreMigrationLevel: 0, moduleImportSequence: [], overleaf: {}, devToolbar: { diff --git a/services/web/test/unit/src/ThirdPartyDataStore/TpdsUpdateSenderTests.js b/services/web/test/unit/src/ThirdPartyDataStore/TpdsUpdateSenderTests.js index f7f733388e..a0f0276f4a 100644 --- a/services/web/test/unit/src/ThirdPartyDataStore/TpdsUpdateSenderTests.js +++ b/services/web/test/unit/src/ThirdPartyDataStore/TpdsUpdateSenderTests.js @@ -57,7 +57,7 @@ describe('TpdsUpdateSender', function () { url: projectHistoryUrl, }, }, - enableProjectHistoryBlobs: true, + filestoreMigrationLevel: true, } const getUsers = sinon.stub() getUsers diff --git a/services/web/test/unit/src/infrastructure/FeaturesTests.js b/services/web/test/unit/src/infrastructure/FeaturesTests.js index dcdf1e4e62..b6d0090b3c 100644 --- a/services/web/test/unit/src/infrastructure/FeaturesTests.js +++ b/services/web/test/unit/src/infrastructure/FeaturesTests.js @@ -7,6 +7,7 @@ describe('Features', function () { this.Features = SandboxedModule.require(modulePath, { requires: { '@overleaf/settings': (this.settings = { + filestoreMigrationLevel: 0, moduleImportSequence: [], enabledLinkedFileTypes: [], }), From 082121d3dacd5e8da9f9c972cc7cce32f335d3c1 Mon Sep 17 00:00:00 2001 From: Jakob Ackermann Date: Mon, 21 Jul 2025 16:02:43 +0200 Subject: [PATCH 26/43] [web] reject upload requests without a file path (#27156) * [web] reject upload requests without a file path * [web] update copy on error message and link to contact form Co-authored-by: Kamal Arkinstall * [web] update copy: move dot to the end --------- Co-authored-by: Kamal Arkinstall GitOrigin-RevId: ba1ee81a91b046540caeb2f3f3da0e305611b35f --- .../Uploads/ProjectUploadController.mjs | 12 ++++-- .../web/frontend/extracted-translations.json | 1 + .../file-tree-create/error-message.tsx | 20 ++++++++- services/web/locales/en.json | 1 + .../acceptance/src/ProjectStructureTests.mjs | 41 +++++++++++++++++++ 5 files changed, 71 insertions(+), 4 deletions(-) diff --git a/services/web/app/src/Features/Uploads/ProjectUploadController.mjs b/services/web/app/src/Features/Uploads/ProjectUploadController.mjs index a3bc434ed7..84b8738af3 100644 --- a/services/web/app/src/Features/Uploads/ProjectUploadController.mjs +++ b/services/web/app/src/Features/Uploads/ProjectUploadController.mjs @@ -66,7 +66,7 @@ function uploadProject(req, res, next) { async function uploadFile(req, res, next) { const timer = new metrics.Timer('file-upload') const name = req.body.name - const path = req.file?.path + const { path } = req.file const projectId = req.params.Project_id const userId = SessionManager.getLoggedInUserId(req.session) let { folder_id: folderId } = req.query @@ -162,8 +162,14 @@ function multerMiddleware(req, res, next) { .status(422) .json({ success: false, error: req.i18n.translate('file_too_large') }) } - - return next(err) + if (err) return next(err) + if (!req.file?.path) { + logger.info({ req }, 'missing req.file.path on upload') + return res + .status(400) + .json({ success: false, error: 'invalid_upload_request' }) + } + next() }) } diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index 2775c04601..639c9fcdfc 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -867,6 +867,7 @@ "invalid_password_too_similar": "", "invalid_regular_expression": "", "invalid_request": "", + "invalid_upload_request": "", "invite": "", "invite_expired": "", "invite_more_collabs": "", diff --git a/services/web/frontend/js/features/file-tree/components/file-tree-create/error-message.tsx b/services/web/frontend/js/features/file-tree/components/file-tree-create/error-message.tsx index 02cc083928..244ef1a76b 100644 --- a/services/web/frontend/js/features/file-tree/components/file-tree-create/error-message.tsx +++ b/services/web/frontend/js/features/file-tree/components/file-tree-create/error-message.tsx @@ -1,4 +1,4 @@ -import { useTranslation } from 'react-i18next' +import { useTranslation, Trans } from 'react-i18next' import { FetchError } from '../../../../infrastructure/fetch-json' import RedirectToLogin from './redirect-to-login' import { @@ -7,6 +7,7 @@ import { InvalidFilenameError, } from '../../errors' import DangerMessage from './danger-message' +import getMeta from '@/utils/meta' // TODO: Update the error type when we properly type FileTreeActionableContext export default function ErrorMessage({ @@ -15,6 +16,7 @@ export default function ErrorMessage({ error: string | Record }) { const { t } = useTranslation() + const { isOverleaf } = getMeta('ol-ExposedSettings') const fileNameLimit = 150 // the error is a string @@ -46,6 +48,22 @@ export default function ErrorMessage({ ) + case 'invalid_upload_request': + if (!isOverleaf) { + return ( + {t('generic_something_went_wrong')} + ) + } + return ( + + ]} + /> + + ) + case 'duplicate_file_name': return ( diff --git a/services/web/locales/en.json b/services/web/locales/en.json index ddaeb8e79f..4df2e389cd 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -1111,6 +1111,7 @@ "invalid_password_too_similar": "Password is too similar to parts of email address", "invalid_regular_expression": "Invalid regular expression", "invalid_request": "Invalid Request. Please correct the data and try again.", + "invalid_upload_request": "The upload failed. If the problem persists, <0>let us know.", "invalid_zip_file": "Invalid zip file", "invite": "Invite", "invite_expired": "The invite may have expired", diff --git a/services/web/test/acceptance/src/ProjectStructureTests.mjs b/services/web/test/acceptance/src/ProjectStructureTests.mjs index a1f48a8448..d87daba5f5 100644 --- a/services/web/test/acceptance/src/ProjectStructureTests.mjs +++ b/services/web/test/acceptance/src/ProjectStructureTests.mjs @@ -138,6 +138,47 @@ describe('ProjectStructureChanges', function () { }) }) + describe('when sending an upload request without a file', function () { + describe('project', function () { + it('should reject the request with status 400', async function () { + const { response, body } = await owner.doRequest('POST', { + uri: 'project/new/upload', + json: true, + formData: { + name: 'foo', + }, + }) + + expect(response.statusCode).to.equal(400) + expect(body).to.deep.equal({ + success: false, + error: 'invalid_upload_request', + }) + }) + }) + + describe('file', function () { + it('should reject the request with status 400', async function () { + const projectId = await owner.createProject('foo', { + template: 'blank', + }) + const { response, body } = await owner.doRequest('POST', { + uri: `project/${projectId}/upload`, + json: true, + formData: { + name: 'foo.txt', + }, + }) + + expect(response.statusCode).to.equal(400) + expect(body).to.deep.equal({ + success: false, + error: 'invalid_upload_request', + }) + }) + }) + }) + describe('uploading an empty zipfile', function () { let res From b1880ba64d75251e6405ddb1326a9849d6cbf958 Mon Sep 17 00:00:00 2001 From: Jakob Ackermann Date: Mon, 21 Jul 2025 16:02:51 +0200 Subject: [PATCH 27/43] [monorepo] upgrade tough-cookie in request to latest version (#27249) GitOrigin-RevId: 9096e05d2c337c3d3a9b4ca6efec8fd40c51a622 --- package-lock.json | 31 +++++++++++++++++++++++++------ package.json | 3 +++ 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2b3a5868a2..d9d8285618 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35581,6 +35581,7 @@ "resolved": "https://registry.npmjs.org/request/-/request-2.88.2.tgz", "integrity": "sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw==", "deprecated": "request has been deprecated, see https://github.com/request/request/issues/3142", + "license": "Apache-2.0", "dependencies": { "aws-sign2": "~0.7.0", "aws4": "^1.8.0", @@ -35638,15 +35639,15 @@ } }, "node_modules/request/node_modules/tough-cookie": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.5.0.tgz", - "integrity": "sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==", + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "license": "BSD-3-Clause", "dependencies": { - "psl": "^1.1.28", - "punycode": "^2.1.1" + "tldts": "^6.1.32" }, "engines": { - "node": ">=0.8" + "node": ">=16" } }, "node_modules/requestretry": { @@ -39612,6 +39613,24 @@ "tlds": "bin.js" } }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "license": "MIT" + }, "node_modules/tmp": { "version": "0.2.3", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.3.tgz", diff --git a/package.json b/package.json index 388b750c3d..44fffc4664 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,9 @@ "path-to-regexp": "3.3.0", "body-parser": "1.20.3", "multer": "2.0.1" + }, + "request@2.88.2": { + "tough-cookie": "5.1.2" } }, "scripts": { From 0546fb72332a820dc5eb2754a2fd149aacc8fdc0 Mon Sep 17 00:00:00 2001 From: Jakob Ackermann Date: Mon, 21 Jul 2025 16:05:11 +0200 Subject: [PATCH 28/43] [third-party-datastore] improve error handling (#26881) * [third-party-datastore] use generic serializer for dropboxError The `err` serializer will not pick up all the dropbox fields. Co-authored-by: Thomas Mees * [third-party-datastore] handle user_suspended like insufficient_space Unlink dropbox and display a notification (same key to clear later). Co-authored-by: Thomas Mees * [third-party-datastore] skip retries when rejected with disallowed_name Co-authored-by: Thomas Mees * [web] sort translations * [web] update copy for dropbox_unlinked_because_suspended Co-authored-by: Kamal Arkinstall --------- Co-authored-by: Thomas Mees Co-authored-by: Kamal Arkinstall GitOrigin-RevId: 8fbb9074d1d6eb879e904d79dd4b2a2c952ff902 --- services/web/locales/en.json | 1 + services/web/scripts/translations/cleanupUnusedLocales.js | 1 + 2 files changed, 2 insertions(+) diff --git a/services/web/locales/en.json b/services/web/locales/en.json index 4df2e389cd..7254f4d809 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -615,6 +615,7 @@ "dropbox_synced": "Overleaf and Dropbox have processed all updates. Note that your local Dropbox might still be synchronizing", "dropbox_unlinked_because_access_denied": "Your Dropbox account has been unlinked because the Dropbox service rejected your stored credentials. Please relink your Dropbox account to continue using it with Overleaf.", "dropbox_unlinked_because_full": "Your Dropbox account has been unlinked because it is full, and we can no longer send updates to it. Please free up some space and relink your Dropbox account to continue using it with Overleaf.", + "dropbox_unlinked_because_suspended": "We’ve unlinked your Dropbox account because it’s been suspended by Dropbox. You’ll be able to relink once you’ve resolved the issue with Dropbox.", "dropbox_unlinked_premium_feature": "<0>Your Dropbox account has been unlinked because Dropbox Sync is a premium feature that you had through an institutional license.", "due_date": "Due __date__", "due_today": "Due today", diff --git a/services/web/scripts/translations/cleanupUnusedLocales.js b/services/web/scripts/translations/cleanupUnusedLocales.js index e3d4d85435..d98ee6eb7e 100644 --- a/services/web/scripts/translations/cleanupUnusedLocales.js +++ b/services/web/scripts/translations/cleanupUnusedLocales.js @@ -74,6 +74,7 @@ async function main() { 'dropbox_email_not_verified', 'dropbox_unlinked_because_access_denied', 'dropbox_unlinked_because_full', + 'dropbox_unlinked_because_suspended', // Actually used without the spurious space. // TODO: fix the space and upload the changed locales From a0a4a9d518628eb71a5e43c2b77716781f50c778 Mon Sep 17 00:00:00 2001 From: yu-i-i Date: Tue, 3 Dec 2024 01:18:19 +0100 Subject: [PATCH 29/43] Enable LDAP and SAML authentication support --- patches/@node-saml+node-saml+4.0.5.patch | 23 +++ patches/ldapauth-fork+4.3.3.patch | 64 +++++++ .../AuthenticationController.js | 5 +- .../PasswordReset/PasswordResetController.mjs | 4 + .../PasswordReset/PasswordResetHandler.mjs | 4 + .../app/src/Features/User/UserController.js | 3 +- services/web/app/views/user/login.pug | 13 +- services/web/app/views/user/settings.pug | 4 +- services/web/config/settings.defaults.js | 2 + services/web/locales/en.json | 2 + .../launchpad/app/src/LaunchpadController.mjs | 3 +- .../modules/launchpad/app/views/launchpad.pug | 75 +++++++- .../app/src/AuthenticationControllerLdap.mjs | 64 +++++++ .../app/src/AuthenticationManagerLdap.mjs | 80 +++++++++ .../app/src/InitLdapSettings.mjs | 17 ++ .../app/src/LdapContacts.mjs | 136 +++++++++++++++ .../app/src/LdapStrategy.mjs | 78 +++++++++ .../web/modules/ldap-authentication/index.mjs | 30 ++++ .../app/src/AuthenticationControllerSaml.mjs | 160 ++++++++++++++++++ .../app/src/AuthenticationManagerSaml.mjs | 60 +++++++ .../app/src/InitSamlSettings.mjs | 16 ++ .../app/src/SamlNonCsrfRouter.mjs | 12 ++ .../app/src/SamlRouter.mjs | 14 ++ .../app/src/SamlStrategy.mjs | 62 +++++++ .../web/modules/saml-authentication/index.mjs | 26 +++ 25 files changed, 947 insertions(+), 10 deletions(-) create mode 100644 patches/@node-saml+node-saml+4.0.5.patch create mode 100644 patches/ldapauth-fork+4.3.3.patch create mode 100644 services/web/modules/ldap-authentication/app/src/AuthenticationControllerLdap.mjs create mode 100644 services/web/modules/ldap-authentication/app/src/AuthenticationManagerLdap.mjs create mode 100644 services/web/modules/ldap-authentication/app/src/InitLdapSettings.mjs create mode 100644 services/web/modules/ldap-authentication/app/src/LdapContacts.mjs create mode 100644 services/web/modules/ldap-authentication/app/src/LdapStrategy.mjs create mode 100644 services/web/modules/ldap-authentication/index.mjs create mode 100644 services/web/modules/saml-authentication/app/src/AuthenticationControllerSaml.mjs create mode 100644 services/web/modules/saml-authentication/app/src/AuthenticationManagerSaml.mjs create mode 100644 services/web/modules/saml-authentication/app/src/InitSamlSettings.mjs create mode 100644 services/web/modules/saml-authentication/app/src/SamlNonCsrfRouter.mjs create mode 100644 services/web/modules/saml-authentication/app/src/SamlRouter.mjs create mode 100644 services/web/modules/saml-authentication/app/src/SamlStrategy.mjs create mode 100644 services/web/modules/saml-authentication/index.mjs diff --git a/patches/@node-saml+node-saml+4.0.5.patch b/patches/@node-saml+node-saml+4.0.5.patch new file mode 100644 index 0000000000..81fd700b31 --- /dev/null +++ b/patches/@node-saml+node-saml+4.0.5.patch @@ -0,0 +1,23 @@ +diff --git a/node_modules/@node-saml/node-saml/lib/saml.js b/node_modules/@node-saml/node-saml/lib/saml.js +index fba15b9..a5778cb 100644 +--- a/node_modules/@node-saml/node-saml/lib/saml.js ++++ b/node_modules/@node-saml/node-saml/lib/saml.js +@@ -336,7 +336,8 @@ class SAML { + const requestOrResponse = request || response; + (0, utility_1.assertRequired)(requestOrResponse, "either request or response is required"); + let buffer; +- if (this.options.skipRequestCompression) { ++ // logout requestOrResponse must be compressed anyway ++ if (this.options.skipRequestCompression && operation !== "logout") { + buffer = Buffer.from(requestOrResponse, "utf8"); + } + else { +@@ -495,7 +496,7 @@ class SAML { + try { + xml = Buffer.from(container.SAMLResponse, "base64").toString("utf8"); + doc = await (0, xml_1.parseDomFromString)(xml); +- const inResponseToNodes = xml_1.xpath.selectAttributes(doc, "/*[local-name()='Response']/@InResponseTo"); ++ const inResponseToNodes = xml_1.xpath.selectAttributes(doc, "/*[local-name()='Response' or local-name()='LogoutResponse']/@InResponseTo"); + if (inResponseToNodes) { + inResponseTo = inResponseToNodes.length ? inResponseToNodes[0].nodeValue : null; + await this.validateInResponseTo(inResponseTo); diff --git a/patches/ldapauth-fork+4.3.3.patch b/patches/ldapauth-fork+4.3.3.patch new file mode 100644 index 0000000000..4d31210c9d --- /dev/null +++ b/patches/ldapauth-fork+4.3.3.patch @@ -0,0 +1,64 @@ +diff --git a/node_modules/ldapauth-fork/lib/ldapauth.js b/node_modules/ldapauth-fork/lib/ldapauth.js +index 85ecf36a8b..a7d07e0f78 100644 +--- a/node_modules/ldapauth-fork/lib/ldapauth.js ++++ b/node_modules/ldapauth-fork/lib/ldapauth.js +@@ -69,6 +69,7 @@ function LdapAuth(opts) { + this.opts.bindProperty || (this.opts.bindProperty = 'dn'); + this.opts.groupSearchScope || (this.opts.groupSearchScope = 'sub'); + this.opts.groupDnProperty || (this.opts.groupDnProperty = 'dn'); ++ this.opts.tlsStarted = false; + + EventEmitter.call(this); + +@@ -108,21 +109,7 @@ function LdapAuth(opts) { + this._userClient.on('error', this._handleError.bind(this)); + + var self = this; +- if (this.opts.starttls) { +- // When starttls is enabled, this callback supplants the 'connect' callback +- this._adminClient.starttls(this.opts.tlsOptions, this._adminClient.controls, function(err) { +- if (err) { +- self._handleError(err); +- } else { +- self._onConnectAdmin(); +- } +- }); +- this._userClient.starttls(this.opts.tlsOptions, this._userClient.controls, function(err) { +- if (err) { +- self._handleError(err); +- } +- }); +- } else if (opts.reconnect) { ++ if (opts.reconnect && !this.opts.starttls) { + this.once('_installReconnectListener', function() { + self.log && self.log.trace('install reconnect listener'); + self._adminClient.on('connect', function() { +@@ -384,6 +371,28 @@ LdapAuth.prototype._findGroups = function(user, callback) { + */ + LdapAuth.prototype.authenticate = function(username, password, callback) { + var self = this; ++ if (this.opts.starttls && !this.opts.tlsStarted) { ++ // When starttls is enabled, this callback supplants the 'connect' callback ++ this._adminClient.starttls(this.opts.tlsOptions, this._adminClient.controls, function (err) { ++ if (err) { ++ self._handleError(err); ++ } else { ++ self._onConnectAdmin(function(){self._handleAuthenticate(username, password, callback);}); ++ } ++ }); ++ this._userClient.starttls(this.opts.tlsOptions, this._userClient.controls, function (err) { ++ if (err) { ++ self._handleError(err); ++ } ++ }); ++ } else { ++ self._handleAuthenticate(username, password, callback); ++ } ++}; ++ ++LdapAuth.prototype._handleAuthenticate = function (username, password, callback) { ++ this.opts.tlsStarted = true; ++ var self = this; + + if (typeof password === 'undefined' || password === null || password === '') { + return callback(new Error('no password given')); diff --git a/services/web/app/src/Features/Authentication/AuthenticationController.js b/services/web/app/src/Features/Authentication/AuthenticationController.js index 99c418df1b..fc761ecbfb 100644 --- a/services/web/app/src/Features/Authentication/AuthenticationController.js +++ b/services/web/app/src/Features/Authentication/AuthenticationController.js @@ -117,9 +117,9 @@ const AuthenticationController = { // so we can send back our custom `{message: {text: "", type: ""}}` responses on failure, // and send a `{redir: ""}` response on success passport.authenticate( - 'local', + Settings.ldap?.enable ? ['custom-fail-ldapauth','local'] : ['local'], { keepSessionInfo: true }, - async function (err, user, info) { + async function (err, user, infoArray) { if (err) { return next(err) } @@ -141,6 +141,7 @@ const AuthenticationController = { return next(err) } } else { + let info = infoArray[0] if (info.redir != null) { return res.json({ redir: info.redir }) } else { diff --git a/services/web/app/src/Features/PasswordReset/PasswordResetController.mjs b/services/web/app/src/Features/PasswordReset/PasswordResetController.mjs index 2963c56653..54f847ef9c 100644 --- a/services/web/app/src/Features/PasswordReset/PasswordResetController.mjs +++ b/services/web/app/src/Features/PasswordReset/PasswordResetController.mjs @@ -140,6 +140,10 @@ async function requestReset(req, res, next) { return res.status(404).json({ message: req.i18n.translate('secondary_email_password_reset'), }) + } else if (status === 'external') { + return res.status(403).json({ + message: req.i18n.translate('password_managed_externally'), + }) } else { return res.status(404).json({ message: req.i18n.translate('cant_find_email'), diff --git a/services/web/app/src/Features/PasswordReset/PasswordResetHandler.mjs b/services/web/app/src/Features/PasswordReset/PasswordResetHandler.mjs index 094f18b95f..0ac203222c 100644 --- a/services/web/app/src/Features/PasswordReset/PasswordResetHandler.mjs +++ b/services/web/app/src/Features/PasswordReset/PasswordResetHandler.mjs @@ -18,6 +18,10 @@ async function generateAndEmailResetToken(email) { return null } + if (!user.hashedPassword) { + return 'external' + } + if (user.email !== email) { return 'secondary' } diff --git a/services/web/app/src/Features/User/UserController.js b/services/web/app/src/Features/User/UserController.js index b767dcd4a1..772e77e3e4 100644 --- a/services/web/app/src/Features/User/UserController.js +++ b/services/web/app/src/Features/User/UserController.js @@ -404,7 +404,7 @@ async function updateUserSettings(req, res, next) { if ( newEmail == null || newEmail === user.email || - req.externalAuthenticationSystemUsed() + (req.externalAuthenticationSystemUsed() && !user.hashedPassword) ) { // end here, don't update email SessionManager.setInSessionUser(req.session, { @@ -481,6 +481,7 @@ async function doLogout(req) { } async function logout(req, res, next) { + if (req?.session.saml_extce) return res.redirect(308, '/saml/logout') const requestedRedirect = req.body.redirect ? UrlHelper.getSafeRedirectPath(req.body.redirect) : undefined diff --git a/services/web/app/views/user/login.pug b/services/web/app/views/user/login.pug index 03112a0e16..3008b11b1d 100644 --- a/services/web/app/views/user/login.pug +++ b/services/web/app/views/user/login.pug @@ -24,9 +24,9 @@ block content .form-group input.form-control( name='email' - type='email' + type=(settings.ldap && settings.ldap.enable) ? 'text' : 'email' required - placeholder='email@example.com' + placeholder=(settings.ldap && settings.ldap.enable) ? settings.ldap.placeholder : 'email@example.com' autofocus='true' ) .form-group @@ -44,3 +44,12 @@ block content if login_support_text hr p.text-center !{login_support_text} + if settings.saml && settings.saml.enable + form(data-ol-async-form, name="samlLoginForm") + .actions(style='margin-top: 30px;') + a.btn.btn-secondary.btn-block( + href='/saml/login', + data-ol-disabled-inflight + ) + span(data-ol-inflight="idle") #{settings.saml.identityServiceName} + span(hidden data-ol-inflight="pending") #{translate("logging_in")}… diff --git a/services/web/app/views/user/settings.pug b/services/web/app/views/user/settings.pug index 45d21c7572..a07863682e 100644 --- a/services/web/app/views/user/settings.pug +++ b/services/web/app/views/user/settings.pug @@ -11,7 +11,7 @@ block append meta meta( name='ol-shouldAllowEditingDetails' data-type='boolean' - content=shouldAllowEditingDetails + content=shouldAllowEditingDetails || hasPassword ) meta(name='ol-oauthProviders' data-type='json' content=oauthProviders) meta(name='ol-institutionLinked' data-type='json' content=institutionLinked) @@ -34,7 +34,7 @@ block append meta meta( name='ol-isExternalAuthenticationSystemUsed' data-type='boolean' - content=externalAuthenticationSystemUsed() + content=externalAuthenticationSystemUsed() && !hasPassword ) meta(name='ol-user' data-type='json' content=user) meta(name='ol-labsExperiments' data-type='json' content=labsExperiments) diff --git a/services/web/config/settings.defaults.js b/services/web/config/settings.defaults.js index 4df63ebd7c..fa1e5766d0 100644 --- a/services/web/config/settings.defaults.js +++ b/services/web/config/settings.defaults.js @@ -1033,6 +1033,8 @@ module.exports = { 'launchpad', 'server-ce-scripts', 'user-activate', + 'ldap-authentication', + 'saml-authentication', ], viewIncludes: {}, diff --git a/services/web/locales/en.json b/services/web/locales/en.json index 7254f4d809..f699d59812 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -164,6 +164,7 @@ "already_have_sl_account": "Already have an __appName__ account?", "also": "Also", "alternatively_create_new_institution_account": "Alternatively, you can create a new account with your institution email (__email__) by clicking __clickText__.", + "alternatively_create_local_admin_account": "Alternatively, you can create __appName__ local admin account.", "an_email_has_already_been_sent_to": "An email has already been sent to <0>__email__. Please wait and try again later.", "an_error_occured_while_restoring_project": "An error occured while restoring the project", "an_error_occurred_when_verifying_the_coupon_code": "An error occurred when verifying the coupon code", @@ -1256,6 +1257,7 @@ "loading_prices": "loading prices", "loading_recent_github_commits": "Loading recent commits", "loading_writefull": "Loading Writefull", + "local_account": "Local account", "log_entry_description": "Log entry with level: __level__", "log_entry_maximum_entries": "Maximum log entries limit hit", "log_entry_maximum_entries_enable_stop_on_first_error": "Try to fix the first error and recompile. Often one error causes many later error messages. You can <0>Enable “Stop on first error” to focus on fixing errors. We recommend fixing errors as soon as possible; letting them accumulate may lead to hard-to-debug and fatal errors. <1>Learn more", diff --git a/services/web/modules/launchpad/app/src/LaunchpadController.mjs b/services/web/modules/launchpad/app/src/LaunchpadController.mjs index b626e0176e..49dd9ea9cb 100644 --- a/services/web/modules/launchpad/app/src/LaunchpadController.mjs +++ b/services/web/modules/launchpad/app/src/LaunchpadController.mjs @@ -154,7 +154,8 @@ function registerExternalAuthAdmin(authMethod) { await User.updateOne( { _id: user._id }, { - $set: { isAdmin: true, emails: [{ email, reversedHostname }] }, + $set: { isAdmin: true, emails: [{ email, reversedHostname, 'confirmedAt' : Date.now() }] }, + $unset: { 'hashedPassword': "" }, // external-auth user must not have a hashedPassword } ).exec() } catch (err) { diff --git a/services/web/modules/launchpad/app/views/launchpad.pug b/services/web/modules/launchpad/app/views/launchpad.pug index ff917eeb74..2c0382e71e 100644 --- a/services/web/modules/launchpad/app/views/launchpad.pug +++ b/services/web/modules/launchpad/app/views/launchpad.pug @@ -29,7 +29,7 @@ block vars block append meta meta(name='ol-adminUserExists' data-type='boolean' content=adminUserExists) - meta(name='ol-ideJsPath' content=buildJsPath('ide.js')) + meta(name='ol-ideJsPath' content=buildJsPath('ide-detached.js')) block content script( @@ -122,6 +122,42 @@ block content span(data-ol-inflight='idle') #{translate("register")} span(hidden data-ol-inflight='pending') #{translate("registering")}… + h3 #{translate('local_account')} + p + | #{translate('alternatively_create_local_admin_account')} + + form( + data-ol-async-form + data-ol-register-admin + action='/launchpad/register_admin' + method='POST' + ) + input(name='_csrf', type='hidden', value=csrfToken) + +formMessages() + .form-group + label(for='email') #{translate("email")} + input.form-control( + type='email' + name='email' + placeholder='email@example.com' + autocomplete='username' + required + autofocus='true' + ) + .form-group + label(for='password') #{translate("password")} + input.form-control#passwordField( + type='password' + name='password' + placeholder='********' + autocomplete='new-password' + required + ) + .actions + button.btn-primary.btn(type='submit' data-ol-disabled-inflight) + span(data-ol-inflight="idle") #{translate("register")} + span(hidden data-ol-inflight="pending") #{translate("registering")}… + // Saml Form if authMethod === 'saml' h3 #{translate('saml')} @@ -140,12 +176,47 @@ block content label(for='email') #{translate("email")} input.form-control( name='email' - type='email' placeholder='email@example.com' autocomplete='username' required autofocus='true' ) + .actions + button.btn-primary.btn(type='submit' data-ol-disabled-inflight) + span(data-ol-inflight="idle") #{translate("register")} + span(hidden data-ol-inflight="pending") #{translate("registering")}… + + h3 #{translate('local_account')} + p + | #{translate('alternatively_create_local_admin_account')} + + form( + data-ol-async-form + data-ol-register-admin + action='/launchpad/register_admin' + method='POST' + ) + input(name='_csrf', type='hidden', value=csrfToken) + +formMessages() + .form-group + label(for='email') #{translate("email")} + input.form-control( + type='email' + name='email' + placeholder='email@example.com' + autocomplete='username' + required + autofocus='true' + ) + .form-group + label(for='password') #{translate("password")} + input.form-control#passwordField( + type='password' + name='password' + placeholder='********' + autocomplete='new-password' + required + ) .actions button.btn-primary.btn(type='submit' data-ol-disabled-inflight) span(data-ol-inflight='idle') #{translate("register")} diff --git a/services/web/modules/ldap-authentication/app/src/AuthenticationControllerLdap.mjs b/services/web/modules/ldap-authentication/app/src/AuthenticationControllerLdap.mjs new file mode 100644 index 0000000000..64fa4f5a96 --- /dev/null +++ b/services/web/modules/ldap-authentication/app/src/AuthenticationControllerLdap.mjs @@ -0,0 +1,64 @@ +import logger from '@overleaf/logger' +import LoginRateLimiter from '../../../../app/src/Features/Security/LoginRateLimiter.js' +import { handleAuthenticateErrors } from '../../../../app/src/Features/Authentication/AuthenticationErrors.js' +import AuthenticationController from '../../../../app/src/Features/Authentication/AuthenticationController.js' +import AuthenticationManagerLdap from './AuthenticationManagerLdap.mjs' + +const AuthenticationControllerLdap = { + async doPassportLdapLogin(req, ldapUser, done) { + let user, info + try { + ;({ user, info } = await AuthenticationControllerLdap._doPassportLdapLogin( + req, + ldapUser + )) + } catch (error) { + return done(error) + } + return done(undefined, user, info) + }, + async _doPassportLdapLogin(req, ldapUser) { + const { fromKnownDevice } = AuthenticationController.getAuditInfo(req) + const auditLog = { + ipAddress: req.ip, + info: { method: 'LDAP password login', fromKnownDevice }, + } + + let user, isPasswordReused + try { + user = await AuthenticationManagerLdap.promises.findOrCreateLdapUser(ldapUser, auditLog) + } catch (error) { + return { + user: false, + info: handleAuthenticateErrors(error, req), + } + } + if (user && AuthenticationController.captchaRequiredForLogin(req, user)) { + return { + user: false, + info: { + text: req.i18n.translate('cannot_verify_user_not_robot'), + type: 'error', + errorReason: 'cannot_verify_user_not_robot', + status: 400, + }, + } + } else if (user) { + // async actions + return { user, info: undefined } + } else { //something wrong + logger.debug({ email : ldapUser.mail }, 'failed LDAP log in') + return { + user: false, + info: { + type: 'error', + status: 500, + }, + } + } + }, +} + +export const { + doPassportLdapLogin, +} = AuthenticationControllerLdap diff --git a/services/web/modules/ldap-authentication/app/src/AuthenticationManagerLdap.mjs b/services/web/modules/ldap-authentication/app/src/AuthenticationManagerLdap.mjs new file mode 100644 index 0000000000..1371f76d52 --- /dev/null +++ b/services/web/modules/ldap-authentication/app/src/AuthenticationManagerLdap.mjs @@ -0,0 +1,80 @@ +import Settings from '@overleaf/settings' +import { callbackify } from '@overleaf/promise-utils' +import UserCreator from '../../../../app/src/Features/User/UserCreator.js' +import { User } from '../../../../app/src/models/User.js' + +const AuthenticationManagerLdap = { + splitFullName(fullName) { + fullName = fullName.trim(); + let lastSpaceIndex = fullName.lastIndexOf(' '); + let firstNames = fullName.substring(0, lastSpaceIndex).trim(); + let lastName = fullName.substring(lastSpaceIndex + 1).trim(); + return [firstNames, lastName]; + }, + async findOrCreateLdapUser(profile, auditLog) { + //user is already authenticated in Ldap + const { + attEmail, + attFirstName, + attLastName, + attName, + attAdmin, + valAdmin, + updateUserDetailsOnLogin, + } = Settings.ldap + + const email = Array.isArray(profile[attEmail]) + ? profile[attEmail][0].toLowerCase() + : profile[attEmail].toLowerCase() + let nameParts = ["",""] + if ((!attFirstName || !attLastName) && attName) { + nameParts = this.splitFullName(profile[attName] || "") + } + const firstName = attFirstName ? (profile[attFirstName] || "") : nameParts[0] + let lastName = attLastName ? (profile[attLastName] || "") : nameParts[1] + if (!firstName && !lastName) lastName = email + let isAdmin = false + if( attAdmin && valAdmin ) { + isAdmin = (profile._groups?.length > 0) || + (Array.isArray(profile[attAdmin]) ? profile[attAdmin].includes(valAdmin) : + profile[attAdmin] === valAdmin) + } + let user = await User.findOne({ 'email': email }).exec() + if( !user ) { + user = await UserCreator.promises.createNewUser( + { + email: email, + first_name: firstName, + last_name: lastName, + isAdmin: isAdmin, + holdingAccount: false, + } + ) + await User.updateOne( + { _id: user._id }, + { $set : { 'emails.0.confirmedAt' : Date.now() } } + ).exec() //email of ldap user is confirmed + } + let userDetails = updateUserDetailsOnLogin ? { first_name : firstName, last_name: lastName } : {} + if( attAdmin && valAdmin ) { + user.isAdmin = isAdmin + userDetails.isAdmin = isAdmin + } + const result = await User.updateOne( + { _id: user._id, loginEpoch: user.loginEpoch }, { $inc: { loginEpoch: 1 }, $set: userDetails }, + {} + ).exec() + if (result.modifiedCount !== 1) { + throw new ParallelLoginError() + } + return user + }, +} + +export default { + findOrCreateLdapUser: callbackify(AuthenticationManagerLdap.findOrCreateLdapUser), + promises: AuthenticationManagerLdap, +} +export const { + splitFullName, +} = AuthenticationManagerLdap diff --git a/services/web/modules/ldap-authentication/app/src/InitLdapSettings.mjs b/services/web/modules/ldap-authentication/app/src/InitLdapSettings.mjs new file mode 100644 index 0000000000..e7f312fc11 --- /dev/null +++ b/services/web/modules/ldap-authentication/app/src/InitLdapSettings.mjs @@ -0,0 +1,17 @@ +import Settings from '@overleaf/settings' + +function initLdapSettings() { + Settings.ldap = { + enable: true, + placeholder: process.env.OVERLEAF_LDAP_PLACEHOLDER || 'Username', + attEmail: process.env.OVERLEAF_LDAP_EMAIL_ATT || 'mail', + attFirstName: process.env.OVERLEAF_LDAP_FIRST_NAME_ATT, + attLastName: process.env.OVERLEAF_LDAP_LAST_NAME_ATT, + attName: process.env.OVERLEAF_LDAP_NAME_ATT, + attAdmin: process.env.OVERLEAF_LDAP_IS_ADMIN_ATT, + valAdmin: process.env.OVERLEAF_LDAP_IS_ADMIN_ATT_VALUE, + updateUserDetailsOnLogin: String(process.env.OVERLEAF_LDAP_UPDATE_USER_DETAILS_ON_LOGIN ).toLowerCase() === 'true', + } +} + +export default initLdapSettings diff --git a/services/web/modules/ldap-authentication/app/src/LdapContacts.mjs b/services/web/modules/ldap-authentication/app/src/LdapContacts.mjs new file mode 100644 index 0000000000..c4093b8684 --- /dev/null +++ b/services/web/modules/ldap-authentication/app/src/LdapContacts.mjs @@ -0,0 +1,136 @@ +import Settings from '@overleaf/settings' +import logger from '@overleaf/logger' +import passport from 'passport' +import ldapjs from 'ldapauth-fork/node_modules/ldapjs/lib/index.js' +import UserGetter from '../../../../app/src/Features/User/UserGetter.js' +import { splitFullName } from './AuthenticationManagerLdap.mjs' + +async function fetchLdapContacts(userId, contacts) { + if (!Settings.ldap?.enable || !process.env.OVERLEAF_LDAP_CONTACTS_FILTER) { + return [] + } + + const ldapOpts = passport._strategy('custom-fail-ldapauth').options.server + const { attEmail, attFirstName = "", attLastName = "", attName = "" } = Settings.ldap + const { + url, + timeout, + connectTimeout, + tlsOptions, + starttls, + bindDN, + bindCredentials, + } = ldapOpts + const searchBase = process.env.OVERLEAF_LDAP_CONTACTS_SEARCH_BASE || ldapOpts.searchBase + const searchScope = process.env.OVERLEAF_LDAP_CONTACTS_SEARCH_SCOPE || 'sub' + const ldapConfig = { url, timeout, connectTimeout, tlsOptions } + + let ldapUsers + const client = ldapjs.createClient(ldapConfig) + try { + if (starttls) { + await _upgradeToTLS(client, tlsOptions) + } + await _bindLdap(client, bindDN, bindCredentials) + + const filter = await _formContactsSearchFilter(client, ldapOpts, userId, process.env.OVERLEAF_LDAP_CONTACTS_FILTER) + const searchOptions = { scope: searchScope, attributes: [attEmail, attFirstName, attLastName, attName], filter } + + ldapUsers = await _searchLdap(client, searchBase, searchOptions) + } catch (err) { + logger.warn({ err }, 'error in fetchLdapContacts') + return [] + } finally { + client.unbind() + } + + const newLdapContacts = ldapUsers.reduce((acc, ldapUser) => { + const email = Array.isArray(ldapUser[attEmail]) + ? ldapUser[attEmail][0]?.toLowerCase() + : ldapUser[attEmail]?.toLowerCase() + if (!email) return acc + if (!contacts.some(contact => contact.email === email)) { + let nameParts = ["",""] + if ((!attFirstName || !attLastName) && attName) { + nameParts = splitFullName(ldapUser[attName] || "") + } + const firstName = attFirstName ? (ldapUser[attFirstName] || "") : nameParts[0] + const lastName = attLastName ? (ldapUser[attLastName] || "") : nameParts[1] + acc.push({ + first_name: firstName, + last_name: lastName, + email: email, + type: 'user', + }) + } + return acc + }, []) + + return newLdapContacts.sort((a, b) => + a.last_name.localeCompare(b.last_name) || + a.first_name.localeCompare(a.first_name) || + a.email.localeCompare(b.email) + ) +} + +function _upgradeToTLS(client, tlsOptions) { + return new Promise((resolve, reject) => { + client.on('error', error => reject(new Error(`LDAP client error: ${error}`))) + client.on('connect', () => { + client.starttls(tlsOptions, null, error => { + if (error) { + reject(new Error(`StartTLS error: ${error}`)) + } else { + resolve() + } + }) + }) + }) +} + +function _bindLdap(client, bindDN, bindCredentials) { + return new Promise((resolve, reject) => { + client.bind(bindDN, bindCredentials, error => { + if (error) { + reject(error) + } else { + resolve() + } + }) + }) +} + +function _searchLdap(client, baseDN, options) { + return new Promise((resolve, reject) => { + const searchEntries = [] + client.search(baseDN, options, (error, res) => { + if (error) { + reject(error) + } else { + res.on('searchEntry', entry => searchEntries.push(entry.object)) + res.on('error', reject) + res.on('end', () => resolve(searchEntries)) + } + }) + }) +} + +async function _formContactsSearchFilter(client, ldapOpts, userId, contactsFilter) { + const searchProperty = process.env.OVERLEAF_LDAP_CONTACTS_PROPERTY + if (!searchProperty) { + return contactsFilter + } + const email = await UserGetter.promises.getUserEmail(userId) + const searchOptions = { + scope: ldapOpts.searchScope, + attributes: [searchProperty], + filter: `(${Settings.ldap.attEmail}=${email})`, + } + const searchBase = ldapOpts.searchBase + const ldapUser = (await _searchLdap(client, searchBase, searchOptions))[0] + const searchPropertyValue = ldapUser ? ldapUser[searchProperty] + : process.env.OVERLEAF_LDAP_CONTACTS_NON_LDAP_VALUE || 'IMATCHNOTHING' + return contactsFilter.replace(/{{userProperty}}/g, searchPropertyValue) +} + +export default fetchLdapContacts diff --git a/services/web/modules/ldap-authentication/app/src/LdapStrategy.mjs b/services/web/modules/ldap-authentication/app/src/LdapStrategy.mjs new file mode 100644 index 0000000000..b07dc3f3bd --- /dev/null +++ b/services/web/modules/ldap-authentication/app/src/LdapStrategy.mjs @@ -0,0 +1,78 @@ +import fs from 'fs' +import passport from 'passport' +import Settings from '@overleaf/settings' +import { doPassportLdapLogin } from './AuthenticationControllerLdap.mjs' +import { Strategy as LdapStrategy } from 'passport-ldapauth' + +function _readFilesContentFromEnv(envVar) { +// envVar is either a file name: 'file.pem', or string with array: '["file.pem", "file2.pem"]' + if (!envVar) return undefined + try { + const parsedFileNames = JSON.parse(envVar) + return parsedFileNames.map(filename => fs.readFileSync(filename, 'utf8')) + } catch (error) { + if (error instanceof SyntaxError) { // failed to parse, envVar must be a file name + return fs.readFileSync(envVar, 'utf8') + } else { + throw error + } + } +} + +// custom responses on authentication failure +class CustomFailLdapStrategy extends LdapStrategy { + constructor(options, validate) { + super(options, validate); + this.name = 'custom-fail-ldapauth' + } + authenticate(req, options) { + const defaultFail = this.fail.bind(this) + this.fail = function(info, status) { + info.type = 'error' + info.key = 'invalid-password-retry-or-reset' + info.status = 401 + return defaultFail(info, status) + }.bind(this) + super.authenticate(req, options) + } +} + +const ldapServerOpts = { + url: process.env.OVERLEAF_LDAP_URL, + bindDN: process.env.OVERLEAF_LDAP_BIND_DN || "", + bindCredentials: process.env.OVERLEAF_LDAP_BIND_CREDENTIALS || "", + bindProperty: process.env.OVERLEAF_LDAP_BIND_PROPERTY, + searchBase: process.env.OVERLEAF_LDAP_SEARCH_BASE, + searchFilter: process.env.OVERLEAF_LDAP_SEARCH_FILTER, + searchScope: process.env.OVERLEAF_LDAP_SEARCH_SCOPE || 'sub', + searchAttributes: JSON.parse(process.env.OVERLEAF_LDAP_SEARCH_ATTRIBUTES || '[]'), + groupSearchBase: process.env.OVERLEAF_LDAP_ADMIN_SEARCH_BASE, + groupSearchFilter: process.env.OVERLEAF_LDAP_ADMIN_SEARCH_FILTER, + groupSearchScope: process.env.OVERLEAF_LDAP_ADMIN_SEARCH_SCOPE || 'sub', + groupSearchAttributes: ["dn"], + groupDnProperty: process.env.OVERLEAF_LDAP_ADMIN_DN_PROPERTY, + cache: String(process.env.OVERLEAF_LDAP_CACHE).toLowerCase() === 'true', + timeout: process.env.OVERLEAF_LDAP_TIMEOUT ? Number(process.env.OVERLEAF_LDAP_TIMEOUT) : undefined, + connectTimeout: process.env.OVERLEAF_LDAP_CONNECT_TIMEOUT ? Number(process.env.OVERLEAF_LDAP_CONNECT_TIMEOUT) : undefined, + starttls: String(process.env.OVERLEAF_LDAP_STARTTLS).toLowerCase() === 'true', + tlsOptions: { + ca: _readFilesContentFromEnv(process.env.OVERLEAF_LDAP_TLS_OPTS_CA_PATH), + rejectUnauthorized: String(process.env.OVERLEAF_LDAP_TLS_OPTS_REJECT_UNAUTH).toLowerCase() === 'true', + } +} + +function addLdapStrategy(passport) { + passport.use( + new CustomFailLdapStrategy( + { + server: ldapServerOpts, + passReqToCallback: true, + usernameField: 'email', + passwordField: 'password', + }, + doPassportLdapLogin + ) + ) +} + +export default addLdapStrategy diff --git a/services/web/modules/ldap-authentication/index.mjs b/services/web/modules/ldap-authentication/index.mjs new file mode 100644 index 0000000000..f56d7ffee0 --- /dev/null +++ b/services/web/modules/ldap-authentication/index.mjs @@ -0,0 +1,30 @@ +import initLdapSettings from './app/src/InitLdapSettings.mjs' +import addLdapStrategy from './app/src/LdapStrategy.mjs' +import fetchLdapContacts from './app/src/LdapContacts.mjs' + +let ldapModule = {}; +if (process.env.EXTERNAL_AUTH === 'ldap') { + initLdapSettings() + ldapModule = { + name: 'ldap-authentication', + hooks: { + passportSetup: function (passport, callback) { + try { + addLdapStrategy(passport) + callback(null) + } catch (error) { + callback(error) + } + }, + getContacts: async function (userId, contacts, callback) { + try { + const newLdapContacts = await fetchLdapContacts(userId, contacts) + callback(null, newLdapContacts) + } catch (error) { + callback(error) + } + }, + } + } +} +export default ldapModule diff --git a/services/web/modules/saml-authentication/app/src/AuthenticationControllerSaml.mjs b/services/web/modules/saml-authentication/app/src/AuthenticationControllerSaml.mjs new file mode 100644 index 0000000000..f5db3f738d --- /dev/null +++ b/services/web/modules/saml-authentication/app/src/AuthenticationControllerSaml.mjs @@ -0,0 +1,160 @@ +import Settings from '@overleaf/settings' +import logger from '@overleaf/logger' +import passport from 'passport' +import AuthenticationController from '../../../../app/src/Features/Authentication/AuthenticationController.js' +import AuthenticationManagerSaml from './AuthenticationManagerSaml.mjs' +import UserController from '../../../../app/src/Features/User/UserController.js' +import UserSessionsManager from '../../../../app/src/Features/User/UserSessionsManager.js' +import { handleAuthenticateErrors } from '../../../../app/src/Features/Authentication/AuthenticationErrors.js' +import { xmlResponse } from '../../../../app/src/infrastructure/Response.js' + +const AuthenticationControllerSaml = { + passportSamlAuthWithIdP(req, res, next) { + if ( passport._strategy('saml')._saml.options.authnRequestBinding === 'HTTP-POST') { + const csp = res.getHeader('Content-Security-Policy') + if (csp) { + res.setHeader( + 'Content-Security-Policy', + csp.replace(/(?:^|\s)(default-src|form-action)[^;]*;?/g, '') + ) + } + } + passport.authenticate('saml')(req, res, next) + }, + passportSamlLogin(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( + 'saml', + { keepSessionInfo: true }, + async function (err, user, info) { + if (err) { + return next(err) + } + if (user) { + // `user` is either a user object or false + AuthenticationController.setAuditInfo(req, { + method: 'SAML login', + }) + try { + await AuthenticationController.promises.finishLogin(user, req, res) + } catch (err) { + return next(err) + } + } else { + if (info.redir != null) { + return res.json({ redir: info.redir }) + } else { + res.status(info.status || 200) + delete info.status + const body = { message: info } + const { errorReason } = info + if (errorReason) { + body.errorReason = errorReason + delete info.errorReason + } + return res.json(body) + } + } + } + )(req, res, next) + }, + async doPassportSamlLogin(req, profile, done) { + let user, info + try { + ;({ user, info } = await AuthenticationControllerSaml._doPassportSamlLogin( + req, + profile + )) + } catch (error) { + return done(error) + } + return done(undefined, user, info) + }, + async _doPassportSamlLogin(req, profile) { + const { fromKnownDevice } = AuthenticationController.getAuditInfo(req) + const auditLog = { + ipAddress: req.ip, + info: { method: 'SAML login', fromKnownDevice }, + } + + let user + try { + user = await AuthenticationManagerSaml.promises.findOrCreateSamlUser(profile, auditLog) + } catch (error) { + return { + user: false, + info: handleAuthenticateErrors(error, req), + } + } + if (user) { + req.session.saml_extce = {nameID : profile.nameID, sessionIndex : profile.sessionIndex} + return { user, info: undefined } + } else { //something wrong + logger.debug({ email : profile.mail }, 'failed SAML log in') + return { + user: false, + info: { + type: 'error', + text: 'Unknown error', + status: 500, + }, + } + } + }, + async passportSamlSPLogout(req, res, next) { + passport._strategy('saml').logout(req, async (err, url) => { + if (err) logger.error({ err }, 'can not generate logout url') + await UserController.promises.doLogout(req) + res.redirect(url) + }) + }, + passportSamlIdPLogout(req, res, next) { + passport.authenticate('saml')(req, res, (err) => { + if (err) return next(err) + res.redirect('/login'); + }) + }, + async doPassportSamlLogout(req, profile, done) { + let user, info + try { + ;({ user, info } = await AuthenticationControllerSaml._doPassportSamlLogout( + req, + profile + )) + } catch (error) { + return done(error) + } + return done(undefined, user, info) + }, + async _doPassportSamlLogout(req, profile) { + if (req?.session?.saml_extce?.nameID === profile.nameID && + req?.session?.saml_extce?.sessionIndex === profile.sessionIndex) { + profile = req.user + } + await UserSessionsManager.promises.untrackSession(req.user, req.sessionID).catch(err => { + logger.warn({ err, userId: req.user._id }, 'failed to untrack session') + }) + return { user: profile, info: undefined } + }, + passportSamlMetadata(req, res) { + const samlStratery = passport._strategy('saml') + res.setHeader('Content-Disposition', `attachment; filename="${samlStratery._saml.options.issuer}-meta.xml"`) + xmlResponse(res, + samlStratery.generateServiceProviderMetadata( + samlStratery._saml.options.decryptionCert, + samlStratery._saml.options.signingCert + ) + ) + }, +} +export const { + passportSamlAuthWithIdP, + passportSamlLogin, + passportSamlSPLogout, + passportSamlIdPLogout, + doPassportSamlLogin, + doPassportSamlLogout, + passportSamlMetadata, +} = AuthenticationControllerSaml diff --git a/services/web/modules/saml-authentication/app/src/AuthenticationManagerSaml.mjs b/services/web/modules/saml-authentication/app/src/AuthenticationManagerSaml.mjs new file mode 100644 index 0000000000..47d97f3019 --- /dev/null +++ b/services/web/modules/saml-authentication/app/src/AuthenticationManagerSaml.mjs @@ -0,0 +1,60 @@ +import Settings from '@overleaf/settings' +import UserCreator from '../../../../app/src/Features/User/UserCreator.js' +import { User } from '../../../../app/src/models/User.js' + +const AuthenticationManagerSaml = { + async findOrCreateSamlUser(profile, auditLog) { + const { + attEmail, + attFirstName, + attLastName, + attAdmin, + valAdmin, + updateUserDetailsOnLogin, + } = Settings.saml + const email = Array.isArray(profile[attEmail]) + ? profile[attEmail][0].toLowerCase() + : profile[attEmail].toLowerCase() + const firstName = attFirstName ? profile[attFirstName] : "" + const lastName = attLastName ? profile[attLastName] : email + let isAdmin = false + if( attAdmin && valAdmin ) { + isAdmin = (Array.isArray(profile[attAdmin]) ? profile[attAdmin].includes(valAdmin) : + profile[attAdmin] === valAdmin) + } + let user = await User.findOne({ 'email': email }).exec() + if( !user ) { + user = await UserCreator.promises.createNewUser( + { + email: email, + first_name: firstName, + last_name: lastName, + isAdmin: isAdmin, + holdingAccount: false, + } + ) + await User.updateOne( + { _id: user._id }, + { $set : { 'emails.0.confirmedAt' : Date.now() } } + ).exec() //email of saml user is confirmed + } + let userDetails = updateUserDetailsOnLogin ? { first_name : firstName, last_name: lastName } : {} + if( attAdmin && valAdmin ) { + user.isAdmin = isAdmin + userDetails.isAdmin = isAdmin + } + const result = await User.updateOne( + { _id: user._id, loginEpoch: user.loginEpoch }, { $inc: { loginEpoch: 1 }, $set: userDetails }, + {} + ).exec() + + if (result.modifiedCount !== 1) { + throw new ParallelLoginError() + } + return user + }, +} + +export default { + promises: AuthenticationManagerSaml, +} diff --git a/services/web/modules/saml-authentication/app/src/InitSamlSettings.mjs b/services/web/modules/saml-authentication/app/src/InitSamlSettings.mjs new file mode 100644 index 0000000000..441f9033af --- /dev/null +++ b/services/web/modules/saml-authentication/app/src/InitSamlSettings.mjs @@ -0,0 +1,16 @@ +import Settings from '@overleaf/settings' + +function initSamlSettings() { + Settings.saml = { + enable: true, + identityServiceName: process.env.OVERLEAF_SAML_IDENTITY_SERVICE_NAME || 'Login with SAML IdP', + attEmail: process.env.OVERLEAF_SAML_EMAIL_FIELD || 'nameID', + attFirstName: process.env.OVERLEAF_SAML_FIRST_NAME_FIELD || 'givenName', + attLastName: process.env.OVERLEAF_SAML_LAST_NAME_FIELD || 'lastName', + attAdmin: process.env.OVERLEAF_SAML_IS_ADMIN_FIELD, + valAdmin: process.env.OVERLEAF_SAML_IS_ADMIN_FIELD_VALUE, + updateUserDetailsOnLogin: String(process.env.OVERLEAF_SAML_UPDATE_USER_DETAILS_ON_LOGIN).toLowerCase() === 'true', + } +} + +export default initSamlSettings diff --git a/services/web/modules/saml-authentication/app/src/SamlNonCsrfRouter.mjs b/services/web/modules/saml-authentication/app/src/SamlNonCsrfRouter.mjs new file mode 100644 index 0000000000..65b42c92ae --- /dev/null +++ b/services/web/modules/saml-authentication/app/src/SamlNonCsrfRouter.mjs @@ -0,0 +1,12 @@ +import logger from '@overleaf/logger' +import { passportSamlLogin, passportSamlIdPLogout } from './AuthenticationControllerSaml.mjs' + +export default { + apply(webRouter) { + logger.debug({}, 'Init SAML NonCsrfRouter') + webRouter.get('/saml/login/callback', passportSamlLogin) + webRouter.post('/saml/login/callback', passportSamlLogin) + webRouter.get('/saml/logout/callback', passportSamlIdPLogout) + webRouter.post('/saml/logout/callback', passportSamlIdPLogout) + }, +} diff --git a/services/web/modules/saml-authentication/app/src/SamlRouter.mjs b/services/web/modules/saml-authentication/app/src/SamlRouter.mjs new file mode 100644 index 0000000000..9ee3677901 --- /dev/null +++ b/services/web/modules/saml-authentication/app/src/SamlRouter.mjs @@ -0,0 +1,14 @@ +import logger from '@overleaf/logger' +import AuthenticationController from '../../../../app/src/Features/Authentication/AuthenticationController.js' +import { passportSamlAuthWithIdP, passportSamlSPLogout, passportSamlMetadata} from './AuthenticationControllerSaml.mjs' + +export default { + apply(webRouter) { + logger.debug({}, 'Init SAML router') + webRouter.get('/saml/login', passportSamlAuthWithIdP) + AuthenticationController.addEndpointToLoginWhitelist('/saml/login') + webRouter.post('/saml/logout', AuthenticationController.requireLogin(), passportSamlSPLogout) + webRouter.get('/saml/meta', passportSamlMetadata) + AuthenticationController.addEndpointToLoginWhitelist('/saml/meta') + }, +} diff --git a/services/web/modules/saml-authentication/app/src/SamlStrategy.mjs b/services/web/modules/saml-authentication/app/src/SamlStrategy.mjs new file mode 100644 index 0000000000..3a16459f98 --- /dev/null +++ b/services/web/modules/saml-authentication/app/src/SamlStrategy.mjs @@ -0,0 +1,62 @@ +import fs from 'fs' +import passport from 'passport' +import Settings from '@overleaf/settings' +import { doPassportSamlLogin, doPassportSamlLogout } from './AuthenticationControllerSaml.mjs' +import { Strategy as SamlStrategy } from '@node-saml/passport-saml' + +function _readFilesContentFromEnv(envVar) { +// envVar is either a file name: 'file.pem', or string with array: '["file.pem", "file2.pem"]' + if (!envVar) return undefined + try { + const parsedFileNames = JSON.parse(envVar) + return parsedFileNames.map(filename => fs.readFileSync(filename, 'utf8')) + } catch (error) { + if (error instanceof SyntaxError) { // failed to parse, envVar must be a file name + return fs.readFileSync(envVar, 'utf8') + } else { + throw error + } + } +} + +const samlOptions = { + entryPoint: process.env.OVERLEAF_SAML_ENTRYPOINT, + callbackUrl: process.env.OVERLEAF_SAML_CALLBACK_URL, + issuer: process.env.OVERLEAF_SAML_ISSUER, + audience: process.env.OVERLEAF_SAML_AUDIENCE, + cert: _readFilesContentFromEnv(process.env.OVERLEAF_SAML_IDP_CERT), + signingCert: _readFilesContentFromEnv(process.env.OVERLEAF_SAML_PUBLIC_CERT), + privateKey: _readFilesContentFromEnv(process.env.OVERLEAF_SAML_PRIVATE_KEY), + decryptionCert: _readFilesContentFromEnv(process.env.OVERLEAF_SAML_DECRYPTION_CERT), + decryptionPvk: _readFilesContentFromEnv(process.env.OVERLEAF_SAML_DECRYPTION_PVK), + signatureAlgorithm: process.env.OVERLEAF_SAML_SIGNATURE_ALGORITHM, + additionalParams: JSON.parse(process.env.OVERLEAF_SAML_ADDITIONAL_PARAMS || '{}'), + additionalAuthorizeParams: JSON.parse(process.env.OVERLEAF_SAML_ADDITIONAL_AUTHORIZE_PARAMS || '{}'), + identifierFormat: process.env.OVERLEAF_SAML_IDENTIFIER_FORMAT, + acceptedClockSkewMs: process.env.OVERLEAF_SAML_ACCEPTED_CLOCK_SKEW_MS ? Number(process.env.OVERLEAF_SAML_ACCEPTED_CLOCK_SKEW_MS) : undefined, + attributeConsumingServiceIndex: process.env.OVERLEAF_SAML_ATTRIBUTE_CONSUMING_SERVICE_INDEX, + authnContext: process.env.OVERLEAF_SAML_AUTHN_CONTEXT ? JSON.parse(process.env.OVERLEAF_SAML_AUTHN_CONTEXT) : undefined, + forceAuthn: String(process.env.OVERLEAF_SAML_FORCE_AUTHN).toLowerCase() === 'true', + disableRequestedAuthnContext: String(process.env.OVERLEAF_SAML_DISABLE_REQUESTED_AUTHN_CONTEXT).toLowerCase() === 'true', + skipRequestCompression: process.env.OVERLEAF_SAML_AUTHN_REQUEST_BINDING === 'HTTP-POST', // compression should be skipped iff authnRequestBinding is POST + authnRequestBinding: process.env.OVERLEAF_SAML_AUTHN_REQUEST_BINDING, + validateInResponseTo: process.env.OVERLEAF_SAML_VALIDATE_IN_RESPONSE_TO, + requestIdExpirationPeriodMs: process.env.OVERLEAF_SAML_REQUEST_ID_EXPIRATION_PERIOD_MS ? Number(process.env.OVERLEAF_SAML_REQUEST_ID_EXPIRATION_PERIOD_MS) : undefined, +// cacheProvider: process.env.OVERLEAF_SAML_CACHE_PROVIDER, + logoutUrl: process.env.OVERLEAF_SAML_LOGOUT_URL, + logoutCallbackUrl: process.env.OVERLEAF_SAML_LOGOUT_CALLBACK_URL, + additionalLogoutParams: JSON.parse(process.env.OVERLEAF_SAML_ADDITIONAL_LOGOUT_PARAMS || '{}'), + passReqToCallback: true, +} + +function addSamlStrategy(passport) { + passport.use( + new SamlStrategy( + samlOptions, + doPassportSamlLogin, + doPassportSamlLogout + ) + ) +} + +export default addSamlStrategy diff --git a/services/web/modules/saml-authentication/index.mjs b/services/web/modules/saml-authentication/index.mjs new file mode 100644 index 0000000000..35ea70283f --- /dev/null +++ b/services/web/modules/saml-authentication/index.mjs @@ -0,0 +1,26 @@ +import initSamlSettings from './app/src/InitSamlSettings.mjs' +import addSamlStrategy from './app/src/SamlStrategy.mjs' +import SamlRouter from './app/src/SamlRouter.mjs' +import SamlNonCsrfRouter from './app/src/SamlNonCsrfRouter.mjs' + +let samlModule = {}; + +if (process.env.EXTERNAL_AUTH === 'saml') { + initSamlSettings() + samlModule = { + name: 'saml-authentication', + hooks: { + passportSetup: function (passport, callback) { + try { + addSamlStrategy(passport) + callback(null) + } catch (error) { + callback(error) + } + }, + }, + router: SamlRouter, + nonCsrfRouter: SamlNonCsrfRouter, + } +} +export default samlModule From fcd181e12c9fce565dce2db88a23c972855b0eae Mon Sep 17 00:00:00 2001 From: yu-i-i Date: Tue, 17 Dec 2024 18:36:18 +0100 Subject: [PATCH 30/43] Refactor authentication code; add OIDC support --- .../AuthenticationController.js | 6 +- .../PasswordReset/PasswordResetController.mjs | 4 - .../PasswordReset/PasswordResetHandler.mjs | 5 +- .../app/src/Features/User/UserController.js | 3 +- .../src/Features/User/UserPagesController.mjs | 6 +- .../app/src/infrastructure/ExpressLocals.js | 4 +- services/web/app/src/router.mjs | 4 +- services/web/app/views/user/login.pug | 9 + services/web/app/views/user/passwordReset.pug | 2 +- services/web/app/views/user/settings.pug | 4 +- services/web/config/settings.defaults.js | 19 +- .../web/frontend/extracted-translations.json | 1 + .../settings/components/linking-section.tsx | 3 +- .../components/linking/sso-widget.tsx | 4 +- .../settings/components/password-section.tsx | 6 +- .../frontend/js/shared/svgs/openid-logo.jsx | 27 +++ services/web/locales/en.json | 2 + .../app/src/LDAPAuthenticationController.mjs | 112 ++++++++++++ .../app/src/LDAPAuthenticationManager.mjs} | 36 ++-- .../ldap/app/src/LDAPContacts.mjs | 120 ++++++++++++ .../ldap/app/src/LDAPModuleManager.mjs | 112 ++++++++++++ .../ldap/app/src/LDAPRouter.mjs | 19 ++ .../web/modules/authentication/ldap/index.mjs | 17 ++ .../web/modules/authentication/logout.mjs | 18 ++ .../app/src/OIDCAuthenticationController.mjs | 171 ++++++++++++++++++ .../app/src/OIDCAuthenticationManager.mjs | 94 ++++++++++ .../oidc/app/src/OIDCModuleManager.mjs | 82 +++++++++ .../oidc/app/src/OIDCRouter.mjs | 15 ++ .../web/modules/authentication/oidc/index.mjs | 16 ++ .../app/src/SAMLAuthenticationController.mjs} | 66 +++---- .../app/src/SAMLAuthenticationManager.mjs | 85 +++++++++ .../saml/app/src/SAMLModuleManager.mjs | 100 ++++++++++ .../saml/app/src/SAMLNonCsrfRouter.mjs | 11 ++ .../saml/app/src/SAMLRouter.mjs | 16 ++ .../web/modules/authentication/saml/index.mjs | 18 ++ services/web/modules/authentication/utils.mjs | 42 +++++ .../app/src/AuthenticationControllerLdap.mjs | 64 ------- .../app/src/InitLdapSettings.mjs | 17 -- .../app/src/LdapContacts.mjs | 136 -------------- .../app/src/LdapStrategy.mjs | 78 -------- .../web/modules/ldap-authentication/index.mjs | 30 --- .../app/src/AuthenticationManagerSaml.mjs | 60 ------ .../app/src/InitSamlSettings.mjs | 16 -- .../app/src/SamlNonCsrfRouter.mjs | 12 -- .../app/src/SamlRouter.mjs | 14 -- .../app/src/SamlStrategy.mjs | 62 ------- .../web/modules/saml-authentication/index.mjs | 26 --- services/web/package.json | 1 + 48 files changed, 1169 insertions(+), 606 deletions(-) create mode 100644 services/web/frontend/js/shared/svgs/openid-logo.jsx create mode 100644 services/web/modules/authentication/ldap/app/src/LDAPAuthenticationController.mjs rename services/web/modules/{ldap-authentication/app/src/AuthenticationManagerLdap.mjs => authentication/ldap/app/src/LDAPAuthenticationManager.mjs} (67%) create mode 100644 services/web/modules/authentication/ldap/app/src/LDAPContacts.mjs create mode 100644 services/web/modules/authentication/ldap/app/src/LDAPModuleManager.mjs create mode 100644 services/web/modules/authentication/ldap/app/src/LDAPRouter.mjs create mode 100644 services/web/modules/authentication/ldap/index.mjs create mode 100644 services/web/modules/authentication/logout.mjs create mode 100644 services/web/modules/authentication/oidc/app/src/OIDCAuthenticationController.mjs create mode 100644 services/web/modules/authentication/oidc/app/src/OIDCAuthenticationManager.mjs create mode 100644 services/web/modules/authentication/oidc/app/src/OIDCModuleManager.mjs create mode 100644 services/web/modules/authentication/oidc/app/src/OIDCRouter.mjs create mode 100644 services/web/modules/authentication/oidc/index.mjs rename services/web/modules/{saml-authentication/app/src/AuthenticationControllerSaml.mjs => authentication/saml/app/src/SAMLAuthenticationController.mjs} (65%) create mode 100644 services/web/modules/authentication/saml/app/src/SAMLAuthenticationManager.mjs create mode 100644 services/web/modules/authentication/saml/app/src/SAMLModuleManager.mjs create mode 100644 services/web/modules/authentication/saml/app/src/SAMLNonCsrfRouter.mjs create mode 100644 services/web/modules/authentication/saml/app/src/SAMLRouter.mjs create mode 100644 services/web/modules/authentication/saml/index.mjs create mode 100644 services/web/modules/authentication/utils.mjs delete mode 100644 services/web/modules/ldap-authentication/app/src/AuthenticationControllerLdap.mjs delete mode 100644 services/web/modules/ldap-authentication/app/src/InitLdapSettings.mjs delete mode 100644 services/web/modules/ldap-authentication/app/src/LdapContacts.mjs delete mode 100644 services/web/modules/ldap-authentication/app/src/LdapStrategy.mjs delete mode 100644 services/web/modules/ldap-authentication/index.mjs delete mode 100644 services/web/modules/saml-authentication/app/src/AuthenticationManagerSaml.mjs delete mode 100644 services/web/modules/saml-authentication/app/src/InitSamlSettings.mjs delete mode 100644 services/web/modules/saml-authentication/app/src/SamlNonCsrfRouter.mjs delete mode 100644 services/web/modules/saml-authentication/app/src/SamlRouter.mjs delete mode 100644 services/web/modules/saml-authentication/app/src/SamlStrategy.mjs delete mode 100644 services/web/modules/saml-authentication/index.mjs diff --git a/services/web/app/src/Features/Authentication/AuthenticationController.js b/services/web/app/src/Features/Authentication/AuthenticationController.js index fc761ecbfb..a190ddab5a 100644 --- a/services/web/app/src/Features/Authentication/AuthenticationController.js +++ b/services/web/app/src/Features/Authentication/AuthenticationController.js @@ -97,6 +97,7 @@ const AuthenticationController = { analyticsId: user.analyticsId || user._id, alphaProgram: user.alphaProgram || undefined, // only store if set betaProgram: user.betaProgram || undefined, // only store if set + externalAuth: user.externalAuth || false, } if (user.isAdmin) { lightUser.isAdmin = true @@ -117,9 +118,9 @@ const AuthenticationController = { // so we can send back our custom `{message: {text: "", type: ""}}` responses on failure, // and send a `{redir: ""}` response on success passport.authenticate( - Settings.ldap?.enable ? ['custom-fail-ldapauth','local'] : ['local'], + 'local', { keepSessionInfo: true }, - async function (err, user, infoArray) { + async function (err, user, info) { if (err) { return next(err) } @@ -141,7 +142,6 @@ const AuthenticationController = { return next(err) } } else { - let info = infoArray[0] if (info.redir != null) { return res.json({ redir: info.redir }) } else { diff --git a/services/web/app/src/Features/PasswordReset/PasswordResetController.mjs b/services/web/app/src/Features/PasswordReset/PasswordResetController.mjs index 54f847ef9c..2963c56653 100644 --- a/services/web/app/src/Features/PasswordReset/PasswordResetController.mjs +++ b/services/web/app/src/Features/PasswordReset/PasswordResetController.mjs @@ -140,10 +140,6 @@ async function requestReset(req, res, next) { return res.status(404).json({ message: req.i18n.translate('secondary_email_password_reset'), }) - } else if (status === 'external') { - return res.status(403).json({ - message: req.i18n.translate('password_managed_externally'), - }) } else { return res.status(404).json({ message: req.i18n.translate('cant_find_email'), diff --git a/services/web/app/src/Features/PasswordReset/PasswordResetHandler.mjs b/services/web/app/src/Features/PasswordReset/PasswordResetHandler.mjs index 0ac203222c..2c1aefe6a6 100644 --- a/services/web/app/src/Features/PasswordReset/PasswordResetHandler.mjs +++ b/services/web/app/src/Features/PasswordReset/PasswordResetHandler.mjs @@ -18,10 +18,6 @@ async function generateAndEmailResetToken(email) { return null } - if (!user.hashedPassword) { - return 'external' - } - if (user.email !== email) { return 'secondary' } @@ -76,6 +72,7 @@ async function getUserForPasswordResetToken(token) { 'overleaf.id': 1, email: 1, must_reconfirm: 1, + hashedPassword: 1, }) await assertUserPermissions(user, ['change-password']) diff --git a/services/web/app/src/Features/User/UserController.js b/services/web/app/src/Features/User/UserController.js index 772e77e3e4..b767dcd4a1 100644 --- a/services/web/app/src/Features/User/UserController.js +++ b/services/web/app/src/Features/User/UserController.js @@ -404,7 +404,7 @@ async function updateUserSettings(req, res, next) { if ( newEmail == null || newEmail === user.email || - (req.externalAuthenticationSystemUsed() && !user.hashedPassword) + req.externalAuthenticationSystemUsed() ) { // end here, don't update email SessionManager.setInSessionUser(req.session, { @@ -481,7 +481,6 @@ async function doLogout(req) { } async function logout(req, res, next) { - if (req?.session.saml_extce) return res.redirect(308, '/saml/logout') const requestedRedirect = req.body.redirect ? UrlHelper.getSafeRedirectPath(req.body.redirect) : undefined diff --git a/services/web/app/src/Features/User/UserPagesController.mjs b/services/web/app/src/Features/User/UserPagesController.mjs index 8b5263c37d..c7ad5b30f4 100644 --- a/services/web/app/src/Features/User/UserPagesController.mjs +++ b/services/web/app/src/Features/User/UserPagesController.mjs @@ -52,10 +52,8 @@ async function settingsPage(req, res) { const reconfirmedViaSAML = _.get(req.session, ['saml', 'reconfirmed']) delete req.session.saml let shouldAllowEditingDetails = true - if (Settings.ldap && Settings.ldap.updateUserDetailsOnLogin) { - shouldAllowEditingDetails = false - } - if (Settings.saml && Settings.saml.updateUserDetailsOnLogin) { + const externalAuth = req.user.externalAuth + if (externalAuth && Settings[externalAuth].updateUserDetailsOnLogin) { shouldAllowEditingDetails = false } const oauthProviders = Settings.oauthProviders || {} diff --git a/services/web/app/src/infrastructure/ExpressLocals.js b/services/web/app/src/infrastructure/ExpressLocals.js index 5cf9501c29..26364cdc5c 100644 --- a/services/web/app/src/infrastructure/ExpressLocals.js +++ b/services/web/app/src/infrastructure/ExpressLocals.js @@ -107,9 +107,9 @@ module.exports = function (webRouter, privateApiRouter, publicApiRouter) { webRouter.use(function (req, res, next) { req.externalAuthenticationSystemUsed = - Features.externalAuthenticationSystemUsed + () => !!req?.user?.externalAuth res.locals.externalAuthenticationSystemUsed = - Features.externalAuthenticationSystemUsed + () => !!req?.user?.externalAuth req.hasFeature = res.locals.hasFeature = Features.hasFeature next() }) diff --git a/services/web/app/src/router.mjs b/services/web/app/src/router.mjs index e727fa7bc5..af855242cb 100644 --- a/services/web/app/src/router.mjs +++ b/services/web/app/src/router.mjs @@ -217,6 +217,8 @@ async function initialize(webRouter, privateApiRouter, publicApiRouter) { CaptchaMiddleware.canSkipCaptcha ) + await Modules.applyRouter(webRouter, privateApiRouter, publicApiRouter) + webRouter.get('/login', UserPagesController.loginPage) AuthenticationController.addEndpointToLoginWhitelist('/login') @@ -285,8 +287,6 @@ async function initialize(webRouter, privateApiRouter, publicApiRouter) { TokenAccessRouter.apply(webRouter) HistoryRouter.apply(webRouter, privateApiRouter) - await Modules.applyRouter(webRouter, privateApiRouter, publicApiRouter) - if (Settings.enableSubscriptions) { webRouter.get( '/user/bonus', diff --git a/services/web/app/views/user/login.pug b/services/web/app/views/user/login.pug index 3008b11b1d..97ef72476d 100644 --- a/services/web/app/views/user/login.pug +++ b/services/web/app/views/user/login.pug @@ -53,3 +53,12 @@ block content ) span(data-ol-inflight="idle") #{settings.saml.identityServiceName} span(hidden data-ol-inflight="pending") #{translate("logging_in")}… + if settings.oidc && settings.oidc.enable + form(data-ol-async-form, name="oidcLoginForm") + .actions(style='margin-top: 30px;') + a.btn.btn-secondary.btn-block( + href='/oidc/login', + data-ol-disabled-inflight + ) + span(data-ol-inflight="idle") #{settings.oidc.identityServiceName} + span(hidden data-ol-inflight="pending") #{translate("logging_in")}… diff --git a/services/web/app/views/user/passwordReset.pug b/services/web/app/views/user/passwordReset.pug index f498baff23..b6cd42c840 100644 --- a/services/web/app/views/user/passwordReset.pug +++ b/services/web/app/views/user/passwordReset.pug @@ -50,7 +50,7 @@ block content +notification({ariaLive: 'assertive', type: 'error', className: 'mb-3', content: translate(error)}) div(data-ol-custom-form-message='no-password-allowed-due-to-sso' hidden) - +notification({ariaLive: 'polite', type: 'error', className: 'mb-3', content: translate('you_cant_reset_password_due_to_sso', {}, [{name: 'a', attrs: {href: '/sso-login'}}])}) + +notification({ariaLive: 'polite', type: 'error', className: 'mb-3', content: translate('you_cant_reset_password_due_to_ldap_or_sso')}) input(name='_csrf' type='hidden' value=csrfToken) .form-group.mb-3 label.form-label(for='email') #{translate("email")} diff --git a/services/web/app/views/user/settings.pug b/services/web/app/views/user/settings.pug index a07863682e..45d21c7572 100644 --- a/services/web/app/views/user/settings.pug +++ b/services/web/app/views/user/settings.pug @@ -11,7 +11,7 @@ block append meta meta( name='ol-shouldAllowEditingDetails' data-type='boolean' - content=shouldAllowEditingDetails || hasPassword + content=shouldAllowEditingDetails ) meta(name='ol-oauthProviders' data-type='json' content=oauthProviders) meta(name='ol-institutionLinked' data-type='json' content=institutionLinked) @@ -34,7 +34,7 @@ block append meta meta( name='ol-isExternalAuthenticationSystemUsed' data-type='boolean' - content=externalAuthenticationSystemUsed() && !hasPassword + content=externalAuthenticationSystemUsed() ) meta(name='ol-user' data-type='json' content=user) meta(name='ol-labsExperiments' data-type='json' content=labsExperiments) diff --git a/services/web/config/settings.defaults.js b/services/web/config/settings.defaults.js index fa1e5766d0..f74aec972d 100644 --- a/services/web/config/settings.defaults.js +++ b/services/web/config/settings.defaults.js @@ -1033,8 +1033,9 @@ module.exports = { 'launchpad', 'server-ce-scripts', 'user-activate', - 'ldap-authentication', - 'saml-authentication', + 'authentication/ldap', + 'authentication/saml', + 'authentication/oidc', ], viewIncludes: {}, @@ -1061,6 +1062,20 @@ module.exports = { managedUsers: { enabled: false, }, + + oauthProviders: { + ...(process.env.EXTERNAL_AUTH && process.env.EXTERNAL_AUTH.includes('oidc') && { + [process.env.OVERLEAF_OIDC_PROVIDER_ID || 'oidc']: { + name: process.env.OVERLEAF_OIDC_PROVIDER_NAME || 'OIDC Provider', + descriptionKey: process.env.OVERLEAF_OIDC_PROVIDER_DESCRIPTION, + descriptionOptions: { link: process.env.OVERLEAF_OIDC_PROVIDER_INFO_LINK }, + hideWhenNotLinked: process.env.OVERLEAF_OIDC_PROVIDER_HIDE_NOT_LINKED ? + process.env.OVERLEAF_OIDC_PROVIDER_HIDE_NOT_LINKED.toLowerCase() === 'true' : undefined, + linkPath: '/oidc/login', + }, + }), + }, + } module.exports.mergeWith = function (overrides) { diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index 639c9fcdfc..f1b5a154e8 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -2145,6 +2145,7 @@ "you_can_select_or_invite_collaborator": "", "you_can_select_or_invite_collaborator_plural": "", "you_can_still_use_your_premium_features": "", + "you_cant_add_or_change_password_due_to_ldap_or_sso": "", "you_cant_add_or_change_password_due_to_sso": "", "you_cant_join_this_group_subscription": "", "you_dont_have_any_add_ons_on_your_account": "", diff --git a/services/web/frontend/js/features/settings/components/linking-section.tsx b/services/web/frontend/js/features/settings/components/linking-section.tsx index a198cb1328..411d38e650 100644 --- a/services/web/frontend/js/features/settings/components/linking-section.tsx +++ b/services/web/frontend/js/features/settings/components/linking-section.tsx @@ -204,7 +204,8 @@ function SSOLinkingWidgetContainer({ const { t } = useTranslation() const { unlink } = useSSOContext() - let description = '' + let description = subscription.provider.descriptionKey || + `${t('login_with_service', { service: subscription.provider.name, })}.` switch (subscription.providerId) { case 'collabratec': description = t('linked_collabratec_description') diff --git a/services/web/frontend/js/features/settings/components/linking/sso-widget.tsx b/services/web/frontend/js/features/settings/components/linking/sso-widget.tsx index 800a7540ae..bb767d984c 100644 --- a/services/web/frontend/js/features/settings/components/linking/sso-widget.tsx +++ b/services/web/frontend/js/features/settings/components/linking/sso-widget.tsx @@ -4,6 +4,7 @@ import { FetchError } from '../../../../infrastructure/fetch-json' import IEEELogo from '../../../../shared/svgs/ieee-logo' import GoogleLogo from '../../../../shared/svgs/google-logo' import OrcidLogo from '../../../../shared/svgs/orcid-logo' +import OpenIDLogo from '../../../../shared/svgs/openid-logo' import LinkingStatus from './status' import OLButton from '@/features/ui/components/ol/ol-button' import OLModal, { @@ -17,6 +18,7 @@ const providerLogos: { readonly [p: string]: JSX.Element } = { collabratec: , google: , orcid: , + oidc: , } type SSOLinkingWidgetProps = { @@ -66,7 +68,7 @@ export function SSOLinkingWidget({ return (
-
{providerLogos[providerId]}
+
{providerLogos[providerId] || providerLogos['oidc']}

{title}

diff --git a/services/web/frontend/js/features/settings/components/password-section.tsx b/services/web/frontend/js/features/settings/components/password-section.tsx index 739636e998..c09ad50562 100644 --- a/services/web/frontend/js/features/settings/components/password-section.tsx +++ b/services/web/frontend/js/features/settings/components/password-section.tsx @@ -39,11 +39,7 @@ function CanOnlyLogInThroughSSO() { return (

, - ]} + i18nKey="you_cant_add_or_change_password_due_to_ldap_or_sso" />

) diff --git a/services/web/frontend/js/shared/svgs/openid-logo.jsx b/services/web/frontend/js/shared/svgs/openid-logo.jsx new file mode 100644 index 0000000000..3de933820b --- /dev/null +++ b/services/web/frontend/js/shared/svgs/openid-logo.jsx @@ -0,0 +1,27 @@ +function OpenIDLogo() { + return ( + + + + + + + ) +} + +export default OpenIDLogo + diff --git a/services/web/locales/en.json b/services/web/locales/en.json index f699d59812..ec4562c36e 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -2703,8 +2703,10 @@ "you_can_select_or_invite_collaborator": "You can select or invite __count__ collaborator on your current plan. Upgrade to add more editors or reviewers.", "you_can_select_or_invite_collaborator_plural": "You can select or invite __count__ collaborators on your current plan. Upgrade to add more editors or reviewers.", "you_can_still_use_your_premium_features": "You can still use your premium features until the pause becomes active.", + "you_cant_add_or_change_password_due_to_ldap_or_sso": "You can’t add or change your password because your group or organization uses LDAP or SSO.", "you_cant_add_or_change_password_due_to_sso": "You can’t add or change your password because your group or organization uses <0>single sign-on (SSO).", "you_cant_join_this_group_subscription": "You can’t join this group subscription", + "you_cant_reset_password_due_to_ldap_or_sso": "You can’t reset your password because your group or organization uses LDAP or SSO. Contact your system administrator.", "you_cant_reset_password_due_to_sso": "You can’t reset your password because your group or organization uses SSO. <0>Log in with SSO.", "you_dont_have_any_add_ons_on_your_account": "You don’t have any add-ons on your account.", "you_dont_have_any_repositories": "You don’t have any repositories", diff --git a/services/web/modules/authentication/ldap/app/src/LDAPAuthenticationController.mjs b/services/web/modules/authentication/ldap/app/src/LDAPAuthenticationController.mjs new file mode 100644 index 0000000000..1a3ed01d3c --- /dev/null +++ b/services/web/modules/authentication/ldap/app/src/LDAPAuthenticationController.mjs @@ -0,0 +1,112 @@ +import logger from '@overleaf/logger' +import passport from 'passport' +import EmailHelper from '../../../../../app/src/Features/Helpers/EmailHelper.js' +import { handleAuthenticateErrors } from '../../../../../app/src/Features/Authentication/AuthenticationErrors.js' +import AuthenticationController from '../../../../../app/src/Features/Authentication/AuthenticationController.js' +import LDAPAuthenticationManager from './LDAPAuthenticationManager.mjs' + +const LDAPAuthenticationController = { + 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( + 'ldapauth', + { keepSessionInfo: true }, + async function (err, user, info, status) { + if (err) { //we cannot be here as long as errors are treated as fails + return next(err) + } + if (user) { + // `user` is either a user object or false + AuthenticationController.setAuditInfo(req, { + method: 'LDAP password login', + }) + + try { + await AuthenticationController.promises.finishLogin(user, req, res) + res.status(200) + return + } catch (err) { + return next(err) + } + } else { + if (status != 401) { + logger.warn(status, 'LDAP: ' + info.message) + } + if (EmailHelper.parseEmail(req.body.email)) return next() //Try local authentication + if (info.redir != null) { + return res.json({ redir: info.redir }) + } else { + res.status(status || info.status || 401) + delete info.status + info.type = 'error' + info.key = 'invalid-password-retry-or-reset' + const body = { message: info } + const { errorReason } = info + if (errorReason) { + body.errorReason = errorReason + delete info.errorReason + } + return res.json(body) + } + } + } + )(req, res, next) + }, + async doPassportLogin(req, profile, done) { + let user, info + try { + ;({ user, info } = await LDAPAuthenticationController._doPassportLogin( + req, + profile + )) + } catch (error) { + return done(error) + } + return done(undefined, user, info) + }, + async _doPassportLogin(req, profile) { + const { fromKnownDevice } = AuthenticationController.getAuditInfo(req) + const auditLog = { + ipAddress: req.ip, + info: { method: 'LDAP password login', fromKnownDevice }, + } + + let user, isPasswordReused + try { + user = await LDAPAuthenticationManager.promises.findOrCreateUser(profile, auditLog) + } catch (error) { + return { + user: false, + info: handleAuthenticateErrors(error, req), + } + } + if (user && AuthenticationController.captchaRequiredForLogin(req, user)) { + return { + user: false, + info: { + text: req.i18n.translate('cannot_verify_user_not_robot'), + type: 'error', + errorReason: 'cannot_verify_user_not_robot', + status: 400, + }, + } + } else if (user) { + user.externalAuth = 'ldap' + return { user, info: undefined } + } else { //we cannot be here, something is terribly wrong + logger.debug({ email : profile.mail }, 'failed LDAP log in') + return { + user: false, + info: { + type: 'error', + text: 'Unknown error', + status: 500, + }, + } + } + }, +} + +export default LDAPAuthenticationController diff --git a/services/web/modules/ldap-authentication/app/src/AuthenticationManagerLdap.mjs b/services/web/modules/authentication/ldap/app/src/LDAPAuthenticationManager.mjs similarity index 67% rename from services/web/modules/ldap-authentication/app/src/AuthenticationManagerLdap.mjs rename to services/web/modules/authentication/ldap/app/src/LDAPAuthenticationManager.mjs index 1371f76d52..66943e82a3 100644 --- a/services/web/modules/ldap-authentication/app/src/AuthenticationManagerLdap.mjs +++ b/services/web/modules/authentication/ldap/app/src/LDAPAuthenticationManager.mjs @@ -1,18 +1,13 @@ import Settings from '@overleaf/settings' import { callbackify } from '@overleaf/promise-utils' -import UserCreator from '../../../../app/src/Features/User/UserCreator.js' -import { User } from '../../../../app/src/models/User.js' +import UserCreator from '../../../../../app/src/Features/User/UserCreator.js' +import { ParallelLoginError } from '../../../../../app/src/Features/Authentication/AuthenticationErrors.js' +import { User } from '../../../../../app/src/models/User.js' +import { splitFullName } from '../../../utils.mjs' -const AuthenticationManagerLdap = { - splitFullName(fullName) { - fullName = fullName.trim(); - let lastSpaceIndex = fullName.lastIndexOf(' '); - let firstNames = fullName.substring(0, lastSpaceIndex).trim(); - let lastName = fullName.substring(lastSpaceIndex + 1).trim(); - return [firstNames, lastName]; - }, - async findOrCreateLdapUser(profile, auditLog) { - //user is already authenticated in Ldap +const LDAPAuthenticationManager = { + async findOrCreateUser(profile, auditLog) { + //user is already authenticated in LDAP const { attEmail, attFirstName, @@ -28,7 +23,7 @@ const AuthenticationManagerLdap = { : profile[attEmail].toLowerCase() let nameParts = ["",""] if ((!attFirstName || !attLastName) && attName) { - nameParts = this.splitFullName(profile[attName] || "") + nameParts = splitFullName(profile[attName] || "") } const firstName = attFirstName ? (profile[attFirstName] || "") : nameParts[0] let lastName = attLastName ? (profile[attLastName] || "") : nameParts[1] @@ -40,6 +35,7 @@ const AuthenticationManagerLdap = { profile[attAdmin] === valAdmin) } let user = await User.findOne({ 'email': email }).exec() + if( !user ) { user = await UserCreator.promises.createNewUser( { @@ -61,8 +57,12 @@ const AuthenticationManagerLdap = { userDetails.isAdmin = isAdmin } const result = await User.updateOne( - { _id: user._id, loginEpoch: user.loginEpoch }, { $inc: { loginEpoch: 1 }, $set: userDetails }, - {} + { _id: user._id, loginEpoch: user.loginEpoch }, + { + $inc: { loginEpoch: 1 }, + $set: userDetails, + $unset: { hashedPassword: "" }, + } ).exec() if (result.modifiedCount !== 1) { throw new ParallelLoginError() @@ -72,9 +72,5 @@ const AuthenticationManagerLdap = { } export default { - findOrCreateLdapUser: callbackify(AuthenticationManagerLdap.findOrCreateLdapUser), - promises: AuthenticationManagerLdap, + promises: LDAPAuthenticationManager, } -export const { - splitFullName, -} = AuthenticationManagerLdap diff --git a/services/web/modules/authentication/ldap/app/src/LDAPContacts.mjs b/services/web/modules/authentication/ldap/app/src/LDAPContacts.mjs new file mode 100644 index 0000000000..4557b4a4e4 --- /dev/null +++ b/services/web/modules/authentication/ldap/app/src/LDAPContacts.mjs @@ -0,0 +1,120 @@ +import Settings from '@overleaf/settings' +import logger from '@overleaf/logger' +import { promisify } from 'util' +import passport from 'passport' +import ldapjs from 'ldapauth-fork/node_modules/ldapjs/lib/index.js' +import UserGetter from '../../../../../app/src/Features/User/UserGetter.js' +import { splitFullName } from '../../../utils.mjs' + +function _searchLDAP(client, baseDN, options) { + return new Promise((resolve, reject) => { + const searchEntries = [] + client.search(baseDN, options, (error, res) => { + if (error) { + reject(error) + } else { + res.on('searchEntry', entry => searchEntries.push(entry.object)) + res.on('error', reject) + res.on('end', () => resolve(searchEntries)) + } + }) + }) +} + +async function fetchLDAPContacts(userId, contacts) { + if (!Settings.ldap?.enable || !process.env.OVERLEAF_LDAP_CONTACTS_FILTER) { + return [] + } + + const ldapOptions = passport._strategy('ldapauth').options.server + const { attEmail, attFirstName = "", attLastName = "", attName = "" } = Settings.ldap + const { + url, + timeout, + connectTimeout, + tlsOptions, + starttls, + bindDN, + bindCredentials + } = ldapOptions + const searchBase = process.env.OVERLEAF_LDAP_CONTACTS_SEARCH_BASE || ldapOptions.searchBase + const searchScope = process.env.OVERLEAF_LDAP_CONTACTS_SEARCH_SCOPE || 'sub' + const ldapConfig = { url, timeout, connectTimeout, tlsOptions } + + let ldapUsers + let client + + try { + await new Promise((resolve, reject) => { + client = ldapjs.createClient(ldapConfig) + client.on('error', (error) => { reject(error) }) + client.on('connectTimeout', (error) => { reject(error) }) + client.on('connect', () => { resolve() }) + }) + + if (starttls) { + const starttlsAsync = promisify(client.starttls).bind(client) + await starttlsAsync(tlsOptions, null) + } + const bindAsync = promisify(client.bind).bind(client) + await bindAsync(bindDN, bindCredentials) + + async function createContactsSearchFilter(client, ldapOptions, userId, contactsFilter) { + const searchProperty = process.env.OVERLEAF_LDAP_CONTACTS_PROPERTY + if (!searchProperty) { + return contactsFilter + } + const email = await UserGetter.promises.getUserEmail(userId) + const searchOptions = { + scope: ldapOptions.searchScope, + attributes: [searchProperty], + filter: `(${Settings.ldap.attEmail}=${email})` + } + const searchBase = ldapOptions.searchBase + const ldapUser = (await _searchLDAP(client, searchBase, searchOptions))[0] + const searchPropertyValue = ldapUser ? ldapUser[searchProperty] + : process.env.OVERLEAF_LDAP_CONTACTS_NON_LDAP_VALUE || 'IMATCHNOTHING' + return contactsFilter.replace(/{{userProperty}}/g, searchPropertyValue) + } + + const filter = await createContactsSearchFilter(client, ldapOptions, userId, process.env.OVERLEAF_LDAP_CONTACTS_FILTER) + const searchOptions = { scope: searchScope, attributes: [attEmail, attFirstName, attLastName, attName], filter } + + ldapUsers = await _searchLDAP(client, searchBase, searchOptions) + } catch (error) { + logger.warn({ error }, 'Error in fetchLDAPContacts') + return [] + } finally { + client?.unbind() + } + + const newLDAPContacts = ldapUsers.reduce((acc, ldapUser) => { + const email = Array.isArray(ldapUser[attEmail]) + ? ldapUser[attEmail][0]?.toLowerCase() + : ldapUser[attEmail]?.toLowerCase() + if (!email) return acc + if (!contacts.some(contact => contact.email === email)) { + let nameParts = ["", ""] + if ((!attFirstName || !attLastName) && attName) { + nameParts = splitFullName(ldapUser[attName] || "") + } + const firstName = attFirstName ? (ldapUser[attFirstName] || "") : nameParts[0] + const lastName = attLastName ? (ldapUser[attLastName] || "") : nameParts[1] + acc.push({ + first_name: firstName, + last_name: lastName, + email: email, + type: 'user' + }) + } + return acc + }, []) + + return newLDAPContacts.sort((a, b) => + a.last_name.localeCompare(b.last_name) || + a.first_name.localeCompare(b.first_name) || + a.email.localeCompare(b.email) + ) +} + +export default fetchLDAPContacts diff --git a/services/web/modules/authentication/ldap/app/src/LDAPModuleManager.mjs b/services/web/modules/authentication/ldap/app/src/LDAPModuleManager.mjs new file mode 100644 index 0000000000..846ca9b158 --- /dev/null +++ b/services/web/modules/authentication/ldap/app/src/LDAPModuleManager.mjs @@ -0,0 +1,112 @@ +import logger from '@overleaf/logger' +import passport from 'passport' +import { Strategy as LDAPStrategy } from 'passport-ldapauth' +import Settings from '@overleaf/settings' +import PermissionsManager from '../../../../../app/src/Features/Authorization/PermissionsManager.js' +import { readFilesContentFromEnv, numFromEnv, boolFromEnv } from '../../../utils.mjs' +import LDAPAuthenticationController from './LDAPAuthenticationController.mjs' +import fetchLDAPContacts from './LDAPContacts.mjs' + +const LDAPModuleManager = { + initSettings() { + Settings.ldap = { + enable: true, + placeholder: process.env.OVERLEAF_LDAP_PLACEHOLDER || 'Username', + attEmail: process.env.OVERLEAF_LDAP_EMAIL_ATT || 'mail', + attFirstName: process.env.OVERLEAF_LDAP_FIRST_NAME_ATT, + attLastName: process.env.OVERLEAF_LDAP_LAST_NAME_ATT, + attName: process.env.OVERLEAF_LDAP_NAME_ATT, + attAdmin: process.env.OVERLEAF_LDAP_IS_ADMIN_ATT, + valAdmin: process.env.OVERLEAF_LDAP_IS_ADMIN_ATT_VALUE, + updateUserDetailsOnLogin: boolFromEnv(process.env.OVERLEAF_LDAP_UPDATE_USER_DETAILS_ON_LOGIN), + } + }, + passportSetup(passport, callback) { + const ldapOptions = { + url: process.env.OVERLEAF_LDAP_URL, + bindDN: process.env.OVERLEAF_LDAP_BIND_DN || "", + bindCredentials: process.env.OVERLEAF_LDAP_BIND_CREDENTIALS || "", + bindProperty: process.env.OVERLEAF_LDAP_BIND_PROPERTY, + searchBase: process.env.OVERLEAF_LDAP_SEARCH_BASE, + searchFilter: process.env.OVERLEAF_LDAP_SEARCH_FILTER, + searchScope: process.env.OVERLEAF_LDAP_SEARCH_SCOPE || 'sub', + searchAttributes: JSON.parse(process.env.OVERLEAF_LDAP_SEARCH_ATTRIBUTES || '[]'), + groupSearchBase: process.env.OVERLEAF_LDAP_ADMIN_SEARCH_BASE, + groupSearchFilter: process.env.OVERLEAF_LDAP_ADMIN_SEARCH_FILTER, + groupSearchScope: process.env.OVERLEAF_LDAP_ADMIN_SEARCH_SCOPE || 'sub', + groupSearchAttributes: ["dn"], + groupDnProperty: process.env.OVERLEAF_LDAP_ADMIN_DN_PROPERTY, + cache: boolFromEnv(process.env.OVERLEAF_LDAP_CACHE), + timeout: numFromEnv(process.env.OVERLEAF_LDAP_TIMEOUT), + connectTimeout: numFromEnv(process.env.OVERLEAF_LDAP_CONNECT_TIMEOUT), + starttls: boolFromEnv(process.env.OVERLEAF_LDAP_STARTTLS), + tlsOptions: { + ca: readFilesContentFromEnv(process.env.OVERLEAF_LDAP_TLS_OPTS_CA_PATH), + rejectUnauthorized: boolFromEnv(process.env.OVERLEAF_LDAP_TLS_OPTS_REJECT_UNAUTH), + } + } + try { + passport.use( + new LDAPStrategy( + { + server: ldapOptions, + passReqToCallback: true, + usernameField: 'email', + passwordField: 'password', + handleErrorsAsFailures: true, + }, + LDAPAuthenticationController.doPassportLogin + ) + ) + callback(null) + } catch (error) { + callback(error) + } + }, + + async getContacts(userId, contacts, callback) { + try { + const newContacts = await fetchLDAPContacts(userId, contacts) + callback(null, newContacts) + } catch (error) { + callback(error) + } + }, + + initPolicy() { + try { + PermissionsManager.registerCapability('change-password', { default : true }) + } catch (error) { + logger.info({}, error.message) + } + const ldapPolicyValidator = async ({ user, subscription }) => { +// If user is not logged in, user.externalAuth is undefined, +// in this case allow to change password if the user has a hashedPassword + return user.externalAuth === 'ldap' || (user.externalAuth === undefined && !user.hashedPassword) + } + try { + PermissionsManager.registerPolicy( + 'ldapPolicy', + { 'change-password' : false }, + { validator: ldapPolicyValidator } + ) + } catch (error) { + logger.info({}, error.message) + } + }, + async getGroupPolicyForUser(user, callback) { + try { + const userValidationMap = await PermissionsManager.promises.getUserValidationStatus({ + user, + groupPolicy : { 'ldapPolicy' : true }, + subscription : null + }) + let groupPolicy = Object.fromEntries(userValidationMap) + callback(null, {'groupPolicy' : groupPolicy }) + } catch (error) { + callback(error) + } + }, +} + +export default LDAPModuleManager diff --git a/services/web/modules/authentication/ldap/app/src/LDAPRouter.mjs b/services/web/modules/authentication/ldap/app/src/LDAPRouter.mjs new file mode 100644 index 0000000000..44d9d373d2 --- /dev/null +++ b/services/web/modules/authentication/ldap/app/src/LDAPRouter.mjs @@ -0,0 +1,19 @@ +import logger from '@overleaf/logger' +import RateLimiterMiddleware from '../../../../../app/src/Features/Security/RateLimiterMiddleware.js' +import CaptchaMiddleware from '../../../../../app/src/Features/Captcha/CaptchaMiddleware.js' +import AuthenticationController from '../../../../../app/src/Features/Authentication/AuthenticationController.js' +import { overleafLoginRateLimiter } from '../../../../../app/src/infrastructure/RateLimiter.js' +import LDAPAuthenticationController from './LDAPAuthenticationController.mjs' + +export default { + apply(webRouter) { + logger.debug({}, 'Init LDAP router') + webRouter.post('/login', + RateLimiterMiddleware.rateLimit(overleafLoginRateLimiter), // rate limit IP (20 / 60s) + RateLimiterMiddleware.loginRateLimitEmail, // rate limit email (10 / 120s) + CaptchaMiddleware.validateCaptcha('login'), + LDAPAuthenticationController.passportLogin, + AuthenticationController.passportLogin, + ) + }, +} diff --git a/services/web/modules/authentication/ldap/index.mjs b/services/web/modules/authentication/ldap/index.mjs new file mode 100644 index 0000000000..244a8db8e7 --- /dev/null +++ b/services/web/modules/authentication/ldap/index.mjs @@ -0,0 +1,17 @@ +let ldapModule = {} +if (process.env.EXTERNAL_AUTH.includes('ldap')) { + const { default: LDAPModuleManager } = await import('./app/src/LDAPModuleManager.mjs') + const { default: router } = await import('./app/src/LDAPRouter.mjs') + LDAPModuleManager.initSettings() + LDAPModuleManager.initPolicy() + ldapModule = { + name: 'ldap-authentication', + hooks: { + passportSetup: LDAPModuleManager.passportSetup, + getContacts: LDAPModuleManager.getContacts, + getGroupPolicyForUser: LDAPModuleManager.getGroupPolicyForUser, + }, + router: router, + } +} +export default ldapModule diff --git a/services/web/modules/authentication/logout.mjs b/services/web/modules/authentication/logout.mjs new file mode 100644 index 0000000000..4163cf536d --- /dev/null +++ b/services/web/modules/authentication/logout.mjs @@ -0,0 +1,18 @@ +let SAMLAuthenticationController +if (process.env.EXTERNAL_AUTH.includes('saml')) { + SAMLAuthenticationController = await import('./saml/app/src/SAMLAuthenticationController.mjs') +} +let OIDCAuthenticationController +if (process.env.EXTERNAL_AUTH.includes('oidc')) { + OIDCAuthenticationController = await import('./oidc/app/src/OIDCAuthenticationController.mjs') +} +export default async function logout(req, res, next) { + switch(req.user.externalAuth) { + case 'saml': + return SAMLAuthenticationController.default.passportLogout(req, res, next) + case 'oidc': + return OIDCAuthenticationController.default.passportLogout(req, res, next) + default: + next() + } +} diff --git a/services/web/modules/authentication/oidc/app/src/OIDCAuthenticationController.mjs b/services/web/modules/authentication/oidc/app/src/OIDCAuthenticationController.mjs new file mode 100644 index 0000000000..42c01e712f --- /dev/null +++ b/services/web/modules/authentication/oidc/app/src/OIDCAuthenticationController.mjs @@ -0,0 +1,171 @@ +import logger from '@overleaf/logger' +import passport from 'passport' +import Settings from '@overleaf/settings' +import AuthenticationController from '../../../../../app/src/Features/Authentication/AuthenticationController.js' +import UserController from '../../../../../app/src/Features/User/UserController.js' +import ThirdPartyIdentityManager from '../../../../../app/src/Features/User/ThirdPartyIdentityManager.js' +import OIDCAuthenticationManager from './OIDCAuthenticationManager.mjs' +import { acceptsJson } from '../../../../../app/src/infrastructure/RequestContentTypeDetection.js' + +const OIDCAuthenticationController = { + passportLogin(req, res, next) { + req.session.intent = req.query.intent + passport.authenticate('openidconnect')(req, res, next) + }, + passportLoginCallback(req, res, next) { + passport.authenticate( + 'openidconnect', + { keepSessionInfo: true }, + async function (err, user, info) { + if (err) { + return next(err) + } + if(req.session.intent === 'link') { + delete req.session.intent +// After linking, log out from the OIDC provider and redirect back to '/user/settings'. +// Keycloak supports this; Authentik does not (yet). + const logoutUrl = process.env.OVERLEAF_OIDC_LOGOUT_URL + const redirectUri = `${Settings.siteUrl.replace(/\/+$/, '')}/user/settings` + return res.redirect(`${logoutUrl}?id_token_hint=${info.idToken}&post_logout_redirect_uri=${encodeURIComponent(redirectUri)}`) + } + if (user) { + req.session.idToken = info.idToken + user.externalAuth = 'oidc' + // `user` is either a user object or false + AuthenticationController.setAuditInfo(req, { + method: 'OIDC login', + }) + try { + await AuthenticationController.promises.finishLogin(user, req, res) + } catch (err) { + return next(err) + } + } else { + if (info.redir != null) { + return res.json({ redir: info.redir }) + } else { + res.status(info.status || 401) + delete info.status + const body = { message: info } + return res.json(body) + } + } + } + )(req, res, next) + }, + async doPassportLogin(req, issuer, profile, context, idToken, accessToken, refreshToken, done) { + let user, info + try { + if(req.session.intent === 'link') { + ;({ user, info } = await OIDCAuthenticationController._doLink( + req, + profile + )) + } else { + ;({ user, info } = await OIDCAuthenticationController._doLogin( + req, + profile + )) + } + } catch (error) { + return done(error) + } + if (user) { + info = { + ...(info || {}), + idToken: idToken + } + } + return done(null, user, info) + }, + async _doLogin(req, profile) { + const { fromKnownDevice } = AuthenticationController.getAuditInfo(req) + const auditLog = { + ipAddress: req.ip, + info: { method: 'OIDC login', fromKnownDevice }, + } + + let user + try { + user = await OIDCAuthenticationManager.promises.findOrCreateUser(profile, auditLog) + } catch (error) { + logger.debug({ email : profile.emails[0].value }, `OIDC login failed: ${error}`) + return { + user: false, + info: { + type: 'error', + text: error.message, + status: 401, + }, + } + } + if (user) { + return { user, info: undefined } + } else { // we cannot be here, something is terribly wrong + logger.debug({ email : profile.emails[0].value }, 'failed OIDC log in') + return { + user: false, + info: { + type: 'error', + text: 'Unknown error', + status: 500, + }, + } + } + }, + async _doLink(req, profile) { + const { user: { _id: userId }, ip } = req + try { + const auditLog = { + ipAddress: ip, + initiatorId: userId, + } + await OIDCAuthenticationManager.promises.linkAccount(userId, profile, auditLog) + } catch (error) { + logger.error(error.info, error.message) + return { + user: true, + info: { + type: 'error', + text: error.message, + status: 200, + }, + } + } + return { user: true, info: undefined } + }, + async unlinkAccount(req, res, next) { + try { + const { user: { _id: userId }, body: { providerId }, ip } = req + const auditLog = { + ipAddress: ip, + initiatorId: userId, + } + await ThirdPartyIdentityManager.promises.unlink(userId, providerId, auditLog) + return res.status(200).end() + } catch (error) { + logger.error(error.info, error.message) + return { + user: false, + info: { + type: 'error', + text: 'Can not unlink account', + status: 200, + } + } + } + }, + async passportLogout(req, res, next) { +// TODO: instead of storing idToken in session, use refreshToken to obtain a new idToken? + const idTokenHint = req.session.idToken + await UserController.promises.doLogout(req) + const logoutUrl = process.env.OVERLEAF_OIDC_LOGOUT_URL + const redirectUri = Settings.siteUrl + res.redirect(`${logoutUrl}?id_token_hint=${idTokenHint}&post_logout_redirect_uri=${encodeURIComponent(redirectUri)}`) + }, + passportLogoutCallback(req, res, next) { + const redirectUri = Settings.siteUrl + res.redirect(redirectUri) + }, +} +export default OIDCAuthenticationController diff --git a/services/web/modules/authentication/oidc/app/src/OIDCAuthenticationManager.mjs b/services/web/modules/authentication/oidc/app/src/OIDCAuthenticationManager.mjs new file mode 100644 index 0000000000..56ec2e5455 --- /dev/null +++ b/services/web/modules/authentication/oidc/app/src/OIDCAuthenticationManager.mjs @@ -0,0 +1,94 @@ +import Settings from '@overleaf/settings' +import UserCreator from '../../../../../app/src/Features/User/UserCreator.js' +import ThirdPartyIdentityManager from '../../../../../app/src/Features/User/ThirdPartyIdentityManager.js' +import { ParallelLoginError } from '../../../../../app/src/Features/Authentication/AuthenticationErrors.js' +import { User } from '../../../../../app/src/models/User.js' + +const OIDCAuthenticationManager = { + async findOrCreateUser(profile, auditLog) { + const { + attUserId, + attAdmin, + valAdmin, + updateUserDetailsOnLogin, + providerId, + } = Settings.oidc + const oidcUserId = profile[attUserId] + const email = profile.emails[0].value + const firstName = profile.name?.givenName || "" + const lastName = profile.name?.familyName || "" + let isAdmin = false + if (attAdmin && valAdmin) { + if (attAdmin === 'email') { + isAdmin = (email === valAdmin) + } else { + isAdmin = (profile[attAdmin] === valAdmin) + } + } + const oidcUserData = null // Possibly it can be used later + let user + try { + user = await ThirdPartyIdentityManager.promises.login(providerId, oidcUserId, oidcUserData) + } catch { +// A user with the specified OIDC ID and provider ID is not found. Search for a user with the given email. +// If no user exists with this email, create a new user and link the OIDC account to it. +// If a user exists but no account from the specified OIDC provider is linked to this user, link the OIDC account to this user. +// If an account from the specified provider is already linked to this user, unlink it, and link the OIDC account to this user. +// (Is it safe? Concider: If an account from the specified provider is already linked to this user, throw an error) + user = await User.findOne({ 'email': email }).exec() + if (!user) { + user = await UserCreator.promises.createNewUser( + { + email: email, + first_name: firstName, + last_name: lastName, + isAdmin: isAdmin, + holdingAccount: false, + } + ) + } +// const alreadyLinked = user.thirdPartyIdentifiers.some(item => item.providerId === providerId) +// if (!alreadyLinked) { + auditLog.initiatorId = user._id + await ThirdPartyIdentityManager.promises.link(user._id, providerId, oidcUserId, oidcUserData, auditLog) + await User.updateOne( + { _id: user._id }, + { $set : { + 'emails.0.confirmedAt': Date.now(), //email of external user is confirmed + }, + } + ).exec() +// } else { +// throw new Error(`Overleaf user ${user.email} is already linked to another ${providerId} user`) +// } + } + + let userDetails = updateUserDetailsOnLogin ? { first_name : firstName, last_name: lastName } : {} + if (attAdmin && valAdmin) { + user.isAdmin = isAdmin + userDetails.isAdmin = isAdmin + } + const result = await User.updateOne( + { _id: user._id, loginEpoch: user.loginEpoch }, { $inc: { loginEpoch: 1 }, $set: userDetails }, + {} + ).exec() + + if (result.modifiedCount !== 1) { + throw new ParallelLoginError() + } + return user + }, + async linkAccount(userId, profile, auditLog) { + const { + attUserId, + providerId, + } = Settings.oidc + const oidcUserId = profile[attUserId] + const oidcUserData = null // Possibly it can be used later + await ThirdPartyIdentityManager.promises.link(userId, providerId, oidcUserId, oidcUserData, auditLog) + }, +} + +export default { + promises: OIDCAuthenticationManager, +} diff --git a/services/web/modules/authentication/oidc/app/src/OIDCModuleManager.mjs b/services/web/modules/authentication/oidc/app/src/OIDCModuleManager.mjs new file mode 100644 index 0000000000..3a2e6e2780 --- /dev/null +++ b/services/web/modules/authentication/oidc/app/src/OIDCModuleManager.mjs @@ -0,0 +1,82 @@ +import logger from '@overleaf/logger' +import passport from 'passport' +import Settings from '@overleaf/settings' +import { readFilesContentFromEnv, numFromEnv, boolFromEnv } from '../../../utils.mjs' +import PermissionsManager from '../../../../../app/src/Features/Authorization/PermissionsManager.js' +import OIDCAuthenticationController from './OIDCAuthenticationController.mjs' +import { Strategy as OIDCStrategy } from 'passport-openidconnect' + +const OIDCModuleManager = { + initSettings() { + let providerId = process.env.OVERLEAF_OIDC_PROVIDER_ID || 'oidc' + Settings.oidc = { + enable: true, + providerId: providerId, + identityServiceName: process.env.OVERLEAF_OIDC_IDENTITY_SERVICE_NAME || `Log in with ${Settings.oauthProviders[providerId].name}`, + attUserId: process.env.OVERLEAF_OIDC_USER_ID_FIELD || 'id', + attAdmin: process.env.OVERLEAF_OIDC_IS_ADMIN_FIELD, + valAdmin: process.env.OVERLEAF_OIDC_IS_ADMIN_FIELD_VALUE, + updateUserDetailsOnLogin: boolFromEnv(process.env.OVERLEAF_OIDC_UPDATE_USER_DETAILS_ON_LOGIN), + } + }, + passportSetup(passport, callback) { + const oidcOptions = { + issuer: process.env.OVERLEAF_OIDC_ISSUER, + authorizationURL: process.env.OVERLEAF_OIDC_AUTHORIZATION_URL, + tokenURL: process.env.OVERLEAF_OIDC_TOKEN_URL, + userInfoURL: process.env.OVERLEAF_OIDC_USER_INFO_URL, + clientID: process.env.OVERLEAF_OIDC_CLIENT_ID, + clientSecret: process.env.OVERLEAF_OIDC_CLIENT_SECRET, + callbackURL: `${Settings.siteUrl.replace(/\/+$/, '')}/oidc/login/callback`, + scope: process.env.OVERLEAF_OIDC_SCOPE || 'openid profile email', + passReqToCallback: true, + } + try { + passport.use( + new OIDCStrategy( + oidcOptions, + OIDCAuthenticationController.doPassportLogin + ) + ) + callback(null) + } catch (error) { + callback(error) + } + }, + initPolicy() { + try { + PermissionsManager.registerCapability('change-password', { default : true }) + } catch (error) { + logger.info({}, error.message) + } + const oidcPolicyValidator = async ({ user, subscription }) => { +// If user is not logged in, user.externalAuth is undefined, +// in this case allow to change password if the user has a hashedPassword + return user.externalAuth === 'oidc' || (user.externalAuth === undefined && !user.hashedPassword) + } + try { + PermissionsManager.registerPolicy( + 'oidcPolicy', + { 'change-password' : false }, + { validator: oidcPolicyValidator } + ) + } catch (error) { + logger.info({}, error.message) + } + }, + async getGroupPolicyForUser(user, callback) { + try { + const userValidationMap = await PermissionsManager.promises.getUserValidationStatus({ + user, + groupPolicy : { 'oidcPolicy' : true }, + subscription : null + }) + let groupPolicy = Object.fromEntries(userValidationMap) + callback(null, {'groupPolicy' : groupPolicy }) + } catch (error) { + callback(error) + } + }, +} + +export default OIDCModuleManager diff --git a/services/web/modules/authentication/oidc/app/src/OIDCRouter.mjs b/services/web/modules/authentication/oidc/app/src/OIDCRouter.mjs new file mode 100644 index 0000000000..519fa5043a --- /dev/null +++ b/services/web/modules/authentication/oidc/app/src/OIDCRouter.mjs @@ -0,0 +1,15 @@ +import logger from '@overleaf/logger' +import UserController from '../../../../../app/src/Features/User/UserController.js' +import OIDCAuthenticationController from './OIDCAuthenticationController.mjs' +import logout from '../../../logout.mjs' + +export default { + apply(webRouter) { + logger.debug({}, 'Init OIDC router') + webRouter.get('/oidc/login', OIDCAuthenticationController.passportLogin) + webRouter.get('/oidc/login/callback', OIDCAuthenticationController.passportLoginCallback) + webRouter.get('/oidc/logout/callback', OIDCAuthenticationController.passportLogoutCallback) + webRouter.post('/user/oauth-unlink', OIDCAuthenticationController.unlinkAccount) + webRouter.post('/logout', logout, UserController.logout) + }, +} diff --git a/services/web/modules/authentication/oidc/index.mjs b/services/web/modules/authentication/oidc/index.mjs new file mode 100644 index 0000000000..51d9e0d483 --- /dev/null +++ b/services/web/modules/authentication/oidc/index.mjs @@ -0,0 +1,16 @@ +let oidcModule = {} +if (process.env.EXTERNAL_AUTH.includes('oidc')) { + const { default: OIDCModuleManager } = await import('./app/src/OIDCModuleManager.mjs') + const { default: router } = await import('./app/src/OIDCRouter.mjs') + OIDCModuleManager.initSettings() + OIDCModuleManager.initPolicy() + oidcModule = { + name: 'oidc-authentication', + hooks: { + passportSetup: OIDCModuleManager.passportSetup, + getGroupPolicyForUser: OIDCModuleManager.getGroupPolicyForUser, + }, + router: router, + } +} +export default oidcModule diff --git a/services/web/modules/saml-authentication/app/src/AuthenticationControllerSaml.mjs b/services/web/modules/authentication/saml/app/src/SAMLAuthenticationController.mjs similarity index 65% rename from services/web/modules/saml-authentication/app/src/AuthenticationControllerSaml.mjs rename to services/web/modules/authentication/saml/app/src/SAMLAuthenticationController.mjs index f5db3f738d..ac0e5398b2 100644 --- a/services/web/modules/saml-authentication/app/src/AuthenticationControllerSaml.mjs +++ b/services/web/modules/authentication/saml/app/src/SAMLAuthenticationController.mjs @@ -1,15 +1,16 @@ import Settings from '@overleaf/settings' import logger from '@overleaf/logger' import passport from 'passport' -import AuthenticationController from '../../../../app/src/Features/Authentication/AuthenticationController.js' -import AuthenticationManagerSaml from './AuthenticationManagerSaml.mjs' -import UserController from '../../../../app/src/Features/User/UserController.js' -import UserSessionsManager from '../../../../app/src/Features/User/UserSessionsManager.js' -import { handleAuthenticateErrors } from '../../../../app/src/Features/Authentication/AuthenticationErrors.js' -import { xmlResponse } from '../../../../app/src/infrastructure/Response.js' +import AuthenticationController from '../../../../../app/src/Features/Authentication/AuthenticationController.js' +import SAMLAuthenticationManager from './SAMLAuthenticationManager.mjs' +import UserController from '../../../../../app/src/Features/User/UserController.js' +import UserSessionsManager from '../../../../../app/src/Features/User/UserSessionsManager.js' +import { handleAuthenticateErrors } from '../../../../../app/src/Features/Authentication/AuthenticationErrors.js' +import { xmlResponse } from '../../../../../app/src/infrastructure/Response.js' +import { readFilesContentFromEnv } from '../../../utils.mjs' -const AuthenticationControllerSaml = { - passportSamlAuthWithIdP(req, res, next) { +const SAMLAuthenticationController = { + passportLogin(req, res, next) { if ( passport._strategy('saml')._saml.options.authnRequestBinding === 'HTTP-POST') { const csp = res.getHeader('Content-Security-Policy') if (csp) { @@ -21,7 +22,7 @@ const AuthenticationControllerSaml = { } passport.authenticate('saml')(req, res, next) }, - passportSamlLogin(req, res, next) { + passportLoginCallback(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 @@ -46,24 +47,19 @@ const AuthenticationControllerSaml = { if (info.redir != null) { return res.json({ redir: info.redir }) } else { - res.status(info.status || 200) + res.status(info.status || 401) delete info.status const body = { message: info } - const { errorReason } = info - if (errorReason) { - body.errorReason = errorReason - delete info.errorReason - } return res.json(body) } } } )(req, res, next) }, - async doPassportSamlLogin(req, profile, done) { + async doPassportLogin(req, profile, done) { let user, info try { - ;({ user, info } = await AuthenticationControllerSaml._doPassportSamlLogin( + ;({ user, info } = await SAMLAuthenticationController._doPassportLogin( req, profile )) @@ -72,7 +68,7 @@ const AuthenticationControllerSaml = { } return done(undefined, user, info) }, - async _doPassportSamlLogin(req, profile) { + async _doPassportLogin(req, profile) { const { fromKnownDevice } = AuthenticationController.getAuditInfo(req) const auditLog = { ipAddress: req.ip, @@ -81,7 +77,7 @@ const AuthenticationControllerSaml = { let user try { - user = await AuthenticationManagerSaml.promises.findOrCreateSamlUser(profile, auditLog) + user = await SAMLAuthenticationManager.promises.findOrCreateUser(profile, auditLog) } catch (error) { return { user: false, @@ -89,9 +85,10 @@ const AuthenticationControllerSaml = { } } if (user) { + user.externalAuth = 'saml' req.session.saml_extce = {nameID : profile.nameID, sessionIndex : profile.sessionIndex} return { user, info: undefined } - } else { //something wrong + } else { // we cannot be here, something is terribly wrong logger.debug({ email : profile.mail }, 'failed SAML log in') return { user: false, @@ -103,23 +100,24 @@ const AuthenticationControllerSaml = { } } }, - async passportSamlSPLogout(req, res, next) { + async passportLogout(req, res, next) { passport._strategy('saml').logout(req, async (err, url) => { - if (err) logger.error({ err }, 'can not generate logout url') await UserController.promises.doLogout(req) + if (err) return next(err) res.redirect(url) }) }, - passportSamlIdPLogout(req, res, next) { + passportLogoutCallback(req, res, next) { +//TODO: is it possible to close the editor? passport.authenticate('saml')(req, res, (err) => { if (err) return next(err) res.redirect('/login'); }) }, - async doPassportSamlLogout(req, profile, done) { + async doPassportLogout(req, profile, done) { let user, info try { - ;({ user, info } = await AuthenticationControllerSaml._doPassportSamlLogout( + ;({ user, info } = await SAMLAuthenticationController._doPassportLogout( req, profile )) @@ -128,7 +126,7 @@ const AuthenticationControllerSaml = { } return done(undefined, user, info) }, - async _doPassportSamlLogout(req, profile) { + async _doPassportLogout(req, profile) { if (req?.session?.saml_extce?.nameID === profile.nameID && req?.session?.saml_extce?.sessionIndex === profile.sessionIndex) { profile = req.user @@ -138,23 +136,15 @@ const AuthenticationControllerSaml = { }) return { user: profile, info: undefined } }, - passportSamlMetadata(req, res) { + getSPMetadata(req, res) { const samlStratery = passport._strategy('saml') res.setHeader('Content-Disposition', `attachment; filename="${samlStratery._saml.options.issuer}-meta.xml"`) xmlResponse(res, samlStratery.generateServiceProviderMetadata( - samlStratery._saml.options.decryptionCert, - samlStratery._saml.options.signingCert + readFilesContentFromEnv(process.env.OVERLEAF_SAML_DECRYPTION_CERT), + readFilesContentFromEnv(process.env.OVERLEAF_SAML_PUBLIC_CERT) ) ) }, } -export const { - passportSamlAuthWithIdP, - passportSamlLogin, - passportSamlSPLogout, - passportSamlIdPLogout, - doPassportSamlLogin, - doPassportSamlLogout, - passportSamlMetadata, -} = AuthenticationControllerSaml +export default SAMLAuthenticationController diff --git a/services/web/modules/authentication/saml/app/src/SAMLAuthenticationManager.mjs b/services/web/modules/authentication/saml/app/src/SAMLAuthenticationManager.mjs new file mode 100644 index 0000000000..80c4e30ea7 --- /dev/null +++ b/services/web/modules/authentication/saml/app/src/SAMLAuthenticationManager.mjs @@ -0,0 +1,85 @@ +import Settings from '@overleaf/settings' +import UserCreator from '../../../../../app/src/Features/User/UserCreator.js' +import { ParallelLoginError } from '../../../../../app/src/Features/Authentication/AuthenticationErrors.js' +import SAMLIdentityManager from '../../../../../app/src/Features/User/SAMLIdentityManager.js' +import { User } from '../../../../../app/src/models/User.js' + +const SAMLAuthenticationManager = { + async findOrCreateUser(profile, auditLog) { + const { + attUserId, + attEmail, + attFirstName, + attLastName, + attAdmin, + valAdmin, + updateUserDetailsOnLogin, + } = Settings.saml + const externalUserId = profile[attUserId] + const email = Array.isArray(profile[attEmail]) + ? profile[attEmail][0].toLowerCase() + : profile[attEmail].toLowerCase() + const firstName = attFirstName ? profile[attFirstName] : "" + const lastName = attLastName ? profile[attLastName] : email + let isAdmin = false + if (attAdmin && valAdmin) { + isAdmin = (Array.isArray(profile[attAdmin]) ? profile[attAdmin].includes(valAdmin) : + profile[attAdmin] === valAdmin) + } + const providerId = '1' // for now, only one fixed IdP is supported +// We search for a SAML user, and if none is found, we search for a user with the given email. If a user is found, +// we update the user to be a SAML user, otherwise, we create a new SAML user with the given email. In the case of +// multiple SAML IdPs, one would have to do something similar, or possibly report an error like +// 'the email is associated with the wrong IdP' + let user = await SAMLIdentityManager.getUser(providerId, externalUserId, attUserId) + if (!user) { + user = await User.findOne({ 'email': email }).exec() + if (!user) { + user = await UserCreator.promises.createNewUser( + { + email: email, + first_name: firstName, + last_name: lastName, + isAdmin: isAdmin, + holdingAccount: false, + samlIdentifiers: [{ providerId: providerId }], + } + ) + } + // cannot use SAMLIdentityManager.linkAccounts because affilations service is not there + await User.updateOne( + { _id: user._id }, + { + $set : { + 'emails.0.confirmedAt': Date.now(), //email of saml user is confirmed + 'emails.0.samlProviderId': providerId, + 'samlIdentifiers.0.providerId': providerId, + 'samlIdentifiers.0.externalUserId': externalUserId, + 'samlIdentifiers.0.userIdAttribute': attUserId, + }, + } + ).exec() + } + let userDetails = updateUserDetailsOnLogin ? { first_name : firstName, last_name: lastName } : {} + if (attAdmin && valAdmin) { + user.isAdmin = isAdmin + userDetails.isAdmin = isAdmin + } + const result = await User.updateOne( + { _id: user._id, loginEpoch: user.loginEpoch }, + { + $inc: { loginEpoch: 1 }, + $set: userDetails, + $unset: { hashedPassword: "" }, + }, + ).exec() + if (result.modifiedCount !== 1) { + throw new ParallelLoginError() + } + return user + }, +} + +export default { + promises: SAMLAuthenticationManager, +} diff --git a/services/web/modules/authentication/saml/app/src/SAMLModuleManager.mjs b/services/web/modules/authentication/saml/app/src/SAMLModuleManager.mjs new file mode 100644 index 0000000000..c7efdef214 --- /dev/null +++ b/services/web/modules/authentication/saml/app/src/SAMLModuleManager.mjs @@ -0,0 +1,100 @@ +import logger from '@overleaf/logger' +import passport from 'passport' +import Settings from '@overleaf/settings' +import { readFilesContentFromEnv, numFromEnv, boolFromEnv } from '../../../utils.mjs' +import PermissionsManager from '../../../../../app/src/Features/Authorization/PermissionsManager.js' +import SAMLAuthenticationController from './SAMLAuthenticationController.mjs' +import { Strategy as SAMLStrategy } from '@node-saml/passport-saml' + +const SAMLModuleManager = { + initSettings() { + Settings.saml = { + enable: true, + identityServiceName: process.env.OVERLEAF_SAML_IDENTITY_SERVICE_NAME || 'Log in with SAML IdP', + attUserId: process.env.OVERLEAF_SAML_USER_ID_FIELD || 'nameID', + attEmail: process.env.OVERLEAF_SAML_EMAIL_FIELD || 'nameID', + attFirstName: process.env.OVERLEAF_SAML_FIRST_NAME_FIELD || 'givenName', + attLastName: process.env.OVERLEAF_SAML_LAST_NAME_FIELD || 'lastName', + attAdmin: process.env.OVERLEAF_SAML_IS_ADMIN_FIELD, + valAdmin: process.env.OVERLEAF_SAML_IS_ADMIN_FIELD_VALUE, + updateUserDetailsOnLogin: boolFromEnv(process.env.OVERLEAF_SAML_UPDATE_USER_DETAILS_ON_LOGIN), + } +}, + passportSetup(passport, callback) { + const samlOptions = { + entryPoint: process.env.OVERLEAF_SAML_ENTRYPOINT, + callbackUrl: `${Settings.siteUrl.replace(/\/+$/, '')}/saml/login/callback`, + issuer: process.env.OVERLEAF_SAML_ISSUER, + audience: process.env.OVERLEAF_SAML_AUDIENCE, + cert: readFilesContentFromEnv(process.env.OVERLEAF_SAML_IDP_CERT), + privateKey: readFilesContentFromEnv(process.env.OVERLEAF_SAML_PRIVATE_KEY), + decryptionPvk: readFilesContentFromEnv(process.env.OVERLEAF_SAML_DECRYPTION_PVK), + signatureAlgorithm: process.env.OVERLEAF_SAML_SIGNATURE_ALGORITHM, + additionalParams: JSON.parse(process.env.OVERLEAF_SAML_ADDITIONAL_PARAMS || '{}'), + additionalAuthorizeParams: JSON.parse(process.env.OVERLEAF_SAML_ADDITIONAL_AUTHORIZE_PARAMS || '{}'), + identifierFormat: process.env.OVERLEAF_SAML_IDENTIFIER_FORMAT, + acceptedClockSkewMs: numFromEnv(process.env.OVERLEAF_SAML_ACCEPTED_CLOCK_SKEW_MS), + attributeConsumingServiceIndex: process.env.OVERLEAF_SAML_ATTRIBUTE_CONSUMING_SERVICE_INDEX, + authnContext: process.env.OVERLEAF_SAML_AUTHN_CONTEXT ? JSON.parse(process.env.OVERLEAF_SAML_AUTHN_CONTEXT) : undefined, + forceAuthn: boolFromEnv(process.env.OVERLEAF_SAML_FORCE_AUTHN), + disableRequestedAuthnContext: boolFromEnv(process.env.OVERLEAF_SAML_DISABLE_REQUESTED_AUTHN_CONTEXT), + skipRequestCompression: process.env.OVERLEAF_SAML_AUTHN_REQUEST_BINDING === 'HTTP-POST', // compression should be skipped iff authnRequestBinding is POST + authnRequestBinding: process.env.OVERLEAF_SAML_AUTHN_REQUEST_BINDING, + validateInResponseTo: process.env.OVERLEAF_SAML_VALIDATE_IN_RESPONSE_TO, + requestIdExpirationPeriodMs: numFromEnv(process.env.OVERLEAF_SAML_REQUEST_ID_EXPIRATION_PERIOD_MS), + // cacheProvider: process.env.OVERLEAF_SAML_CACHE_PROVIDER, + logoutUrl: process.env.OVERLEAF_SAML_LOGOUT_URL, + logoutCallbackUrl: `${Settings.siteUrl.replace(/\/+$/, '')}/saml/logout/callback`, + additionalLogoutParams: JSON.parse(process.env.OVERLEAF_SAML_ADDITIONAL_LOGOUT_PARAMS || '{}'), + passReqToCallback: true, + } + try { + passport.use( + new SAMLStrategy( + samlOptions, + SAMLAuthenticationController.doPassportLogin, + SAMLAuthenticationController.doPassportLogout + ) + ) + callback(null) + } catch (error) { + callback(error) + } + }, + initPolicy() { + try { + PermissionsManager.registerCapability('change-password', { default : true }) + } catch (error) { + logger.info({}, error.message) + } + const samlPolicyValidator = async ({ user, subscription }) => { +// If user is not logged in, user.externalAuth is undefined, +// in this case allow to change password if the user has a hashedPassword + return user.externalAuth === 'saml' || (user.externalAuth === undefined && !user.hashedPassword) + } + try { + PermissionsManager.registerPolicy( + 'samlPolicy', + { 'change-password' : false }, + { validator: samlPolicyValidator } + ) + } catch (error) { + logger.info({}, error.message) + } + }, + async getGroupPolicyForUser(user, callback) { + try { + const userValidationMap = await PermissionsManager.promises.getUserValidationStatus({ + user, + groupPolicy : { 'samlPolicy' : true }, + subscription : null + }) + let groupPolicy = Object.fromEntries(userValidationMap) + callback(null, {'groupPolicy' : groupPolicy }) + } catch (error) { + callback(error) + } + }, +} + +export default SAMLModuleManager diff --git a/services/web/modules/authentication/saml/app/src/SAMLNonCsrfRouter.mjs b/services/web/modules/authentication/saml/app/src/SAMLNonCsrfRouter.mjs new file mode 100644 index 0000000000..c0d617b299 --- /dev/null +++ b/services/web/modules/authentication/saml/app/src/SAMLNonCsrfRouter.mjs @@ -0,0 +1,11 @@ +import logger from '@overleaf/logger' +import SAMLAuthenticationController from './SAMLAuthenticationController.mjs' + +export default { + apply(webRouter) { + logger.debug({}, 'Init SAML NonCsrfRouter') + webRouter.post('/saml/login/callback', SAMLAuthenticationController.passportLoginCallback) + webRouter.get ('/saml/logout/callback', SAMLAuthenticationController.passportLogoutCallback) + webRouter.post('/saml/logout/callback', SAMLAuthenticationController.passportLogoutCallback) + }, +} diff --git a/services/web/modules/authentication/saml/app/src/SAMLRouter.mjs b/services/web/modules/authentication/saml/app/src/SAMLRouter.mjs new file mode 100644 index 0000000000..3cd6e56e2d --- /dev/null +++ b/services/web/modules/authentication/saml/app/src/SAMLRouter.mjs @@ -0,0 +1,16 @@ +import logger from '@overleaf/logger' +import AuthenticationController from '../../../../../app/src/Features/Authentication/AuthenticationController.js' +import UserController from '../../../../../app/src/Features/User/UserController.js' +import SAMLAuthenticationController from './SAMLAuthenticationController.mjs' +import logout from '../../../logout.mjs' + +export default { + apply(webRouter) { + logger.debug({}, 'Init SAML router') + webRouter.get('/saml/login', SAMLAuthenticationController.passportLogin) + AuthenticationController.addEndpointToLoginWhitelist('/saml/login') + webRouter.get('/saml/meta', SAMLAuthenticationController.getSPMetadata) + AuthenticationController.addEndpointToLoginWhitelist('/saml/meta') + webRouter.post('/logout', logout, UserController.logout) + }, +} diff --git a/services/web/modules/authentication/saml/index.mjs b/services/web/modules/authentication/saml/index.mjs new file mode 100644 index 0000000000..2d6ee5706c --- /dev/null +++ b/services/web/modules/authentication/saml/index.mjs @@ -0,0 +1,18 @@ +let samlModule = {} +if (process.env.EXTERNAL_AUTH.includes('saml')) { + const { default: SAMLModuleManager } = await import('./app/src/SAMLModuleManager.mjs') + const { default: router } = await import('./app/src/SAMLRouter.mjs') + const { default: nonCsrfRouter } = await import('./app/src/SAMLNonCsrfRouter.mjs') + SAMLModuleManager.initSettings() + SAMLModuleManager.initPolicy() + samlModule = { + name: 'saml-authentication', + hooks: { + passportSetup: SAMLModuleManager.passportSetup, + getGroupPolicyForUser: SAMLModuleManager.getGroupPolicyForUser, + }, + router: router, + nonCsrfRouter: nonCsrfRouter, + } +} +export default samlModule diff --git a/services/web/modules/authentication/utils.mjs b/services/web/modules/authentication/utils.mjs new file mode 100644 index 0000000000..468dae32b1 --- /dev/null +++ b/services/web/modules/authentication/utils.mjs @@ -0,0 +1,42 @@ +import fs from 'fs' +function readFilesContentFromEnv(envVar) { +// envVar is either a file name: 'file.pem', or string with array: '["file.pem", "file2.pem"]' + if (!envVar) return undefined + try { + const parsedFileNames = JSON.parse(envVar) + return parsedFileNames.map(filename => fs.readFileSync(filename, 'utf8')) + } catch (error) { + if (error instanceof SyntaxError) { // failed to parse, envVar must be a file name + return fs.readFileSync(envVar, 'utf8') + } else { + throw error + } + } +} +function numFromEnv(env) { + return env ? Number(env) : undefined +} +function boolFromEnv(env) { + if (env === undefined || env === null) return undefined + if (typeof env === "string") { + const envLower = env.toLowerCase() + if (envLower === 'true') return true + if (envLower === 'false') return false + } + throw new Error("Invalid value for boolean envirionment variable") +} + +function splitFullName(fullName) { + fullName = fullName.trim(); + let lastSpaceIndex = fullName.lastIndexOf(' '); + let firstNames = fullName.substring(0, lastSpaceIndex).trim(); + let lastName = fullName.substring(lastSpaceIndex + 1).trim(); + return [firstNames, lastName]; +} + +export { + readFilesContentFromEnv, + numFromEnv, + boolFromEnv, + splitFullName, +} diff --git a/services/web/modules/ldap-authentication/app/src/AuthenticationControllerLdap.mjs b/services/web/modules/ldap-authentication/app/src/AuthenticationControllerLdap.mjs deleted file mode 100644 index 64fa4f5a96..0000000000 --- a/services/web/modules/ldap-authentication/app/src/AuthenticationControllerLdap.mjs +++ /dev/null @@ -1,64 +0,0 @@ -import logger from '@overleaf/logger' -import LoginRateLimiter from '../../../../app/src/Features/Security/LoginRateLimiter.js' -import { handleAuthenticateErrors } from '../../../../app/src/Features/Authentication/AuthenticationErrors.js' -import AuthenticationController from '../../../../app/src/Features/Authentication/AuthenticationController.js' -import AuthenticationManagerLdap from './AuthenticationManagerLdap.mjs' - -const AuthenticationControllerLdap = { - async doPassportLdapLogin(req, ldapUser, done) { - let user, info - try { - ;({ user, info } = await AuthenticationControllerLdap._doPassportLdapLogin( - req, - ldapUser - )) - } catch (error) { - return done(error) - } - return done(undefined, user, info) - }, - async _doPassportLdapLogin(req, ldapUser) { - const { fromKnownDevice } = AuthenticationController.getAuditInfo(req) - const auditLog = { - ipAddress: req.ip, - info: { method: 'LDAP password login', fromKnownDevice }, - } - - let user, isPasswordReused - try { - user = await AuthenticationManagerLdap.promises.findOrCreateLdapUser(ldapUser, auditLog) - } catch (error) { - return { - user: false, - info: handleAuthenticateErrors(error, req), - } - } - if (user && AuthenticationController.captchaRequiredForLogin(req, user)) { - return { - user: false, - info: { - text: req.i18n.translate('cannot_verify_user_not_robot'), - type: 'error', - errorReason: 'cannot_verify_user_not_robot', - status: 400, - }, - } - } else if (user) { - // async actions - return { user, info: undefined } - } else { //something wrong - logger.debug({ email : ldapUser.mail }, 'failed LDAP log in') - return { - user: false, - info: { - type: 'error', - status: 500, - }, - } - } - }, -} - -export const { - doPassportLdapLogin, -} = AuthenticationControllerLdap diff --git a/services/web/modules/ldap-authentication/app/src/InitLdapSettings.mjs b/services/web/modules/ldap-authentication/app/src/InitLdapSettings.mjs deleted file mode 100644 index e7f312fc11..0000000000 --- a/services/web/modules/ldap-authentication/app/src/InitLdapSettings.mjs +++ /dev/null @@ -1,17 +0,0 @@ -import Settings from '@overleaf/settings' - -function initLdapSettings() { - Settings.ldap = { - enable: true, - placeholder: process.env.OVERLEAF_LDAP_PLACEHOLDER || 'Username', - attEmail: process.env.OVERLEAF_LDAP_EMAIL_ATT || 'mail', - attFirstName: process.env.OVERLEAF_LDAP_FIRST_NAME_ATT, - attLastName: process.env.OVERLEAF_LDAP_LAST_NAME_ATT, - attName: process.env.OVERLEAF_LDAP_NAME_ATT, - attAdmin: process.env.OVERLEAF_LDAP_IS_ADMIN_ATT, - valAdmin: process.env.OVERLEAF_LDAP_IS_ADMIN_ATT_VALUE, - updateUserDetailsOnLogin: String(process.env.OVERLEAF_LDAP_UPDATE_USER_DETAILS_ON_LOGIN ).toLowerCase() === 'true', - } -} - -export default initLdapSettings diff --git a/services/web/modules/ldap-authentication/app/src/LdapContacts.mjs b/services/web/modules/ldap-authentication/app/src/LdapContacts.mjs deleted file mode 100644 index c4093b8684..0000000000 --- a/services/web/modules/ldap-authentication/app/src/LdapContacts.mjs +++ /dev/null @@ -1,136 +0,0 @@ -import Settings from '@overleaf/settings' -import logger from '@overleaf/logger' -import passport from 'passport' -import ldapjs from 'ldapauth-fork/node_modules/ldapjs/lib/index.js' -import UserGetter from '../../../../app/src/Features/User/UserGetter.js' -import { splitFullName } from './AuthenticationManagerLdap.mjs' - -async function fetchLdapContacts(userId, contacts) { - if (!Settings.ldap?.enable || !process.env.OVERLEAF_LDAP_CONTACTS_FILTER) { - return [] - } - - const ldapOpts = passport._strategy('custom-fail-ldapauth').options.server - const { attEmail, attFirstName = "", attLastName = "", attName = "" } = Settings.ldap - const { - url, - timeout, - connectTimeout, - tlsOptions, - starttls, - bindDN, - bindCredentials, - } = ldapOpts - const searchBase = process.env.OVERLEAF_LDAP_CONTACTS_SEARCH_BASE || ldapOpts.searchBase - const searchScope = process.env.OVERLEAF_LDAP_CONTACTS_SEARCH_SCOPE || 'sub' - const ldapConfig = { url, timeout, connectTimeout, tlsOptions } - - let ldapUsers - const client = ldapjs.createClient(ldapConfig) - try { - if (starttls) { - await _upgradeToTLS(client, tlsOptions) - } - await _bindLdap(client, bindDN, bindCredentials) - - const filter = await _formContactsSearchFilter(client, ldapOpts, userId, process.env.OVERLEAF_LDAP_CONTACTS_FILTER) - const searchOptions = { scope: searchScope, attributes: [attEmail, attFirstName, attLastName, attName], filter } - - ldapUsers = await _searchLdap(client, searchBase, searchOptions) - } catch (err) { - logger.warn({ err }, 'error in fetchLdapContacts') - return [] - } finally { - client.unbind() - } - - const newLdapContacts = ldapUsers.reduce((acc, ldapUser) => { - const email = Array.isArray(ldapUser[attEmail]) - ? ldapUser[attEmail][0]?.toLowerCase() - : ldapUser[attEmail]?.toLowerCase() - if (!email) return acc - if (!contacts.some(contact => contact.email === email)) { - let nameParts = ["",""] - if ((!attFirstName || !attLastName) && attName) { - nameParts = splitFullName(ldapUser[attName] || "") - } - const firstName = attFirstName ? (ldapUser[attFirstName] || "") : nameParts[0] - const lastName = attLastName ? (ldapUser[attLastName] || "") : nameParts[1] - acc.push({ - first_name: firstName, - last_name: lastName, - email: email, - type: 'user', - }) - } - return acc - }, []) - - return newLdapContacts.sort((a, b) => - a.last_name.localeCompare(b.last_name) || - a.first_name.localeCompare(a.first_name) || - a.email.localeCompare(b.email) - ) -} - -function _upgradeToTLS(client, tlsOptions) { - return new Promise((resolve, reject) => { - client.on('error', error => reject(new Error(`LDAP client error: ${error}`))) - client.on('connect', () => { - client.starttls(tlsOptions, null, error => { - if (error) { - reject(new Error(`StartTLS error: ${error}`)) - } else { - resolve() - } - }) - }) - }) -} - -function _bindLdap(client, bindDN, bindCredentials) { - return new Promise((resolve, reject) => { - client.bind(bindDN, bindCredentials, error => { - if (error) { - reject(error) - } else { - resolve() - } - }) - }) -} - -function _searchLdap(client, baseDN, options) { - return new Promise((resolve, reject) => { - const searchEntries = [] - client.search(baseDN, options, (error, res) => { - if (error) { - reject(error) - } else { - res.on('searchEntry', entry => searchEntries.push(entry.object)) - res.on('error', reject) - res.on('end', () => resolve(searchEntries)) - } - }) - }) -} - -async function _formContactsSearchFilter(client, ldapOpts, userId, contactsFilter) { - const searchProperty = process.env.OVERLEAF_LDAP_CONTACTS_PROPERTY - if (!searchProperty) { - return contactsFilter - } - const email = await UserGetter.promises.getUserEmail(userId) - const searchOptions = { - scope: ldapOpts.searchScope, - attributes: [searchProperty], - filter: `(${Settings.ldap.attEmail}=${email})`, - } - const searchBase = ldapOpts.searchBase - const ldapUser = (await _searchLdap(client, searchBase, searchOptions))[0] - const searchPropertyValue = ldapUser ? ldapUser[searchProperty] - : process.env.OVERLEAF_LDAP_CONTACTS_NON_LDAP_VALUE || 'IMATCHNOTHING' - return contactsFilter.replace(/{{userProperty}}/g, searchPropertyValue) -} - -export default fetchLdapContacts diff --git a/services/web/modules/ldap-authentication/app/src/LdapStrategy.mjs b/services/web/modules/ldap-authentication/app/src/LdapStrategy.mjs deleted file mode 100644 index b07dc3f3bd..0000000000 --- a/services/web/modules/ldap-authentication/app/src/LdapStrategy.mjs +++ /dev/null @@ -1,78 +0,0 @@ -import fs from 'fs' -import passport from 'passport' -import Settings from '@overleaf/settings' -import { doPassportLdapLogin } from './AuthenticationControllerLdap.mjs' -import { Strategy as LdapStrategy } from 'passport-ldapauth' - -function _readFilesContentFromEnv(envVar) { -// envVar is either a file name: 'file.pem', or string with array: '["file.pem", "file2.pem"]' - if (!envVar) return undefined - try { - const parsedFileNames = JSON.parse(envVar) - return parsedFileNames.map(filename => fs.readFileSync(filename, 'utf8')) - } catch (error) { - if (error instanceof SyntaxError) { // failed to parse, envVar must be a file name - return fs.readFileSync(envVar, 'utf8') - } else { - throw error - } - } -} - -// custom responses on authentication failure -class CustomFailLdapStrategy extends LdapStrategy { - constructor(options, validate) { - super(options, validate); - this.name = 'custom-fail-ldapauth' - } - authenticate(req, options) { - const defaultFail = this.fail.bind(this) - this.fail = function(info, status) { - info.type = 'error' - info.key = 'invalid-password-retry-or-reset' - info.status = 401 - return defaultFail(info, status) - }.bind(this) - super.authenticate(req, options) - } -} - -const ldapServerOpts = { - url: process.env.OVERLEAF_LDAP_URL, - bindDN: process.env.OVERLEAF_LDAP_BIND_DN || "", - bindCredentials: process.env.OVERLEAF_LDAP_BIND_CREDENTIALS || "", - bindProperty: process.env.OVERLEAF_LDAP_BIND_PROPERTY, - searchBase: process.env.OVERLEAF_LDAP_SEARCH_BASE, - searchFilter: process.env.OVERLEAF_LDAP_SEARCH_FILTER, - searchScope: process.env.OVERLEAF_LDAP_SEARCH_SCOPE || 'sub', - searchAttributes: JSON.parse(process.env.OVERLEAF_LDAP_SEARCH_ATTRIBUTES || '[]'), - groupSearchBase: process.env.OVERLEAF_LDAP_ADMIN_SEARCH_BASE, - groupSearchFilter: process.env.OVERLEAF_LDAP_ADMIN_SEARCH_FILTER, - groupSearchScope: process.env.OVERLEAF_LDAP_ADMIN_SEARCH_SCOPE || 'sub', - groupSearchAttributes: ["dn"], - groupDnProperty: process.env.OVERLEAF_LDAP_ADMIN_DN_PROPERTY, - cache: String(process.env.OVERLEAF_LDAP_CACHE).toLowerCase() === 'true', - timeout: process.env.OVERLEAF_LDAP_TIMEOUT ? Number(process.env.OVERLEAF_LDAP_TIMEOUT) : undefined, - connectTimeout: process.env.OVERLEAF_LDAP_CONNECT_TIMEOUT ? Number(process.env.OVERLEAF_LDAP_CONNECT_TIMEOUT) : undefined, - starttls: String(process.env.OVERLEAF_LDAP_STARTTLS).toLowerCase() === 'true', - tlsOptions: { - ca: _readFilesContentFromEnv(process.env.OVERLEAF_LDAP_TLS_OPTS_CA_PATH), - rejectUnauthorized: String(process.env.OVERLEAF_LDAP_TLS_OPTS_REJECT_UNAUTH).toLowerCase() === 'true', - } -} - -function addLdapStrategy(passport) { - passport.use( - new CustomFailLdapStrategy( - { - server: ldapServerOpts, - passReqToCallback: true, - usernameField: 'email', - passwordField: 'password', - }, - doPassportLdapLogin - ) - ) -} - -export default addLdapStrategy diff --git a/services/web/modules/ldap-authentication/index.mjs b/services/web/modules/ldap-authentication/index.mjs deleted file mode 100644 index f56d7ffee0..0000000000 --- a/services/web/modules/ldap-authentication/index.mjs +++ /dev/null @@ -1,30 +0,0 @@ -import initLdapSettings from './app/src/InitLdapSettings.mjs' -import addLdapStrategy from './app/src/LdapStrategy.mjs' -import fetchLdapContacts from './app/src/LdapContacts.mjs' - -let ldapModule = {}; -if (process.env.EXTERNAL_AUTH === 'ldap') { - initLdapSettings() - ldapModule = { - name: 'ldap-authentication', - hooks: { - passportSetup: function (passport, callback) { - try { - addLdapStrategy(passport) - callback(null) - } catch (error) { - callback(error) - } - }, - getContacts: async function (userId, contacts, callback) { - try { - const newLdapContacts = await fetchLdapContacts(userId, contacts) - callback(null, newLdapContacts) - } catch (error) { - callback(error) - } - }, - } - } -} -export default ldapModule diff --git a/services/web/modules/saml-authentication/app/src/AuthenticationManagerSaml.mjs b/services/web/modules/saml-authentication/app/src/AuthenticationManagerSaml.mjs deleted file mode 100644 index 47d97f3019..0000000000 --- a/services/web/modules/saml-authentication/app/src/AuthenticationManagerSaml.mjs +++ /dev/null @@ -1,60 +0,0 @@ -import Settings from '@overleaf/settings' -import UserCreator from '../../../../app/src/Features/User/UserCreator.js' -import { User } from '../../../../app/src/models/User.js' - -const AuthenticationManagerSaml = { - async findOrCreateSamlUser(profile, auditLog) { - const { - attEmail, - attFirstName, - attLastName, - attAdmin, - valAdmin, - updateUserDetailsOnLogin, - } = Settings.saml - const email = Array.isArray(profile[attEmail]) - ? profile[attEmail][0].toLowerCase() - : profile[attEmail].toLowerCase() - const firstName = attFirstName ? profile[attFirstName] : "" - const lastName = attLastName ? profile[attLastName] : email - let isAdmin = false - if( attAdmin && valAdmin ) { - isAdmin = (Array.isArray(profile[attAdmin]) ? profile[attAdmin].includes(valAdmin) : - profile[attAdmin] === valAdmin) - } - let user = await User.findOne({ 'email': email }).exec() - if( !user ) { - user = await UserCreator.promises.createNewUser( - { - email: email, - first_name: firstName, - last_name: lastName, - isAdmin: isAdmin, - holdingAccount: false, - } - ) - await User.updateOne( - { _id: user._id }, - { $set : { 'emails.0.confirmedAt' : Date.now() } } - ).exec() //email of saml user is confirmed - } - let userDetails = updateUserDetailsOnLogin ? { first_name : firstName, last_name: lastName } : {} - if( attAdmin && valAdmin ) { - user.isAdmin = isAdmin - userDetails.isAdmin = isAdmin - } - const result = await User.updateOne( - { _id: user._id, loginEpoch: user.loginEpoch }, { $inc: { loginEpoch: 1 }, $set: userDetails }, - {} - ).exec() - - if (result.modifiedCount !== 1) { - throw new ParallelLoginError() - } - return user - }, -} - -export default { - promises: AuthenticationManagerSaml, -} diff --git a/services/web/modules/saml-authentication/app/src/InitSamlSettings.mjs b/services/web/modules/saml-authentication/app/src/InitSamlSettings.mjs deleted file mode 100644 index 441f9033af..0000000000 --- a/services/web/modules/saml-authentication/app/src/InitSamlSettings.mjs +++ /dev/null @@ -1,16 +0,0 @@ -import Settings from '@overleaf/settings' - -function initSamlSettings() { - Settings.saml = { - enable: true, - identityServiceName: process.env.OVERLEAF_SAML_IDENTITY_SERVICE_NAME || 'Login with SAML IdP', - attEmail: process.env.OVERLEAF_SAML_EMAIL_FIELD || 'nameID', - attFirstName: process.env.OVERLEAF_SAML_FIRST_NAME_FIELD || 'givenName', - attLastName: process.env.OVERLEAF_SAML_LAST_NAME_FIELD || 'lastName', - attAdmin: process.env.OVERLEAF_SAML_IS_ADMIN_FIELD, - valAdmin: process.env.OVERLEAF_SAML_IS_ADMIN_FIELD_VALUE, - updateUserDetailsOnLogin: String(process.env.OVERLEAF_SAML_UPDATE_USER_DETAILS_ON_LOGIN).toLowerCase() === 'true', - } -} - -export default initSamlSettings diff --git a/services/web/modules/saml-authentication/app/src/SamlNonCsrfRouter.mjs b/services/web/modules/saml-authentication/app/src/SamlNonCsrfRouter.mjs deleted file mode 100644 index 65b42c92ae..0000000000 --- a/services/web/modules/saml-authentication/app/src/SamlNonCsrfRouter.mjs +++ /dev/null @@ -1,12 +0,0 @@ -import logger from '@overleaf/logger' -import { passportSamlLogin, passportSamlIdPLogout } from './AuthenticationControllerSaml.mjs' - -export default { - apply(webRouter) { - logger.debug({}, 'Init SAML NonCsrfRouter') - webRouter.get('/saml/login/callback', passportSamlLogin) - webRouter.post('/saml/login/callback', passportSamlLogin) - webRouter.get('/saml/logout/callback', passportSamlIdPLogout) - webRouter.post('/saml/logout/callback', passportSamlIdPLogout) - }, -} diff --git a/services/web/modules/saml-authentication/app/src/SamlRouter.mjs b/services/web/modules/saml-authentication/app/src/SamlRouter.mjs deleted file mode 100644 index 9ee3677901..0000000000 --- a/services/web/modules/saml-authentication/app/src/SamlRouter.mjs +++ /dev/null @@ -1,14 +0,0 @@ -import logger from '@overleaf/logger' -import AuthenticationController from '../../../../app/src/Features/Authentication/AuthenticationController.js' -import { passportSamlAuthWithIdP, passportSamlSPLogout, passportSamlMetadata} from './AuthenticationControllerSaml.mjs' - -export default { - apply(webRouter) { - logger.debug({}, 'Init SAML router') - webRouter.get('/saml/login', passportSamlAuthWithIdP) - AuthenticationController.addEndpointToLoginWhitelist('/saml/login') - webRouter.post('/saml/logout', AuthenticationController.requireLogin(), passportSamlSPLogout) - webRouter.get('/saml/meta', passportSamlMetadata) - AuthenticationController.addEndpointToLoginWhitelist('/saml/meta') - }, -} diff --git a/services/web/modules/saml-authentication/app/src/SamlStrategy.mjs b/services/web/modules/saml-authentication/app/src/SamlStrategy.mjs deleted file mode 100644 index 3a16459f98..0000000000 --- a/services/web/modules/saml-authentication/app/src/SamlStrategy.mjs +++ /dev/null @@ -1,62 +0,0 @@ -import fs from 'fs' -import passport from 'passport' -import Settings from '@overleaf/settings' -import { doPassportSamlLogin, doPassportSamlLogout } from './AuthenticationControllerSaml.mjs' -import { Strategy as SamlStrategy } from '@node-saml/passport-saml' - -function _readFilesContentFromEnv(envVar) { -// envVar is either a file name: 'file.pem', or string with array: '["file.pem", "file2.pem"]' - if (!envVar) return undefined - try { - const parsedFileNames = JSON.parse(envVar) - return parsedFileNames.map(filename => fs.readFileSync(filename, 'utf8')) - } catch (error) { - if (error instanceof SyntaxError) { // failed to parse, envVar must be a file name - return fs.readFileSync(envVar, 'utf8') - } else { - throw error - } - } -} - -const samlOptions = { - entryPoint: process.env.OVERLEAF_SAML_ENTRYPOINT, - callbackUrl: process.env.OVERLEAF_SAML_CALLBACK_URL, - issuer: process.env.OVERLEAF_SAML_ISSUER, - audience: process.env.OVERLEAF_SAML_AUDIENCE, - cert: _readFilesContentFromEnv(process.env.OVERLEAF_SAML_IDP_CERT), - signingCert: _readFilesContentFromEnv(process.env.OVERLEAF_SAML_PUBLIC_CERT), - privateKey: _readFilesContentFromEnv(process.env.OVERLEAF_SAML_PRIVATE_KEY), - decryptionCert: _readFilesContentFromEnv(process.env.OVERLEAF_SAML_DECRYPTION_CERT), - decryptionPvk: _readFilesContentFromEnv(process.env.OVERLEAF_SAML_DECRYPTION_PVK), - signatureAlgorithm: process.env.OVERLEAF_SAML_SIGNATURE_ALGORITHM, - additionalParams: JSON.parse(process.env.OVERLEAF_SAML_ADDITIONAL_PARAMS || '{}'), - additionalAuthorizeParams: JSON.parse(process.env.OVERLEAF_SAML_ADDITIONAL_AUTHORIZE_PARAMS || '{}'), - identifierFormat: process.env.OVERLEAF_SAML_IDENTIFIER_FORMAT, - acceptedClockSkewMs: process.env.OVERLEAF_SAML_ACCEPTED_CLOCK_SKEW_MS ? Number(process.env.OVERLEAF_SAML_ACCEPTED_CLOCK_SKEW_MS) : undefined, - attributeConsumingServiceIndex: process.env.OVERLEAF_SAML_ATTRIBUTE_CONSUMING_SERVICE_INDEX, - authnContext: process.env.OVERLEAF_SAML_AUTHN_CONTEXT ? JSON.parse(process.env.OVERLEAF_SAML_AUTHN_CONTEXT) : undefined, - forceAuthn: String(process.env.OVERLEAF_SAML_FORCE_AUTHN).toLowerCase() === 'true', - disableRequestedAuthnContext: String(process.env.OVERLEAF_SAML_DISABLE_REQUESTED_AUTHN_CONTEXT).toLowerCase() === 'true', - skipRequestCompression: process.env.OVERLEAF_SAML_AUTHN_REQUEST_BINDING === 'HTTP-POST', // compression should be skipped iff authnRequestBinding is POST - authnRequestBinding: process.env.OVERLEAF_SAML_AUTHN_REQUEST_BINDING, - validateInResponseTo: process.env.OVERLEAF_SAML_VALIDATE_IN_RESPONSE_TO, - requestIdExpirationPeriodMs: process.env.OVERLEAF_SAML_REQUEST_ID_EXPIRATION_PERIOD_MS ? Number(process.env.OVERLEAF_SAML_REQUEST_ID_EXPIRATION_PERIOD_MS) : undefined, -// cacheProvider: process.env.OVERLEAF_SAML_CACHE_PROVIDER, - logoutUrl: process.env.OVERLEAF_SAML_LOGOUT_URL, - logoutCallbackUrl: process.env.OVERLEAF_SAML_LOGOUT_CALLBACK_URL, - additionalLogoutParams: JSON.parse(process.env.OVERLEAF_SAML_ADDITIONAL_LOGOUT_PARAMS || '{}'), - passReqToCallback: true, -} - -function addSamlStrategy(passport) { - passport.use( - new SamlStrategy( - samlOptions, - doPassportSamlLogin, - doPassportSamlLogout - ) - ) -} - -export default addSamlStrategy diff --git a/services/web/modules/saml-authentication/index.mjs b/services/web/modules/saml-authentication/index.mjs deleted file mode 100644 index 35ea70283f..0000000000 --- a/services/web/modules/saml-authentication/index.mjs +++ /dev/null @@ -1,26 +0,0 @@ -import initSamlSettings from './app/src/InitSamlSettings.mjs' -import addSamlStrategy from './app/src/SamlStrategy.mjs' -import SamlRouter from './app/src/SamlRouter.mjs' -import SamlNonCsrfRouter from './app/src/SamlNonCsrfRouter.mjs' - -let samlModule = {}; - -if (process.env.EXTERNAL_AUTH === 'saml') { - initSamlSettings() - samlModule = { - name: 'saml-authentication', - hooks: { - passportSetup: function (passport, callback) { - try { - addSamlStrategy(passport) - callback(null) - } catch (error) { - callback(error) - } - }, - }, - router: SamlRouter, - nonCsrfRouter: SamlNonCsrfRouter, - } -} -export default samlModule diff --git a/services/web/package.json b/services/web/package.json index b0cee1af06..7fccc9e500 100644 --- a/services/web/package.json +++ b/services/web/package.json @@ -162,6 +162,7 @@ "passport-ldapauth": "^2.1.4", "passport-local": "^1.0.0", "passport-oauth2": "^1.5.0", + "passport-openidconnect": "^0.1.2", "passport-orcid": "0.0.4", "pug": "^3.0.3", "pug-runtime": "^3.0.1", From 42f446be67abd860390a9b4209a4390ec25907ae Mon Sep 17 00:00:00 2001 From: yu-i-i Date: Mon, 27 Jan 2025 04:58:23 +0100 Subject: [PATCH 31/43] Re-export `doLogout` (was removed from exports in commit b9fb636). --- services/web/app/src/Features/User/UserController.js | 1 + .../oidc/app/src/OIDCAuthenticationController.mjs | 2 +- .../saml/app/src/SAMLAuthenticationController.mjs | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/services/web/app/src/Features/User/UserController.js b/services/web/app/src/Features/User/UserController.js index b767dcd4a1..cabab8c891 100644 --- a/services/web/app/src/Features/User/UserController.js +++ b/services/web/app/src/Features/User/UserController.js @@ -518,4 +518,5 @@ module.exports = { expireDeletedUsersAfterDuration: expressify(expireDeletedUsersAfterDuration), ensureAffiliationMiddleware: expressify(ensureAffiliationMiddleware), ensureAffiliation, + doLogout, } diff --git a/services/web/modules/authentication/oidc/app/src/OIDCAuthenticationController.mjs b/services/web/modules/authentication/oidc/app/src/OIDCAuthenticationController.mjs index 42c01e712f..0b8dc501e0 100644 --- a/services/web/modules/authentication/oidc/app/src/OIDCAuthenticationController.mjs +++ b/services/web/modules/authentication/oidc/app/src/OIDCAuthenticationController.mjs @@ -158,7 +158,7 @@ const OIDCAuthenticationController = { async passportLogout(req, res, next) { // TODO: instead of storing idToken in session, use refreshToken to obtain a new idToken? const idTokenHint = req.session.idToken - await UserController.promises.doLogout(req) + await UserController.doLogout(req) const logoutUrl = process.env.OVERLEAF_OIDC_LOGOUT_URL const redirectUri = Settings.siteUrl res.redirect(`${logoutUrl}?id_token_hint=${idTokenHint}&post_logout_redirect_uri=${encodeURIComponent(redirectUri)}`) diff --git a/services/web/modules/authentication/saml/app/src/SAMLAuthenticationController.mjs b/services/web/modules/authentication/saml/app/src/SAMLAuthenticationController.mjs index ac0e5398b2..3ed834608f 100644 --- a/services/web/modules/authentication/saml/app/src/SAMLAuthenticationController.mjs +++ b/services/web/modules/authentication/saml/app/src/SAMLAuthenticationController.mjs @@ -102,7 +102,7 @@ const SAMLAuthenticationController = { }, async passportLogout(req, res, next) { passport._strategy('saml').logout(req, async (err, url) => { - await UserController.promises.doLogout(req) + await UserController.doLogout(req) if (err) return next(err) res.redirect(url) }) From 177b57590eaa6e0d9cb5fad3d871098fb535640d Mon Sep 17 00:00:00 2001 From: yu-i-i Date: Thu, 6 Feb 2025 12:12:03 +0100 Subject: [PATCH 32/43] Add ENV variables to control SAML signature validation --- .../modules/authentication/saml/app/src/SAMLModuleManager.mjs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/services/web/modules/authentication/saml/app/src/SAMLModuleManager.mjs b/services/web/modules/authentication/saml/app/src/SAMLModuleManager.mjs index c7efdef214..29e9ae52cd 100644 --- a/services/web/modules/authentication/saml/app/src/SAMLModuleManager.mjs +++ b/services/web/modules/authentication/saml/app/src/SAMLModuleManager.mjs @@ -46,6 +46,8 @@ const SAMLModuleManager = { logoutUrl: process.env.OVERLEAF_SAML_LOGOUT_URL, logoutCallbackUrl: `${Settings.siteUrl.replace(/\/+$/, '')}/saml/logout/callback`, additionalLogoutParams: JSON.parse(process.env.OVERLEAF_SAML_ADDITIONAL_LOGOUT_PARAMS || '{}'), + wantAssertionsSigned: boolFromEnv(process.env.OVERLEAF_SAML_WANT_ASSERTIONS_SIGNED), + wantAuthnResponseSigned: boolFromEnv(process.env.OVERLEAF_SAML_WANT_AUTHN_RESPONSE_SIGNED), passReqToCallback: true, } try { From 360918956fc8a5c5f7fe9fcd8ed17914cb5c0bd6 Mon Sep 17 00:00:00 2001 From: yu-i-i Date: Sat, 22 Feb 2025 03:26:25 +0100 Subject: [PATCH 33/43] Whitelist /oidc/login endpoint, fixes #21 --- .../web/modules/authentication/oidc/app/src/OIDCRouter.mjs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/services/web/modules/authentication/oidc/app/src/OIDCRouter.mjs b/services/web/modules/authentication/oidc/app/src/OIDCRouter.mjs index 519fa5043a..0857e41889 100644 --- a/services/web/modules/authentication/oidc/app/src/OIDCRouter.mjs +++ b/services/web/modules/authentication/oidc/app/src/OIDCRouter.mjs @@ -1,5 +1,6 @@ import logger from '@overleaf/logger' import UserController from '../../../../../app/src/Features/User/UserController.js' +import AuthenticationController from '../../../../../app/src/Features/Authentication/AuthenticationController.js' import OIDCAuthenticationController from './OIDCAuthenticationController.mjs' import logout from '../../../logout.mjs' @@ -7,7 +8,9 @@ export default { apply(webRouter) { logger.debug({}, 'Init OIDC router') webRouter.get('/oidc/login', OIDCAuthenticationController.passportLogin) + AuthenticationController.addEndpointToLoginWhitelist('/oidc/login') webRouter.get('/oidc/login/callback', OIDCAuthenticationController.passportLoginCallback) + AuthenticationController.addEndpointToLoginWhitelist('/oidc/login/callback') webRouter.get('/oidc/logout/callback', OIDCAuthenticationController.passportLogoutCallback) webRouter.post('/user/oauth-unlink', OIDCAuthenticationController.unlinkAccount) webRouter.post('/logout', logout, UserController.logout) From e24b15ef2f2dab3a4dadd851d5c0c7bc52784a72 Mon Sep 17 00:00:00 2001 From: yu-i-i Date: Mon, 10 Mar 2025 05:55:01 +0100 Subject: [PATCH 34/43] See upstream commit 42ee56e --- services/web/modules/authentication/ldap/app/src/LDAPRouter.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/web/modules/authentication/ldap/app/src/LDAPRouter.mjs b/services/web/modules/authentication/ldap/app/src/LDAPRouter.mjs index 44d9d373d2..d2bbb35236 100644 --- a/services/web/modules/authentication/ldap/app/src/LDAPRouter.mjs +++ b/services/web/modules/authentication/ldap/app/src/LDAPRouter.mjs @@ -10,7 +10,7 @@ export default { logger.debug({}, 'Init LDAP router') webRouter.post('/login', RateLimiterMiddleware.rateLimit(overleafLoginRateLimiter), // rate limit IP (20 / 60s) - RateLimiterMiddleware.loginRateLimitEmail, // rate limit email (10 / 120s) + RateLimiterMiddleware.loginRateLimitEmail(), // rate limit email (10 / 120s) CaptchaMiddleware.validateCaptcha('login'), LDAPAuthenticationController.passportLogin, AuthenticationController.passportLogin, From 492917502c12d83d1362180bbc8ca4f518409cdc Mon Sep 17 00:00:00 2001 From: yu-i-i Date: Mon, 10 Mar 2025 06:37:50 +0100 Subject: [PATCH 35/43] Make OVERLEAF_OIDC_USER_ID_FIELD support 'email' as a value --- .../authentication/oidc/app/src/OIDCAuthenticationManager.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/services/web/modules/authentication/oidc/app/src/OIDCAuthenticationManager.mjs b/services/web/modules/authentication/oidc/app/src/OIDCAuthenticationManager.mjs index 56ec2e5455..5295ce63d0 100644 --- a/services/web/modules/authentication/oidc/app/src/OIDCAuthenticationManager.mjs +++ b/services/web/modules/authentication/oidc/app/src/OIDCAuthenticationManager.mjs @@ -13,8 +13,8 @@ const OIDCAuthenticationManager = { updateUserDetailsOnLogin, providerId, } = Settings.oidc - const oidcUserId = profile[attUserId] const email = profile.emails[0].value + const oidcUserId = (attUserId === 'email') ? email : profile[attUserId] const firstName = profile.name?.givenName || "" const lastName = profile.name?.familyName || "" let isAdmin = false @@ -83,7 +83,7 @@ const OIDCAuthenticationManager = { attUserId, providerId, } = Settings.oidc - const oidcUserId = profile[attUserId] + const oidcUserId = (attUserId === 'email') ? profile.emails[0].value : profile[attUserId] const oidcUserData = null // Possibly it can be used later await ThirdPartyIdentityManager.promises.link(userId, providerId, oidcUserId, oidcUserData, auditLog) }, From c59d1e1780bef80c5f72502518f009a4210ad5fc Mon Sep 17 00:00:00 2001 From: yu-i-i Date: Fri, 4 Apr 2025 15:14:14 +0200 Subject: [PATCH 36/43] Allow EXTERNAL_AUTH to be undefined, fixes #26 --- services/web/modules/authentication/ldap/index.mjs | 2 +- services/web/modules/authentication/oidc/index.mjs | 2 +- services/web/modules/authentication/saml/index.mjs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/services/web/modules/authentication/ldap/index.mjs b/services/web/modules/authentication/ldap/index.mjs index 244a8db8e7..94743a6611 100644 --- a/services/web/modules/authentication/ldap/index.mjs +++ b/services/web/modules/authentication/ldap/index.mjs @@ -1,5 +1,5 @@ let ldapModule = {} -if (process.env.EXTERNAL_AUTH.includes('ldap')) { +if (process.env.EXTERNAL_AUTH?.includes('ldap')) { const { default: LDAPModuleManager } = await import('./app/src/LDAPModuleManager.mjs') const { default: router } = await import('./app/src/LDAPRouter.mjs') LDAPModuleManager.initSettings() diff --git a/services/web/modules/authentication/oidc/index.mjs b/services/web/modules/authentication/oidc/index.mjs index 51d9e0d483..f10ff64c82 100644 --- a/services/web/modules/authentication/oidc/index.mjs +++ b/services/web/modules/authentication/oidc/index.mjs @@ -1,5 +1,5 @@ let oidcModule = {} -if (process.env.EXTERNAL_AUTH.includes('oidc')) { +if (process.env.EXTERNAL_AUTH?.includes('oidc')) { const { default: OIDCModuleManager } = await import('./app/src/OIDCModuleManager.mjs') const { default: router } = await import('./app/src/OIDCRouter.mjs') OIDCModuleManager.initSettings() diff --git a/services/web/modules/authentication/saml/index.mjs b/services/web/modules/authentication/saml/index.mjs index 2d6ee5706c..36f0281637 100644 --- a/services/web/modules/authentication/saml/index.mjs +++ b/services/web/modules/authentication/saml/index.mjs @@ -1,5 +1,5 @@ let samlModule = {} -if (process.env.EXTERNAL_AUTH.includes('saml')) { +if (process.env.EXTERNAL_AUTH?.includes('saml')) { const { default: SAMLModuleManager } = await import('./app/src/SAMLModuleManager.mjs') const { default: router } = await import('./app/src/SAMLRouter.mjs') const { default: nonCsrfRouter } = await import('./app/src/SAMLNonCsrfRouter.mjs') From 2e35bfe14f80e537c1afb5db88d17c66cdaa148d Mon Sep 17 00:00:00 2001 From: yu-i-i Date: Fri, 23 May 2025 16:00:40 +0200 Subject: [PATCH 37/43] Fix login page --- services/web/app/views/user/login.pug | 32 +++++++++++++-------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/services/web/app/views/user/login.pug b/services/web/app/views/user/login.pug index 97ef72476d..907e45dbb7 100644 --- a/services/web/app/views/user/login.pug +++ b/services/web/app/views/user/login.pug @@ -45,20 +45,20 @@ block content hr p.text-center !{login_support_text} if settings.saml && settings.saml.enable - form(data-ol-async-form, name="samlLoginForm") - .actions(style='margin-top: 30px;') - a.btn.btn-secondary.btn-block( - href='/saml/login', - data-ol-disabled-inflight - ) - span(data-ol-inflight="idle") #{settings.saml.identityServiceName} - span(hidden data-ol-inflight="pending") #{translate("logging_in")}… + .actions(style='margin-top: 30px;') + a.button.btn-secondary.btn( + href='/saml/login', + style="width: 100%;" + data-ol-disabled-inflight + ) + span(data-ol-inflight="idle") #{settings.saml.identityServiceName} + span(hidden data-ol-inflight="pending") #{translate("logging_in")}… if settings.oidc && settings.oidc.enable - form(data-ol-async-form, name="oidcLoginForm") - .actions(style='margin-top: 30px;') - a.btn.btn-secondary.btn-block( - href='/oidc/login', - data-ol-disabled-inflight - ) - span(data-ol-inflight="idle") #{settings.oidc.identityServiceName} - span(hidden data-ol-inflight="pending") #{translate("logging_in")}… + .actions(style='margin-top: 30px;') + a.button.btn-secondary.btn( + href='/oidc/login', + style="width: 100%;" + data-ol-disabled-inflight + ) + span(data-ol-inflight="idle") #{settings.oidc.identityServiceName} + span(hidden data-ol-inflight="pending") #{translate("logging_in")}… From 3736a0c27d23e9eef9d6e1c8ba1b6b5bf8399f1e Mon Sep 17 00:00:00 2001 From: yu-i-i Date: Mon, 16 Jun 2025 13:39:16 +0200 Subject: [PATCH 38/43] Avoid DEP0174 by removing async from callback-based getGroupPolicyForUser --- .../ldap/app/src/LDAPModuleManager.mjs | 20 +++++++++---------- .../oidc/app/src/OIDCModuleManager.mjs | 20 +++++++++---------- .../saml/app/src/SAMLModuleManager.mjs | 19 +++++++++--------- 3 files changed, 29 insertions(+), 30 deletions(-) diff --git a/services/web/modules/authentication/ldap/app/src/LDAPModuleManager.mjs b/services/web/modules/authentication/ldap/app/src/LDAPModuleManager.mjs index 846ca9b158..64afd02b0c 100644 --- a/services/web/modules/authentication/ldap/app/src/LDAPModuleManager.mjs +++ b/services/web/modules/authentication/ldap/app/src/LDAPModuleManager.mjs @@ -94,18 +94,18 @@ const LDAPModuleManager = { logger.info({}, error.message) } }, - async getGroupPolicyForUser(user, callback) { - try { - const userValidationMap = await PermissionsManager.promises.getUserValidationStatus({ - user, - groupPolicy : { 'ldapPolicy' : true }, - subscription : null - }) + + getGroupPolicyForUser(user, callback) { + PermissionsManager.promises.getUserValidationStatus({ + user, + groupPolicy : { 'ldapPolicy' : true }, + subscription : null + }).then(userValidationMap => { let groupPolicy = Object.fromEntries(userValidationMap) - callback(null, {'groupPolicy' : groupPolicy }) - } catch (error) { + callback(null, { groupPolicy }) + }).catch(error => { callback(error) - } + }) }, } diff --git a/services/web/modules/authentication/oidc/app/src/OIDCModuleManager.mjs b/services/web/modules/authentication/oidc/app/src/OIDCModuleManager.mjs index 3a2e6e2780..ec734ced19 100644 --- a/services/web/modules/authentication/oidc/app/src/OIDCModuleManager.mjs +++ b/services/web/modules/authentication/oidc/app/src/OIDCModuleManager.mjs @@ -64,18 +64,18 @@ const OIDCModuleManager = { logger.info({}, error.message) } }, - async getGroupPolicyForUser(user, callback) { - try { - const userValidationMap = await PermissionsManager.promises.getUserValidationStatus({ - user, - groupPolicy : { 'oidcPolicy' : true }, - subscription : null - }) + + getGroupPolicyForUser(user, callback) { + PermissionsManager.promises.getUserValidationStatus({ + user, + groupPolicy : { 'oidcPolicy' : true }, + subscription : null + }).then(userValidationMap => { let groupPolicy = Object.fromEntries(userValidationMap) - callback(null, {'groupPolicy' : groupPolicy }) - } catch (error) { + callback(null, { groupPolicy }) + }).catch(error => { callback(error) - } + }) }, } diff --git a/services/web/modules/authentication/saml/app/src/SAMLModuleManager.mjs b/services/web/modules/authentication/saml/app/src/SAMLModuleManager.mjs index 29e9ae52cd..67545f7fc9 100644 --- a/services/web/modules/authentication/saml/app/src/SAMLModuleManager.mjs +++ b/services/web/modules/authentication/saml/app/src/SAMLModuleManager.mjs @@ -84,18 +84,17 @@ const SAMLModuleManager = { logger.info({}, error.message) } }, - async getGroupPolicyForUser(user, callback) { - try { - const userValidationMap = await PermissionsManager.promises.getUserValidationStatus({ - user, - groupPolicy : { 'samlPolicy' : true }, - subscription : null - }) + getGroupPolicyForUser(user, callback) { + PermissionsManager.promises.getUserValidationStatus({ + user, + groupPolicy : { 'samlPolicy' : true }, + subscription : null + }).then(userValidationMap => { let groupPolicy = Object.fromEntries(userValidationMap) - callback(null, {'groupPolicy' : groupPolicy }) - } catch (error) { + callback(null, { groupPolicy }) + }).catch(error => { callback(error) - } + }) }, } From 3b3c9e7305a4896d69e4eec321a6e2a6e5993480 Mon Sep 17 00:00:00 2001 From: yu-i-i Date: Tue, 24 Jun 2025 00:51:45 +0200 Subject: [PATCH 39/43] Introduce an environment variable to control user creation in OIDC authentication, closes #47 --- services/web/app/src/infrastructure/Features.js | 2 +- .../oidc/app/src/OIDCAuthenticationController.mjs | 14 +++++++------- .../oidc/app/src/OIDCAuthenticationManager.mjs | 3 +++ .../oidc/app/src/OIDCModuleManager.mjs | 1 + 4 files changed, 12 insertions(+), 8 deletions(-) diff --git a/services/web/app/src/infrastructure/Features.js b/services/web/app/src/infrastructure/Features.js index 6147e70e0f..03732d8a32 100644 --- a/services/web/app/src/infrastructure/Features.js +++ b/services/web/app/src/infrastructure/Features.js @@ -63,7 +63,7 @@ const Features = { case 'registration-page': return ( !Features.externalAuthenticationSystemUsed() || - Boolean(Settings.overleaf) + Boolean(Settings.overleaf) || Settings.oidc?.disableJITAccountCreation ) case 'registration': return Boolean(Settings.overleaf) diff --git a/services/web/modules/authentication/oidc/app/src/OIDCAuthenticationController.mjs b/services/web/modules/authentication/oidc/app/src/OIDCAuthenticationController.mjs index 0b8dc501e0..f8bbd32c29 100644 --- a/services/web/modules/authentication/oidc/app/src/OIDCAuthenticationController.mjs +++ b/services/web/modules/authentication/oidc/app/src/OIDCAuthenticationController.mjs @@ -42,7 +42,8 @@ const OIDCAuthenticationController = { } } else { if (info.redir != null) { - return res.json({ redir: info.redir }) + await UserController.doLogout(req) + return res.redirect(info.redir) } else { res.status(info.status || 401) delete info.status @@ -95,20 +96,19 @@ const OIDCAuthenticationController = { info: { type: 'error', text: error.message, - status: 401, + status: 500, }, } } if (user) { return { user, info: undefined } - } else { // we cannot be here, something is terribly wrong - logger.debug({ email : profile.emails[0].value }, 'failed OIDC log in') + } else { // user account is not created + logger.debug({ email : profile.emails[0].value }, 'OIDC users JIT account creation is off') return { user: false, info: { - type: 'error', - text: 'Unknown error', - status: 500, + redir: '/register', + status: 401, }, } } diff --git a/services/web/modules/authentication/oidc/app/src/OIDCAuthenticationManager.mjs b/services/web/modules/authentication/oidc/app/src/OIDCAuthenticationManager.mjs index 5295ce63d0..3082558e39 100644 --- a/services/web/modules/authentication/oidc/app/src/OIDCAuthenticationManager.mjs +++ b/services/web/modules/authentication/oidc/app/src/OIDCAuthenticationManager.mjs @@ -37,6 +37,9 @@ const OIDCAuthenticationManager = { // (Is it safe? Concider: If an account from the specified provider is already linked to this user, throw an error) user = await User.findOne({ 'email': email }).exec() if (!user) { + if (Settings.oidc.disableJITAccountCreation) { + return null + } user = await UserCreator.promises.createNewUser( { email: email, diff --git a/services/web/modules/authentication/oidc/app/src/OIDCModuleManager.mjs b/services/web/modules/authentication/oidc/app/src/OIDCModuleManager.mjs index ec734ced19..debb5c8c5d 100644 --- a/services/web/modules/authentication/oidc/app/src/OIDCModuleManager.mjs +++ b/services/web/modules/authentication/oidc/app/src/OIDCModuleManager.mjs @@ -17,6 +17,7 @@ const OIDCModuleManager = { attAdmin: process.env.OVERLEAF_OIDC_IS_ADMIN_FIELD, valAdmin: process.env.OVERLEAF_OIDC_IS_ADMIN_FIELD_VALUE, updateUserDetailsOnLogin: boolFromEnv(process.env.OVERLEAF_OIDC_UPDATE_USER_DETAILS_ON_LOGIN), + disableJITAccountCreation: boolFromEnv(process.env.OVERLEAF_OIDC_DISABLE_JIT_ACCOUNT_CREATION), } }, passportSetup(passport, callback) { From 097aeaaf3a7d89294c8b47593a979a320bd93fa0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Syn=C3=A9sio=20Neto?= Date: Mon, 21 Jul 2025 15:51:13 -0300 Subject: [PATCH 40/43] Introduce an environment variable to allow JIT OIDC users creation based on their email address domain. --- .../oidc/app/src/OIDCAuthenticationManager.mjs | 7 +++++++ .../authentication/oidc/app/src/OIDCModuleManager.mjs | 1 + 2 files changed, 8 insertions(+) diff --git a/services/web/modules/authentication/oidc/app/src/OIDCAuthenticationManager.mjs b/services/web/modules/authentication/oidc/app/src/OIDCAuthenticationManager.mjs index 3082558e39..5d703b9711 100644 --- a/services/web/modules/authentication/oidc/app/src/OIDCAuthenticationManager.mjs +++ b/services/web/modules/authentication/oidc/app/src/OIDCAuthenticationManager.mjs @@ -37,6 +37,13 @@ const OIDCAuthenticationManager = { // (Is it safe? Concider: If an account from the specified provider is already linked to this user, throw an error) user = await User.findOne({ 'email': email }).exec() if (!user) { + let allowedDomains = Settings.oidc.allowedOIDCEmailDomains; + allowedDomains = allowedDomains.split(',').map(d => d.trim()); // Make sure it's an array + const domain = email.split('@')[1]; + + if (!allowedDomains.includes(domain)) { + return null; + } if (Settings.oidc.disableJITAccountCreation) { return null } diff --git a/services/web/modules/authentication/oidc/app/src/OIDCModuleManager.mjs b/services/web/modules/authentication/oidc/app/src/OIDCModuleManager.mjs index debb5c8c5d..b9f68f3ff8 100644 --- a/services/web/modules/authentication/oidc/app/src/OIDCModuleManager.mjs +++ b/services/web/modules/authentication/oidc/app/src/OIDCModuleManager.mjs @@ -16,6 +16,7 @@ const OIDCModuleManager = { attUserId: process.env.OVERLEAF_OIDC_USER_ID_FIELD || 'id', attAdmin: process.env.OVERLEAF_OIDC_IS_ADMIN_FIELD, valAdmin: process.env.OVERLEAF_OIDC_IS_ADMIN_FIELD_VALUE, + allowedOIDCEmailDomains: process.env.OVERLEAF_OIDC_ALLOWED_EMAIL_DOMAINS, updateUserDetailsOnLogin: boolFromEnv(process.env.OVERLEAF_OIDC_UPDATE_USER_DETAILS_ON_LOGIN), disableJITAccountCreation: boolFromEnv(process.env.OVERLEAF_OIDC_DISABLE_JIT_ACCOUNT_CREATION), } From 9ece5d0dad232d97d01ad78840c341af4e73861b Mon Sep 17 00:00:00 2001 From: yu-i-i Date: Tue, 22 Jul 2025 05:30:08 +0200 Subject: [PATCH 41/43] Remove OVERLEAF_OIDC_DISABLE_JIT_ACCOUNT_CREATION in favor of OVERLEAF_OIDC_ALLOWED_EMAIL_DOMAINS --- .../web/app/src/infrastructure/Features.js | 2 +- .../app/src/OIDCAuthenticationController.mjs | 2 +- .../app/src/OIDCAuthenticationManager.mjs | 22 +++++++++++-------- .../oidc/app/src/OIDCModuleManager.mjs | 5 +++-- 4 files changed, 18 insertions(+), 13 deletions(-) diff --git a/services/web/app/src/infrastructure/Features.js b/services/web/app/src/infrastructure/Features.js index 03732d8a32..bcf1cb00e5 100644 --- a/services/web/app/src/infrastructure/Features.js +++ b/services/web/app/src/infrastructure/Features.js @@ -63,7 +63,7 @@ const Features = { case 'registration-page': return ( !Features.externalAuthenticationSystemUsed() || - Boolean(Settings.overleaf) || Settings.oidc?.disableJITAccountCreation + Boolean(Settings.overleaf) || Settings.oidc?.allowedOIDCEmailDomains ) case 'registration': return Boolean(Settings.overleaf) diff --git a/services/web/modules/authentication/oidc/app/src/OIDCAuthenticationController.mjs b/services/web/modules/authentication/oidc/app/src/OIDCAuthenticationController.mjs index f8bbd32c29..9365c9ea73 100644 --- a/services/web/modules/authentication/oidc/app/src/OIDCAuthenticationController.mjs +++ b/services/web/modules/authentication/oidc/app/src/OIDCAuthenticationController.mjs @@ -103,7 +103,7 @@ const OIDCAuthenticationController = { if (user) { return { user, info: undefined } } else { // user account is not created - logger.debug({ email : profile.emails[0].value }, 'OIDC users JIT account creation is off') + logger.debug({ email : profile.emails[0].value }, 'OIDC JIT account creation is not allowed for this email') return { user: false, info: { diff --git a/services/web/modules/authentication/oidc/app/src/OIDCAuthenticationManager.mjs b/services/web/modules/authentication/oidc/app/src/OIDCAuthenticationManager.mjs index 5d703b9711..9d3c1becc9 100644 --- a/services/web/modules/authentication/oidc/app/src/OIDCAuthenticationManager.mjs +++ b/services/web/modules/authentication/oidc/app/src/OIDCAuthenticationManager.mjs @@ -31,20 +31,24 @@ const OIDCAuthenticationManager = { user = await ThirdPartyIdentityManager.promises.login(providerId, oidcUserId, oidcUserData) } catch { // A user with the specified OIDC ID and provider ID is not found. Search for a user with the given email. -// If no user exists with this email, create a new user and link the OIDC account to it. +// If no user exists with this email, create a new user and link the OIDC account to it (provided this is allowed by allowedOIDCEmailDomains). // If a user exists but no account from the specified OIDC provider is linked to this user, link the OIDC account to this user. // If an account from the specified provider is already linked to this user, unlink it, and link the OIDC account to this user. // (Is it safe? Concider: If an account from the specified provider is already linked to this user, throw an error) user = await User.findOne({ 'email': email }).exec() if (!user) { - let allowedDomains = Settings.oidc.allowedOIDCEmailDomains; - allowedDomains = allowedDomains.split(',').map(d => d.trim()); // Make sure it's an array - const domain = email.split('@')[1]; - - if (!allowedDomains.includes(domain)) { - return null; - } - if (Settings.oidc.disableJITAccountCreation) { + const allowedDomains = Settings.oidc.allowedOIDCEmailDomains + if ( + allowedDomains && + !allowedDomains.some(pattern => { + const domain = email.split('@')[1] + if (pattern.startsWith('*.')) { + const base = pattern.slice(2) + return domain.endsWith(`.${base}`) + } + return domain === pattern + }) + ) { return null } user = await UserCreator.promises.createNewUser( diff --git a/services/web/modules/authentication/oidc/app/src/OIDCModuleManager.mjs b/services/web/modules/authentication/oidc/app/src/OIDCModuleManager.mjs index b9f68f3ff8..d57cde49da 100644 --- a/services/web/modules/authentication/oidc/app/src/OIDCModuleManager.mjs +++ b/services/web/modules/authentication/oidc/app/src/OIDCModuleManager.mjs @@ -16,9 +16,10 @@ const OIDCModuleManager = { attUserId: process.env.OVERLEAF_OIDC_USER_ID_FIELD || 'id', attAdmin: process.env.OVERLEAF_OIDC_IS_ADMIN_FIELD, valAdmin: process.env.OVERLEAF_OIDC_IS_ADMIN_FIELD_VALUE, - allowedOIDCEmailDomains: process.env.OVERLEAF_OIDC_ALLOWED_EMAIL_DOMAINS, updateUserDetailsOnLogin: boolFromEnv(process.env.OVERLEAF_OIDC_UPDATE_USER_DETAILS_ON_LOGIN), - disableJITAccountCreation: boolFromEnv(process.env.OVERLEAF_OIDC_DISABLE_JIT_ACCOUNT_CREATION), + allowedOIDCEmailDomains: process.env.OVERLEAF_OIDC_ALLOWED_EMAIL_DOMAINS === undefined + ? null + : process.env.OVERLEAF_OIDC_ALLOWED_EMAIL_DOMAINS.split(',').map(s => s.trim()).filter(Boolean), } }, passportSetup(passport, callback) { From c0d3cb3622e9c2ffed84823a70234291369dd2e4 Mon Sep 17 00:00:00 2001 From: yu-i-i Date: Wed, 16 Jul 2025 09:16:36 +0200 Subject: [PATCH 42/43] Set `use-ai` capability to false --- .../modules/authentication/ldap/app/src/LDAPModuleManager.mjs | 1 + .../modules/authentication/oidc/app/src/OIDCModuleManager.mjs | 1 + .../modules/authentication/saml/app/src/SAMLModuleManager.mjs | 1 + 3 files changed, 3 insertions(+) diff --git a/services/web/modules/authentication/ldap/app/src/LDAPModuleManager.mjs b/services/web/modules/authentication/ldap/app/src/LDAPModuleManager.mjs index 64afd02b0c..2e915b23ce 100644 --- a/services/web/modules/authentication/ldap/app/src/LDAPModuleManager.mjs +++ b/services/web/modules/authentication/ldap/app/src/LDAPModuleManager.mjs @@ -76,6 +76,7 @@ const LDAPModuleManager = { initPolicy() { try { PermissionsManager.registerCapability('change-password', { default : true }) + PermissionsManager.registerCapability('use-ai', { default : false }) } catch (error) { logger.info({}, error.message) } diff --git a/services/web/modules/authentication/oidc/app/src/OIDCModuleManager.mjs b/services/web/modules/authentication/oidc/app/src/OIDCModuleManager.mjs index d57cde49da..7a36c0fbd0 100644 --- a/services/web/modules/authentication/oidc/app/src/OIDCModuleManager.mjs +++ b/services/web/modules/authentication/oidc/app/src/OIDCModuleManager.mjs @@ -49,6 +49,7 @@ const OIDCModuleManager = { initPolicy() { try { PermissionsManager.registerCapability('change-password', { default : true }) + PermissionsManager.registerCapability('use-ai', { default : false }) } catch (error) { logger.info({}, error.message) } diff --git a/services/web/modules/authentication/saml/app/src/SAMLModuleManager.mjs b/services/web/modules/authentication/saml/app/src/SAMLModuleManager.mjs index 67545f7fc9..fba235de3d 100644 --- a/services/web/modules/authentication/saml/app/src/SAMLModuleManager.mjs +++ b/services/web/modules/authentication/saml/app/src/SAMLModuleManager.mjs @@ -66,6 +66,7 @@ const SAMLModuleManager = { initPolicy() { try { PermissionsManager.registerCapability('change-password', { default : true }) + PermissionsManager.registerCapability('use-ai', { default : false }) } catch (error) { logger.info({}, error.message) } From 51f2f5b8dbdba697c1ff40a19cba58d0e4a336fe Mon Sep 17 00:00:00 2001 From: yu-i-i Date: Mon, 21 Jul 2025 18:40:25 +0200 Subject: [PATCH 43/43] Remove LDAP admin check via group search in ldap-passport --- .../ldap/app/src/LDAPAuthenticationManager.mjs | 5 ++--- .../authentication/ldap/app/src/LDAPModuleManager.mjs | 5 ----- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/services/web/modules/authentication/ldap/app/src/LDAPAuthenticationManager.mjs b/services/web/modules/authentication/ldap/app/src/LDAPAuthenticationManager.mjs index 66943e82a3..6a35b77566 100644 --- a/services/web/modules/authentication/ldap/app/src/LDAPAuthenticationManager.mjs +++ b/services/web/modules/authentication/ldap/app/src/LDAPAuthenticationManager.mjs @@ -30,9 +30,8 @@ const LDAPAuthenticationManager = { if (!firstName && !lastName) lastName = email let isAdmin = false if( attAdmin && valAdmin ) { - isAdmin = (profile._groups?.length > 0) || - (Array.isArray(profile[attAdmin]) ? profile[attAdmin].includes(valAdmin) : - profile[attAdmin] === valAdmin) + isAdmin = Array.isArray(profile[attAdmin]) ? profile[attAdmin].includes(valAdmin) : + profile[attAdmin] === valAdmin } let user = await User.findOne({ 'email': email }).exec() diff --git a/services/web/modules/authentication/ldap/app/src/LDAPModuleManager.mjs b/services/web/modules/authentication/ldap/app/src/LDAPModuleManager.mjs index 2e915b23ce..643342430a 100644 --- a/services/web/modules/authentication/ldap/app/src/LDAPModuleManager.mjs +++ b/services/web/modules/authentication/ldap/app/src/LDAPModuleManager.mjs @@ -31,11 +31,6 @@ const LDAPModuleManager = { searchFilter: process.env.OVERLEAF_LDAP_SEARCH_FILTER, searchScope: process.env.OVERLEAF_LDAP_SEARCH_SCOPE || 'sub', searchAttributes: JSON.parse(process.env.OVERLEAF_LDAP_SEARCH_ATTRIBUTES || '[]'), - groupSearchBase: process.env.OVERLEAF_LDAP_ADMIN_SEARCH_BASE, - groupSearchFilter: process.env.OVERLEAF_LDAP_ADMIN_SEARCH_FILTER, - groupSearchScope: process.env.OVERLEAF_LDAP_ADMIN_SEARCH_SCOPE || 'sub', - groupSearchAttributes: ["dn"], - groupDnProperty: process.env.OVERLEAF_LDAP_ADMIN_DN_PROPERTY, cache: boolFromEnv(process.env.OVERLEAF_LDAP_CACHE), timeout: numFromEnv(process.env.OVERLEAF_LDAP_TIMEOUT), connectTimeout: numFromEnv(process.env.OVERLEAF_LDAP_CONNECT_TIMEOUT),