| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399 |
- 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')
- 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 _ensureCanAddIdentifier(userId, institutionEmail, providerId) {
- const userWithProvider = await UserGetter.promises.getUser(
- { _id: 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
- ) {
- providerId = providerId.toString()
- await _ensureCanAddIdentifier(userId, institutionEmail, providerId)
- 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 = 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 && emailAlreadyAssociated.confirmedAt) {
- await UserUpdater.promises.updateUser(query, update)
- } else 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) {
- 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
- )
- try {
- await _addInstitutionEmail(userId, institutionEmail, providerId, auditLog)
- } catch (error) {
- await _removeIdentifier(userId, providerId)
- throw error
- }
- await UserUpdater.promises.confirmEmail(userId, institutionEmail) // will set confirmedAt if not set, and will always update reconfirmedAt
- 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()
- await _addAuditLogEntry(
- false,
- 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()
- // 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
|