2019-10-03 10:10:22 -04:00
|
|
|
const EmailHandler = require('../Email/EmailHandler')
|
2019-08-27 16:48:00 -04:00
|
|
|
const Errors = require('../Errors/Errors')
|
2019-10-21 08:33:53 -04:00
|
|
|
const InstitutionsAPI = require('../Institutions/InstitutionsAPI')
|
2020-05-20 10:21:13 -04:00
|
|
|
const NotificationsBuilder = require('../Notifications/NotificationsBuilder')
|
2019-09-12 10:01:12 -04:00
|
|
|
const OError = require('@overleaf/o-error')
|
2020-05-20 10:21:13 -04:00
|
|
|
const SubscriptionLocator = require('../Subscription/SubscriptionLocator')
|
2020-09-14 09:54:19 -04:00
|
|
|
const UserAuditLogHandler = require('../User/UserAuditLogHandler')
|
2019-09-12 10:01:12 -04:00
|
|
|
const UserGetter = require('../User/UserGetter')
|
|
|
|
const UserUpdater = require('../User/UserUpdater')
|
2019-10-21 08:33:53 -04:00
|
|
|
const logger = require('logger-sharelatex')
|
|
|
|
const { User } = require('../../models/User')
|
2019-09-12 10:01:12 -04:00
|
|
|
|
2020-09-14 09:54:19 -04:00
|
|
|
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
|
|
|
|
}
|
|
|
|
)
|
|
|
|
}
|
|
|
|
|
2019-10-14 09:18:00 -04:00
|
|
|
async function _addIdentifier(
|
|
|
|
userId,
|
|
|
|
externalUserId,
|
|
|
|
providerId,
|
|
|
|
hasEntitlement,
|
2020-09-14 09:54:19 -04:00
|
|
|
institutionEmail,
|
|
|
|
providerName,
|
|
|
|
auditLog
|
2019-10-14 09:18:00 -04:00
|
|
|
) {
|
|
|
|
// 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()) {
|
2019-10-15 11:53:10 -04:00
|
|
|
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()
|
|
|
|
}
|
2019-10-14 09:18:00 -04:00
|
|
|
}
|
2020-09-14 09:54:19 -04:00
|
|
|
|
2019-09-30 09:21:31 -04:00
|
|
|
providerId = providerId.toString()
|
2020-09-14 09:54:19 -04:00
|
|
|
|
|
|
|
await _addAuditLogEntry(
|
|
|
|
true,
|
|
|
|
userId,
|
|
|
|
auditLog,
|
|
|
|
institutionEmail,
|
|
|
|
providerId,
|
|
|
|
providerName
|
|
|
|
)
|
|
|
|
|
2019-10-05 13:43:21 -04:00
|
|
|
hasEntitlement = !!hasEntitlement
|
2019-09-12 10:01:12 -04:00
|
|
|
const query = {
|
|
|
|
_id: userId,
|
|
|
|
'samlIdentifiers.providerId': {
|
|
|
|
$ne: providerId
|
|
|
|
}
|
|
|
|
}
|
|
|
|
const update = {
|
|
|
|
$push: {
|
|
|
|
samlIdentifiers: {
|
2019-10-05 13:43:21 -04:00
|
|
|
hasEntitlement,
|
2019-09-12 10:01:12 -04:00
|
|
|
externalUserId,
|
|
|
|
providerId
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
try {
|
2019-10-21 08:33:53 -04:00
|
|
|
// update v2 user record
|
|
|
|
const updatedUser = User.findOneAndUpdate(query, update, {
|
|
|
|
new: true
|
|
|
|
}).exec()
|
|
|
|
return updatedUser
|
2019-09-12 10:01:12 -04:00
|
|
|
} catch (err) {
|
2019-10-21 08:33:53 -04:00
|
|
|
if (err.code === 11000) {
|
2019-09-12 10:01:12 -04:00
|
|
|
throw new Errors.SAMLIdentityExistsError()
|
2019-10-21 08:33:53 -04:00
|
|
|
} else {
|
2020-08-11 05:28:29 -04:00
|
|
|
throw OError.tag(err)
|
2019-09-12 10:01:12 -04:00
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2020-09-14 09:54:19 -04:00
|
|
|
async function _addInstitutionEmail(userId, email, providerId, auditLog) {
|
2019-09-12 10:01:12 -04:00
|
|
|
const user = await UserGetter.promises.getUser(userId)
|
2019-09-30 09:21:31 -04:00
|
|
|
const query = {
|
|
|
|
_id: userId,
|
|
|
|
'emails.email': email
|
|
|
|
}
|
|
|
|
const update = {
|
|
|
|
$set: {
|
|
|
|
'emails.$.samlProviderId': providerId.toString()
|
|
|
|
}
|
|
|
|
}
|
2019-09-12 10:01:12 -04:00
|
|
|
if (user == null) {
|
|
|
|
throw new Errors.NotFoundError('user not found')
|
|
|
|
}
|
|
|
|
const emailAlreadyAssociated = user.emails.find(e => e.email === email)
|
|
|
|
if (emailAlreadyAssociated && emailAlreadyAssociated.confirmedAt) {
|
2019-09-30 09:21:31 -04:00
|
|
|
await UserUpdater.promises.updateUser(query, update)
|
2019-09-12 10:01:12 -04:00
|
|
|
} else if (emailAlreadyAssociated) {
|
2019-09-30 09:21:31 -04:00
|
|
|
await UserUpdater.promises.confirmEmail(user._id, email)
|
|
|
|
await UserUpdater.promises.updateUser(query, update)
|
2019-09-12 10:01:12 -04:00
|
|
|
} else {
|
2020-09-14 09:54:19 -04:00
|
|
|
await UserUpdater.promises.addEmailAddress(user._id, email, {}, auditLog)
|
2019-09-30 09:21:31 -04:00
|
|
|
await UserUpdater.promises.confirmEmail(user._id, email)
|
|
|
|
await UserUpdater.promises.updateUser(query, update)
|
2019-09-12 10:01:12 -04:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2020-09-29 10:05:37 -04:00
|
|
|
async function _sendLinkedEmail(userId, providerName, institutionEmail) {
|
2019-10-03 10:10:22 -04:00
|
|
|
const user = await UserGetter.promises.getUser(userId, { email: 1 })
|
|
|
|
const emailOptions = {
|
|
|
|
to: user.email,
|
2020-07-21 10:28:52 -04:00
|
|
|
actionDescribed: `an Institutional SSO account at ${providerName} was linked to your account ${
|
|
|
|
user.email
|
|
|
|
}`,
|
2020-09-29 10:05:37 -04:00
|
|
|
action: 'institutional SSO account linked',
|
|
|
|
message: [
|
|
|
|
`<span style="display:inline-block;padding: 0 20px;width:100%;">Linked: <br/><b>${institutionEmail}</b></span>`
|
|
|
|
]
|
2019-10-03 10:10:22 -04:00
|
|
|
}
|
2020-07-21 10:28:52 -04:00
|
|
|
EmailHandler.sendEmail('securityAlert', emailOptions, error => {
|
|
|
|
if (error) {
|
|
|
|
logger.warn({ err: error })
|
2019-10-03 10:10:22 -04:00
|
|
|
}
|
2020-07-21 10:28:52 -04:00
|
|
|
})
|
2019-10-03 10:10:22 -04:00
|
|
|
}
|
|
|
|
|
2020-09-29 10:05:37 -04:00
|
|
|
function _sendUnlinkedEmail(primaryEmail, providerName, institutionEmail) {
|
2019-10-03 10:10:22 -04:00
|
|
|
const emailOptions = {
|
|
|
|
to: primaryEmail,
|
2020-09-29 10:05:37 -04:00
|
|
|
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>`
|
|
|
|
]
|
2019-10-03 10:10:22 -04:00
|
|
|
}
|
2020-07-21 10:28:52 -04:00
|
|
|
EmailHandler.sendEmail('securityAlert', emailOptions, error => {
|
|
|
|
if (error) {
|
|
|
|
logger.warn({ err: error })
|
2019-10-03 10:10:22 -04:00
|
|
|
}
|
2020-07-21 10:28:52 -04:00
|
|
|
})
|
2019-10-03 10:10:22 -04:00
|
|
|
}
|
|
|
|
|
2019-08-27 16:48:00 -04:00
|
|
|
async function getUser(providerId, externalUserId) {
|
2020-07-15 05:48:02 -04:00
|
|
|
if (!providerId || !externalUserId) {
|
2019-10-05 13:43:21 -04:00
|
|
|
throw new Error(
|
|
|
|
`invalid arguments: providerId: ${providerId}, externalUserId: ${externalUserId}`
|
|
|
|
)
|
2019-08-27 16:48:00 -04:00
|
|
|
}
|
2020-07-15 05:48:02 -04:00
|
|
|
const user = await User.findOne({
|
|
|
|
'samlIdentifiers.externalUserId': externalUserId.toString(),
|
|
|
|
'samlIdentifiers.providerId': providerId.toString()
|
|
|
|
}).exec()
|
|
|
|
|
2019-10-03 10:10:22 -04:00
|
|
|
return user
|
2019-08-27 16:48:00 -04:00
|
|
|
}
|
|
|
|
|
2020-05-20 10:21:13 -04:00
|
|
|
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()
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2019-09-12 10:01:12 -04:00
|
|
|
async function linkAccounts(
|
|
|
|
userId,
|
|
|
|
externalUserId,
|
|
|
|
institutionEmail,
|
2019-10-03 10:10:22 -04:00
|
|
|
providerId,
|
2019-10-05 13:43:21 -04:00
|
|
|
providerName,
|
2020-09-14 09:54:19 -04:00
|
|
|
hasEntitlement,
|
|
|
|
auditLog
|
2019-09-12 10:01:12 -04:00
|
|
|
) {
|
2019-10-14 09:18:00 -04:00
|
|
|
await _addIdentifier(
|
|
|
|
userId,
|
|
|
|
externalUserId,
|
|
|
|
providerId,
|
|
|
|
hasEntitlement,
|
2020-09-14 09:54:19 -04:00
|
|
|
institutionEmail,
|
|
|
|
providerName,
|
|
|
|
auditLog
|
2019-10-14 09:18:00 -04:00
|
|
|
)
|
2020-09-14 09:54:19 -04:00
|
|
|
await _addInstitutionEmail(userId, institutionEmail, providerId, auditLog)
|
2020-09-29 10:05:37 -04:00
|
|
|
await _sendLinkedEmail(userId, providerName, institutionEmail)
|
2019-10-22 08:29:27 -04:00
|
|
|
// update v1 affiliations record
|
|
|
|
if (hasEntitlement) {
|
|
|
|
await InstitutionsAPI.promises.addEntitlement(userId, institutionEmail)
|
2020-05-20 10:21:13 -04:00
|
|
|
try {
|
|
|
|
await redundantSubscription(userId, providerId, providerName)
|
|
|
|
} catch (error) {
|
|
|
|
logger.err({ err: error }, 'error checking redundant subscription')
|
|
|
|
}
|
2020-03-31 06:04:25 -04:00
|
|
|
} else {
|
|
|
|
await InstitutionsAPI.promises.removeEntitlement(userId, institutionEmail)
|
2019-10-22 08:29:27 -04:00
|
|
|
}
|
2019-10-03 10:10:22 -04:00
|
|
|
}
|
|
|
|
|
2019-10-21 08:33:53 -04:00
|
|
|
async function unlinkAccounts(
|
|
|
|
userId,
|
|
|
|
institutionEmail,
|
|
|
|
primaryEmail,
|
|
|
|
providerId,
|
2020-09-14 09:54:19 -04:00
|
|
|
providerName,
|
|
|
|
auditLog
|
2019-10-21 08:33:53 -04:00
|
|
|
) {
|
2019-10-05 13:43:21 -04:00
|
|
|
providerId = providerId.toString()
|
2019-10-03 10:10:22 -04:00
|
|
|
const query = {
|
|
|
|
_id: userId
|
|
|
|
}
|
2020-09-14 09:54:19 -04:00
|
|
|
|
|
|
|
await _addAuditLogEntry(
|
|
|
|
false,
|
|
|
|
userId,
|
|
|
|
auditLog,
|
|
|
|
institutionEmail,
|
|
|
|
providerId,
|
|
|
|
providerName
|
|
|
|
)
|
|
|
|
|
2019-10-03 10:10:22 -04:00
|
|
|
const update = {
|
|
|
|
$pull: {
|
|
|
|
samlIdentifiers: {
|
|
|
|
providerId
|
|
|
|
}
|
|
|
|
}
|
2019-09-30 09:21:31 -04:00
|
|
|
}
|
2019-10-21 08:33:53 -04:00
|
|
|
// update v2 user
|
2020-10-12 08:35:50 -04:00
|
|
|
await User.update(query, update).exec()
|
2019-10-21 08:33:53 -04:00
|
|
|
// update v1 affiliations record
|
|
|
|
await InstitutionsAPI.promises.removeEntitlement(userId, institutionEmail)
|
|
|
|
// send email
|
2020-09-29 10:05:37 -04:00
|
|
|
_sendUnlinkedEmail(primaryEmail, providerName, institutionEmail)
|
2019-09-12 10:01:12 -04:00
|
|
|
}
|
|
|
|
|
2019-10-21 08:33:53 -04:00
|
|
|
async function updateEntitlement(
|
|
|
|
userId,
|
|
|
|
institutionEmail,
|
|
|
|
providerId,
|
|
|
|
hasEntitlement
|
|
|
|
) {
|
2019-10-05 13:43:21 -04:00
|
|
|
providerId = providerId.toString()
|
|
|
|
hasEntitlement = !!hasEntitlement
|
|
|
|
const query = {
|
|
|
|
_id: userId,
|
|
|
|
'samlIdentifiers.providerId': providerId.toString()
|
|
|
|
}
|
|
|
|
const update = {
|
|
|
|
$set: {
|
|
|
|
'samlIdentifiers.$.hasEntitlement': hasEntitlement
|
|
|
|
}
|
|
|
|
}
|
2019-10-21 08:33:53 -04:00
|
|
|
// update v2 user
|
2020-10-12 08:35:50 -04:00
|
|
|
await User.update(query, update).exec()
|
2019-10-21 08:33:53 -04:00
|
|
|
// update v1 affiliations record
|
|
|
|
if (hasEntitlement) {
|
|
|
|
await InstitutionsAPI.promises.addEntitlement(userId, institutionEmail)
|
|
|
|
} else {
|
|
|
|
await InstitutionsAPI.promises.removeEntitlement(userId, institutionEmail)
|
|
|
|
}
|
2019-10-05 13:43:21 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
function entitlementAttributeMatches(entitlementAttribute, entitlementMatcher) {
|
2019-12-19 10:06:30 -05:00
|
|
|
if (Array.isArray(entitlementAttribute)) {
|
|
|
|
entitlementAttribute = entitlementAttribute.join(' ')
|
|
|
|
}
|
2019-10-05 13:43:21 -04:00
|
|
|
if (
|
|
|
|
typeof entitlementAttribute !== 'string' ||
|
|
|
|
typeof entitlementMatcher !== 'string'
|
|
|
|
) {
|
|
|
|
return false
|
|
|
|
}
|
2019-12-19 10:06:30 -05:00
|
|
|
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
|
|
|
|
}
|
2019-10-05 13:43:21 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
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
|
|
|
|
}
|
|
|
|
|
2019-08-27 16:48:00 -04:00
|
|
|
const SAMLIdentityManager = {
|
2019-10-05 13:43:21 -04:00
|
|
|
entitlementAttributeMatches,
|
2019-09-12 10:01:12 -04:00
|
|
|
getUser,
|
2019-10-03 10:10:22 -04:00
|
|
|
linkAccounts,
|
2020-05-20 10:21:13 -04:00
|
|
|
redundantSubscription,
|
2019-10-05 13:43:21 -04:00
|
|
|
unlinkAccounts,
|
|
|
|
updateEntitlement,
|
|
|
|
userHasEntitlement
|
2019-08-27 16:48:00 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
module.exports = SAMLIdentityManager
|