From fb3570054ec2b193c8300c6979f6f56caa626972 Mon Sep 17 00:00:00 2001 From: yu-i-i Date: Tue, 17 Dec 2024 18:36:18 +0100 Subject: [PATCH] Refactor authentication code; add OIDC support --- .../AuthenticationController.mjs | 6 +- .../PasswordReset/PasswordResetController.mjs | 4 - .../PasswordReset/PasswordResetHandler.mjs | 5 +- .../app/src/Features/User/UserController.mjs | 3 +- .../src/Features/User/UserPagesController.mjs | 6 +- .../app/src/infrastructure/ExpressLocals.mjs | 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.mjs b/services/web/app/src/Features/Authentication/AuthenticationController.mjs index 52a1fb1367..668df0a292 100644 --- a/services/web/app/src/Features/Authentication/AuthenticationController.mjs +++ b/services/web/app/src/Features/Authentication/AuthenticationController.mjs @@ -84,6 +84,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 5ee322c873..8079e54187 100644 --- a/services/web/app/src/Features/PasswordReset/PasswordResetController.mjs +++ b/services/web/app/src/Features/PasswordReset/PasswordResetController.mjs @@ -158,10 +158,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 b4594dba15..965bf42561 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.mjs b/services/web/app/src/Features/User/UserController.mjs index a422cbd472..ac88451221 100644 --- a/services/web/app/src/Features/User/UserController.mjs +++ b/services/web/app/src/Features/User/UserController.mjs @@ -450,7 +450,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, { @@ -537,7 +537,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 edf00d1960..48a000988b 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.mjs b/services/web/app/src/infrastructure/ExpressLocals.mjs index c20a883fc4..661f1594e8 100644 --- a/services/web/app/src/infrastructure/ExpressLocals.mjs +++ b/services/web/app/src/infrastructure/ExpressLocals.mjs @@ -116,9 +116,9 @@ export default async 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 fccdec2ce3..91b87e8e1c 100644 --- a/services/web/app/src/router.mjs +++ b/services/web/app/src/router.mjs @@ -236,6 +236,8 @@ async function initialize(webRouter, privateApiRouter, publicApiRouter) { CaptchaMiddleware.canSkipCaptcha ) + await Modules.applyRouter(webRouter, privateApiRouter, publicApiRouter) + webRouter.get('/login', UserPagesController.loginPage) AuthenticationController.addEndpointToLoginWhitelist('/login') @@ -305,8 +307,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 e17769d1ea..bf39b8a1f7 100644 --- a/services/web/app/views/user/login.pug +++ b/services/web/app/views/user/login.pug @@ -57,3 +57,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 ca2483c9a8..36afc18973 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 f864185821..d690f0b4e1 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-showAiFeatures' data-type='boolean' content=showAiFeatures) diff --git a/services/web/config/settings.defaults.js b/services/web/config/settings.defaults.js index 3ddebb02ae..329f8191ed 100644 --- a/services/web/config/settings.defaults.js +++ b/services/web/config/settings.defaults.js @@ -1084,10 +1084,11 @@ module.exports = { 'launchpad', 'server-ce-scripts', 'user-activate', - 'ldap-authentication', - 'saml-authentication', 'symbol-palette', 'track-changes', + 'authentication/ldap', + 'authentication/saml', + 'authentication/oidc', ], viewIncludes: {}, @@ -1125,6 +1126,20 @@ module.exports = { || imageName.split(':')[1], })) : undefined, + + 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 5b44bbebfc..ef6ddedc34 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -2416,6 +2416,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_currently_have_x_linked_with_your_overleaf_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 cd92ab614b..e1f53ef5ca 100644 --- a/services/web/frontend/js/features/settings/components/linking-section.tsx +++ b/services/web/frontend/js/features/settings/components/linking-section.tsx @@ -200,7 +200,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 c80605aefb..0769db713e 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 '@/shared/components/ol/ol-button' import { @@ -18,6 +19,7 @@ const providerLogos: { readonly [p: string]: JSX.Element } = { collabratec: , google: , orcid: , + oidc: , } type SSOLinkingWidgetProps = { @@ -67,7 +69,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 9128119dc3..73054dae66 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 25a8fb0564..f19233495b 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -3080,8 +3080,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_currently_have_x_linked_with_your_overleaf_account": "You currently have <0>__managers__ linked with your __appName__ account.", "you_dont_have_any_add_ons_on_your_account": "You don’t have any add-ons on your account.", 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 230100c87e..cce7e31ebc 100644 --- a/services/web/package.json +++ b/services/web/package.json @@ -171,6 +171,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",