Files
overleaf-cep/services/web/app/src/Features/Authentication/AuthenticationManager.mjs
Andrew Rumble ae6dec9dcb Merge pull request #29656 from overleaf/revert-29521-ar-models-es-conversion
Revert "[web] Convert models and self-referential test files to ESM "

GitOrigin-RevId: 5455cccbb513bd9ca36ce526ff1553065f83d233
2025-11-13 09:06:36 +00:00

477 lines
15 KiB
JavaScript

import Settings from '@overleaf/settings'
import { User } from '../../models/User.js'
import { db, ObjectId } from '../../infrastructure/mongodb.js'
import bcrypt from 'bcrypt'
import EmailHelper from '../Helpers/EmailHelper.js'
import {
InvalidEmailError,
InvalidPasswordError,
ParallelLoginError,
PasswordMustBeDifferentError,
PasswordReusedError,
} from './AuthenticationErrors.js'
import { callbackify, callbackifyMultiResult } from '@overleaf/promise-utils'
import HaveIBeenPwned from './HaveIBeenPwned.mjs'
import UserAuditLogHandler from '../User/UserAuditLogHandler.mjs'
import logger from '@overleaf/logger'
import DiffHelper from '../Helpers/DiffHelper.js'
import Metrics from '@overleaf/metrics'
const BCRYPT_ROUNDS = Settings.security.bcryptRounds || 12
const BCRYPT_MINOR_VERSION = Settings.security.bcryptMinorVersion || 'a'
const MAX_SIMILARITY = 0.7
function _exceedsMaximumLengthRatio(password, maxSimilarity, value) {
const passwordLength = password.length
const lengthBoundSimilarity = (maxSimilarity / 2) * passwordLength
const valueLength = value.length
return (
passwordLength >= 10 * valueLength && valueLength < lengthBoundSimilarity
)
}
const _checkWriteResult = function (result) {
// for MongoDB
return !!(result && result.modifiedCount === 1)
}
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
}
function _metricsForSuccessfulPasswordMatch(password) {
const validationResult = AuthenticationManager.validatePassword(password)
const status =
validationResult === null ? 'success' : validationResult?.info?.code
Metrics.inc('check-password', { status })
return null
}
const AuthenticationManager = {
async _checkUserPassword(query, password) {
// 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)
const user = await User.findOne(query).exec()
if (!user || !user.hashedPassword) {
return { user: null, match: null }
}
let rounds = 0
try {
rounds = bcrypt.getRounds(user.hashedPassword)
} catch (err) {
let prefix, suffix, length
if (typeof user.hashedPassword === 'string') {
length = user.hashedPassword.length
if (user.hashedPassword.length > 50) {
// A full bcrypt hash is 60 characters long.
prefix = user.hashedPassword.slice(0, '$2a$12$x'.length)
suffix = user.hashedPassword.slice(-4)
} else if (user.hashedPassword.length > 20) {
prefix = user.hashedPassword.slice(0, 4)
suffix = user.hashedPassword.slice(-4)
} else {
prefix = user.hashedPassword.slice(0, 4)
}
}
logger.warn(
{
err,
userId: user._id,
hashedPassword: {
type: typeof user.hashedPassword,
length,
prefix,
suffix,
},
},
'unexpected user.hashedPassword value'
)
}
Metrics.inc('bcrypt', 1, {
method: 'compare',
path: rounds,
})
const match = await bcrypt.compare(password, user.hashedPassword)
if (match) {
_metricsForSuccessfulPasswordMatch(password)
}
return { user, match }
},
async authenticate(query, password, auditLog, { enforceHIBPCheck = true }) {
const { user, match } = await AuthenticationManager._checkUserPassword(
query,
password
)
if (!user) {
return { user: null }
}
const update = { $inc: { loginEpoch: 1 } }
if (!match) {
update.$set = { lastFailedLogin: new Date() }
}
const result = await User.updateOne(
{ _id: user._id, loginEpoch: user.loginEpoch },
update,
{}
).exec()
if (result.modifiedCount !== 1) {
throw new ParallelLoginError()
}
if (!match) {
if (!auditLog) {
return { user: null }
} else {
try {
await UserAuditLogHandler.promises.addEntry(
user._id,
'failed-password-match',
user._id,
auditLog.ipAddress,
auditLog.info
)
} catch (err) {
logger.error(
{ userId: user._id, err, info: auditLog.info },
'Error while adding AuditLog entry for failed-password-match'
)
}
return { user: null }
}
}
await AuthenticationManager.checkRounds(user, user.hashedPassword, password)
let isPasswordReused
try {
isPasswordReused =
await HaveIBeenPwned.promises.checkPasswordForReuse(password)
} catch (err) {
logger.err({ err }, 'cannot check password for re-use')
}
if (isPasswordReused && enforceHIBPCheck) {
throw new PasswordReusedError()
}
return { user, isPasswordReused }
},
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 previously used by `passfield.js` on the frontend
// note that `passfield.js` enforced 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' },
})
}
Metrics.inc('try-validate-password')
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 || 8
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.includes(email) ||
password.includes(startOfEmail) ||
email.includes(password)
) {
return new InvalidPasswordError({
message: 'password contains part of email address',
info: { code: 'contains_email' },
})
}
try {
const passwordTooSimilarError =
AuthenticationManager._validatePasswordNotTooSimilar(password, email)
if (passwordTooSimilarError) {
Metrics.inc('password-too-similar-to-email')
return new InvalidPasswordError({
message: 'password is too similar to email address',
info: { code: 'too_similar' },
})
}
} catch (error) {
logger.error(
{ error },
'error while checking password similarity to email'
)
}
// TODO: remove this check once the password-too-similar checks are active?
}
return null
},
async setUserPassword(user, password) {
return await AuthenticationManager.setUserPasswordInV2(user, password)
},
async checkRounds(user, hashedPassword, password) {
// Temporarily disable this function, TODO: re-enable this
if (Settings.security.disableBcryptRoundsUpgrades) {
Metrics.inc('bcrypt_check_rounds', 1, { status: 'disabled' })
return
}
// check current number of rounds and rehash if necessary
const currentRounds = bcrypt.getRounds(hashedPassword)
if (currentRounds < BCRYPT_ROUNDS) {
Metrics.inc('bcrypt_check_rounds', 1, { status: 'upgrade' })
return await AuthenticationManager._setUserPasswordInMongo(user, password)
} else {
Metrics.inc('bcrypt_check_rounds', 1, { status: 'success' })
}
},
async hashPassword(password) {
// Double-check the size to avoid truncating in bcrypt.
const error = _validatePasswordNotTooLong(password)
if (error) {
throw error
}
const salt = await bcrypt.genSalt(BCRYPT_ROUNDS, BCRYPT_MINOR_VERSION)
Metrics.inc('bcrypt', 1, {
method: 'hash',
path: BCRYPT_ROUNDS,
})
return await bcrypt.hash(password, salt)
},
async setUserPasswordInV2(user, password) {
if (!user || !user.email || !user._id) {
throw new Error('invalid user object')
}
const validationError = this.validatePassword(password, user.email)
if (validationError) {
throw 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.
const { match } = await AuthenticationManager._checkUserPassword(
{ _id: user._id },
password
)
if (match) {
throw new PasswordMustBeDifferentError()
}
let isPasswordReused
try {
isPasswordReused =
await HaveIBeenPwned.promises.checkPasswordForReuse(password)
} catch (error) {
logger.err({ error }, 'cannot check password for re-use')
}
if (isPasswordReused) {
throw new PasswordReusedError()
}
// password is strong enough or the validation with the service did not happen
return await this._setUserPasswordInMongo(user, password)
},
async _setUserPasswordInMongo(user, password) {
const hash = await this.hashPassword(password)
const result = await db.users.updateOne(
{ _id: new ObjectId(user._id.toString()) },
{
$set: {
hashedPassword: hash,
},
$unset: {
password: true,
},
}
)
return _checkWriteResult(result)
},
_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
},
/**
* Check if the password is similar to (parts of) the email address.
* For now, this merely sends a metric when the password and
* email address are deemed to be too similar to each other.
* Later we will reject passwords that fail this check.
*
* This logic was borrowed from the django project:
* https://github.com/django/django/blob/fa3afc5d86f1f040922cca2029d6a34301597a70/django/contrib/auth/password_validation.py#L159-L214
*/
_validatePasswordNotTooSimilar(password, email) {
password = password.toLowerCase()
email = email.toLowerCase()
const stringsToCheck = [email]
.concat(email.split(/\W+/))
.concat(email.split(/@/))
for (const emailPart of stringsToCheck) {
if (!_exceedsMaximumLengthRatio(password, MAX_SIMILARITY, emailPart)) {
const similarity = DiffHelper.stringSimilarity(password, emailPart)
if (similarity > MAX_SIMILARITY) {
return new Error('password is too similar to email')
}
}
}
},
getMessageForInvalidPasswordError(error, req) {
const errorCode = error?.info?.code
const message = {
type: 'error',
}
switch (errorCode) {
case 'not_set':
message.key = 'password-not-set'
message.text = req.i18n.translate('invalid_password_not_set')
break
case 'invalid_character':
message.key = 'password-invalid-character'
message.text = req.i18n.translate('invalid_password_invalid_character')
break
case 'contains_email':
message.key = 'password-contains-email'
message.text = req.i18n.translate('invalid_password_contains_email')
break
case 'too_similar':
message.key = 'password-too-similar'
message.text = req.i18n.translate('invalid_password_too_similar')
break
case 'too_short':
message.key = 'password-too-short'
message.text = req.i18n.translate('invalid_password_too_short', {
minLength: Settings.passwordStrengthOptions?.length?.min || 8,
})
break
case 'too_long':
message.key = 'password-too-long'
message.text = req.i18n.translate('invalid_password_too_long', {
maxLength: Settings.passwordStrengthOptions?.length?.max || 72,
})
break
default:
logger.error({ err: error }, 'Unknown password validation error code')
message.text = req.i18n.translate('invalid_password')
break
}
return message
},
}
export default {
_validatePasswordNotTooSimilar:
AuthenticationManager._validatePasswordNotTooSimilar, // Private function exported for tests
validateEmail: AuthenticationManager.validateEmail,
validatePassword: AuthenticationManager.validatePassword,
getMessageForInvalidPasswordError:
AuthenticationManager.getMessageForInvalidPasswordError,
authenticate: callbackifyMultiResult(AuthenticationManager.authenticate, [
'user',
'isPasswordReused',
]),
setUserPassword: callbackify(AuthenticationManager.setUserPassword),
checkRounds: callbackify(AuthenticationManager.checkRounds),
hashPassword: callbackify(AuthenticationManager.hashPassword),
setUserPasswordInV2: callbackify(AuthenticationManager.setUserPasswordInV2),
promises: AuthenticationManager,
}