Files
overleaf-cep/services/web/app/src/Features/User/SAMLIdentityManager.js
Alf Eaton e0e8a2ffaa Merge pull request #17525 from overleaf/ae-upgrade-prettier
Upgrade Prettier to v3

GitOrigin-RevId: 6f1338f196408f3edb4892d5220ad3665ff1a5bc
2024-03-26 09:04:05 +00:00

468 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