mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-05-31 21:01:33 +02:00
[web] Use constructor for ObjectId GitOrigin-RevId: 9eb8b377ea599605b72af237d1ab12f4d8287162
469 lines
12 KiB
JavaScript
469 lines
12 KiB
JavaScript
const { ObjectId } = require('mongodb')
|
|
const EmailHandler = require('../Email/EmailHandler')
|
|
const Errors = require('../Errors/Errors')
|
|
const InstitutionsAPI = require('../Institutions/InstitutionsAPI')
|
|
const NotificationsBuilder = require('../Notifications/NotificationsBuilder')
|
|
const OError = require('@overleaf/o-error')
|
|
const SubscriptionLocator = require('../Subscription/SubscriptionLocator')
|
|
const UserAuditLogHandler = require('../User/UserAuditLogHandler')
|
|
const UserGetter = require('../User/UserGetter')
|
|
const UserUpdater = require('../User/UserUpdater')
|
|
const logger = require('@overleaf/logger')
|
|
const { User } = require('../../models/User')
|
|
const { promiseMapWithLimit } = require('@overleaf/promise-utils')
|
|
|
|
async function _addAuditLogEntry(operation, userId, auditLog, extraInfo) {
|
|
await UserAuditLogHandler.promises.addEntry(
|
|
userId,
|
|
operation,
|
|
auditLog.initiatorId,
|
|
auditLog.ipAddress,
|
|
extraInfo
|
|
)
|
|
}
|
|
|
|
async function _ensureCanAddIdentifier(userId, institutionEmail, providerId) {
|
|
const userWithProvider = await UserGetter.promises.getUser(
|
|
{ _id: new ObjectId(userId), 'samlIdentifiers.providerId': providerId },
|
|
{ _id: 1 }
|
|
)
|
|
|
|
if (userWithProvider) {
|
|
throw new Errors.SAMLAlreadyLinkedError()
|
|
}
|
|
|
|
const userWithEmail = await UserGetter.promises.getUserByAnyEmail(
|
|
institutionEmail
|
|
)
|
|
|
|
if (!userWithEmail) {
|
|
// email doesn't exist; all good
|
|
return
|
|
}
|
|
|
|
const emailBelongToUser = userWithEmail._id.toString() === userId.toString()
|
|
const existingEmailData = userWithEmail.emails.find(
|
|
emailData => emailData.email === institutionEmail
|
|
)
|
|
|
|
if (!emailBelongToUser && existingEmailData.samlProviderId) {
|
|
// email exists and institution link.
|
|
// Return back to requesting page with error
|
|
throw new Errors.SAMLIdentityExistsError()
|
|
}
|
|
|
|
if (!emailBelongToUser) {
|
|
// email exists but not linked, so redirect to linking page
|
|
// which will tell this user to log out to link
|
|
throw new Errors.EmailExistsError()
|
|
}
|
|
|
|
// email belongs to user. Make sure it's already affiliated with the provider
|
|
const fullEmails = await UserGetter.promises.getUserFullEmails(
|
|
userWithEmail._id
|
|
)
|
|
const existingFullEmailData = fullEmails.find(
|
|
emailData => emailData.email === institutionEmail
|
|
)
|
|
|
|
if (!existingFullEmailData.affiliation) {
|
|
throw new Errors.SAMLEmailNotAffiliatedError()
|
|
}
|
|
|
|
if (
|
|
existingFullEmailData.affiliation.institution.id.toString() !== providerId
|
|
) {
|
|
throw new Errors.SAMLEmailAffiliatedWithAnotherInstitutionError()
|
|
}
|
|
}
|
|
|
|
async function _addIdentifier(
|
|
userId,
|
|
externalUserId,
|
|
providerId,
|
|
hasEntitlement,
|
|
institutionEmail,
|
|
providerName,
|
|
auditLog,
|
|
userIdAttribute
|
|
) {
|
|
providerId = providerId.toString()
|
|
|
|
await _ensureCanAddIdentifier(userId, institutionEmail, providerId)
|
|
|
|
const auditLogInfo = {
|
|
institutionEmail,
|
|
providerId,
|
|
providerName,
|
|
userIdAttribute,
|
|
}
|
|
|
|
await _addAuditLogEntry(
|
|
'link-institution-sso',
|
|
userId,
|
|
auditLog,
|
|
auditLogInfo
|
|
)
|
|
|
|
hasEntitlement = !!hasEntitlement
|
|
const query = {
|
|
_id: userId,
|
|
'samlIdentifiers.providerId': {
|
|
$ne: providerId,
|
|
},
|
|
}
|
|
|
|
const update = {
|
|
$push: {
|
|
samlIdentifiers: {
|
|
hasEntitlement,
|
|
externalUserId,
|
|
providerId,
|
|
userIdAttribute,
|
|
},
|
|
},
|
|
}
|
|
|
|
try {
|
|
// update v2 user record
|
|
const updatedUser = await User.findOneAndUpdate(query, update, {
|
|
new: true,
|
|
}).exec()
|
|
if (!updatedUser) {
|
|
throw new OError('No update while linking user')
|
|
}
|
|
return updatedUser
|
|
} catch (err) {
|
|
if (err.code === 11000) {
|
|
throw new Errors.SAMLIdentityExistsError()
|
|
} else {
|
|
throw OError.tag(err)
|
|
}
|
|
}
|
|
}
|
|
|
|
async function _addInstitutionEmail(userId, email, providerId, auditLog) {
|
|
const user = await UserGetter.promises.getUser(userId)
|
|
const query = {
|
|
_id: userId,
|
|
'emails.email': email,
|
|
}
|
|
const update = {
|
|
$set: {
|
|
'emails.$.samlProviderId': providerId.toString(),
|
|
},
|
|
}
|
|
if (user == null) {
|
|
throw new Errors.NotFoundError('user not found')
|
|
}
|
|
const emailAlreadyAssociated = user.emails.find(e => e.email === email)
|
|
if (emailAlreadyAssociated) {
|
|
await UserUpdater.promises.updateUser(query, update)
|
|
} else {
|
|
await UserUpdater.promises.addEmailAddress(
|
|
user._id,
|
|
email,
|
|
{ university: { id: providerId }, rejectIfBlocklisted: true },
|
|
auditLog
|
|
)
|
|
await UserUpdater.promises.updateUser(query, update)
|
|
}
|
|
}
|
|
|
|
async function _sendLinkedEmail(userId, providerName, institutionEmail) {
|
|
const user = await UserGetter.promises.getUser(userId, { email: 1 })
|
|
const emailOptions = {
|
|
to: user.email,
|
|
actionDescribed: `an Institutional SSO account at ${providerName} was linked to your account ${user.email}`,
|
|
action: 'institutional SSO account linked',
|
|
message: [
|
|
`<span style="display:inline-block;padding: 0 20px;width:100%;">Linked: <br/><b>${institutionEmail}</b></span>`,
|
|
],
|
|
}
|
|
EmailHandler.sendEmail('securityAlert', emailOptions, error => {
|
|
if (error) {
|
|
logger.warn({ err: error })
|
|
}
|
|
})
|
|
}
|
|
|
|
function _sendUnlinkedEmail(primaryEmail, providerName, institutionEmail) {
|
|
const emailOptions = {
|
|
to: primaryEmail,
|
|
actionDescribed: `an Institutional SSO account at ${providerName} was unlinked from your account ${primaryEmail}`,
|
|
action: 'institutional SSO account no longer linked',
|
|
message: [
|
|
`<span style="display:inline-block;padding: 0 20px;width:100%;">No longer linked: <br/><b>${institutionEmail}</b></span>`,
|
|
],
|
|
}
|
|
EmailHandler.sendEmail('securityAlert', emailOptions, error => {
|
|
if (error) {
|
|
logger.warn({ err: error })
|
|
}
|
|
})
|
|
}
|
|
|
|
async function getUser(providerId, externalUserId, userIdAttribute) {
|
|
if (!providerId || !externalUserId || !userIdAttribute) {
|
|
throw new Error(
|
|
`invalid arguments: providerId: ${providerId}, externalUserId: ${externalUserId}, userIdAttribute: ${userIdAttribute}`
|
|
)
|
|
}
|
|
const user = await User.findOne({
|
|
'samlIdentifiers.externalUserId': externalUserId.toString(),
|
|
'samlIdentifiers.providerId': providerId.toString(),
|
|
'samlIdentifiers.userIdAttribute': userIdAttribute.toString(),
|
|
}).exec()
|
|
|
|
return user
|
|
}
|
|
|
|
async function redundantSubscription(userId, providerId, providerName) {
|
|
const subscription =
|
|
await SubscriptionLocator.promises.getUserIndividualSubscription(userId)
|
|
|
|
if (subscription && !subscription.groupPlan) {
|
|
await NotificationsBuilder.promises
|
|
.redundantPersonalSubscription(
|
|
{
|
|
institutionId: providerId,
|
|
institutionName: providerName,
|
|
},
|
|
{ _id: userId }
|
|
)
|
|
.create()
|
|
}
|
|
}
|
|
|
|
async function linkAccounts(userId, samlData, auditLog) {
|
|
const {
|
|
externalUserId,
|
|
institutionEmail,
|
|
universityId: providerId,
|
|
universityName: providerName,
|
|
hasEntitlement,
|
|
userIdAttribute,
|
|
} = samlData
|
|
|
|
if (!externalUserId || !institutionEmail || !providerId || !userIdAttribute) {
|
|
throw new Error(
|
|
`missing data when linking institution SSO: ${JSON.stringify(samlData)}`
|
|
)
|
|
}
|
|
|
|
await _addIdentifier(
|
|
userId,
|
|
externalUserId,
|
|
providerId,
|
|
hasEntitlement,
|
|
institutionEmail,
|
|
providerName,
|
|
auditLog,
|
|
userIdAttribute
|
|
)
|
|
try {
|
|
await _addInstitutionEmail(userId, institutionEmail, providerId, auditLog)
|
|
} catch (error) {
|
|
await _removeIdentifier(userId, providerId)
|
|
throw error
|
|
}
|
|
await UserUpdater.promises.confirmEmail(userId, institutionEmail, {
|
|
entitlement: hasEntitlement,
|
|
}) // will set confirmedAt if not set, and will always update reconfirmedAt
|
|
await _sendLinkedEmail(userId, providerName, institutionEmail)
|
|
}
|
|
|
|
async function unlinkAccounts(
|
|
userId,
|
|
institutionEmail,
|
|
primaryEmail,
|
|
providerId,
|
|
providerName,
|
|
auditLog
|
|
) {
|
|
providerId = providerId.toString()
|
|
|
|
await _addAuditLogEntry('unlink-institution-sso', userId, auditLog, {
|
|
institutionEmail,
|
|
providerId,
|
|
providerName,
|
|
})
|
|
// update v2 user
|
|
await _removeIdentifier(userId, providerId)
|
|
// update v1 affiliations record
|
|
await InstitutionsAPI.promises.removeEntitlement(userId, institutionEmail)
|
|
// send email
|
|
_sendUnlinkedEmail(primaryEmail, providerName, institutionEmail)
|
|
}
|
|
|
|
async function _removeIdentifier(userId, providerId) {
|
|
providerId = providerId.toString()
|
|
|
|
const query = {
|
|
_id: userId,
|
|
}
|
|
const update = {
|
|
$pull: {
|
|
samlIdentifiers: {
|
|
providerId,
|
|
},
|
|
},
|
|
}
|
|
await User.updateOne(query, update).exec()
|
|
}
|
|
|
|
async function updateEntitlement(
|
|
userId,
|
|
institutionEmail,
|
|
providerId,
|
|
hasEntitlement
|
|
) {
|
|
providerId = providerId.toString()
|
|
hasEntitlement = !!hasEntitlement
|
|
const query = {
|
|
_id: userId,
|
|
'samlIdentifiers.providerId': providerId.toString(),
|
|
}
|
|
const update = {
|
|
$set: {
|
|
'samlIdentifiers.$.hasEntitlement': hasEntitlement,
|
|
},
|
|
}
|
|
// update v2 user
|
|
await User.updateOne(query, update).exec()
|
|
}
|
|
|
|
function entitlementAttributeMatches(entitlementAttribute, entitlementMatcher) {
|
|
if (Array.isArray(entitlementAttribute)) {
|
|
entitlementAttribute = entitlementAttribute.join(' ')
|
|
}
|
|
if (
|
|
typeof entitlementAttribute !== 'string' ||
|
|
typeof entitlementMatcher !== 'string'
|
|
) {
|
|
return false
|
|
}
|
|
try {
|
|
const entitlementRegExp = new RegExp(entitlementMatcher)
|
|
return !!entitlementAttribute.match(entitlementRegExp)
|
|
} catch (err) {
|
|
logger.error({ err }, 'Invalid SAML entitlement matcher')
|
|
// this is likely caused by an invalid regex in the matcher string
|
|
// log the error but do not bubble so that user can still sign in
|
|
// even if they don't have the entitlement
|
|
return false
|
|
}
|
|
}
|
|
|
|
function userHasEntitlement(user, providerId) {
|
|
providerId = providerId.toString()
|
|
if (!user || !Array.isArray(user.samlIdentifiers)) {
|
|
return false
|
|
}
|
|
for (const samlIdentifier of user.samlIdentifiers) {
|
|
if (providerId && samlIdentifier.providerId !== providerId) {
|
|
continue
|
|
}
|
|
if (samlIdentifier.hasEntitlement) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
async function migrateIdentifier(
|
|
userId,
|
|
externalUserId,
|
|
providerId,
|
|
hasEntitlement,
|
|
institutionEmail,
|
|
providerName,
|
|
auditLog,
|
|
userIdAttribute
|
|
) {
|
|
providerId = providerId.toString()
|
|
|
|
const query = {
|
|
_id: userId,
|
|
'samlIdentifiers.providerId': providerId,
|
|
}
|
|
|
|
const update = {
|
|
$set: {
|
|
'samlIdentifiers.$.externalUserId': externalUserId,
|
|
'samlIdentifiers.$.userIdAttribute': userIdAttribute,
|
|
},
|
|
}
|
|
await User.updateOne(query, update).exec()
|
|
|
|
const auditLogInfo = {
|
|
institutionEmail,
|
|
providerId,
|
|
providerName,
|
|
userIdAttribute,
|
|
}
|
|
|
|
await _addAuditLogEntry(
|
|
'migrate-institution-sso-id',
|
|
userId,
|
|
auditLog,
|
|
auditLogInfo
|
|
)
|
|
}
|
|
|
|
async function unlinkNotMigrated(userId, providerId, providerName, auditLog) {
|
|
providerId = providerId.toString()
|
|
|
|
const query = {
|
|
_id: userId,
|
|
'emails.samlProviderId': providerId,
|
|
}
|
|
const update = {
|
|
$pull: {
|
|
samlIdentifiers: {
|
|
providerId,
|
|
},
|
|
},
|
|
$unset: {
|
|
'emails.$.samlProviderId': 1,
|
|
},
|
|
}
|
|
|
|
const originalDoc = await User.findOneAndUpdate(query, update).exec()
|
|
|
|
// should only be 1
|
|
const linkedEmails = originalDoc.emails.filter(email => {
|
|
return email.samlProviderId === providerId
|
|
})
|
|
|
|
const auditLogInfo = {
|
|
providerId,
|
|
providerName,
|
|
}
|
|
|
|
await _addAuditLogEntry(
|
|
'unlink-institution-sso-not-migrated',
|
|
userId,
|
|
auditLog,
|
|
auditLogInfo
|
|
)
|
|
|
|
await promiseMapWithLimit(10, linkedEmails, async emailData => {
|
|
await InstitutionsAPI.promises.removeEntitlement(userId, emailData.email)
|
|
})
|
|
}
|
|
|
|
const SAMLIdentityManager = {
|
|
entitlementAttributeMatches,
|
|
getUser,
|
|
linkAccounts,
|
|
migrateIdentifier,
|
|
redundantSubscription,
|
|
unlinkAccounts,
|
|
unlinkNotMigrated,
|
|
updateEntitlement,
|
|
userHasEntitlement,
|
|
}
|
|
|
|
module.exports = SAMLIdentityManager
|