| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341 |
- const Settings = require('@overleaf/settings')
- const { User } = require('../../models/User')
- const { db, ObjectId } = require('../../infrastructure/mongodb')
- const bcrypt = require('bcrypt')
- const EmailHelper = require('../Helpers/EmailHelper')
- const {
- InvalidEmailError,
- InvalidPasswordError,
- ParallelLoginError,
- PasswordMustBeDifferentError,
- PasswordReusedError,
- } = require('./AuthenticationErrors')
- const util = require('util')
- const HaveIBeenPwned = require('./HaveIBeenPwned')
- const UserAuditLogHandler = require('../User/UserAuditLogHandler')
- const logger = require('@overleaf/logger')
- const BCRYPT_ROUNDS = Settings.security.bcryptRounds || 12
- const BCRYPT_MINOR_VERSION = Settings.security.bcryptMinorVersion || 'a'
- const _checkWriteResult = function (result, callback) {
- // for MongoDB
- if (result && result.modifiedCount === 1) {
- callback(null, true)
- } else {
- callback(null, false)
- }
- }
- function _validatePasswordNotTooLong(password) {
- // bcrypt has a hard limit of 72 characters.
- if (password.length > 72) {
- return new InvalidPasswordError({
- message: 'password is too long',
- info: { code: 'too_long' },
- })
- }
- return null
- }
- const AuthenticationManager = {
- _checkUserPassword(query, password, callback) {
- // Using Mongoose for legacy reasons here. The returned User instance
- // gets serialized into the session and there may be subtle differences
- // between the user returned by Mongoose vs mongodb (such as default values)
- User.findOne(query, (error, user) => {
- if (error) {
- return callback(error)
- }
- if (!user || !user.hashedPassword) {
- return callback(null, null, null)
- }
- bcrypt.compare(password, user.hashedPassword, function (error, match) {
- if (error) {
- return callback(error)
- }
- return callback(null, user, match)
- })
- })
- },
- authenticate(query, password, auditLog, callback) {
- if (typeof callback === 'undefined') {
- callback = auditLog
- auditLog = null
- }
- AuthenticationManager._checkUserPassword(
- query,
- password,
- (error, user, match) => {
- if (error) {
- return callback(error)
- }
- if (!user) {
- return callback(null, null)
- }
- const update = { $inc: { loginEpoch: 1 } }
- if (!match) {
- update.$set = { lastFailedLogin: new Date() }
- }
- User.updateOne(
- { _id: user._id, loginEpoch: user.loginEpoch },
- update,
- {},
- (err, result) => {
- if (err) {
- return callback(err)
- }
- if (result.nModified !== 1) {
- return callback(new ParallelLoginError())
- }
- if (!match) {
- if (!auditLog) {
- return callback(null, null)
- } else {
- return UserAuditLogHandler.addEntry(
- user._id,
- 'failed-password-match',
- user._id,
- auditLog.ipAddress,
- auditLog.info,
- err => {
- if (err) {
- logger.error(
- { userId: user._id, err, info: auditLog.info },
- 'Error while adding AuditLog entry for failed-password-match'
- )
- }
- callback(null, null)
- }
- )
- }
- }
- AuthenticationManager.checkRounds(
- user,
- user.hashedPassword,
- password,
- function (err) {
- if (err) {
- return callback(err)
- }
- callback(null, user)
- HaveIBeenPwned.checkPasswordForReuseInBackground(password)
- }
- )
- }
- )
- }
- )
- },
- validateEmail(email) {
- const parsed = EmailHelper.parseEmail(email)
- if (!parsed) {
- return new InvalidEmailError({ message: 'email not valid' })
- }
- return null
- },
- // validates a password based on a similar set of rules to `complexPassword.js` on the frontend
- // note that `passfield.js` enforces more rules than this, but these are the most commonly set.
- // returns null on success, or an error object.
- validatePassword(password, email) {
- if (password == null) {
- return new InvalidPasswordError({
- message: 'password not set',
- info: { code: 'not_set' },
- })
- }
- let allowAnyChars, min, max
- if (Settings.passwordStrengthOptions) {
- allowAnyChars = Settings.passwordStrengthOptions.allowAnyChars === true
- if (Settings.passwordStrengthOptions.length) {
- min = Settings.passwordStrengthOptions.length.min
- max = Settings.passwordStrengthOptions.length.max
- }
- }
- allowAnyChars = !!allowAnyChars
- min = min || 6
- max = max || 72
- // we don't support passwords > 72 characters in length, because bcrypt truncates them
- if (max > 72) {
- max = 72
- }
- if (password.length < min) {
- return new InvalidPasswordError({
- message: 'password is too short',
- info: { code: 'too_short' },
- })
- }
- if (password.length > max) {
- return new InvalidPasswordError({
- message: 'password is too long',
- info: { code: 'too_long' },
- })
- }
- const passwordLengthError = _validatePasswordNotTooLong(password)
- if (passwordLengthError) {
- return passwordLengthError
- }
- if (
- !allowAnyChars &&
- !AuthenticationManager._passwordCharactersAreValid(password)
- ) {
- return new InvalidPasswordError({
- message: 'password contains an invalid character',
- info: { code: 'invalid_character' },
- })
- }
- if (typeof email === 'string' && email !== '') {
- const startOfEmail = email.split('@')[0]
- if (
- password.indexOf(email) !== -1 ||
- password.indexOf(startOfEmail) !== -1
- ) {
- return new InvalidPasswordError({
- message: 'password contains part of email address',
- info: { code: 'contains_email' },
- })
- }
- }
- return null
- },
- setUserPassword(user, password, callback) {
- AuthenticationManager.setUserPasswordInV2(user, password, callback)
- },
- checkRounds(user, hashedPassword, password, callback) {
- // Temporarily disable this function, TODO: re-enable this
- if (Settings.security.disableBcryptRoundsUpgrades) {
- return callback()
- }
- // check current number of rounds and rehash if necessary
- const currentRounds = bcrypt.getRounds(hashedPassword)
- if (currentRounds < BCRYPT_ROUNDS) {
- AuthenticationManager._setUserPasswordInMongo(user, password, callback)
- } else {
- callback()
- }
- },
- hashPassword(password, callback) {
- // Double-check the size to avoid truncating in bcrypt.
- const error = _validatePasswordNotTooLong(password)
- if (error) {
- return callback(error)
- }
- bcrypt.genSalt(BCRYPT_ROUNDS, BCRYPT_MINOR_VERSION, function (error, salt) {
- if (error) {
- return callback(error)
- }
- bcrypt.hash(password, salt, callback)
- })
- },
- setUserPasswordInV2(user, password, callback) {
- if (!user || !user.email || !user._id) {
- return callback(new Error('invalid user object'))
- }
- const validationError = this.validatePassword(password, user.email)
- if (validationError) {
- return callback(validationError)
- }
- // check if we can log in with this password. In which case we should reject it,
- // because it is the same as the existing password.
- AuthenticationManager._checkUserPassword(
- { _id: user._id },
- password,
- (err, _user, match) => {
- if (err) {
- return callback(err)
- }
- if (match) {
- return callback(new PasswordMustBeDifferentError())
- }
- HaveIBeenPwned.checkPasswordForReuse(
- password,
- (error, isPasswordReused) => {
- if (error) {
- logger.err({ error }, 'cannot check password for re-use')
- }
- if (!error && isPasswordReused) {
- return callback(new PasswordReusedError())
- }
- // password is strong enough or the validation with the service did not happen
- this._setUserPasswordInMongo(user, password, callback)
- }
- )
- }
- )
- },
- _setUserPasswordInMongo(user, password, callback) {
- this.hashPassword(password, function (error, hash) {
- if (error) {
- return callback(error)
- }
- db.users.updateOne(
- { _id: ObjectId(user._id.toString()) },
- {
- $set: {
- hashedPassword: hash,
- },
- $unset: {
- password: true,
- },
- },
- function (updateError, result) {
- if (updateError) {
- return callback(updateError)
- }
- _checkWriteResult(result, callback)
- }
- )
- })
- },
- _passwordCharactersAreValid(password) {
- let digits, letters, lettersUp, symbols
- if (
- Settings.passwordStrengthOptions &&
- Settings.passwordStrengthOptions.chars
- ) {
- digits = Settings.passwordStrengthOptions.chars.digits
- letters = Settings.passwordStrengthOptions.chars.letters
- lettersUp = Settings.passwordStrengthOptions.chars.letters_up
- symbols = Settings.passwordStrengthOptions.chars.symbols
- }
- digits = digits || '1234567890'
- letters = letters || 'abcdefghijklmnopqrstuvwxyz'
- lettersUp = lettersUp || 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
- symbols = symbols || '@#$%^&*()-_=+[]{};:<>/?!£€.,'
- for (let charIndex = 0; charIndex <= password.length - 1; charIndex++) {
- if (
- digits.indexOf(password[charIndex]) === -1 &&
- letters.indexOf(password[charIndex]) === -1 &&
- lettersUp.indexOf(password[charIndex]) === -1 &&
- symbols.indexOf(password[charIndex]) === -1
- ) {
- return false
- }
- }
- return true
- },
- }
- AuthenticationManager.promises = {
- authenticate: util.promisify(AuthenticationManager.authenticate),
- hashPassword: util.promisify(AuthenticationManager.hashPassword),
- setUserPassword: util.promisify(AuthenticationManager.setUserPassword),
- }
- module.exports = AuthenticationManager
|