SAMLIdentityManager.js 10 KB

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