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('logger-sharelatex')
const { User } = require('../../models/User')
async function _addAuditLogEntry(
link,
userId,
auditLog,
institutionEmail,
providerId,
providerName
) {
const operation = link ? 'link-institution-sso' : 'unlink-institution-sso'
await UserAuditLogHandler.promises.addEntry(
userId,
operation,
auditLog.initiatorId,
auditLog.ipAddress,
{
institutionEmail,
providerId,
providerName
}
)
}
async function _addIdentifier(
userId,
externalUserId,
providerId,
hasEntitlement,
institutionEmail,
providerName,
auditLog
) {
// first check if institutionEmail linked to another account
// before adding the identifier for the email
const user = await UserGetter.promises.getUserByAnyEmail(institutionEmail)
if (user && user._id.toString() !== userId.toString()) {
const existingEmailData = user.emails.find(
emailData => emailData.email === institutionEmail
)
if (existingEmailData && existingEmailData.samlProviderId) {
// email exists and institution link.
// Return back to requesting page with error
throw new Errors.SAMLIdentityExistsError()
} else {
// Only email exists but not linked, so redirect to linking page
// which will tell this user to log out to link
throw new Errors.EmailExistsError()
}
}
providerId = providerId.toString()
await _addAuditLogEntry(
true,
userId,
auditLog,
institutionEmail,
providerId,
providerName
)
hasEntitlement = !!hasEntitlement
const query = {
_id: userId,
'samlIdentifiers.providerId': {
$ne: providerId
}
}
const update = {
$push: {
samlIdentifiers: {
hasEntitlement,
externalUserId,
providerId
}
}
}
try {
// update v2 user record
const updatedUser = User.findOneAndUpdate(query, update, {
new: true
}).exec()
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 && emailAlreadyAssociated.confirmedAt) {
await UserUpdater.promises.updateUser(query, update)
} else if (emailAlreadyAssociated) {
await UserUpdater.promises.confirmEmail(user._id, email)
await UserUpdater.promises.updateUser(query, update)
} else {
await UserUpdater.promises.addEmailAddress(user._id, email, {}, auditLog)
await UserUpdater.promises.confirmEmail(user._id, email)
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: [
`Linked:
${institutionEmail}`
]
}
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: [
`No longer linked:
${institutionEmail}`
]
}
EmailHandler.sendEmail('securityAlert', emailOptions, error => {
if (error) {
logger.warn({ err: error })
}
})
}
async function getUser(providerId, externalUserId) {
if (!providerId || !externalUserId) {
throw new Error(
`invalid arguments: providerId: ${providerId}, externalUserId: ${externalUserId}`
)
}
const user = await User.findOne({
'samlIdentifiers.externalUserId': externalUserId.toString(),
'samlIdentifiers.providerId': providerId.toString()
}).exec()
return user
}
async function redundantSubscription(userId, providerId, providerName) {
const subscription = await SubscriptionLocator.promises.getUserIndividualSubscription(
userId
)
if (subscription) {
await NotificationsBuilder.promises
.redundantPersonalSubscription(
{
institutionId: providerId,
institutionName: providerName
},
{ _id: userId }
)
.create()
}
}
async function linkAccounts(
userId,
externalUserId,
institutionEmail,
providerId,
providerName,
hasEntitlement,
auditLog
) {
await _addIdentifier(
userId,
externalUserId,
providerId,
hasEntitlement,
institutionEmail,
providerName,
auditLog
)
await _addInstitutionEmail(userId, institutionEmail, providerId, auditLog)
await _sendLinkedEmail(userId, providerName, institutionEmail)
// update v1 affiliations record
if (hasEntitlement) {
await InstitutionsAPI.promises.addEntitlement(userId, institutionEmail)
try {
await redundantSubscription(userId, providerId, providerName)
} catch (error) {
logger.err({ err: error }, 'error checking redundant subscription')
}
} else {
await InstitutionsAPI.promises.removeEntitlement(userId, institutionEmail)
}
}
async function unlinkAccounts(
userId,
institutionEmail,
primaryEmail,
providerId,
providerName,
auditLog
) {
providerId = providerId.toString()
const query = {
_id: userId
}
await _addAuditLogEntry(
false,
userId,
auditLog,
institutionEmail,
providerId,
providerName
)
const update = {
$pull: {
samlIdentifiers: {
providerId
}
}
}
// update v2 user
await User.updateOne(query, update).exec()
// update v1 affiliations record
await InstitutionsAPI.promises.removeEntitlement(userId, institutionEmail)
// send email
_sendUnlinkedEmail(primaryEmail, providerName, institutionEmail)
}
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()
// update v1 affiliations record
if (hasEntitlement) {
await InstitutionsAPI.promises.addEntitlement(userId, institutionEmail)
} else {
await InstitutionsAPI.promises.removeEntitlement(userId, institutionEmail)
}
}
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
}
const SAMLIdentityManager = {
entitlementAttributeMatches,
getUser,
linkAccounts,
redundantSubscription,
unlinkAccounts,
updateEntitlement,
userHasEntitlement
}
module.exports = SAMLIdentityManager