SAMLIdentityManager.mjs 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474
  1. import mongodb from 'mongodb-legacy'
  2. import EmailHandler from '../Email/EmailHandler.mjs'
  3. import Errors from '../Errors/Errors.js'
  4. import InstitutionsAPI from '../Institutions/InstitutionsAPI.mjs'
  5. import NotificationsBuilder from '../Notifications/NotificationsBuilder.mjs'
  6. import OError from '@overleaf/o-error'
  7. import SubscriptionLocator from '../Subscription/SubscriptionLocator.mjs'
  8. import UserAuditLogHandler from '../User/UserAuditLogHandler.mjs'
  9. import UserGetter from '../User/UserGetter.mjs'
  10. import UserUpdater from '../User/UserUpdater.mjs'
  11. import logger from '@overleaf/logger'
  12. import { User } from '../../models/User.mjs'
  13. import { promiseMapWithLimit } from '@overleaf/promise-utils'
  14. const { ObjectId } = mongodb
  15. async function _addAuditLogEntry(operation, userId, auditLog, extraInfo) {
  16. await UserAuditLogHandler.promises.addEntry(
  17. userId,
  18. operation,
  19. auditLog.initiatorId,
  20. auditLog.ipAddress,
  21. extraInfo
  22. )
  23. }
  24. async function _ensureCanAddIdentifier(userId, institutionEmail, providerId) {
  25. const userWithProvider = await UserGetter.promises.getUser(
  26. { _id: new ObjectId(userId), 'samlIdentifiers.providerId': providerId },
  27. { _id: 1 }
  28. )
  29. if (userWithProvider) {
  30. throw new Errors.SAMLAlreadyLinkedError()
  31. }
  32. const userWithEmail =
  33. await UserGetter.promises.getUserByAnyEmail(institutionEmail)
  34. if (!userWithEmail) {
  35. // email doesn't exist; all good
  36. return
  37. }
  38. const emailBelongToUser = userWithEmail._id.toString() === userId.toString()
  39. const existingEmailData = userWithEmail.emails.find(
  40. emailData => emailData.email === institutionEmail
  41. )
  42. if (!emailBelongToUser && existingEmailData.samlProviderId) {
  43. // email exists and institution link.
  44. // Return back to requesting page with error
  45. throw new Errors.SAMLIdentityExistsError()
  46. }
  47. if (!emailBelongToUser) {
  48. // email exists but not linked, so redirect to linking page
  49. // which will tell this user to log out to link
  50. throw new Errors.EmailExistsError()
  51. }
  52. // email belongs to user. Make sure it's already affiliated with the provider
  53. const fullEmails = await UserGetter.promises.getUserFullEmails(
  54. userWithEmail._id
  55. )
  56. const existingFullEmailData = fullEmails.find(
  57. emailData => emailData.email === institutionEmail
  58. )
  59. if (!existingFullEmailData.affiliation) {
  60. throw new Errors.SAMLEmailNotAffiliatedError()
  61. }
  62. if (
  63. existingFullEmailData.affiliation.institution.id.toString() !== providerId
  64. ) {
  65. throw new Errors.SAMLEmailAffiliatedWithAnotherInstitutionError()
  66. }
  67. }
  68. async function _addIdentifier(
  69. userId,
  70. externalUserId,
  71. providerId,
  72. hasEntitlement,
  73. institutionEmail,
  74. providerName,
  75. auditLog,
  76. userIdAttribute
  77. ) {
  78. providerId = providerId.toString()
  79. await _ensureCanAddIdentifier(userId, institutionEmail, providerId)
  80. const auditLogInfo = {
  81. institutionEmail,
  82. providerId,
  83. providerName,
  84. userIdAttribute,
  85. externalUserId,
  86. }
  87. await _addAuditLogEntry(
  88. 'link-institution-sso',
  89. userId,
  90. auditLog,
  91. auditLogInfo
  92. )
  93. hasEntitlement = !!hasEntitlement
  94. const query = {
  95. _id: userId,
  96. 'samlIdentifiers.providerId': {
  97. $ne: providerId,
  98. },
  99. }
  100. const update = {
  101. $push: {
  102. samlIdentifiers: {
  103. hasEntitlement,
  104. externalUserId,
  105. providerId,
  106. userIdAttribute,
  107. },
  108. },
  109. }
  110. try {
  111. // update v2 user record
  112. const updatedUser = await User.findOneAndUpdate(query, update, {
  113. new: true,
  114. }).exec()
  115. if (!updatedUser) {
  116. throw new OError('No update while linking user')
  117. }
  118. return updatedUser
  119. } catch (err) {
  120. if (err.code === 11000) {
  121. throw new Errors.SAMLIdentityExistsError()
  122. } else {
  123. throw OError.tag(err)
  124. }
  125. }
  126. }
  127. async function _addInstitutionEmail(userId, email, providerId, auditLog) {
  128. const user = await UserGetter.promises.getUser(userId)
  129. const query = {
  130. _id: userId,
  131. 'emails.email': email,
  132. }
  133. const update = {
  134. $set: {
  135. 'emails.$.samlProviderId': providerId.toString(),
  136. },
  137. }
  138. if (user == null) {
  139. throw new Errors.NotFoundError('user not found')
  140. }
  141. const emailAlreadyAssociated = user.emails.find(e => e.email === email)
  142. if (emailAlreadyAssociated) {
  143. await UserUpdater.promises.updateUser(query, update)
  144. } else {
  145. await UserUpdater.promises.addEmailAddress(
  146. user._id,
  147. email,
  148. { university: { id: providerId }, rejectIfBlocklisted: true },
  149. auditLog
  150. )
  151. await UserUpdater.promises.updateUser(query, update)
  152. }
  153. }
  154. async function _sendLinkedEmail(userId, providerName, institutionEmail) {
  155. const user = await UserGetter.promises.getUser(userId, { email: 1 })
  156. const emailOptions = {
  157. to: user.email,
  158. actionDescribed: `an Institutional SSO account at ${providerName} was linked to your account ${user.email}`,
  159. action: 'institutional SSO account linked',
  160. message: [
  161. `<span style="display:inline-block;padding: 0 20px;width:100%;">Linked: <br/><b>${institutionEmail}</b></span>`,
  162. ],
  163. }
  164. EmailHandler.sendEmail('securityAlert', emailOptions, error => {
  165. if (error) {
  166. logger.warn({ err: error })
  167. }
  168. })
  169. }
  170. function _sendUnlinkedEmail(primaryEmail, providerName, institutionEmail) {
  171. const emailOptions = {
  172. to: primaryEmail,
  173. actionDescribed: `an Institutional SSO account at ${providerName} was unlinked from your account ${primaryEmail}`,
  174. action: 'institutional SSO account no longer linked',
  175. message: [
  176. `<span style="display:inline-block;padding: 0 20px;width:100%;">No longer linked: <br/><b>${institutionEmail}</b></span>`,
  177. ],
  178. }
  179. EmailHandler.sendEmail('securityAlert', emailOptions, error => {
  180. if (error) {
  181. logger.warn({ err: error })
  182. }
  183. })
  184. }
  185. async function getUser(providerId, externalUserId, userIdAttribute) {
  186. if (!providerId || !externalUserId || !userIdAttribute) {
  187. throw new OError('invalid arguments', {
  188. providerId,
  189. externalUserId,
  190. userIdAttribute,
  191. })
  192. }
  193. const user = await User.findOne({
  194. samlIdentifiers: {
  195. $elemMatch: {
  196. externalUserId: externalUserId.toString(),
  197. providerId: providerId.toString(),
  198. userIdAttribute: userIdAttribute.toString(),
  199. },
  200. },
  201. }).exec()
  202. return user
  203. }
  204. async function redundantSubscription(userId, providerId, providerName) {
  205. const subscription =
  206. await SubscriptionLocator.promises.getUserIndividualSubscription(userId)
  207. if (subscription && !subscription.groupPlan) {
  208. await NotificationsBuilder.promises
  209. .redundantPersonalSubscription(
  210. {
  211. institutionId: providerId,
  212. institutionName: providerName,
  213. },
  214. { _id: userId }
  215. )
  216. .create()
  217. }
  218. }
  219. async function linkAccounts(userId, samlData, auditLog) {
  220. const {
  221. externalUserId,
  222. institutionEmail,
  223. universityId: providerId,
  224. universityName: providerName,
  225. hasEntitlement,
  226. userIdAttribute,
  227. } = samlData
  228. if (!externalUserId || !institutionEmail || !providerId || !userIdAttribute) {
  229. throw new OError('missing data when linking institution SSO', { samlData })
  230. }
  231. await _addIdentifier(
  232. userId,
  233. externalUserId,
  234. providerId,
  235. hasEntitlement,
  236. institutionEmail,
  237. providerName,
  238. auditLog,
  239. userIdAttribute
  240. )
  241. try {
  242. await _addInstitutionEmail(userId, institutionEmail, providerId, auditLog)
  243. } catch (error) {
  244. await _removeIdentifier(userId, providerId)
  245. throw error
  246. }
  247. await UserUpdater.promises.confirmEmail(userId, institutionEmail, {
  248. entitlement: hasEntitlement,
  249. }) // will set confirmedAt if not set, and will always update reconfirmedAt
  250. await _sendLinkedEmail(userId, providerName, institutionEmail)
  251. }
  252. async function unlinkAccounts(
  253. userId,
  254. institutionEmail,
  255. primaryEmail,
  256. providerId,
  257. providerName,
  258. auditLog
  259. ) {
  260. providerId = providerId.toString()
  261. await _addAuditLogEntry('unlink-institution-sso', userId, auditLog, {
  262. institutionEmail,
  263. providerId,
  264. providerName,
  265. })
  266. // update v2 user
  267. await _removeIdentifier(userId, providerId)
  268. // update v1 affiliations record
  269. await InstitutionsAPI.promises.removeEntitlement(userId, institutionEmail)
  270. // send email
  271. _sendUnlinkedEmail(primaryEmail, providerName, institutionEmail)
  272. }
  273. async function _removeIdentifier(userId, providerId) {
  274. providerId = providerId.toString()
  275. const query = {
  276. _id: userId,
  277. }
  278. const update = {
  279. $pull: {
  280. samlIdentifiers: {
  281. providerId,
  282. },
  283. },
  284. }
  285. await User.updateOne(query, update).exec()
  286. }
  287. async function updateEntitlement(
  288. userId,
  289. institutionEmail,
  290. providerId,
  291. hasEntitlement
  292. ) {
  293. providerId = providerId.toString()
  294. hasEntitlement = !!hasEntitlement
  295. const query = {
  296. _id: userId,
  297. 'samlIdentifiers.providerId': providerId.toString(),
  298. }
  299. const update = {
  300. $set: {
  301. 'samlIdentifiers.$.hasEntitlement': hasEntitlement,
  302. },
  303. }
  304. // update v2 user
  305. await User.updateOne(query, update).exec()
  306. }
  307. function entitlementAttributeMatches(entitlementAttribute, entitlementMatcher) {
  308. if (Array.isArray(entitlementAttribute)) {
  309. entitlementAttribute = entitlementAttribute.join(' ')
  310. }
  311. if (
  312. typeof entitlementAttribute !== 'string' ||
  313. typeof entitlementMatcher !== 'string'
  314. ) {
  315. return false
  316. }
  317. try {
  318. const entitlementRegExp = new RegExp(entitlementMatcher)
  319. return !!entitlementAttribute.match(entitlementRegExp)
  320. } catch (err) {
  321. logger.error({ err }, 'Invalid SAML entitlement matcher')
  322. // this is likely caused by an invalid regex in the matcher string
  323. // log the error but do not bubble so that user can still sign in
  324. // even if they don't have the entitlement
  325. return false
  326. }
  327. }
  328. function userHasEntitlement(user, providerId) {
  329. providerId = providerId.toString()
  330. if (!user || !Array.isArray(user.samlIdentifiers)) {
  331. return false
  332. }
  333. for (const samlIdentifier of user.samlIdentifiers) {
  334. if (providerId && samlIdentifier.providerId !== providerId) {
  335. continue
  336. }
  337. if (samlIdentifier.hasEntitlement) {
  338. return true
  339. }
  340. }
  341. return false
  342. }
  343. async function migrateIdentifier(
  344. userId,
  345. externalUserId,
  346. providerId,
  347. hasEntitlement,
  348. institutionEmail,
  349. providerName,
  350. auditLog,
  351. userIdAttribute
  352. ) {
  353. providerId = providerId.toString()
  354. const query = {
  355. _id: userId,
  356. 'samlIdentifiers.providerId': providerId,
  357. }
  358. const update = {
  359. $set: {
  360. 'samlIdentifiers.$.externalUserId': externalUserId,
  361. 'samlIdentifiers.$.userIdAttribute': userIdAttribute,
  362. },
  363. }
  364. await User.updateOne(query, update).exec()
  365. const auditLogInfo = {
  366. institutionEmail,
  367. providerId,
  368. providerName,
  369. userIdAttribute,
  370. }
  371. await _addAuditLogEntry(
  372. 'migrate-institution-sso-id',
  373. userId,
  374. auditLog,
  375. auditLogInfo
  376. )
  377. }
  378. async function unlinkNotMigrated(userId, providerId, providerName, auditLog) {
  379. providerId = providerId.toString()
  380. const query = {
  381. _id: userId,
  382. 'emails.samlProviderId': providerId,
  383. }
  384. const update = {
  385. $pull: {
  386. samlIdentifiers: {
  387. providerId,
  388. },
  389. },
  390. $unset: {
  391. 'emails.$.samlProviderId': 1,
  392. },
  393. }
  394. const originalDoc = await User.findOneAndUpdate(query, update).exec()
  395. // should only be 1
  396. const linkedEmails = originalDoc.emails.filter(email => {
  397. return email.samlProviderId === providerId
  398. })
  399. const auditLogInfo = {
  400. providerId,
  401. providerName,
  402. }
  403. await _addAuditLogEntry(
  404. 'unlink-institution-sso-not-migrated',
  405. userId,
  406. auditLog,
  407. auditLogInfo
  408. )
  409. await promiseMapWithLimit(10, linkedEmails, async emailData => {
  410. await InstitutionsAPI.promises.removeEntitlement(userId, emailData.email)
  411. })
  412. }
  413. const SAMLIdentityManager = {
  414. entitlementAttributeMatches,
  415. getUser,
  416. linkAccounts,
  417. migrateIdentifier,
  418. redundantSubscription,
  419. unlinkAccounts,
  420. unlinkNotMigrated,
  421. updateEntitlement,
  422. userHasEntitlement,
  423. }
  424. export default SAMLIdentityManager