mirror of
https://github.com/overleaf/overleaf.git
synced 2024-11-21 20:47:08 -05:00
Merge pull request #6457 from overleaf/jpa-harden-login
[web] harden login process GitOrigin-RevId: 5c0b7cc725efd5e3e879067ad8a42fe46a47b60d
This commit is contained in:
parent
8e77ada424
commit
d812b88e76
10 changed files with 354 additions and 41 deletions
|
@ -23,6 +23,7 @@ const AnalyticsRegistrationSourceHelper = require('../Analytics/AnalyticsRegistr
|
||||||
const {
|
const {
|
||||||
acceptsJson,
|
acceptsJson,
|
||||||
} = require('../../infrastructure/RequestContentTypeDetection')
|
} = require('../../infrastructure/RequestContentTypeDetection')
|
||||||
|
const { ParallelLoginError } = require('./AuthenticationErrors')
|
||||||
|
|
||||||
function send401WithChallenge(res) {
|
function send401WithChallenge(res) {
|
||||||
res.setHeader('WWW-Authenticate', 'OverleafLogin')
|
res.setHeader('WWW-Authenticate', 'OverleafLogin')
|
||||||
|
@ -89,7 +90,13 @@ const AuthenticationController = {
|
||||||
} else {
|
} else {
|
||||||
res.status(info.status || 200)
|
res.status(info.status || 200)
|
||||||
delete info.status
|
delete info.status
|
||||||
return res.json({ message: info })
|
const body = { message: info }
|
||||||
|
const { errorReason } = info
|
||||||
|
if (errorReason) {
|
||||||
|
body.errorReason = errorReason
|
||||||
|
delete info.errorReason
|
||||||
|
}
|
||||||
|
return res.json(body)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})(req, res, next)
|
})(req, res, next)
|
||||||
|
@ -188,9 +195,22 @@ const AuthenticationController = {
|
||||||
password,
|
password,
|
||||||
function (error, user) {
|
function (error, user) {
|
||||||
if (error != null) {
|
if (error != null) {
|
||||||
|
if (error instanceof ParallelLoginError) {
|
||||||
|
return done(null, false, { status: 429 })
|
||||||
|
}
|
||||||
return done(error)
|
return done(error)
|
||||||
}
|
}
|
||||||
if (user != null) {
|
if (
|
||||||
|
user &&
|
||||||
|
AuthenticationController.captchaRequiredForLogin(req, user)
|
||||||
|
) {
|
||||||
|
done(null, false, {
|
||||||
|
text: req.i18n.translate('cannot_verify_user_not_robot'),
|
||||||
|
type: 'error',
|
||||||
|
errorReason: 'cannot_verify_user_not_robot',
|
||||||
|
status: 400,
|
||||||
|
})
|
||||||
|
} else if (user) {
|
||||||
// async actions
|
// async actions
|
||||||
done(null, user)
|
done(null, user)
|
||||||
} else {
|
} else {
|
||||||
|
@ -209,6 +229,30 @@ const AuthenticationController = {
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
captchaRequiredForLogin(req, user) {
|
||||||
|
switch (AuthenticationController.getAuditInfo(req).captcha) {
|
||||||
|
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) {
|
ipMatchCheck(req, user) {
|
||||||
if (req.ip !== user.lastLoginIp) {
|
if (req.ip !== user.lastLoginIp) {
|
||||||
NotificationsBuilder.ipMatcherAffiliation(user._id).create(
|
NotificationsBuilder.ipMatcherAffiliation(user._id).create(
|
||||||
|
|
|
@ -2,8 +2,10 @@ const Errors = require('../Errors/Errors')
|
||||||
|
|
||||||
class InvalidEmailError extends Errors.BackwardCompatibleError {}
|
class InvalidEmailError extends Errors.BackwardCompatibleError {}
|
||||||
class InvalidPasswordError extends Errors.BackwardCompatibleError {}
|
class InvalidPasswordError extends Errors.BackwardCompatibleError {}
|
||||||
|
class ParallelLoginError extends Errors.BackwardCompatibleError {}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
InvalidEmailError,
|
InvalidEmailError,
|
||||||
InvalidPasswordError,
|
InvalidPasswordError,
|
||||||
|
ParallelLoginError,
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,6 +6,7 @@ const EmailHelper = require('../Helpers/EmailHelper')
|
||||||
const {
|
const {
|
||||||
InvalidEmailError,
|
InvalidEmailError,
|
||||||
InvalidPasswordError,
|
InvalidPasswordError,
|
||||||
|
ParallelLoginError,
|
||||||
} = require('./AuthenticationErrors')
|
} = require('./AuthenticationErrors')
|
||||||
const util = require('util')
|
const util = require('util')
|
||||||
const HaveIBeenPwned = require('./HaveIBeenPwned')
|
const HaveIBeenPwned = require('./HaveIBeenPwned')
|
||||||
|
@ -38,19 +39,36 @@ const AuthenticationManager = {
|
||||||
if (error) {
|
if (error) {
|
||||||
return callback(error)
|
return callback(error)
|
||||||
}
|
}
|
||||||
|
const update = { $inc: { loginEpoch: 1 } }
|
||||||
if (!match) {
|
if (!match) {
|
||||||
return callback(null, null)
|
update.$set = { lastFailedLogin: new Date() }
|
||||||
}
|
}
|
||||||
AuthenticationManager.checkRounds(
|
User.updateOne(
|
||||||
user,
|
{ _id: user._id, loginEpoch: user.loginEpoch },
|
||||||
user.hashedPassword,
|
update,
|
||||||
password,
|
{},
|
||||||
function (err) {
|
(err, result) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
return callback(err)
|
return callback(err)
|
||||||
}
|
}
|
||||||
callback(null, user)
|
if (result.nModified !== 1) {
|
||||||
HaveIBeenPwned.checkPasswordForReuseInBackground(password)
|
return callback(new ParallelLoginError())
|
||||||
|
}
|
||||||
|
if (!match) {
|
||||||
|
return callback(null, null)
|
||||||
|
}
|
||||||
|
AuthenticationManager.checkRounds(
|
||||||
|
user,
|
||||||
|
user.hashedPassword,
|
||||||
|
password,
|
||||||
|
function (err) {
|
||||||
|
if (err) {
|
||||||
|
return callback(err)
|
||||||
|
}
|
||||||
|
callback(null, user)
|
||||||
|
HaveIBeenPwned.checkPasswordForReuseInBackground(password)
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
|
@ -6,11 +6,11 @@ const DeviceHistory = require('./DeviceHistory')
|
||||||
const AuthenticationController = require('../Authentication/AuthenticationController')
|
const AuthenticationController = require('../Authentication/AuthenticationController')
|
||||||
const { expressify } = require('../../util/promises')
|
const { expressify } = require('../../util/promises')
|
||||||
|
|
||||||
function respondInvalidCaptcha(res) {
|
function respondInvalidCaptcha(req, res) {
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
errorReason: 'cannot_verify_user_not_robot',
|
errorReason: 'cannot_verify_user_not_robot',
|
||||||
message: {
|
message: {
|
||||||
text: 'Sorry, we could not verify that you are not a robot. Please check that Google reCAPTCHA is not being blocked by an ad blocker or firewall.',
|
text: req.i18n.translate('cannot_verify_user_not_robot'),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
@ -36,24 +36,27 @@ async function canSkipCaptcha(req, res) {
|
||||||
function validateCaptcha(action) {
|
function validateCaptcha(action) {
|
||||||
return expressify(async function (req, res, next) {
|
return expressify(async function (req, res, next) {
|
||||||
if (!Settings.recaptcha?.siteKey || Settings.recaptcha.disabled[action]) {
|
if (!Settings.recaptcha?.siteKey || Settings.recaptcha.disabled[action]) {
|
||||||
|
if (action === 'login') {
|
||||||
|
AuthenticationController.setAuditInfo(req, { captcha: 'disabled' })
|
||||||
|
}
|
||||||
Metrics.inc('captcha', 1, { path: action, status: 'disabled' })
|
Metrics.inc('captcha', 1, { path: action, status: 'disabled' })
|
||||||
return next()
|
return next()
|
||||||
}
|
}
|
||||||
|
const reCaptchaResponse = req.body['g-recaptcha-response']
|
||||||
if (action === 'login') {
|
if (action === 'login') {
|
||||||
await initializeDeviceHistory(req)
|
await initializeDeviceHistory(req)
|
||||||
if (req.deviceHistory.has(req.body?.email)) {
|
if (!reCaptchaResponse && req.deviceHistory.has(req.body?.email)) {
|
||||||
// The user has previously logged in from this device, which required
|
// The user has previously logged in from this device, which required
|
||||||
// solving a captcha or keeping the device history alive.
|
// solving a captcha or keeping the device history alive.
|
||||||
// We can skip checking the (potentially missing) captcha response.
|
// We can skip checking the (missing) captcha response.
|
||||||
AuthenticationController.setAuditInfo(req, { captcha: 'skipped' })
|
AuthenticationController.setAuditInfo(req, { captcha: 'skipped' })
|
||||||
Metrics.inc('captcha', 1, { path: action, status: 'skipped' })
|
Metrics.inc('captcha', 1, { path: action, status: 'skipped' })
|
||||||
return next()
|
return next()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const reCaptchaResponse = req.body['g-recaptcha-response']
|
|
||||||
if (!reCaptchaResponse) {
|
if (!reCaptchaResponse) {
|
||||||
Metrics.inc('captcha', 1, { path: action, status: 'missing' })
|
Metrics.inc('captcha', 1, { path: action, status: 'missing' })
|
||||||
return respondInvalidCaptcha(res)
|
return respondInvalidCaptcha(req, res)
|
||||||
}
|
}
|
||||||
const options = {
|
const options = {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
@ -84,7 +87,7 @@ function validateCaptcha(action) {
|
||||||
'failed recaptcha siteverify request'
|
'failed recaptcha siteverify request'
|
||||||
)
|
)
|
||||||
Metrics.inc('captcha', 1, { path: action, status: 'failed' })
|
Metrics.inc('captcha', 1, { path: action, status: 'failed' })
|
||||||
return respondInvalidCaptcha(res)
|
return respondInvalidCaptcha(req, res)
|
||||||
}
|
}
|
||||||
Metrics.inc('captcha', 1, { path: action, status: 'solved' })
|
Metrics.inc('captcha', 1, { path: action, status: 'solved' })
|
||||||
if (action === 'login') {
|
if (action === 'login') {
|
||||||
|
@ -95,6 +98,7 @@ function validateCaptcha(action) {
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
respondInvalidCaptcha,
|
||||||
validateCaptcha,
|
validateCaptcha,
|
||||||
canSkipCaptcha: expressify(canSkipCaptcha),
|
canSkipCaptcha: expressify(canSkipCaptcha),
|
||||||
}
|
}
|
||||||
|
|
|
@ -56,7 +56,9 @@ const UserSchema = new Schema({
|
||||||
return new Date()
|
return new Date()
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
loginEpoch: { type: Number },
|
||||||
lastActive: { type: Date },
|
lastActive: { type: Date },
|
||||||
|
lastFailedLogin: { type: Date },
|
||||||
lastLoggedIn: { type: Date },
|
lastLoggedIn: { type: Date },
|
||||||
lastLoginIp: { type: String, default: '' },
|
lastLoginIp: { type: String, default: '' },
|
||||||
loginCount: { type: Number, default: 0 },
|
loginCount: { type: Number, default: 0 },
|
||||||
|
|
|
@ -106,7 +106,11 @@ function initialize(webRouter, privateApiRouter, publicApiRouter) {
|
||||||
if (Settings.enableLegacyLogin) {
|
if (Settings.enableLegacyLogin) {
|
||||||
AuthenticationController.addEndpointToLoginWhitelist('/login/legacy')
|
AuthenticationController.addEndpointToLoginWhitelist('/login/legacy')
|
||||||
webRouter.get('/login/legacy', UserPagesController.loginPage)
|
webRouter.get('/login/legacy', UserPagesController.loginPage)
|
||||||
webRouter.post('/login/legacy', AuthenticationController.passportLogin)
|
webRouter.post(
|
||||||
|
'/login/legacy',
|
||||||
|
CaptchaMiddleware.validateCaptcha('login'),
|
||||||
|
AuthenticationController.passportLogin
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
webRouter.get(
|
webRouter.get(
|
||||||
|
|
|
@ -435,6 +435,10 @@ module.exports = {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
elevateAccountSecurityAfterFailedLogin:
|
||||||
|
parseInt(process.env.ELEVATED_ACCOUNT_SECURITY_AFTER_FAILED_LOGIN_MS, 10) ||
|
||||||
|
24 * 60 * 60 * 1000,
|
||||||
|
|
||||||
deviceHistory: {
|
deviceHistory: {
|
||||||
cookieName: process.env.DEVICE_HISTORY_COOKIE_NAME || 'deviceHistory',
|
cookieName: process.env.DEVICE_HISTORY_COOKIE_NAME || 'deviceHistory',
|
||||||
entryExpiry:
|
entryExpiry:
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
const { db } = require('../../../app/src/infrastructure/mongodb')
|
||||||
const { expect } = require('chai')
|
const { expect } = require('chai')
|
||||||
const User = require('./helpers/User').promises
|
const User = require('./helpers/User').promises
|
||||||
|
|
||||||
|
@ -9,22 +10,26 @@ describe('Captcha', function () {
|
||||||
await user.ensureUserExists()
|
await user.ensureUserExists()
|
||||||
})
|
})
|
||||||
|
|
||||||
async function loginWithCaptcha(captchaResponse) {
|
async function login(email, password, captchaResponse) {
|
||||||
return loginWithEmailAndCaptcha(user.email, captchaResponse)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loginWithEmailAndCaptcha(email, captchaResponse) {
|
|
||||||
await user.getCsrfToken()
|
await user.getCsrfToken()
|
||||||
return user.doRequest('POST', {
|
return user.doRequest('POST', {
|
||||||
url: '/login',
|
url: '/login',
|
||||||
json: {
|
json: {
|
||||||
email,
|
email,
|
||||||
password: user.password,
|
password,
|
||||||
'g-recaptcha-response': captchaResponse,
|
'g-recaptcha-response': captchaResponse,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loginWithCaptcha(captchaResponse) {
|
||||||
|
return login(user.email, user.password, captchaResponse)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loginWithEmailAndCaptcha(email, captchaResponse) {
|
||||||
|
return login(email, user.password, captchaResponse)
|
||||||
|
}
|
||||||
|
|
||||||
async function canSkipCaptcha(email) {
|
async function canSkipCaptcha(email) {
|
||||||
await user.getCsrfToken()
|
await user.getCsrfToken()
|
||||||
const { response, body } = await user.doRequest('POST', {
|
const { response, body } = await user.doRequest('POST', {
|
||||||
|
@ -45,6 +50,16 @@ describe('Captcha', function () {
|
||||||
expect(body).to.deep.equal({ redir: '/project' })
|
expect(body).to.deep.equal({ redir: '/project' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function expectBadLogin(response, body) {
|
||||||
|
expect(response.statusCode).to.equal(401)
|
||||||
|
expect(body).to.deep.equal({
|
||||||
|
message: {
|
||||||
|
text: 'Your email or password is incorrect. Please try again',
|
||||||
|
type: 'error',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
it('should reject a login without captcha response', async function () {
|
it('should reject a login without captcha response', async function () {
|
||||||
const { response, body } = await loginWithCaptcha('')
|
const { response, body } = await loginWithCaptcha('')
|
||||||
expectBadCaptchaResponse(response, body)
|
expectBadCaptchaResponse(response, body)
|
||||||
|
@ -104,6 +119,58 @@ describe('Captcha', function () {
|
||||||
expectBadCaptchaResponse(response, body)
|
expectBadCaptchaResponse(response, body)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('login failure', function () {
|
||||||
|
beforeEach(async function () {
|
||||||
|
const { response, body } = await login(
|
||||||
|
user.email,
|
||||||
|
'bad password',
|
||||||
|
'valid'
|
||||||
|
)
|
||||||
|
expectBadLogin(response, body)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should be able to skip captcha per device history', async function () {
|
||||||
|
expect(await canSkipCaptcha(user.email)).to.equal(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should request a captcha despite device history entry', async function () {
|
||||||
|
const { response, body } = await loginWithCaptcha('')
|
||||||
|
expectBadCaptchaResponse(response, body)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should accept the login with captcha', async function () {
|
||||||
|
const { response, body } = await loginWithCaptcha('valid')
|
||||||
|
expectSuccessfulLogin(response, body)
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('when the login failure happened a long time ago', function () {
|
||||||
|
beforeEach(async function () {
|
||||||
|
db.users.updateOne(
|
||||||
|
{ email: user.email },
|
||||||
|
{
|
||||||
|
$set: {
|
||||||
|
lastFailedLogin: new Date(
|
||||||
|
Date.now() - 90 * 24 * 60 * 60 * 1000
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should be able to skip captcha per device history', async function () {
|
||||||
|
expect(await canSkipCaptcha(user.email)).to.equal(true)
|
||||||
|
})
|
||||||
|
it('should accept the login without captcha', async function () {
|
||||||
|
const { response, body } = await loginWithCaptcha('')
|
||||||
|
expectSuccessfulLogin(response, body)
|
||||||
|
})
|
||||||
|
it('should accept the login with captcha', async function () {
|
||||||
|
const { response, body } = await loginWithCaptcha('valid')
|
||||||
|
expectSuccessfulLogin(response, body)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('cycle history', function () {
|
describe('cycle history', function () {
|
||||||
beforeEach('create and login with 10 other users', async function () {
|
beforeEach('create and login with 10 other users', async function () {
|
||||||
for (let i = 0; i < 10; i++) {
|
for (let i = 0; i < 10; i++) {
|
||||||
|
|
|
@ -7,6 +7,7 @@ const tk = require('timekeeper')
|
||||||
const MockRequest = require('../helpers/MockRequest')
|
const MockRequest = require('../helpers/MockRequest')
|
||||||
const MockResponse = require('../helpers/MockResponse')
|
const MockResponse = require('../helpers/MockResponse')
|
||||||
const { ObjectId } = require('mongodb')
|
const { ObjectId } = require('mongodb')
|
||||||
|
const AuthenticationErrors = require('../../../../app/src/Features/Authentication/AuthenticationErrors')
|
||||||
|
|
||||||
describe('AuthenticationController', function () {
|
describe('AuthenticationController', function () {
|
||||||
beforeEach(function () {
|
beforeEach(function () {
|
||||||
|
@ -32,6 +33,7 @@ describe('AuthenticationController', function () {
|
||||||
|
|
||||||
this.AuthenticationController = SandboxedModule.require(modulePath, {
|
this.AuthenticationController = SandboxedModule.require(modulePath, {
|
||||||
requires: {
|
requires: {
|
||||||
|
'./AuthenticationErrors': AuthenticationErrors,
|
||||||
'../User/UserAuditLogHandler': (this.UserAuditLogHandler = {
|
'../User/UserAuditLogHandler': (this.UserAuditLogHandler = {
|
||||||
addEntry: sinon.stub().yields(null),
|
addEntry: sinon.stub().yields(null),
|
||||||
}),
|
}),
|
||||||
|
@ -63,6 +65,7 @@ describe('AuthenticationController', function () {
|
||||||
'@overleaf/settings': (this.Settings = {
|
'@overleaf/settings': (this.Settings = {
|
||||||
siteUrl: 'http://www.foo.bar',
|
siteUrl: 'http://www.foo.bar',
|
||||||
httpAuthUsers: this.httpAuthUsers,
|
httpAuthUsers: this.httpAuthUsers,
|
||||||
|
elevateAccountSecurityAfterFailedLogin: 90 * 24 * 60 * 60 * 1000,
|
||||||
}),
|
}),
|
||||||
passport: (this.passport = {
|
passport: (this.passport = {
|
||||||
authenticate: sinon.stub().returns(sinon.stub()),
|
authenticate: sinon.stub().returns(sinon.stub()),
|
||||||
|
@ -254,7 +257,7 @@ describe('AuthenticationController', function () {
|
||||||
this.next
|
this.next
|
||||||
)
|
)
|
||||||
this.res.json.callCount.should.equal(1)
|
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)
|
expect(this.res.json.lastCall.args[0].redir != null).to.equal(false)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
@ -273,6 +276,7 @@ describe('AuthenticationController', function () {
|
||||||
postLoginRedirect: '/path/to/redir/to',
|
postLoginRedirect: '/path/to/redir/to',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
this.req.__authAuditInfo = { captcha: 'disabled' }
|
||||||
this.cb = sinon.stub()
|
this.cb = sinon.stub()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -325,22 +329,103 @@ describe('AuthenticationController', function () {
|
||||||
.stub()
|
.stub()
|
||||||
.callsArgWith(2, null, this.user)
|
.callsArgWith(2, null, this.user)
|
||||||
this.req.sessionID = Math.random()
|
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 () {
|
describe('happy path', function () {
|
||||||
this.AuthenticationManager.authenticate
|
beforeEach(function () {
|
||||||
.calledWith({ email: this.email.toLowerCase() }, this.password)
|
this.AuthenticationController.doPassportLogin(
|
||||||
.should.equal(true)
|
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 () {
|
describe('when authenticate flags a parallel login', function () {
|
||||||
this.cb.calledWith(null, this.user).should.equal(true)
|
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 SandboxedModule = require('sandboxed-module')
|
||||||
const { ObjectId } = require('mongodb')
|
const { ObjectId } = require('mongodb')
|
||||||
const AuthenticationErrors = require('../../../../app/src/Features/Authentication/AuthenticationErrors')
|
const AuthenticationErrors = require('../../../../app/src/Features/Authentication/AuthenticationErrors')
|
||||||
|
const tk = require('timekeeper')
|
||||||
|
|
||||||
const modulePath =
|
const modulePath =
|
||||||
'../../../../app/src/Features/Authentication/AuthenticationManager.js'
|
'../../../../app/src/Features/Authentication/AuthenticationManager.js'
|
||||||
|
|
||||||
describe('AuthenticationManager', function () {
|
describe('AuthenticationManager', function () {
|
||||||
beforeEach(function () {
|
beforeEach(function () {
|
||||||
|
tk.freeze(Date.now())
|
||||||
this.settings = { security: { bcryptRounds: 4 } }
|
this.settings = { security: { bcryptRounds: 4 } }
|
||||||
this.AuthenticationManager = SandboxedModule.require(modulePath, {
|
this.AuthenticationManager = SandboxedModule.require(modulePath, {
|
||||||
requires: {
|
requires: {
|
||||||
'../../models/User': {
|
'../../models/User': {
|
||||||
User: (this.User = {}),
|
User: (this.User = {
|
||||||
|
updateOne: sinon.stub().callsArgWith(3, null, { nModified: 1 }),
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
'../../infrastructure/mongodb': {
|
'../../infrastructure/mongodb': {
|
||||||
db: (this.db = { users: {} }),
|
db: (this.db = { users: {} }),
|
||||||
|
@ -31,6 +35,10 @@ describe('AuthenticationManager', function () {
|
||||||
this.callback = sinon.stub()
|
this.callback = sinon.stub()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
afterEach(function () {
|
||||||
|
tk.reset()
|
||||||
|
})
|
||||||
|
|
||||||
describe('with real bcrypt', function () {
|
describe('with real bcrypt', function () {
|
||||||
beforeEach(function () {
|
beforeEach(function () {
|
||||||
const bcrypt = require('bcrypt')
|
const bcrypt = require('bcrypt')
|
||||||
|
@ -49,13 +57,13 @@ describe('AuthenticationManager', function () {
|
||||||
_id: 'user-id',
|
_id: 'user-id',
|
||||||
email: (this.email = 'USER@sharelatex.com'),
|
email: (this.email = 'USER@sharelatex.com'),
|
||||||
}
|
}
|
||||||
|
this.user.hashedPassword = this.testPassword
|
||||||
this.User.findOne = sinon.stub().callsArgWith(1, null, this.user)
|
this.User.findOne = sinon.stub().callsArgWith(1, null, this.user)
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('when the hashed password matches', function () {
|
describe('when the hashed password matches', function () {
|
||||||
beforeEach(function (done) {
|
beforeEach(function (done) {
|
||||||
this.unencryptedPassword = 'testpassword'
|
this.unencryptedPassword = 'testpassword'
|
||||||
this.user.hashedPassword = this.testPassword
|
|
||||||
this.AuthenticationManager.authenticate(
|
this.AuthenticationManager.authenticate(
|
||||||
{ email: this.email },
|
{ email: this.email },
|
||||||
this.unencryptedPassword,
|
this.unencryptedPassword,
|
||||||
|
@ -70,17 +78,46 @@ describe('AuthenticationManager', function () {
|
||||||
this.User.findOne.calledWith({ email: this.email }).should.equal(true)
|
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 () {
|
it('should return the user', function () {
|
||||||
this.callback.calledWith(null, this.user).should.equal(true)
|
this.callback.calledWith(null, this.user).should.equal(true)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('when the encrypted passwords do not match', function () {
|
describe('when the encrypted passwords do not match', function () {
|
||||||
beforeEach(function () {
|
beforeEach(function (done) {
|
||||||
this.AuthenticationManager.authenticate(
|
this.AuthenticationManager.authenticate(
|
||||||
{ email: this.email },
|
{ email: this.email },
|
||||||
'notthecorrectpassword',
|
'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)
|
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 () {
|
describe('setUserPasswordInV2', function () {
|
||||||
|
|
Loading…
Reference in a new issue