| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474 |
- import mongodb from 'mongodb-legacy'
- import EmailHandler from '../Email/EmailHandler.mjs'
- import Errors from '../Errors/Errors.js'
- import InstitutionsAPI from '../Institutions/InstitutionsAPI.mjs'
- import NotificationsBuilder from '../Notifications/NotificationsBuilder.mjs'
- import OError from '@overleaf/o-error'
- import SubscriptionLocator from '../Subscription/SubscriptionLocator.mjs'
- import UserAuditLogHandler from '../User/UserAuditLogHandler.mjs'
- import UserGetter from '../User/UserGetter.mjs'
- import UserUpdater from '../User/UserUpdater.mjs'
- import logger from '@overleaf/logger'
- import { User } from '../../models/User.mjs'
- import { promiseMapWithLimit } from '@overleaf/promise-utils'
- const { ObjectId } = mongodb
- 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,
- externalUserId,
- }
- 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 OError('invalid arguments', {
- providerId,
- externalUserId,
- userIdAttribute,
- })
- }
- const user = await User.findOne({
- samlIdentifiers: {
- $elemMatch: {
- externalUserId: externalUserId.toString(),
- providerId: providerId.toString(),
- 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 OError('missing data when linking institution SSO', { 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,
- }
- export default SAMLIdentityManager
|