[web] Promisify LdapController (#18500)

* Promisify LdapController

* Update tests LdapControllerTests.js

* Promisify `AuthenticationController.finishLogin`

* Simplify null checks in LdapController

* Fix: don't use spread operator in module.exports

* Make `AuthenticationController.promises.finishLogin` a promise that resolves

* Fixup: `finishLogin` does not call `next` then the promise finishes, it calls it only on errors

* Use `Modules.promises.hooks.fire`

* Revert `processPassportLogin` callback style

* Update error handling: Use `OError.tag` instead of `logger.err`

* Fix unit tests: Rely on callbacks rather than promises

* Fix: Actually call `passport.authenticate` (!!)

* Update test: fixup `passport.authenticate` mocks

This would have caught the bugs that the previous commit is solving

* Remove `.then(() => next())` in `processPassportLogin`

Co-authored-by: Eric Mc Sween <eric.mcsween@overleaf.com>

---------

Co-authored-by: Eric Mc Sween <eric.mcsween@overleaf.com>
GitOrigin-RevId: a7eab5f5289956aeb8f2418408958daef3511ab7
This commit is contained in:
Antoine Clausse 2024-06-05 12:24:31 +02:00 committed by Copybot
parent 258289e65a
commit e452f1df5b
2 changed files with 121 additions and 123 deletions

View file

@ -28,7 +28,7 @@ const {
} = require('./AuthenticationErrors') } = require('./AuthenticationErrors')
const { hasAdminAccess } = require('../Helpers/AdminAuthorizationHelper') const { hasAdminAccess } = require('../Helpers/AdminAuthorizationHelper')
const Modules = require('../../infrastructure/Modules') const Modules = require('../../infrastructure/Modules')
const { expressify } = require('@overleaf/promise-utils') const { expressify, promisify } = require('@overleaf/promise-utils')
function send401WithChallenge(res) { function send401WithChallenge(res) {
res.setHeader('WWW-Authenticate', 'OverleafLogin') res.setHeader('WWW-Authenticate', 'OverleafLogin')
@ -63,6 +63,7 @@ function userHasStaffAccess(user) {
return user.staffAccess && Object.values(user.staffAccess).includes(true) return user.staffAccess && Object.values(user.staffAccess).includes(true)
} }
// TODO: Finish making these methods async
const AuthenticationController = { const AuthenticationController = {
serializeUser(user, callback) { serializeUser(user, callback) {
if (!user._id || !user.email) { if (!user._id || !user.email) {
@ -134,7 +135,7 @@ const AuthenticationController = {
)(req, res, next) )(req, res, next)
}, },
finishLogin(user, req, res, next) { async _finishLoginAsync(user, req, res) {
if (user === false) { if (user === false) {
return AsyncFormHelper.redirect(req, res, '/login') return AsyncFormHelper.redirect(req, res, '/login')
} // OAuth2 'state' mismatch } // OAuth2 'state' mismatch
@ -154,54 +155,46 @@ const AuthenticationController = {
const anonymousAnalyticsId = req.session.analyticsId const anonymousAnalyticsId = req.session.analyticsId
const isNewUser = req.session.justRegistered || false const isNewUser = req.session.justRegistered || false
Modules.hooks.fire( const results = await Modules.promises.hooks.fire(
'preFinishLogin', 'preFinishLogin',
req, req,
res, res,
user, user
function (error, results) { )
if (error) {
return next(error)
}
if (results.some(result => result && result.doNotFinish)) {
return
}
if (user.must_reconfirm) { if (results.some(result => result && result.doNotFinish)) {
return AuthenticationController._redirectToReconfirmPage( return
req, }
res,
user
)
}
const redir = if (user.must_reconfirm) {
AuthenticationController.getRedirectFromSession(req) || '/project' return AuthenticationController._redirectToReconfirmPage(req, res, user)
}
_loginAsyncHandlers(req, user, anonymousAnalyticsId, isNewUser) const redir =
const userId = user._id AuthenticationController.getRedirectFromSession(req) || '/project'
UserAuditLogHandler.addEntry(
userId, _loginAsyncHandlers(req, user, anonymousAnalyticsId, isNewUser)
'login', const userId = user._id
userId,
req.ip, await UserAuditLogHandler.promises.addEntry(
auditInfo, userId,
err => { 'login',
if (err) { userId,
return next(err) req.ip,
} auditInfo
_afterLoginSessionSetup(req, user, function (err) { )
if (err) {
return next(err) await _afterLoginSessionSetupAsync(req, user)
}
AuthenticationController._clearRedirectFromSession(req) AuthenticationController._clearRedirectFromSession(req)
AnalyticsRegistrationSourceHelper.clearSource(req.session) AnalyticsRegistrationSourceHelper.clearSource(req.session)
AnalyticsRegistrationSourceHelper.clearInbound(req.session) AnalyticsRegistrationSourceHelper.clearInbound(req.session)
AsyncFormHelper.redirect(req, res, redir) AsyncFormHelper.redirect(req, res, redir)
}) },
}
) finishLogin(user, req, res, next) {
} AuthenticationController._finishLoginAsync(user, req, res).catch(err =>
next(err)
) )
}, },
@ -637,6 +630,8 @@ function _afterLoginSessionSetup(req, user, callback) {
}) })
} }
const _afterLoginSessionSetupAsync = promisify(_afterLoginSessionSetup)
function _loginAsyncHandlers(req, user, anonymousAnalyticsId, isNewUser) { function _loginAsyncHandlers(req, user, anonymousAnalyticsId, isNewUser) {
UserHandler.setupLoginData(user, err => { UserHandler.setupLoginData(user, err => {
if (err != null) { if (err != null) {
@ -663,4 +658,8 @@ function _loginAsyncHandlers(req, user, anonymousAnalyticsId, isNewUser) {
return (user._login_req_ip = req.ip) return (user._login_req_ip = req.ip)
} }
AuthenticationController.promises = {
finishLogin: AuthenticationController._finishLoginAsync,
}
module.exports = AuthenticationController module.exports = AuthenticationController

View file

@ -68,6 +68,9 @@ describe('AuthenticationController', function () {
'./AuthenticationErrors': AuthenticationErrors, './AuthenticationErrors': AuthenticationErrors,
'../User/UserAuditLogHandler': (this.UserAuditLogHandler = { '../User/UserAuditLogHandler': (this.UserAuditLogHandler = {
addEntry: sinon.stub().yields(null), addEntry: sinon.stub().yields(null),
promises: {
addEntry: sinon.stub().resolves(),
},
}), }),
'../Helpers/AsyncFormHelper': (this.AsyncFormHelper = { '../Helpers/AsyncFormHelper': (this.AsyncFormHelper = {
redirect: sinon.stub(), redirect: sinon.stub(),
@ -107,6 +110,7 @@ describe('AuthenticationController', function () {
}), }),
'../../infrastructure/Modules': (this.Modules = { '../../infrastructure/Modules': (this.Modules = {
hooks: { fire: sinon.stub().yields(null, []) }, hooks: { fire: sinon.stub().yields(null, []) },
promises: { hooks: { fire: sinon.stub().resolves([]) } },
}), }),
'../Notifications/NotificationsBuilder': (this.NotificationsBuilder = { '../Notifications/NotificationsBuilder': (this.NotificationsBuilder = {
ipMatcherAffiliation: sinon.stub().returns({ create: sinon.stub() }), ipMatcherAffiliation: sinon.stub().returns({ create: sinon.stub() }),
@ -350,7 +354,6 @@ describe('AuthenticationController', function () {
beforeEach(function () { beforeEach(function () {
this.AuthenticationController._recordFailedLogin = sinon.stub() this.AuthenticationController._recordFailedLogin = sinon.stub()
this.AuthenticationController._recordSuccessfulLogin = sinon.stub() this.AuthenticationController._recordSuccessfulLogin = sinon.stub()
this.Modules.hooks.fire = sinon.stub().yields(null, [])
this.req.body = { this.req.body = {
email: this.email, email: this.email,
password: this.password, password: this.password,
@ -1125,8 +1128,8 @@ describe('AuthenticationController', function () {
this.res.redirect = sinon.stub() this.res.redirect = sinon.stub()
}) })
it('should extract the redirect from the session', function () { it('should extract the redirect from the session', async function () {
this.AuthenticationController.finishLogin( await this.AuthenticationController.promises.finishLogin(
this.user, this.user,
this.req, this.req,
this.res, this.res,
@ -1142,8 +1145,8 @@ describe('AuthenticationController', function () {
).to.equal(true) ).to.equal(true)
}) })
it('should clear redirect from session', function () { it('should clear redirect from session', async function () {
this.AuthenticationController.finishLogin( await this.AuthenticationController.promises.finishLogin(
this.user, this.user,
this.req, this.req,
this.res, this.res,
@ -1159,8 +1162,8 @@ describe('AuthenticationController', function () {
).to.equal(true) ).to.equal(true)
}) })
it('should issue a json response with a redirect', function () { it('should issue a json response with a redirect', async function () {
this.AuthenticationController.finishLogin( await this.AuthenticationController.promises.finishLogin(
this.user, this.user,
this.req, this.req,
this.res, this.res,
@ -1182,8 +1185,8 @@ describe('AuthenticationController', function () {
this.res.redirect = sinon.stub() this.res.redirect = sinon.stub()
}) })
it('should issue a plain redirect', function () { it('should issue a plain redirect', async function () {
this.AuthenticationController.finishLogin( await this.AuthenticationController.promises.finishLogin(
this.user, this.user,
this.req, this.req,
this.res, this.res,
@ -1204,8 +1207,8 @@ describe('AuthenticationController', function () {
this.req.session = {} this.req.session = {}
this.user.must_reconfirm = true this.user.must_reconfirm = true
}) })
it('should redirect to reconfirm page', function () { it('should redirect to reconfirm page', async function () {
this.AuthenticationController.finishLogin( await this.AuthenticationController.promises.finishLogin(
this.user, this.user,
this.req, this.req,
this.res, this.res,
@ -1224,8 +1227,8 @@ describe('AuthenticationController', function () {
this.req.session = {} this.req.session = {}
this.user.suspended = true this.user.suspended = true
}) })
it('should not log in and instead redirect to suspended account page', function () { it('should not log in and instead redirect to suspended account page', async function () {
this.AuthenticationController.finishLogin( await this.AuthenticationController.promises.finishLogin(
this.user, this.user,
this.req, this.req,
this.res, this.res,
@ -1242,16 +1245,15 @@ describe('AuthenticationController', function () {
}) })
describe('preFinishLogin hook', function () { describe('preFinishLogin hook', function () {
it('call hook and proceed', function () { it('call hook and proceed', async function () {
this.Modules.hooks.fire = sinon.stub().yields(null, []) await this.AuthenticationController.promises.finishLogin(
this.AuthenticationController.finishLogin(
this.user, this.user,
this.req, this.req,
this.res, this.res,
this.next this.next
) )
sinon.assert.calledWith( sinon.assert.calledWith(
this.Modules.hooks.fire, this.Modules.promises.hooks.fire,
'preFinishLogin', 'preFinishLogin',
this.req, this.req,
this.res, this.res,
@ -1260,11 +1262,11 @@ describe('AuthenticationController', function () {
expect(this.AsyncFormHelper.redirect.called).to.equal(true) expect(this.AsyncFormHelper.redirect.called).to.equal(true)
}) })
it('stop if hook has redirected', function (done) { it('stop if hook has redirected', async function () {
this.Modules.hooks.fire = sinon this.Modules.promises.hooks.fire = sinon
.stub() .stub()
.yields(null, [{ doNotFinish: true }]) .resolves([{ doNotFinish: true }])
this.AuthenticationController.finishLogin( await this.AuthenticationController.promises.finishLogin(
this.user, this.user,
this.req, this.req,
this.res, this.res,
@ -1272,33 +1274,31 @@ describe('AuthenticationController', function () {
) )
expect(this.next.callCount).to.equal(0) expect(this.next.callCount).to.equal(0)
expect(this.res.json.callCount).to.equal(0) expect(this.res.json.callCount).to.equal(0)
done()
}) })
it('call next with hook errors', function (done) { it('call next with hook errors', function (done) {
this.Modules.hooks.fire = sinon.stub().yields(new Error()) this.Modules.promises.hooks.fire = sinon.stub().yields(new Error())
this.AuthenticationController.finishLogin( this.AuthenticationController.promises
this.user, .finishLogin(this.user, this.req, this.res)
this.req, .catch(err => {
this.res, expect(err).to.exist
error => {
expect(error).to.exist
expect(this.res.json.callCount).to.equal(0) expect(this.res.json.callCount).to.equal(0)
done() done()
} })
)
}) })
}) })
describe('UserAuditLog', function () { describe('UserAuditLog', function () {
it('should add an audit log entry', function () { it('should add an audit log entry', async function () {
this.AuthenticationController.finishLogin( await this.AuthenticationController.promises.finishLogin(
this.user, this.user,
this.req, this.req,
this.res, this.res,
this.next this.next
) )
expect(this.UserAuditLogHandler.addEntry).to.have.been.calledWith( expect(
this.UserAuditLogHandler.promises.addEntry
).to.have.been.calledWith(
this.user._id, this.user._id,
'login', 'login',
this.user._id, this.user._id,
@ -1306,34 +1306,26 @@ describe('AuthenticationController', function () {
) )
}) })
it('should add an audit log entry before logging the user in', function () { it('should add an audit log entry before logging the user in', async function () {
this.AuthenticationController.finishLogin( await this.AuthenticationController.promises.finishLogin(
this.user, this.user,
this.req, this.req,
this.res, this.res,
this.next this.next
) )
expect(this.UserAuditLogHandler.addEntry).to.have.been.calledBefore( expect(
this.req.login this.UserAuditLogHandler.promises.addEntry
) ).to.have.been.calledBefore(this.req.login)
}) })
it('should not log the user in without an audit log entry', function () { it('should not log the user in without an audit log entry', function (done) {
const theError = new Error() const theError = new Error()
this.UserAuditLogHandler.addEntry.yields(theError) this.UserAuditLogHandler.promises.addEntry.rejects(theError)
this.AuthenticationController.finishLogin( this.next.callsFake(err => {
this.user, expect(err).to.equal(theError)
this.req, expect(this.next).to.have.been.calledWith(theError)
this.res, expect(this.req.login).to.not.have.been.called
this.next done()
)
expect(this.next).to.have.been.calledWith(theError)
expect(this.req.login).to.not.have.been.called
})
it('should pass along auditInfo when present', function () {
this.AuthenticationController.setAuditInfo(this.req, {
method: 'Login',
}) })
this.AuthenticationController.finishLogin( this.AuthenticationController.finishLogin(
this.user, this.user,
@ -1341,7 +1333,20 @@ describe('AuthenticationController', function () {
this.res, this.res,
this.next this.next
) )
expect(this.UserAuditLogHandler.addEntry).to.have.been.calledWith( })
it('should pass along auditInfo when present', async function () {
this.AuthenticationController.setAuditInfo(this.req, {
method: 'Login',
})
await this.AuthenticationController.promises.finishLogin(
this.user,
this.req,
this.res
)
expect(
this.UserAuditLogHandler.promises.addEntry
).to.have.been.calledWith(
this.user._id, this.user._id,
'login', 'login',
this.user._id, this.user._id,
@ -1354,8 +1359,8 @@ describe('AuthenticationController', function () {
describe('_afterLoginSessionSetup', function () { describe('_afterLoginSessionSetup', function () {
beforeEach(function () {}) beforeEach(function () {})
it('should call req.login', function () { it('should call req.login', async function () {
this.AuthenticationController.finishLogin( await this.AuthenticationController.promises.finishLogin(
this.user, this.user,
this.req, this.req,
this.res, this.res,
@ -1364,8 +1369,8 @@ describe('AuthenticationController', function () {
this.req.login.callCount.should.equal(1) this.req.login.callCount.should.equal(1)
}) })
it('should erase the CSRF secret', function () { it('should erase the CSRF secret', async function () {
this.AuthenticationController.finishLogin( await this.AuthenticationController.promises.finishLogin(
this.user, this.user,
this.req, this.req,
this.res, this.res,
@ -1374,8 +1379,8 @@ describe('AuthenticationController', function () {
expect(this.req.session.csrfSecret).to.not.exist expect(this.req.session.csrfSecret).to.not.exist
}) })
it('should call req.session.save', function () { it('should call req.session.save', async function () {
this.AuthenticationController.finishLogin( await this.AuthenticationController.promises.finishLogin(
this.user, this.user,
this.req, this.req,
this.res, this.res,
@ -1384,8 +1389,8 @@ describe('AuthenticationController', function () {
this.req.session.save.callCount.should.equal(1) this.req.session.save.callCount.should.equal(1)
}) })
it('should call UserSessionsManager.trackSession', function () { it('should call UserSessionsManager.trackSession', async function () {
this.AuthenticationController.finishLogin( await this.AuthenticationController.promises.finishLogin(
this.user, this.user,
this.req, this.req,
this.res, this.res,
@ -1400,36 +1405,30 @@ describe('AuthenticationController', function () {
}) })
it('should produce an error', function (done) { it('should produce an error', function (done) {
this.AuthenticationController.finishLogin( this.AuthenticationController.promises
this.user, .finishLogin(this.user, this.req, this.res)
this.req, .catch(err => {
this.res,
err => {
expect(err).to.not.be.oneOf([null, undefined]) expect(err).to.not.be.oneOf([null, undefined])
expect(err).to.be.instanceof(Error) expect(err).to.be.instanceof(Error)
done() done()
} })
)
}) })
it('should not call UserSessionsManager.trackSession', function (done) { it('should not call UserSessionsManager.trackSession', function (done) {
this.AuthenticationController.finishLogin( this.AuthenticationController.promises
this.user, .finishLogin(this.user, this.req, this.res)
this.req, .catch(err => {
this.res,
err => {
expect(err).to.exist expect(err).to.exist
this.UserSessionsManager.trackSession.callCount.should.equal(0) this.UserSessionsManager.trackSession.callCount.should.equal(0)
done() done()
} })
)
}) })
}) })
}) })
describe('_loginAsyncHandlers', function () { describe('_loginAsyncHandlers', function () {
beforeEach(function () { beforeEach(async function () {
this.AuthenticationController.finishLogin( await this.AuthenticationController.promises.finishLogin(
this.user, this.user,
this.req, this.req,
this.res, this.res,