SAMLIdentityManager.js 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289
  1. const EmailHandler = require('../Email/EmailHandler')
  2. const Errors = require('../Errors/Errors')
  3. const InstitutionsAPI = require('../Institutions/InstitutionsAPI')
  4. const NotificationsBuilder = require('../Notifications/NotificationsBuilder')
  5. const OError = require('@overleaf/o-error')
  6. const SubscriptionLocator = require('../Subscription/SubscriptionLocator')
  7. const UserGetter = require('../User/UserGetter')
  8. const UserUpdater = require('../User/UserUpdater')
  9. const logger = require('logger-sharelatex')
  10. const { User } = require('../../models/User')
  11. async function _addIdentifier(
  12. userId,
  13. externalUserId,
  14. providerId,
  15. hasEntitlement,
  16. institutionEmail
  17. ) {
  18. // first check if institutionEmail linked to another account
  19. // before adding the identifier for the email
  20. const user = await UserGetter.promises.getUserByAnyEmail(institutionEmail)
  21. if (user && user._id.toString() !== userId.toString()) {
  22. const existingEmailData = user.emails.find(
  23. emailData => emailData.email === institutionEmail
  24. )
  25. if (existingEmailData && existingEmailData.samlProviderId) {
  26. // email exists and institution link.
  27. // Return back to requesting page with error
  28. throw new Errors.SAMLIdentityExistsError()
  29. } else {
  30. // Only email exists but not linked, so redirect to linking page
  31. // which will tell this user to log out to link
  32. throw new Errors.EmailExistsError()
  33. }
  34. }
  35. providerId = providerId.toString()
  36. hasEntitlement = !!hasEntitlement
  37. const query = {
  38. _id: userId,
  39. 'samlIdentifiers.providerId': {
  40. $ne: providerId
  41. }
  42. }
  43. const update = {
  44. $push: {
  45. samlIdentifiers: {
  46. hasEntitlement,
  47. externalUserId,
  48. providerId
  49. }
  50. }
  51. }
  52. try {
  53. // update v2 user record
  54. const updatedUser = User.findOneAndUpdate(query, update, {
  55. new: true
  56. }).exec()
  57. return updatedUser
  58. } catch (err) {
  59. if (err.code === 11000) {
  60. throw new Errors.SAMLIdentityExistsError()
  61. } else {
  62. throw OError.tag(err)
  63. }
  64. }
  65. }
  66. async function _addInstitutionEmail(userId, email, providerId) {
  67. const user = await UserGetter.promises.getUser(userId)
  68. const query = {
  69. _id: userId,
  70. 'emails.email': email
  71. }
  72. const update = {
  73. $set: {
  74. 'emails.$.samlProviderId': providerId.toString()
  75. }
  76. }
  77. if (user == null) {
  78. throw new Errors.NotFoundError('user not found')
  79. }
  80. const emailAlreadyAssociated = user.emails.find(e => e.email === email)
  81. if (emailAlreadyAssociated && emailAlreadyAssociated.confirmedAt) {
  82. await UserUpdater.promises.updateUser(query, update)
  83. } else if (emailAlreadyAssociated) {
  84. // add and confirm email
  85. await UserUpdater.promises.confirmEmail(user._id, email)
  86. await UserUpdater.promises.updateUser(query, update)
  87. } else {
  88. // add and confirm email
  89. await UserUpdater.promises.addEmailAddress(user._id, email)
  90. await UserUpdater.promises.confirmEmail(user._id, email)
  91. await UserUpdater.promises.updateUser(query, update)
  92. }
  93. }
  94. async function _sendLinkedEmail(userId, providerName) {
  95. const user = await UserGetter.promises.getUser(userId, { email: 1 })
  96. const emailOptions = {
  97. to: user.email,
  98. actionDescribed: `an Institutional SSO account at ${providerName} was linked to your account ${
  99. user.email
  100. }`,
  101. action: 'institutional SSO account linked'
  102. }
  103. EmailHandler.sendEmail('securityAlert', emailOptions, error => {
  104. if (error) {
  105. logger.warn({ err: error })
  106. }
  107. })
  108. }
  109. function _sendUnlinkedEmail(primaryEmail, providerName) {
  110. const emailOptions = {
  111. to: primaryEmail,
  112. actionDescribed: `an Institutional SSO account at ${providerName} is no longer linked to your account ${primaryEmail}`,
  113. action: 'institutional SSO account no longer linked'
  114. }
  115. EmailHandler.sendEmail('securityAlert', emailOptions, error => {
  116. if (error) {
  117. logger.warn({ err: error })
  118. }
  119. })
  120. }
  121. async function getUser(providerId, externalUserId) {
  122. if (!providerId || !externalUserId) {
  123. throw new Error(
  124. `invalid arguments: providerId: ${providerId}, externalUserId: ${externalUserId}`
  125. )
  126. }
  127. const user = await User.findOne({
  128. 'samlIdentifiers.externalUserId': externalUserId.toString(),
  129. 'samlIdentifiers.providerId': providerId.toString()
  130. }).exec()
  131. return user
  132. }
  133. async function redundantSubscription(userId, providerId, providerName) {
  134. const subscription = await SubscriptionLocator.promises.getUserIndividualSubscription(
  135. userId
  136. )
  137. if (subscription) {
  138. await NotificationsBuilder.promises
  139. .redundantPersonalSubscription(
  140. {
  141. institutionId: providerId,
  142. institutionName: providerName
  143. },
  144. { _id: userId }
  145. )
  146. .create()
  147. }
  148. }
  149. async function linkAccounts(
  150. userId,
  151. externalUserId,
  152. institutionEmail,
  153. providerId,
  154. providerName,
  155. hasEntitlement
  156. ) {
  157. await _addIdentifier(
  158. userId,
  159. externalUserId,
  160. providerId,
  161. hasEntitlement,
  162. institutionEmail
  163. )
  164. await _addInstitutionEmail(userId, institutionEmail, providerId)
  165. await _sendLinkedEmail(userId, providerName)
  166. // update v1 affiliations record
  167. if (hasEntitlement) {
  168. await InstitutionsAPI.promises.addEntitlement(userId, institutionEmail)
  169. try {
  170. await redundantSubscription(userId, providerId, providerName)
  171. } catch (error) {
  172. logger.err({ err: error }, 'error checking redundant subscription')
  173. }
  174. } else {
  175. await InstitutionsAPI.promises.removeEntitlement(userId, institutionEmail)
  176. }
  177. }
  178. async function unlinkAccounts(
  179. userId,
  180. institutionEmail,
  181. primaryEmail,
  182. providerId,
  183. providerName
  184. ) {
  185. providerId = providerId.toString()
  186. const query = {
  187. _id: userId
  188. }
  189. const update = {
  190. $pull: {
  191. samlIdentifiers: {
  192. providerId
  193. }
  194. }
  195. }
  196. // update v2 user
  197. await User.update(query, update).exec()
  198. // update v1 affiliations record
  199. await InstitutionsAPI.promises.removeEntitlement(userId, institutionEmail)
  200. // send email
  201. _sendUnlinkedEmail(primaryEmail, providerName)
  202. }
  203. async function updateEntitlement(
  204. userId,
  205. institutionEmail,
  206. providerId,
  207. hasEntitlement
  208. ) {
  209. providerId = providerId.toString()
  210. hasEntitlement = !!hasEntitlement
  211. const query = {
  212. _id: userId,
  213. 'samlIdentifiers.providerId': providerId.toString()
  214. }
  215. const update = {
  216. $set: {
  217. 'samlIdentifiers.$.hasEntitlement': hasEntitlement
  218. }
  219. }
  220. // update v2 user
  221. await User.update(query, update).exec()
  222. // update v1 affiliations record
  223. if (hasEntitlement) {
  224. await InstitutionsAPI.promises.addEntitlement(userId, institutionEmail)
  225. } else {
  226. await InstitutionsAPI.promises.removeEntitlement(userId, institutionEmail)
  227. }
  228. }
  229. function entitlementAttributeMatches(entitlementAttribute, entitlementMatcher) {
  230. if (Array.isArray(entitlementAttribute)) {
  231. entitlementAttribute = entitlementAttribute.join(' ')
  232. }
  233. if (
  234. typeof entitlementAttribute !== 'string' ||
  235. typeof entitlementMatcher !== 'string'
  236. ) {
  237. return false
  238. }
  239. try {
  240. const entitlementRegExp = new RegExp(entitlementMatcher)
  241. return !!entitlementAttribute.match(entitlementRegExp)
  242. } catch (err) {
  243. logger.error({ err }, 'Invalid SAML entitlement matcher')
  244. // this is likely caused by an invalid regex in the matcher string
  245. // log the error but do not bubble so that user can still sign in
  246. // even if they don't have the entitlement
  247. return false
  248. }
  249. }
  250. function userHasEntitlement(user, providerId) {
  251. providerId = providerId.toString()
  252. if (!user || !Array.isArray(user.samlIdentifiers)) {
  253. return false
  254. }
  255. for (const samlIdentifier of user.samlIdentifiers) {
  256. if (providerId && samlIdentifier.providerId !== providerId) {
  257. continue
  258. }
  259. if (samlIdentifier.hasEntitlement) {
  260. return true
  261. }
  262. }
  263. return false
  264. }
  265. const SAMLIdentityManager = {
  266. entitlementAttributeMatches,
  267. getUser,
  268. linkAccounts,
  269. redundantSubscription,
  270. unlinkAccounts,
  271. updateEntitlement,
  272. userHasEntitlement
  273. }
  274. module.exports = SAMLIdentityManager