Refactor authentication code; add OIDC support

This commit is contained in:
yu-i-i
2024-12-17 18:36:18 +01:00
parent fa145af363
commit fb3570054e
48 changed files with 1169 additions and 606 deletions

View File

@@ -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 {

View File

@@ -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'),

View File

@@ -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'])

View File

@@ -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

View File

@@ -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 || {}

View File

@@ -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()
})

View File

@@ -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',

View File

@@ -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")}…

View File

@@ -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")}

View File

@@ -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)

View File

@@ -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) {

View File

@@ -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": "",

View File

@@ -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')

View File

@@ -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: <IEEELogo />,
google: <GoogleLogo />,
orcid: <OrcidLogo />,
oidc: <OpenIDLogo />,
}
type SSOLinkingWidgetProps = {
@@ -67,7 +69,7 @@ export function SSOLinkingWidget({
return (
<div className="settings-widget-container">
<div>{providerLogos[providerId]}</div>
<div>{providerLogos[providerId] || providerLogos['oidc']}</div>
<div className="description-container">
<div className="title-row">
<h4 id={providerId}>{title}</h4>

View File

@@ -39,11 +39,7 @@ function CanOnlyLogInThroughSSO() {
return (
<p>
<Trans
i18nKey="you_cant_add_or_change_password_due_to_sso"
components={[
// eslint-disable-next-line react/jsx-key, jsx-a11y/anchor-has-content
<a href="/learn/how-to/Logging_in_with_Group_single_sign-on" />,
]}
i18nKey="you_cant_add_or_change_password_due_to_ldap_or_sso"
/>
</p>
)

View File

@@ -0,0 +1,27 @@
function OpenIDLogo() {
return (
<svg
width="40"
height="40"
viewBox="0 0 40 40"
xmlns="http://www.w3.org/2000/svg"
>
<rect width="40" height="40" fill="white" />
<path
d="M18.185415 36.042565 23.298193 32.35627 23.060446 3.090316 18.185415 6.8918455Z"
fill="#ff8e00"
/>
<path
d="M18.246064 36.042565C-0.37463741 32.997945 -1.0248032 15.054095 18.13083 11.143396l0.05944 3.322396 c -13.3672163 2.225847 -11.6629563 14.187201 0 15.92785l0.05944 3.127104Z"
fill="#626262"
/>
<path
d="M23.219348 14.720521c2.279219 0.01577 4.262468 1.057732 6.237225 2.117891l-2.917255 2.176115h9.317022l0.05701 -6.371868 -2.917255 2.176115C30.03396 13.32315 27.308358 11.530342 23.169615 11.496378Z"
fill="#626262"
/>
</svg>
)
}
export default OpenIDLogo

View File

@@ -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 cant add or change your password because your group or organization uses LDAP or SSO.",
"you_cant_add_or_change_password_due_to_sso": "You cant add or change your password because your group or organization uses <0>single sign-on (SSO)</0>.",
"you_cant_join_this_group_subscription": "You cant join this group subscription",
"you_cant_reset_password_due_to_ldap_or_sso": "You cant reset your password because your group or organization uses LDAP or SSO. Contact your system administrator.",
"you_cant_reset_password_due_to_sso": "You cant reset your password because your group or organization uses SSO. <0>Log in with SSO</0>.",
"you_currently_have_x_linked_with_your_overleaf_account": "You currently have <0>__managers__</0> linked with your __appName__ account.",
"you_dont_have_any_add_ons_on_your_account": "You dont have any add-ons on your account.",

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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,
)
},
}

View File

@@ -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

View File

@@ -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()
}
}

View File

@@ -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

View File

@@ -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,
}

View File

@@ -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

View File

@@ -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)
},
}

View File

@@ -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

View File

@@ -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

View File

@@ -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,
}

View File

@@ -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

View File

@@ -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)
},
}

View File

@@ -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)
},
}

View File

@@ -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

View File

@@ -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,
}

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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,
}

View File

@@ -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

View File

@@ -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)
},
}

View File

@@ -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')
},
}

View File

@@ -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

View File

@@ -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

View File

@@ -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",