Merge pull request #6457 from overleaf/jpa-harden-login

[web] harden login process

GitOrigin-RevId: 5c0b7cc725efd5e3e879067ad8a42fe46a47b60d
This commit is contained in:
Jakob Ackermann
2022-01-27 09:03:38 +00:00
committed by Copybot
parent 8e77ada424
commit d812b88e76
10 changed files with 354 additions and 41 deletions
@@ -7,6 +7,7 @@ const tk = require('timekeeper')
const MockRequest = require('../helpers/MockRequest')
const MockResponse = require('../helpers/MockResponse')
const { ObjectId } = require('mongodb')
const AuthenticationErrors = require('../../../../app/src/Features/Authentication/AuthenticationErrors')
describe('AuthenticationController', function () {
beforeEach(function () {
@@ -32,6 +33,7 @@ describe('AuthenticationController', function () {
this.AuthenticationController = SandboxedModule.require(modulePath, {
requires: {
'./AuthenticationErrors': AuthenticationErrors,
'../User/UserAuditLogHandler': (this.UserAuditLogHandler = {
addEntry: sinon.stub().yields(null),
}),
@@ -63,6 +65,7 @@ describe('AuthenticationController', function () {
'@overleaf/settings': (this.Settings = {
siteUrl: 'http://www.foo.bar',
httpAuthUsers: this.httpAuthUsers,
elevateAccountSecurityAfterFailedLogin: 90 * 24 * 60 * 60 * 1000,
}),
passport: (this.passport = {
authenticate: sinon.stub().returns(sinon.stub()),
@@ -254,7 +257,7 @@ describe('AuthenticationController', function () {
this.next
)
this.res.json.callCount.should.equal(1)
this.res.json.calledWith({ message: this.info }).should.equal(true)
this.res.json.should.have.been.calledWith({ message: this.info })
expect(this.res.json.lastCall.args[0].redir != null).to.equal(false)
})
})
@@ -273,6 +276,7 @@ describe('AuthenticationController', function () {
postLoginRedirect: '/path/to/redir/to',
},
}
this.req.__authAuditInfo = { captcha: 'disabled' }
this.cb = sinon.stub()
})
@@ -325,22 +329,103 @@ describe('AuthenticationController', function () {
.stub()
.callsArgWith(2, null, this.user)
this.req.sessionID = Math.random()
this.AuthenticationController.doPassportLogin(
this.req,
this.req.body.email,
this.req.body.password,
this.cb
)
})
it('should attempt to authorise the user', function () {
this.AuthenticationManager.authenticate
.calledWith({ email: this.email.toLowerCase() }, this.password)
.should.equal(true)
describe('happy path', function () {
beforeEach(function () {
this.AuthenticationController.doPassportLogin(
this.req,
this.req.body.email,
this.req.body.password,
this.cb
)
})
it('should attempt to authorise the user', function () {
this.AuthenticationManager.authenticate
.calledWith({ email: this.email.toLowerCase() }, this.password)
.should.equal(true)
})
it("should establish the user's session", function () {
this.cb.calledWith(null, this.user).should.equal(true)
})
})
it("should establish the user's session", function () {
this.cb.calledWith(null, this.user).should.equal(true)
describe('when authenticate flags a parallel login', function () {
beforeEach(function () {
this.AuthenticationManager.authenticate = sinon
.stub()
.callsArgWith(2, new AuthenticationErrors.ParallelLoginError())
this.AuthenticationController.doPassportLogin(
this.req,
this.req.body.email,
this.req.body.password,
this.cb
)
})
it('should send a 429', function () {
this.cb.should.have.been.calledWith(null, false, { status: 429 })
})
})
describe('with a user having a recent failed login ', function () {
beforeEach(function () {
this.user.lastFailedLogin = new Date()
})
describe('with captcha disabled', function () {
beforeEach(function () {
this.req.__authAuditInfo.captcha = 'disabled'
this.AuthenticationController.doPassportLogin(
this.req,
this.req.body.email,
this.req.body.password,
this.cb
)
})
it('should let the user log in', function () {
this.cb.should.have.been.calledWith(null, this.user)
})
})
describe('with a solved captcha', function () {
beforeEach(function () {
this.req.__authAuditInfo.captcha = 'solved'
this.AuthenticationController.doPassportLogin(
this.req,
this.req.body.email,
this.req.body.password,
this.cb
)
})
it('should let the user log in', function () {
this.cb.should.have.been.calledWith(null, this.user)
})
})
describe('with a skipped captcha', function () {
beforeEach(function () {
this.req.__authAuditInfo.captcha = 'skipped'
this.AuthenticationController.doPassportLogin(
this.req,
this.req.body.email,
this.req.body.password,
this.cb
)
})
it('should request a captcha', function () {
this.cb.should.have.been.calledWith(null, false, {
text: 'cannot_verify_user_not_robot',
type: 'error',
errorReason: 'cannot_verify_user_not_robot',
status: 400,
})
})
})
})
})
@@ -3,17 +3,21 @@ const { expect } = require('chai')
const SandboxedModule = require('sandboxed-module')
const { ObjectId } = require('mongodb')
const AuthenticationErrors = require('../../../../app/src/Features/Authentication/AuthenticationErrors')
const tk = require('timekeeper')
const modulePath =
'../../../../app/src/Features/Authentication/AuthenticationManager.js'
describe('AuthenticationManager', function () {
beforeEach(function () {
tk.freeze(Date.now())
this.settings = { security: { bcryptRounds: 4 } }
this.AuthenticationManager = SandboxedModule.require(modulePath, {
requires: {
'../../models/User': {
User: (this.User = {}),
User: (this.User = {
updateOne: sinon.stub().callsArgWith(3, null, { nModified: 1 }),
}),
},
'../../infrastructure/mongodb': {
db: (this.db = { users: {} }),
@@ -31,6 +35,10 @@ describe('AuthenticationManager', function () {
this.callback = sinon.stub()
})
afterEach(function () {
tk.reset()
})
describe('with real bcrypt', function () {
beforeEach(function () {
const bcrypt = require('bcrypt')
@@ -49,13 +57,13 @@ describe('AuthenticationManager', function () {
_id: 'user-id',
email: (this.email = 'USER@sharelatex.com'),
}
this.user.hashedPassword = this.testPassword
this.User.findOne = sinon.stub().callsArgWith(1, null, this.user)
})
describe('when the hashed password matches', function () {
beforeEach(function (done) {
this.unencryptedPassword = 'testpassword'
this.user.hashedPassword = this.testPassword
this.AuthenticationManager.authenticate(
{ email: this.email },
this.unencryptedPassword,
@@ -70,17 +78,46 @@ describe('AuthenticationManager', function () {
this.User.findOne.calledWith({ email: this.email }).should.equal(true)
})
it('should bump epoch', function () {
this.User.updateOne.should.have.been.calledWith(
{
_id: this.user._id,
loginEpoch: this.user.loginEpoch,
},
{
$inc: { loginEpoch: 1 },
},
{}
)
})
it('should return the user', function () {
this.callback.calledWith(null, this.user).should.equal(true)
})
})
describe('when the encrypted passwords do not match', function () {
beforeEach(function () {
beforeEach(function (done) {
this.AuthenticationManager.authenticate(
{ email: this.email },
'notthecorrectpassword',
this.callback
(...args) => {
this.callback(...args)
done()
}
)
})
it('should persist the login failure and bump epoch', function () {
this.User.updateOne.should.have.been.calledWith(
{
_id: this.user._id,
loginEpoch: this.user.loginEpoch,
},
{
$inc: { loginEpoch: 1 },
$set: { lastFailedLogin: new Date() },
}
)
})
@@ -88,6 +125,52 @@ describe('AuthenticationManager', function () {
this.callback.calledWith(null, null).should.equal(true)
})
})
describe('when another request runs in parallel', function () {
beforeEach(function () {
this.User.updateOne = sinon
.stub()
.callsArgWith(3, null, { nModified: 0 })
})
describe('correct password', function () {
beforeEach(function (done) {
this.AuthenticationManager.authenticate(
{ email: this.email },
'testpassword',
(...args) => {
this.callback(...args)
done()
}
)
})
it('should return an error', function () {
this.callback.should.have.been.calledWith(
sinon.match.instanceOf(AuthenticationErrors.ParallelLoginError)
)
})
})
describe('bad password', function () {
beforeEach(function (done) {
this.User.updateOne = sinon.stub().yields(null, { nModified: 0 })
this.AuthenticationManager.authenticate(
{ email: this.email },
'notthecorrectpassword',
(...args) => {
this.callback(...args)
done()
}
)
})
it('should return an error', function () {
this.callback.should.have.been.calledWith(
sinon.match.instanceOf(AuthenticationErrors.ParallelLoginError)
)
})
})
})
})
describe('setUserPasswordInV2', function () {