AuthenticationManager.js 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446
  1. const Settings = require('@overleaf/settings')
  2. const { User } = require('../../models/User')
  3. const { db, ObjectId } = require('../../infrastructure/mongodb')
  4. const bcrypt = require('bcrypt')
  5. const EmailHelper = require('../Helpers/EmailHelper')
  6. const {
  7. InvalidEmailError,
  8. InvalidPasswordError,
  9. ParallelLoginError,
  10. PasswordMustBeDifferentError,
  11. PasswordReusedError,
  12. } = require('./AuthenticationErrors')
  13. const util = require('util')
  14. const HaveIBeenPwned = require('./HaveIBeenPwned')
  15. const UserAuditLogHandler = require('../User/UserAuditLogHandler')
  16. const logger = require('@overleaf/logger')
  17. const DiffHelper = require('../Helpers/DiffHelper')
  18. const Metrics = require('@overleaf/metrics')
  19. const BCRYPT_ROUNDS = Settings.security.bcryptRounds || 12
  20. const BCRYPT_MINOR_VERSION = Settings.security.bcryptMinorVersion || 'a'
  21. const MAX_SIMILARITY = 0.7
  22. function _exceedsMaximumLengthRatio(password, maxSimilarity, value) {
  23. const passwordLength = password.length
  24. const lengthBoundSimilarity = (maxSimilarity / 2) * passwordLength
  25. const valueLength = value.length
  26. return (
  27. passwordLength >= 10 * valueLength && valueLength < lengthBoundSimilarity
  28. )
  29. }
  30. const _checkWriteResult = function (result, callback) {
  31. // for MongoDB
  32. if (result && result.modifiedCount === 1) {
  33. callback(null, true)
  34. } else {
  35. callback(null, false)
  36. }
  37. }
  38. function _validatePasswordNotTooLong(password) {
  39. // bcrypt has a hard limit of 72 characters.
  40. if (password.length > 72) {
  41. return new InvalidPasswordError({
  42. message: 'password is too long',
  43. info: { code: 'too_long' },
  44. })
  45. }
  46. return null
  47. }
  48. function _metricsForSuccessfulPasswordMatch(password) {
  49. const validationResult = AuthenticationManager.validatePassword(password)
  50. const status =
  51. validationResult === null ? 'success' : validationResult?.info?.code
  52. Metrics.inc('check-password', { status })
  53. return null
  54. }
  55. const AuthenticationManager = {
  56. _checkUserPassword(query, password, callback) {
  57. // Using Mongoose for legacy reasons here. The returned User instance
  58. // gets serialized into the session and there may be subtle differences
  59. // between the user returned by Mongoose vs mongodb (such as default values)
  60. User.findOne(query, (error, user) => {
  61. if (error) {
  62. return callback(error)
  63. }
  64. if (!user || !user.hashedPassword) {
  65. return callback(null, null, null)
  66. }
  67. bcrypt.compare(password, user.hashedPassword, function (error, match) {
  68. if (error) {
  69. return callback(error)
  70. }
  71. if (match) {
  72. _metricsForSuccessfulPasswordMatch(password)
  73. }
  74. callback(null, user, match)
  75. })
  76. })
  77. },
  78. authenticate(query, password, auditLog, callback) {
  79. if (typeof callback === 'undefined') {
  80. callback = auditLog
  81. auditLog = null
  82. }
  83. AuthenticationManager._checkUserPassword(
  84. query,
  85. password,
  86. (error, user, match) => {
  87. if (error) {
  88. return callback(error)
  89. }
  90. if (!user) {
  91. return callback(null, null)
  92. }
  93. const update = { $inc: { loginEpoch: 1 } }
  94. if (!match) {
  95. update.$set = { lastFailedLogin: new Date() }
  96. }
  97. User.updateOne(
  98. { _id: user._id, loginEpoch: user.loginEpoch },
  99. update,
  100. {},
  101. (err, result) => {
  102. if (err) {
  103. return callback(err)
  104. }
  105. if (result.modifiedCount !== 1) {
  106. return callback(new ParallelLoginError())
  107. }
  108. if (!match) {
  109. if (!auditLog) {
  110. return callback(null, null)
  111. } else {
  112. return UserAuditLogHandler.addEntry(
  113. user._id,
  114. 'failed-password-match',
  115. user._id,
  116. auditLog.ipAddress,
  117. auditLog.info,
  118. err => {
  119. if (err) {
  120. logger.error(
  121. { userId: user._id, err, info: auditLog.info },
  122. 'Error while adding AuditLog entry for failed-password-match'
  123. )
  124. }
  125. callback(null, null)
  126. }
  127. )
  128. }
  129. }
  130. AuthenticationManager.checkRounds(
  131. user,
  132. user.hashedPassword,
  133. password,
  134. function (err) {
  135. if (err) {
  136. return callback(err)
  137. }
  138. callback(null, user)
  139. HaveIBeenPwned.checkPasswordForReuseInBackground(password)
  140. }
  141. )
  142. }
  143. )
  144. }
  145. )
  146. },
  147. validateEmail(email) {
  148. const parsed = EmailHelper.parseEmail(email)
  149. if (!parsed) {
  150. return new InvalidEmailError({ message: 'email not valid' })
  151. }
  152. return null
  153. },
  154. // validates a password based on a similar set of rules to `complexPassword.js` on the frontend
  155. // note that `passfield.js` enforces more rules than this, but these are the most commonly set.
  156. // returns null on success, or an error object.
  157. validatePassword(password, email) {
  158. if (password == null) {
  159. return new InvalidPasswordError({
  160. message: 'password not set',
  161. info: { code: 'not_set' },
  162. })
  163. }
  164. Metrics.inc('try-validate-password')
  165. let allowAnyChars, min, max
  166. if (Settings.passwordStrengthOptions) {
  167. allowAnyChars = Settings.passwordStrengthOptions.allowAnyChars === true
  168. if (Settings.passwordStrengthOptions.length) {
  169. min = Settings.passwordStrengthOptions.length.min
  170. max = Settings.passwordStrengthOptions.length.max
  171. }
  172. }
  173. allowAnyChars = !!allowAnyChars
  174. min = min || 8
  175. max = max || 72
  176. // we don't support passwords > 72 characters in length, because bcrypt truncates them
  177. if (max > 72) {
  178. max = 72
  179. }
  180. if (password.length < min) {
  181. return new InvalidPasswordError({
  182. message: 'password is too short',
  183. info: { code: 'too_short' },
  184. })
  185. }
  186. if (password.length > max) {
  187. return new InvalidPasswordError({
  188. message: 'password is too long',
  189. info: { code: 'too_long' },
  190. })
  191. }
  192. const passwordLengthError = _validatePasswordNotTooLong(password)
  193. if (passwordLengthError) {
  194. return passwordLengthError
  195. }
  196. if (
  197. !allowAnyChars &&
  198. !AuthenticationManager._passwordCharactersAreValid(password)
  199. ) {
  200. return new InvalidPasswordError({
  201. message: 'password contains an invalid character',
  202. info: { code: 'invalid_character' },
  203. })
  204. }
  205. if (typeof email === 'string' && email !== '') {
  206. try {
  207. const substringError =
  208. AuthenticationManager._validatePasswordNotContainsEmailSubstrings(
  209. password,
  210. email
  211. )
  212. if (substringError) {
  213. Metrics.inc('password-contains-substring-of-email')
  214. }
  215. const passwordTooSimilarError =
  216. AuthenticationManager._validatePasswordNotTooSimilar(password, email)
  217. if (passwordTooSimilarError) {
  218. Metrics.inc('password-too-similar-to-email')
  219. }
  220. } catch (error) {
  221. logger.error(
  222. { error },
  223. 'error while checking password similarity to email'
  224. )
  225. }
  226. // TODO: remove this check once the password-too-similar checks are active?
  227. const startOfEmail = email.split('@')[0]
  228. if (
  229. password.indexOf(email) !== -1 ||
  230. password.indexOf(startOfEmail) !== -1
  231. ) {
  232. return new InvalidPasswordError({
  233. message: 'password contains part of email address',
  234. info: { code: 'contains_email' },
  235. })
  236. }
  237. }
  238. return null
  239. },
  240. setUserPassword(user, password, callback) {
  241. AuthenticationManager.setUserPasswordInV2(user, password, callback)
  242. },
  243. checkRounds(user, hashedPassword, password, callback) {
  244. // Temporarily disable this function, TODO: re-enable this
  245. if (Settings.security.disableBcryptRoundsUpgrades) {
  246. return callback()
  247. }
  248. // check current number of rounds and rehash if necessary
  249. const currentRounds = bcrypt.getRounds(hashedPassword)
  250. if (currentRounds < BCRYPT_ROUNDS) {
  251. AuthenticationManager._setUserPasswordInMongo(user, password, callback)
  252. } else {
  253. callback()
  254. }
  255. },
  256. hashPassword(password, callback) {
  257. // Double-check the size to avoid truncating in bcrypt.
  258. const error = _validatePasswordNotTooLong(password)
  259. if (error) {
  260. return callback(error)
  261. }
  262. bcrypt.genSalt(BCRYPT_ROUNDS, BCRYPT_MINOR_VERSION, function (error, salt) {
  263. if (error) {
  264. return callback(error)
  265. }
  266. bcrypt.hash(password, salt, callback)
  267. })
  268. },
  269. setUserPasswordInV2(user, password, callback) {
  270. if (!user || !user.email || !user._id) {
  271. return callback(new Error('invalid user object'))
  272. }
  273. const validationError = this.validatePassword(password, user.email)
  274. if (validationError) {
  275. return callback(validationError)
  276. }
  277. // check if we can log in with this password. In which case we should reject it,
  278. // because it is the same as the existing password.
  279. AuthenticationManager._checkUserPassword(
  280. { _id: user._id },
  281. password,
  282. (err, _user, match) => {
  283. if (err) {
  284. return callback(err)
  285. }
  286. if (match) {
  287. return callback(new PasswordMustBeDifferentError())
  288. }
  289. HaveIBeenPwned.checkPasswordForReuse(
  290. password,
  291. (error, isPasswordReused) => {
  292. if (error) {
  293. logger.err({ error }, 'cannot check password for re-use')
  294. }
  295. if (!error && isPasswordReused) {
  296. return callback(new PasswordReusedError())
  297. }
  298. // password is strong enough or the validation with the service did not happen
  299. this._setUserPasswordInMongo(user, password, callback)
  300. }
  301. )
  302. }
  303. )
  304. },
  305. _setUserPasswordInMongo(user, password, callback) {
  306. this.hashPassword(password, function (error, hash) {
  307. if (error) {
  308. return callback(error)
  309. }
  310. db.users.updateOne(
  311. { _id: ObjectId(user._id.toString()) },
  312. {
  313. $set: {
  314. hashedPassword: hash,
  315. },
  316. $unset: {
  317. password: true,
  318. },
  319. },
  320. function (updateError, result) {
  321. if (updateError) {
  322. return callback(updateError)
  323. }
  324. _checkWriteResult(result, callback)
  325. }
  326. )
  327. })
  328. },
  329. _passwordCharactersAreValid(password) {
  330. let digits, letters, lettersUp, symbols
  331. if (
  332. Settings.passwordStrengthOptions &&
  333. Settings.passwordStrengthOptions.chars
  334. ) {
  335. digits = Settings.passwordStrengthOptions.chars.digits
  336. letters = Settings.passwordStrengthOptions.chars.letters
  337. lettersUp = Settings.passwordStrengthOptions.chars.letters_up
  338. symbols = Settings.passwordStrengthOptions.chars.symbols
  339. }
  340. digits = digits || '1234567890'
  341. letters = letters || 'abcdefghijklmnopqrstuvwxyz'
  342. lettersUp = lettersUp || 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
  343. symbols = symbols || '@#$%^&*()-_=+[]{};:<>/?!£€.,'
  344. for (let charIndex = 0; charIndex <= password.length - 1; charIndex++) {
  345. if (
  346. digits.indexOf(password[charIndex]) === -1 &&
  347. letters.indexOf(password[charIndex]) === -1 &&
  348. lettersUp.indexOf(password[charIndex]) === -1 &&
  349. symbols.indexOf(password[charIndex]) === -1
  350. ) {
  351. return false
  352. }
  353. }
  354. return true
  355. },
  356. /**
  357. * Check if the password is similar to (parts of) the email address.
  358. * For now, this merely sends a metric when the password and
  359. * email address are deemed to be too similar to each other.
  360. * Later we will reject passwords that fail this check.
  361. *
  362. * This logic was borrowed from the django project:
  363. * https://github.com/django/django/blob/fa3afc5d86f1f040922cca2029d6a34301597a70/django/contrib/auth/password_validation.py#L159-L214
  364. */
  365. _validatePasswordNotTooSimilar(password, email) {
  366. password = password.toLowerCase()
  367. email = email.toLowerCase()
  368. const stringsToCheck = [email].concat(email.split(/\W+/))
  369. let largestSimilarity = 0
  370. let err = null
  371. for (const emailPart of stringsToCheck) {
  372. if (!_exceedsMaximumLengthRatio(password, MAX_SIMILARITY, emailPart)) {
  373. const similarity = DiffHelper.stringSimilarity(password, emailPart)
  374. const similarityOneDecimalPlace = Math.floor(similarity * 10) / 10
  375. largestSimilarity = Math.max(
  376. largestSimilarity,
  377. similarityOneDecimalPlace
  378. )
  379. if (similarity > MAX_SIMILARITY) {
  380. logger.warn(
  381. { email, emailPart, similarity, maxSimilarity: MAX_SIMILARITY },
  382. 'Password too similar to email'
  383. )
  384. err = new Error('password is too similar to email')
  385. }
  386. }
  387. }
  388. Metrics.inc('password-validation-similarity', 1, {
  389. similarity: largestSimilarity,
  390. })
  391. return err
  392. },
  393. _validatePasswordNotContainsEmailSubstrings(password, email) {
  394. password = password.toLowerCase()
  395. email = email.toLowerCase()
  396. const chunkLength = 4
  397. if (email.length < chunkLength) {
  398. return
  399. }
  400. let chunk
  401. for (let i = 0; i <= email.length - chunkLength; i++) {
  402. chunk = email.slice(i, i + chunkLength)
  403. if (password.indexOf(chunk) !== -1) {
  404. return new InvalidPasswordError({
  405. message: 'password contains part of email address',
  406. info: { code: 'contains_email' },
  407. })
  408. }
  409. }
  410. },
  411. }
  412. AuthenticationManager.promises = {
  413. authenticate: util.promisify(AuthenticationManager.authenticate),
  414. hashPassword: util.promisify(AuthenticationManager.hashPassword),
  415. setUserPassword: util.promisify(AuthenticationManager.setUserPassword),
  416. }
  417. module.exports = AuthenticationManager