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.mjs b/services/web/app/src/Features/Authentication/AuthenticationController.mjs index 586262d311..1d782a7425 100644 --- a/services/web/app/src/Features/Authentication/AuthenticationController.mjs +++ b/services/web/app/src/Features/Authentication/AuthenticationController.mjs @@ -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 d4140ef90a..bd1b2d2f7b 100644 --- a/services/web/app/src/Features/PasswordReset/PasswordResetController.mjs +++ b/services/web/app/src/Features/PasswordReset/PasswordResetController.mjs @@ -157,6 +157,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 995306a431..0882cedecf 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.mjs b/services/web/app/src/Features/User/UserController.mjs index 46f187e4ed..3e316fb285 100644 --- a/services/web/app/src/Features/User/UserController.mjs +++ b/services/web/app/src/Features/User/UserController.mjs @@ -413,7 +413,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, { @@ -490,6 +490,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 621d5f7020..570128777e 100644 --- a/services/web/app/views/user/login.pug +++ b/services/web/app/views/user/login.pug @@ -25,8 +25,9 @@ block content label(for='email') #{translate("email")} input#email.form-control( name='email' - type='email' + type=(settings.ldap && settings.ldap.enable) ? 'text' : 'email' required + placeholder=(settings.ldap && settings.ldap.enable) ? settings.ldap.placeholder : 'email@example.com' autofocus='true' autocomplete='username' ) @@ -46,3 +47,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 2651b0c118..50057f64a8 100644 --- a/services/web/config/settings.defaults.js +++ b/services/web/config/settings.defaults.js @@ -1046,6 +1046,8 @@ module.exports = { 'launchpad', 'server-ce-scripts', 'user-activate', + 'ldap-authentication', + 'saml-authentication', 'symbol-palette', 'track-changes', ], diff --git a/services/web/locales/en.json b/services/web/locales/en.json index 54425e969b..f30582d81f 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -166,6 +166,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", @@ -1289,6 +1290,7 @@ "loading_github_repositories": "Loading your GitHub repositories", "loading_prices": "loading prices", "loading_recent_github_commits": "Loading recent commits", + "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 aa2b11be04..816a9dbc21 100644 --- a/services/web/modules/launchpad/app/src/LaunchpadController.mjs +++ b/services/web/modules/launchpad/app/src/LaunchpadController.mjs @@ -153,7 +153,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 1af19bb4fe..f7b15d9062 100644 --- a/services/web/modules/launchpad/app/views/launchpad.pug +++ b/services/web/modules/launchpad/app/views/launchpad.pug @@ -129,6 +129,41 @@ 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.form-label(for='email') #{translate("email")} + input.form-control( + name='email' + type='email' + id='email-local' + autocomplete='username' + required + autofocus='true' + ) + .form-group + label.form-label(for='passwordField') #{translate("password")} + input#passwordField.form-control( + name='password' + type='password' + 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')} @@ -158,6 +193,41 @@ 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.form-label(for='email-local') #{translate("email")} + input.form-control( + name='email' + type='email' + id='email-local' + autocomplete='username' + required + autofocus='true' + ) + .form-group + label.form-label(for='passwordField') #{translate("password")} + input#passwordField.form-control( + name='password' + type='password' + 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")}… + br 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