mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-11-14 14:13:43 +01:00
Refactor authentication code; add OIDC support
This commit is contained in:
@@ -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
|
||||
+16
-20
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
},
|
||||
}
|
||||
@@ -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
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
},
|
||||
}
|
||||
@@ -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
|
||||
+28
-38
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
},
|
||||
}
|
||||
@@ -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)
|
||||
},
|
||||
}
|
||||
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
},
|
||||
}
|
||||
@@ -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')
|
||||
},
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user