AuthenticationManager.js 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341
  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 BCRYPT_ROUNDS = Settings.security.bcryptRounds || 12
  18. const BCRYPT_MINOR_VERSION = Settings.security.bcryptMinorVersion || 'a'
  19. const _checkWriteResult = function (result, callback) {
  20. // for MongoDB
  21. if (result && result.modifiedCount === 1) {
  22. callback(null, true)
  23. } else {
  24. callback(null, false)
  25. }
  26. }
  27. function _validatePasswordNotTooLong(password) {
  28. // bcrypt has a hard limit of 72 characters.
  29. if (password.length > 72) {
  30. return new InvalidPasswordError({
  31. message: 'password is too long',
  32. info: { code: 'too_long' },
  33. })
  34. }
  35. return null
  36. }
  37. const AuthenticationManager = {
  38. _checkUserPassword(query, password, callback) {
  39. // Using Mongoose for legacy reasons here. The returned User instance
  40. // gets serialized into the session and there may be subtle differences
  41. // between the user returned by Mongoose vs mongodb (such as default values)
  42. User.findOne(query, (error, user) => {
  43. if (error) {
  44. return callback(error)
  45. }
  46. if (!user || !user.hashedPassword) {
  47. return callback(null, null, null)
  48. }
  49. bcrypt.compare(password, user.hashedPassword, function (error, match) {
  50. if (error) {
  51. return callback(error)
  52. }
  53. return callback(null, user, match)
  54. })
  55. })
  56. },
  57. authenticate(query, password, auditLog, callback) {
  58. if (typeof callback === 'undefined') {
  59. callback = auditLog
  60. auditLog = null
  61. }
  62. AuthenticationManager._checkUserPassword(
  63. query,
  64. password,
  65. (error, user, match) => {
  66. if (error) {
  67. return callback(error)
  68. }
  69. if (!user) {
  70. return callback(null, null)
  71. }
  72. const update = { $inc: { loginEpoch: 1 } }
  73. if (!match) {
  74. update.$set = { lastFailedLogin: new Date() }
  75. }
  76. User.updateOne(
  77. { _id: user._id, loginEpoch: user.loginEpoch },
  78. update,
  79. {},
  80. (err, result) => {
  81. if (err) {
  82. return callback(err)
  83. }
  84. if (result.nModified !== 1) {
  85. return callback(new ParallelLoginError())
  86. }
  87. if (!match) {
  88. if (!auditLog) {
  89. return callback(null, null)
  90. } else {
  91. return UserAuditLogHandler.addEntry(
  92. user._id,
  93. 'failed-password-match',
  94. user._id,
  95. auditLog.ipAddress,
  96. auditLog.info,
  97. err => {
  98. if (err) {
  99. logger.error(
  100. { userId: user._id, err, info: auditLog.info },
  101. 'Error while adding AuditLog entry for failed-password-match'
  102. )
  103. }
  104. callback(null, null)
  105. }
  106. )
  107. }
  108. }
  109. AuthenticationManager.checkRounds(
  110. user,
  111. user.hashedPassword,
  112. password,
  113. function (err) {
  114. if (err) {
  115. return callback(err)
  116. }
  117. callback(null, user)
  118. HaveIBeenPwned.checkPasswordForReuseInBackground(password)
  119. }
  120. )
  121. }
  122. )
  123. }
  124. )
  125. },
  126. validateEmail(email) {
  127. const parsed = EmailHelper.parseEmail(email)
  128. if (!parsed) {
  129. return new InvalidEmailError({ message: 'email not valid' })
  130. }
  131. return null
  132. },
  133. // validates a password based on a similar set of rules to `complexPassword.js` on the frontend
  134. // note that `passfield.js` enforces more rules than this, but these are the most commonly set.
  135. // returns null on success, or an error object.
  136. validatePassword(password, email) {
  137. if (password == null) {
  138. return new InvalidPasswordError({
  139. message: 'password not set',
  140. info: { code: 'not_set' },
  141. })
  142. }
  143. let allowAnyChars, min, max
  144. if (Settings.passwordStrengthOptions) {
  145. allowAnyChars = Settings.passwordStrengthOptions.allowAnyChars === true
  146. if (Settings.passwordStrengthOptions.length) {
  147. min = Settings.passwordStrengthOptions.length.min
  148. max = Settings.passwordStrengthOptions.length.max
  149. }
  150. }
  151. allowAnyChars = !!allowAnyChars
  152. min = min || 6
  153. max = max || 72
  154. // we don't support passwords > 72 characters in length, because bcrypt truncates them
  155. if (max > 72) {
  156. max = 72
  157. }
  158. if (password.length < min) {
  159. return new InvalidPasswordError({
  160. message: 'password is too short',
  161. info: { code: 'too_short' },
  162. })
  163. }
  164. if (password.length > max) {
  165. return new InvalidPasswordError({
  166. message: 'password is too long',
  167. info: { code: 'too_long' },
  168. })
  169. }
  170. const passwordLengthError = _validatePasswordNotTooLong(password)
  171. if (passwordLengthError) {
  172. return passwordLengthError
  173. }
  174. if (
  175. !allowAnyChars &&
  176. !AuthenticationManager._passwordCharactersAreValid(password)
  177. ) {
  178. return new InvalidPasswordError({
  179. message: 'password contains an invalid character',
  180. info: { code: 'invalid_character' },
  181. })
  182. }
  183. if (typeof email === 'string' && email !== '') {
  184. const startOfEmail = email.split('@')[0]
  185. if (
  186. password.indexOf(email) !== -1 ||
  187. password.indexOf(startOfEmail) !== -1
  188. ) {
  189. return new InvalidPasswordError({
  190. message: 'password contains part of email address',
  191. info: { code: 'contains_email' },
  192. })
  193. }
  194. }
  195. return null
  196. },
  197. setUserPassword(user, password, callback) {
  198. AuthenticationManager.setUserPasswordInV2(user, password, callback)
  199. },
  200. checkRounds(user, hashedPassword, password, callback) {
  201. // Temporarily disable this function, TODO: re-enable this
  202. if (Settings.security.disableBcryptRoundsUpgrades) {
  203. return callback()
  204. }
  205. // check current number of rounds and rehash if necessary
  206. const currentRounds = bcrypt.getRounds(hashedPassword)
  207. if (currentRounds < BCRYPT_ROUNDS) {
  208. AuthenticationManager._setUserPasswordInMongo(user, password, callback)
  209. } else {
  210. callback()
  211. }
  212. },
  213. hashPassword(password, callback) {
  214. // Double-check the size to avoid truncating in bcrypt.
  215. const error = _validatePasswordNotTooLong(password)
  216. if (error) {
  217. return callback(error)
  218. }
  219. bcrypt.genSalt(BCRYPT_ROUNDS, BCRYPT_MINOR_VERSION, function (error, salt) {
  220. if (error) {
  221. return callback(error)
  222. }
  223. bcrypt.hash(password, salt, callback)
  224. })
  225. },
  226. setUserPasswordInV2(user, password, callback) {
  227. if (!user || !user.email || !user._id) {
  228. return callback(new Error('invalid user object'))
  229. }
  230. const validationError = this.validatePassword(password, user.email)
  231. if (validationError) {
  232. return callback(validationError)
  233. }
  234. // check if we can log in with this password. In which case we should reject it,
  235. // because it is the same as the existing password.
  236. AuthenticationManager._checkUserPassword(
  237. { _id: user._id },
  238. password,
  239. (err, _user, match) => {
  240. if (err) {
  241. return callback(err)
  242. }
  243. if (match) {
  244. return callback(new PasswordMustBeDifferentError())
  245. }
  246. HaveIBeenPwned.checkPasswordForReuse(
  247. password,
  248. (error, isPasswordReused) => {
  249. if (error) {
  250. logger.err({ error }, 'cannot check password for re-use')
  251. }
  252. if (!error && isPasswordReused) {
  253. return callback(new PasswordReusedError())
  254. }
  255. // password is strong enough or the validation with the service did not happen
  256. this._setUserPasswordInMongo(user, password, callback)
  257. }
  258. )
  259. }
  260. )
  261. },
  262. _setUserPasswordInMongo(user, password, callback) {
  263. this.hashPassword(password, function (error, hash) {
  264. if (error) {
  265. return callback(error)
  266. }
  267. db.users.updateOne(
  268. { _id: ObjectId(user._id.toString()) },
  269. {
  270. $set: {
  271. hashedPassword: hash,
  272. },
  273. $unset: {
  274. password: true,
  275. },
  276. },
  277. function (updateError, result) {
  278. if (updateError) {
  279. return callback(updateError)
  280. }
  281. _checkWriteResult(result, callback)
  282. }
  283. )
  284. })
  285. },
  286. _passwordCharactersAreValid(password) {
  287. let digits, letters, lettersUp, symbols
  288. if (
  289. Settings.passwordStrengthOptions &&
  290. Settings.passwordStrengthOptions.chars
  291. ) {
  292. digits = Settings.passwordStrengthOptions.chars.digits
  293. letters = Settings.passwordStrengthOptions.chars.letters
  294. lettersUp = Settings.passwordStrengthOptions.chars.letters_up
  295. symbols = Settings.passwordStrengthOptions.chars.symbols
  296. }
  297. digits = digits || '1234567890'
  298. letters = letters || 'abcdefghijklmnopqrstuvwxyz'
  299. lettersUp = lettersUp || 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
  300. symbols = symbols || '@#$%^&*()-_=+[]{};:<>/?!£€.,'
  301. for (let charIndex = 0; charIndex <= password.length - 1; charIndex++) {
  302. if (
  303. digits.indexOf(password[charIndex]) === -1 &&
  304. letters.indexOf(password[charIndex]) === -1 &&
  305. lettersUp.indexOf(password[charIndex]) === -1 &&
  306. symbols.indexOf(password[charIndex]) === -1
  307. ) {
  308. return false
  309. }
  310. }
  311. return true
  312. },
  313. }
  314. AuthenticationManager.promises = {
  315. authenticate: util.promisify(AuthenticationManager.authenticate),
  316. hashPassword: util.promisify(AuthenticationManager.hashPassword),
  317. setUserPassword: util.promisify(AuthenticationManager.setUserPassword),
  318. }
  319. module.exports = AuthenticationManager