mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-05-23 09:09:36 +02:00
681 lines
20 KiB
JavaScript
681 lines
20 KiB
JavaScript
import AuthenticationManager from './AuthenticationManager.mjs'
|
|
import SessionManager from './SessionManager.mjs'
|
|
import OError from '@overleaf/o-error'
|
|
import LoginRateLimiter from '../Security/LoginRateLimiter.mjs'
|
|
import UserUpdater from '../User/UserUpdater.mjs'
|
|
import Metrics from '@overleaf/metrics'
|
|
import logger from '@overleaf/logger'
|
|
import querystring from 'node:querystring'
|
|
import Settings from '@overleaf/settings'
|
|
import basicAuth from 'basic-auth'
|
|
import tsscmp from 'tsscmp'
|
|
import UserHandler from '../User/UserHandler.mjs'
|
|
import UserSessionsManager from '../User/UserSessionsManager.mjs'
|
|
import Analytics from '../Analytics/AnalyticsManager.mjs'
|
|
import passport from 'passport'
|
|
import NotificationsBuilder from '../Notifications/NotificationsBuilder.mjs'
|
|
import UrlHelper from '../Helpers/UrlHelper.mjs'
|
|
import AsyncFormHelper from '../Helpers/AsyncFormHelper.mjs'
|
|
import _ from 'lodash'
|
|
import UserAuditLogHandler from '../User/UserAuditLogHandler.mjs'
|
|
import AnalyticsRegistrationSourceHelper from '../Analytics/AnalyticsRegistrationSourceHelper.mjs'
|
|
import { acceptsJson } from '../../infrastructure/RequestContentTypeDetection.mjs'
|
|
import AdminAuthorizationHelper from '../Helpers/AdminAuthorizationHelper.mjs'
|
|
import Modules from '../../infrastructure/Modules.mjs'
|
|
import { expressify, promisify } from '@overleaf/promise-utils'
|
|
import { handleAuthenticateErrors } from './AuthenticationErrors.mjs'
|
|
import EmailHelper from '../Helpers/EmailHelper.mjs'
|
|
import SplitTestHandler from '../SplitTests/SplitTestHandler.mjs'
|
|
|
|
const { hasAdminAccess } = AdminAuthorizationHelper
|
|
|
|
function send401WithChallenge(res) {
|
|
res.setHeader('WWW-Authenticate', 'OverleafLogin')
|
|
res.sendStatus(401)
|
|
}
|
|
|
|
function checkCredentials(userDetailsMap, user, password) {
|
|
const expectedPassword = userDetailsMap.get(user)
|
|
const userExists = userDetailsMap.has(user) && expectedPassword // user exists with a non-null password
|
|
|
|
let isValid = false
|
|
if (userExists) {
|
|
if (Array.isArray(expectedPassword)) {
|
|
const isValidPrimary = Boolean(
|
|
expectedPassword[0] && tsscmp(expectedPassword[0], password)
|
|
)
|
|
const isValidFallback = Boolean(
|
|
expectedPassword[1] && tsscmp(expectedPassword[1], password)
|
|
)
|
|
isValid = isValidPrimary || isValidFallback
|
|
} else {
|
|
isValid = tsscmp(expectedPassword, password)
|
|
}
|
|
}
|
|
|
|
if (!isValid) {
|
|
logger.err({ user }, 'invalid login details')
|
|
}
|
|
Metrics.inc('security.http-auth.check-credentials', 1, {
|
|
path: userExists ? 'known-user' : 'unknown-user',
|
|
status: isValid ? 'pass' : 'fail',
|
|
})
|
|
return isValid
|
|
}
|
|
|
|
// TODO: Finish making these methods async
|
|
const AuthenticationController = {
|
|
serializeUser(user, callback) {
|
|
if (!user._id || !user.email) {
|
|
const err = new Error('serializeUser called with non-user object')
|
|
logger.warn({ user }, err.message)
|
|
return callback(err)
|
|
}
|
|
const lightUser = {
|
|
_id: user._id,
|
|
first_name: user.first_name,
|
|
last_name: user.last_name,
|
|
email: user.email,
|
|
referal_id: user.referal_id,
|
|
session_created: new Date().toISOString(),
|
|
ip_address: user._login_req_ip,
|
|
must_reconfirm: user.must_reconfirm,
|
|
v1_id: user.overleaf != null ? user.overleaf.id : undefined,
|
|
analyticsId: user.analyticsId || user._id,
|
|
alphaProgram: user.alphaProgram || undefined, // only store if set
|
|
betaProgram: user.betaProgram || undefined, // only store if set
|
|
}
|
|
if (user.isAdmin) {
|
|
lightUser.isAdmin = true
|
|
lightUser.adminRoles = user.adminRoles
|
|
}
|
|
|
|
callback(null, lightUser)
|
|
},
|
|
|
|
deserializeUser(user, cb) {
|
|
cb(null, user)
|
|
},
|
|
|
|
passportLogin(req, res, next) {
|
|
// This function is middleware which wraps the passport.authenticate middleware,
|
|
// so we can send back our custom `{message: {text: "", type: ""}}` responses on failure,
|
|
// and send a `{redir: ""}` response on success
|
|
passport.authenticate(
|
|
Settings.ldap?.enable ? ['custom-fail-ldapauth','local'] : ['local'],
|
|
{ keepSessionInfo: true },
|
|
async function (err, user, infoArray) {
|
|
if (err) {
|
|
return next(err)
|
|
}
|
|
if (user) {
|
|
// `user` is either a user object or false
|
|
AuthenticationController.setAuditInfo(req, {
|
|
method: 'Password login',
|
|
})
|
|
|
|
try {
|
|
// We could investigate whether this can be done together with 'preFinishLogin' instead of being its own hook
|
|
await Modules.promises.hooks.fire(
|
|
'saasLogin',
|
|
{ email: user.email },
|
|
req
|
|
)
|
|
await AuthenticationController.promises.finishLogin(user, req, res)
|
|
} catch (err) {
|
|
return next(err)
|
|
}
|
|
} else {
|
|
let info = infoArray[0]
|
|
if (info.redir != null) {
|
|
return res.json({ redir: info.redir })
|
|
} else {
|
|
res.status(info.status || 200)
|
|
delete info.status
|
|
const body = { message: info }
|
|
const { errorReason } = info
|
|
if (errorReason) {
|
|
body.errorReason = errorReason
|
|
delete info.errorReason
|
|
}
|
|
return res.json(body)
|
|
}
|
|
}
|
|
}
|
|
)(req, res, next)
|
|
},
|
|
|
|
async _finishLoginAsync(user, req, res) {
|
|
if (user === false) {
|
|
return AsyncFormHelper.redirect(req, res, '/login')
|
|
} // OAuth2 'state' mismatch
|
|
|
|
if (user.suspended) {
|
|
return AsyncFormHelper.redirect(req, res, '/account-suspended')
|
|
}
|
|
|
|
if (Settings.adminOnlyLogin && !hasAdminAccess(user)) {
|
|
return res.status(403).json({
|
|
message: { type: 'error', text: 'Admin only panel' },
|
|
})
|
|
}
|
|
|
|
const auditInfo = AuthenticationController.getAuditInfo(req)
|
|
|
|
const anonymousAnalyticsId = req.session.analyticsId
|
|
const isNewUser = req.session.justRegistered || false
|
|
|
|
const results = await Modules.promises.hooks.fire(
|
|
'preFinishLogin',
|
|
req,
|
|
res,
|
|
user
|
|
)
|
|
|
|
if (results.some(result => result && result.doNotFinish)) {
|
|
return
|
|
}
|
|
|
|
if (user.must_reconfirm) {
|
|
return AuthenticationController._redirectToReconfirmPage(req, res, user)
|
|
}
|
|
|
|
const redir =
|
|
AuthenticationController.getRedirectFromSession(req) || '/project'
|
|
|
|
_loginAsyncHandlers(req, user, anonymousAnalyticsId, isNewUser)
|
|
const userId = user._id
|
|
|
|
await UserAuditLogHandler.promises.addEntry(
|
|
userId,
|
|
'login',
|
|
userId,
|
|
req.ip,
|
|
auditInfo
|
|
)
|
|
|
|
await _afterLoginSessionSetupAsync(req, user)
|
|
|
|
AuthenticationController._clearRedirectFromSession(req)
|
|
AnalyticsRegistrationSourceHelper.clearSource(req.session)
|
|
AnalyticsRegistrationSourceHelper.clearInbound(req.session)
|
|
AsyncFormHelper.redirect(req, res, redir)
|
|
},
|
|
|
|
finishLogin(user, req, res, next) {
|
|
AuthenticationController._finishLoginAsync(user, req, res).catch(err =>
|
|
next(err)
|
|
)
|
|
},
|
|
|
|
async doPassportLogin(req, username, password, done) {
|
|
let user, info
|
|
try {
|
|
;({ user, info } = await AuthenticationController._doPassportLogin(
|
|
req,
|
|
username,
|
|
password
|
|
))
|
|
} catch (error) {
|
|
return done(error)
|
|
}
|
|
return done(undefined, user, info)
|
|
},
|
|
|
|
/**
|
|
*
|
|
* @param req
|
|
* @param username
|
|
* @param password
|
|
* @returns {Promise<{ user: any, info: any}>}
|
|
*/
|
|
async _doPassportLogin(req, username, password) {
|
|
const email = EmailHelper.parseEmail(username)
|
|
if (!email) {
|
|
Metrics.inc('login_failure_reason', 1, { status: 'invalid_email' })
|
|
return {
|
|
user: null,
|
|
info: {
|
|
status: 400,
|
|
type: 'error',
|
|
text: req.i18n.translate('email_address_is_invalid'),
|
|
},
|
|
}
|
|
}
|
|
AuthenticationController.setAuditInfo(req, { method: 'Password login' })
|
|
|
|
const { fromKnownDevice } = AuthenticationController.getAuditInfo(req)
|
|
const auditLog = {
|
|
ipAddress: req.ip,
|
|
info: { method: 'Password login', fromKnownDevice },
|
|
}
|
|
|
|
let user, isPasswordReused
|
|
try {
|
|
;({ user, isPasswordReused } =
|
|
await AuthenticationManager.promises.authenticate(
|
|
{ email },
|
|
password,
|
|
auditLog,
|
|
{
|
|
enforceHIBPCheck: !fromKnownDevice,
|
|
}
|
|
))
|
|
} catch (error) {
|
|
return {
|
|
user: false,
|
|
info: handleAuthenticateErrors(error, req),
|
|
}
|
|
}
|
|
|
|
if (user && AuthenticationController.captchaRequiredForLogin(req, user)) {
|
|
Metrics.inc('login_failure_reason', 1, { status: 'captcha_missing' })
|
|
return {
|
|
user: false,
|
|
info: {
|
|
text: req.i18n.translate('cannot_verify_user_not_robot'),
|
|
type: 'error',
|
|
errorReason: 'cannot_verify_user_not_robot',
|
|
status: 400,
|
|
},
|
|
}
|
|
} else if (user) {
|
|
if (
|
|
isPasswordReused &&
|
|
AuthenticationController.getRedirectFromSession(req) == null
|
|
) {
|
|
AuthenticationController.setRedirectInSession(
|
|
req,
|
|
'/compromised-password'
|
|
)
|
|
}
|
|
|
|
// async actions
|
|
return { user, info: undefined }
|
|
} else {
|
|
Metrics.inc('login_failure_reason', 1, { status: 'password_invalid' })
|
|
AuthenticationController._recordFailedLogin()
|
|
logger.debug({ email }, 'failed log in')
|
|
return {
|
|
user: false,
|
|
info: {
|
|
type: 'error',
|
|
key: 'invalid-password-retry-or-reset',
|
|
status: 401,
|
|
},
|
|
}
|
|
}
|
|
},
|
|
|
|
captchaRequiredForLogin(req, user) {
|
|
switch (AuthenticationController.getAuditInfo(req).captcha) {
|
|
case 'trusted':
|
|
case 'disabled':
|
|
return false
|
|
case 'solved':
|
|
return false
|
|
case 'skipped': {
|
|
let required = false
|
|
if (user.lastFailedLogin) {
|
|
const requireCaptchaUntil =
|
|
user.lastFailedLogin.getTime() +
|
|
Settings.elevateAccountSecurityAfterFailedLogin
|
|
required = requireCaptchaUntil >= Date.now()
|
|
}
|
|
Metrics.inc('force_captcha_on_login', 1, {
|
|
status: required ? 'yes' : 'no',
|
|
})
|
|
return required
|
|
}
|
|
default:
|
|
throw new Error('captcha middleware missing in handler chain')
|
|
}
|
|
},
|
|
|
|
ipMatchCheck(req, user) {
|
|
if (req.ip !== user.lastLoginIp) {
|
|
NotificationsBuilder.ipMatcherAffiliation(user._id.toString()).create(
|
|
req.ip,
|
|
() => {}
|
|
)
|
|
}
|
|
return UserUpdater.updateUser(
|
|
user._id.toString(),
|
|
{
|
|
$set: { lastLoginIp: req.ip },
|
|
},
|
|
() => {}
|
|
)
|
|
},
|
|
|
|
requireLogin() {
|
|
const doRequest = function (req, res, next) {
|
|
if (next == null) {
|
|
next = function () {}
|
|
}
|
|
if (!SessionManager.isUserLoggedIn(req.session)) {
|
|
if (acceptsJson(req)) return send401WithChallenge(res)
|
|
return AuthenticationController._redirectToLoginOrRegisterPage(req, res)
|
|
} else {
|
|
req.user = SessionManager.getSessionUser(req.session)
|
|
req.logger?.addFields({ userId: req.user._id })
|
|
return next()
|
|
}
|
|
}
|
|
|
|
return doRequest
|
|
},
|
|
|
|
/**
|
|
* @param {string} scope
|
|
* @return {import('express').Handler}
|
|
*/
|
|
requireOauth(scope) {
|
|
if (typeof scope !== 'string' || !scope) {
|
|
throw new Error(
|
|
"requireOauth() expects a non-empty string as 'scope' parameter"
|
|
)
|
|
}
|
|
|
|
const middleware = async (req, res, next) => {
|
|
const Oauth2Server = (
|
|
await import('../../../../modules/oauth2-server/app/src/Oauth2Server.mjs')
|
|
).default
|
|
|
|
const request = new Oauth2Server.Request(req)
|
|
const response = new Oauth2Server.Response(res)
|
|
try {
|
|
const token = await Oauth2Server.server.authenticate(
|
|
request,
|
|
response,
|
|
{ scope }
|
|
)
|
|
req.oauth = { access_token: token.accessToken }
|
|
req.oauth_token = token
|
|
req.oauth_user = token.user
|
|
next()
|
|
} catch (err) {
|
|
if (
|
|
err.code === 400 &&
|
|
err.message === 'Invalid request: malformed authorization header'
|
|
) {
|
|
err.code = 401
|
|
}
|
|
// send all other errors
|
|
res
|
|
.status(err.code)
|
|
.json({ error: err.name, error_description: err.message })
|
|
}
|
|
}
|
|
return expressify(middleware)
|
|
},
|
|
|
|
_globalLoginWhitelist: [],
|
|
addEndpointToLoginWhitelist(endpoint) {
|
|
return AuthenticationController._globalLoginWhitelist.push(endpoint)
|
|
},
|
|
|
|
requireGlobalLogin(req, res, next) {
|
|
if (
|
|
AuthenticationController._globalLoginWhitelist.includes(
|
|
req._parsedUrl.pathname
|
|
)
|
|
) {
|
|
return next()
|
|
}
|
|
|
|
if (req.headers.authorization != null) {
|
|
AuthenticationController.requirePrivateApiAuth()(req, res, next)
|
|
} else if (SessionManager.isUserLoggedIn(req.session)) {
|
|
next()
|
|
} else {
|
|
logger.debug(
|
|
{ url: req.url },
|
|
'user trying to access endpoint not in global whitelist'
|
|
)
|
|
if (acceptsJson(req)) return send401WithChallenge(res)
|
|
AuthenticationController.setRedirectInSession(req)
|
|
res.redirect('/login')
|
|
}
|
|
},
|
|
|
|
validateAdmin(req, res, next) {
|
|
const adminDomains = Settings.adminDomains
|
|
if (
|
|
!adminDomains ||
|
|
!(Array.isArray(adminDomains) && adminDomains.length)
|
|
) {
|
|
return next()
|
|
}
|
|
const user = SessionManager.getSessionUser(req.session)
|
|
if (!hasAdminAccess(user)) {
|
|
return next()
|
|
}
|
|
const email = user.email
|
|
if (email == null) {
|
|
return next(
|
|
new OError('[ValidateAdmin] Admin user without email address', {
|
|
userId: user._id,
|
|
})
|
|
)
|
|
}
|
|
if (!adminDomains.find(domain => email.endsWith(`@${domain}`))) {
|
|
return next(
|
|
new OError('[ValidateAdmin] Admin user with invalid email domain', {
|
|
email,
|
|
userId: user._id,
|
|
})
|
|
)
|
|
}
|
|
return next()
|
|
},
|
|
|
|
checkCredentials,
|
|
|
|
requireBasicAuth: function (userDetails) {
|
|
const userDetailsMap = new Map(Object.entries(userDetails))
|
|
return function (req, res, next) {
|
|
const credentials = basicAuth(req)
|
|
if (
|
|
!credentials ||
|
|
!checkCredentials(userDetailsMap, credentials.name, credentials.pass)
|
|
) {
|
|
send401WithChallenge(res)
|
|
Metrics.inc('security.http-auth', 1, { status: 'reject' })
|
|
} else {
|
|
Metrics.inc('security.http-auth', 1, { status: 'accept' })
|
|
next()
|
|
}
|
|
}
|
|
},
|
|
|
|
requirePrivateApiAuth() {
|
|
return AuthenticationController.requireBasicAuth(Settings.httpAuthUsers)
|
|
},
|
|
|
|
setAuditInfo(req, info) {
|
|
if (!req.__authAuditInfo) {
|
|
req.__authAuditInfo = {}
|
|
}
|
|
Object.assign(req.__authAuditInfo, info)
|
|
},
|
|
|
|
getAuditInfo(req) {
|
|
return req.__authAuditInfo || {}
|
|
},
|
|
|
|
setRedirectInSession(req, value) {
|
|
if (value == null) {
|
|
value =
|
|
Object.keys(req.query).length > 0
|
|
? `${req.path}?${querystring.stringify(req.query)}`
|
|
: `${req.path}`
|
|
}
|
|
if (
|
|
req.session != null &&
|
|
!/^\/(socket.io|js|stylesheets|img)\/.*$/.test(value) &&
|
|
!/^.*\.(png|jpeg|svg)$/.test(value)
|
|
) {
|
|
const safePath = UrlHelper.getSafeRedirectPath(value)
|
|
return (req.session.postLoginRedirect = safePath)
|
|
}
|
|
},
|
|
|
|
_redirectToLoginOrRegisterPage(req, res) {
|
|
if (
|
|
req.query.zipUrl != null ||
|
|
req.session.sharedProjectData ||
|
|
req.path === '/user/subscription/new'
|
|
) {
|
|
AuthenticationController._redirectToRegisterPage(req, res)
|
|
} else {
|
|
AuthenticationController._redirectToLoginPage(req, res)
|
|
}
|
|
},
|
|
|
|
_redirectToLoginPage(req, res) {
|
|
logger.debug(
|
|
{ url: req.url },
|
|
'user not logged in so redirecting to login page'
|
|
)
|
|
AuthenticationController.setRedirectInSession(req)
|
|
const url = `/login?${querystring.stringify(req.query)}`
|
|
res.redirect(url)
|
|
Metrics.inc('security.login-redirect')
|
|
},
|
|
|
|
_redirectToReconfirmPage(req, res, user) {
|
|
logger.debug(
|
|
{ url: req.url },
|
|
'user needs to reconfirm so redirecting to reconfirm page'
|
|
)
|
|
req.session.reconfirm_email = user != null ? user.email : undefined
|
|
const redir = '/user/reconfirm'
|
|
AsyncFormHelper.redirect(req, res, redir)
|
|
},
|
|
|
|
_redirectToRegisterPage(req, res) {
|
|
logger.debug(
|
|
{ url: req.url },
|
|
'user not logged in so redirecting to register page'
|
|
)
|
|
AuthenticationController.setRedirectInSession(req)
|
|
const url = `/register?${querystring.stringify(req.query)}`
|
|
res.redirect(url)
|
|
Metrics.inc('security.login-redirect')
|
|
},
|
|
|
|
_recordSuccessfulLogin(userId, callback) {
|
|
if (callback == null) {
|
|
callback = function () {}
|
|
}
|
|
UserUpdater.updateUser(
|
|
userId.toString(),
|
|
{
|
|
$set: { lastLoggedIn: new Date() },
|
|
$inc: { loginCount: 1 },
|
|
},
|
|
function (error) {
|
|
if (error != null) {
|
|
callback(error)
|
|
}
|
|
Metrics.inc('user.login.success')
|
|
callback()
|
|
}
|
|
)
|
|
},
|
|
|
|
_recordFailedLogin(callback) {
|
|
Metrics.inc('user.login.failed')
|
|
if (callback) callback()
|
|
},
|
|
|
|
getRedirectFromSession(req) {
|
|
let safePath
|
|
const value = _.get(req, ['session', 'postLoginRedirect'])
|
|
if (value) {
|
|
safePath = UrlHelper.getSafeRedirectPath(value)
|
|
}
|
|
return safePath || null
|
|
},
|
|
|
|
_clearRedirectFromSession(req) {
|
|
if (req.session != null) {
|
|
delete req.session.postLoginRedirect
|
|
}
|
|
},
|
|
}
|
|
|
|
function _afterLoginSessionSetup(req, user, callback) {
|
|
req.login(user, { keepSessionInfo: true }, function (err) {
|
|
if (err) {
|
|
OError.tag(err, 'error from req.login', {
|
|
user_id: user._id,
|
|
})
|
|
return callback(err)
|
|
}
|
|
delete req.session.__tmp
|
|
delete req.session.csrfSecret
|
|
|
|
// Populate the analyticsId cache in the session AFTER switching it into logged-in mode.
|
|
req.session.analyticsId = user.analyticsId
|
|
|
|
req.session.save(function (err) {
|
|
if (err) {
|
|
OError.tag(err, 'error saving regenerated session after login', {
|
|
user_id: user._id,
|
|
})
|
|
return callback(err)
|
|
}
|
|
UserSessionsManager.trackSession(user, req.sessionID, function () {})
|
|
if (!req.deviceHistory) {
|
|
// Captcha disabled or SSO-based login.
|
|
return callback()
|
|
}
|
|
req.deviceHistory.add(user.email)
|
|
req.deviceHistory
|
|
.serialize(req.res)
|
|
.catch(err => {
|
|
logger.err({ err }, 'cannot serialize deviceHistory')
|
|
})
|
|
.finally(() => callback())
|
|
})
|
|
})
|
|
}
|
|
|
|
const _afterLoginSessionSetupAsync = promisify(_afterLoginSessionSetup)
|
|
|
|
function _loginAsyncHandlers(req, user, anonymousAnalyticsId, isNewUser) {
|
|
UserHandler.promises.populateTeamInvites(user).catch(err => {
|
|
logger.warn({ err }, 'error setting up login data')
|
|
})
|
|
SplitTestHandler.promises.userMaintenanceOnLogin(user).catch(err => {
|
|
const userId = user._id
|
|
logger.warn({ err, userId }, 'error cleaning up split-tests on login')
|
|
})
|
|
LoginRateLimiter.recordSuccessfulLogin(user.email, () => {})
|
|
AuthenticationController._recordSuccessfulLogin(user._id, () => {})
|
|
AuthenticationController.ipMatchCheck(req, user)
|
|
Analytics.recordEventForUserInBackground(user._id, 'user-logged-in', {
|
|
source: req.session.saml
|
|
? 'saml'
|
|
: req.user_info?.auth_provider || 'email-password',
|
|
})
|
|
Analytics.identifyUser(user._id, anonymousAnalyticsId, isNewUser)
|
|
|
|
logger.debug(
|
|
{ email: user.email, userId: user._id.toString() },
|
|
'successful log in'
|
|
)
|
|
|
|
req.session.justLoggedIn = true
|
|
// capture the request ip for use when creating the session
|
|
return (user._login_req_ip = req.ip)
|
|
}
|
|
|
|
AuthenticationController.promises = {
|
|
finishLogin: AuthenticationController._finishLoginAsync,
|
|
}
|
|
|
|
export default AuthenticationController
|