AuthenticationManagerTests.js 34 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038
  1. const sinon = require('sinon')
  2. const { expect } = require('chai')
  3. const SandboxedModule = require('sandboxed-module')
  4. const { ObjectId } = require('mongodb-legacy')
  5. const AuthenticationErrors = require('../../../../app/src/Features/Authentication/AuthenticationErrors')
  6. const tk = require('timekeeper')
  7. const modulePath =
  8. '../../../../app/src/Features/Authentication/AuthenticationManager.js'
  9. describe('AuthenticationManager', function () {
  10. beforeEach(function () {
  11. tk.freeze(Date.now())
  12. this.settings = { security: { bcryptRounds: 4 } }
  13. this.metrics = { inc: sinon.stub().returns() }
  14. this.HaveIBeenPwned = {
  15. promises: {
  16. checkPasswordForReuse: sinon.stub().resolves(false),
  17. },
  18. checkPasswordForReuseInBackground: sinon.stub(),
  19. }
  20. this.AuthenticationManager = SandboxedModule.require(modulePath, {
  21. requires: {
  22. '../../models/User': {
  23. User: (this.User = {
  24. updateOne: sinon
  25. .stub()
  26. .returns({ exec: sinon.stub().resolves({ modifiedCount: 1 }) }),
  27. }),
  28. },
  29. '../../infrastructure/mongodb': {
  30. db: (this.db = { users: {} }),
  31. ObjectId,
  32. },
  33. bcrypt: (this.bcrypt = {
  34. getRounds: sinon.stub().returns(4),
  35. }),
  36. '@overleaf/settings': this.settings,
  37. '../User/UserGetter': (this.UserGetter = { promises: {} }),
  38. './AuthenticationErrors': AuthenticationErrors,
  39. './HaveIBeenPwned': this.HaveIBeenPwned,
  40. '../User/UserAuditLogHandler': (this.UserAuditLogHandler = {
  41. promises: {
  42. addEntry: sinon.stub().resolves(null),
  43. },
  44. }),
  45. '@overleaf/metrics': this.metrics,
  46. },
  47. })
  48. })
  49. afterEach(function () {
  50. tk.reset()
  51. })
  52. describe('with real bcrypt', function () {
  53. beforeEach(function () {
  54. const bcrypt = require('bcrypt')
  55. this.bcrypt.compare = bcrypt.compare
  56. this.bcrypt.getRounds = bcrypt.getRounds
  57. this.bcrypt.genSalt = bcrypt.genSalt
  58. this.bcrypt.hash = bcrypt.hash
  59. // Hash of 'testpassword'
  60. this.testPassword =
  61. '$2a$04$DcU/3UeJf1PfsWlQL./5H.rGTQL1Z1iyz6r7bN9Do8cy6pVWxpKpK'
  62. })
  63. describe('authenticate', function () {
  64. beforeEach(function () {
  65. this.user = {
  66. _id: 'user-id',
  67. email: (this.email = 'USER@overleaf.com'),
  68. }
  69. this.user.hashedPassword = this.testPassword
  70. this.User.findOne = sinon
  71. .stub()
  72. .returns({ exec: sinon.stub().resolves(this.user) })
  73. this.metrics.inc.reset()
  74. })
  75. describe('when the hashed password matches', function () {
  76. beforeEach(async function () {
  77. this.unencryptedPassword = 'testpassword'
  78. ;({ user: this.result } =
  79. await this.AuthenticationManager.promises.authenticate(
  80. { email: this.email },
  81. this.unencryptedPassword,
  82. null,
  83. { enforceHIBPCheck: false }
  84. ))
  85. })
  86. it('should look up the correct user in the database', function () {
  87. this.User.findOne.calledWith({ email: this.email }).should.equal(true)
  88. })
  89. it('should bump epoch', function () {
  90. this.User.updateOne.should.have.been.calledWith(
  91. {
  92. _id: this.user._id,
  93. loginEpoch: this.user.loginEpoch,
  94. },
  95. {
  96. $inc: { loginEpoch: 1 },
  97. },
  98. {}
  99. )
  100. })
  101. it('should return the user', function () {
  102. this.result.should.equal(this.user)
  103. })
  104. it('should send metrics', function () {
  105. expect(
  106. this.metrics.inc.calledWith('check-password', { status: 'success' })
  107. ).to.equal(true)
  108. })
  109. })
  110. describe('when the encrypted passwords do not match', function () {
  111. beforeEach(async function () {
  112. ;({ user: this.result } =
  113. await this.AuthenticationManager.promises.authenticate(
  114. { email: this.email },
  115. 'notthecorrectpassword',
  116. null,
  117. { enforceHIBPCheck: false }
  118. ))
  119. })
  120. it('should persist the login failure and bump epoch', function () {
  121. this.User.updateOne.should.have.been.calledWith(
  122. {
  123. _id: this.user._id,
  124. loginEpoch: this.user.loginEpoch,
  125. },
  126. {
  127. $inc: { loginEpoch: 1 },
  128. $set: { lastFailedLogin: new Date() },
  129. }
  130. )
  131. })
  132. it('should not return the user', function () {
  133. expect(this.result).to.equal(null)
  134. })
  135. })
  136. describe('when another request runs in parallel', function () {
  137. beforeEach(function () {
  138. this.User.updateOne = sinon
  139. .stub()
  140. .returns({ exec: sinon.stub().resolves({ modifiedCount: 0 }) })
  141. })
  142. describe('correct password', function () {
  143. it('should return an error', async function () {
  144. await expect(
  145. this.AuthenticationManager.promises.authenticate(
  146. { email: this.email },
  147. 'testpassword',
  148. null,
  149. { enforceHIBPCheck: false }
  150. )
  151. ).to.be.rejectedWith(AuthenticationErrors.ParallelLoginError)
  152. })
  153. })
  154. describe('bad password', function () {
  155. beforeEach(function () {
  156. this.User.updateOne = sinon
  157. .stub()
  158. .returns({ exec: sinon.stub().resolves({ modifiedCount: 0 }) })
  159. })
  160. it('should return an error', async function () {
  161. await expect(
  162. this.AuthenticationManager.promises.authenticate(
  163. { email: this.email },
  164. 'notthecorrectpassword',
  165. null,
  166. { enforceHIBPCheck: false }
  167. )
  168. ).to.be.rejectedWith(AuthenticationErrors.ParallelLoginError)
  169. })
  170. })
  171. })
  172. })
  173. describe('setUserPasswordInV2', function () {
  174. beforeEach(function () {
  175. this.user = {
  176. _id: '5c8791477192a80b5e76ca7e',
  177. email: (this.email = 'USER@overleaf.com'),
  178. }
  179. this.db.users.updateOne = sinon
  180. this.User.findOne = sinon
  181. .stub()
  182. .returns({ exec: sinon.stub().resolves(this.user) })
  183. this.bcrypt.compare = sinon.stub().resolves(false)
  184. this.db.users.updateOne = sinon.stub().resolves({ modifiedCount: 1 })
  185. })
  186. it('should not produce an error', async function () {
  187. const updated =
  188. await this.AuthenticationManager.promises.setUserPasswordInV2(
  189. this.user,
  190. 'testpassword'
  191. )
  192. expect(updated).to.equal(true)
  193. })
  194. it('should set the hashed password', async function () {
  195. await this.AuthenticationManager.promises.setUserPasswordInV2(
  196. this.user,
  197. 'testpassword'
  198. )
  199. const { hashedPassword } = this.db.users.updateOne.lastCall.args[1].$set
  200. expect(hashedPassword).to.exist
  201. expect(hashedPassword.length).to.equal(60)
  202. expect(hashedPassword).to.match(/^\$2a\$04\$[a-zA-Z0-9/.]{53}$/)
  203. })
  204. })
  205. })
  206. describe('hashPassword', function () {
  207. it('should block too long passwords', async function () {
  208. await expect(
  209. this.AuthenticationManager.promises.hashPassword('x'.repeat(100))
  210. ).to.be.rejectedWith('password is too long')
  211. })
  212. })
  213. describe('authenticate', function () {
  214. describe('when the user exists in the database', function () {
  215. beforeEach(function () {
  216. this.user = {
  217. _id: 'user-id',
  218. email: (this.email = 'USER@overleaf.com'),
  219. }
  220. this.unencryptedPassword = 'banana'
  221. this.User.findOne = sinon
  222. .stub()
  223. .returns({ exec: sinon.stub().resolves(this.user) })
  224. this.metrics.inc.reset()
  225. })
  226. describe('when the hashed password matches', function () {
  227. beforeEach(async function () {
  228. this.user.hashedPassword = this.hashedPassword = 'asdfjadflasdf'
  229. this.bcrypt.compare = sinon.stub().resolves(true)
  230. this.bcrypt.getRounds = sinon.stub().returns(4)
  231. ;({ user: this.result } =
  232. await this.AuthenticationManager.promises.authenticate(
  233. { email: this.email },
  234. this.unencryptedPassword,
  235. null,
  236. { enforceHIBPCheck: false }
  237. ))
  238. })
  239. it('should look up the correct user in the database', function () {
  240. this.User.findOne.calledWith({ email: this.email }).should.equal(true)
  241. })
  242. it('should check that the passwords match', function () {
  243. this.bcrypt.compare
  244. .calledWith(this.unencryptedPassword, this.hashedPassword)
  245. .should.equal(true)
  246. })
  247. it('should send metrics', function () {
  248. expect(
  249. this.metrics.inc.calledWith('check-password', {
  250. status: 'too_short',
  251. })
  252. ).to.equal(true)
  253. })
  254. it('should return the user', function () {
  255. this.result.should.equal(this.user)
  256. })
  257. describe('HIBP', function () {
  258. it('should enforce HIBP if requested', async function () {
  259. this.HaveIBeenPwned.promises.checkPasswordForReuse.resolves(true)
  260. await expect(
  261. this.AuthenticationManager.promises.authenticate(
  262. { email: this.email },
  263. this.unencryptedPassword,
  264. null,
  265. { enforceHIBPCheck: true }
  266. )
  267. ).to.be.rejectedWith(AuthenticationErrors.PasswordReusedError)
  268. })
  269. it('should check but not enforce HIBP if not requested', async function () {
  270. this.HaveIBeenPwned.promises.checkPasswordForReuse.resolves(true)
  271. const { user } =
  272. await this.AuthenticationManager.promises.authenticate(
  273. { email: this.email },
  274. this.unencryptedPassword,
  275. null,
  276. { enforceHIBPCheck: false }
  277. )
  278. this.HaveIBeenPwned.promises.checkPasswordForReuse.should.have.been.calledWith(
  279. this.unencryptedPassword
  280. )
  281. expect(user).to.equal(this.user)
  282. })
  283. it('should report password reused when check not enforced', async function () {
  284. this.HaveIBeenPwned.promises.checkPasswordForReuse.resolves(true)
  285. const { isPasswordReused } =
  286. await this.AuthenticationManager.promises.authenticate(
  287. { email: this.email },
  288. this.unencryptedPassword,
  289. null,
  290. { enforceHIBPCheck: false }
  291. )
  292. expect(isPasswordReused).to.equal(true)
  293. })
  294. it('should report password not reused when check not enforced', async function () {
  295. this.HaveIBeenPwned.promises.checkPasswordForReuse.resolves(false)
  296. const { isPasswordReused } =
  297. await this.AuthenticationManager.promises.authenticate(
  298. { email: this.email },
  299. this.unencryptedPassword,
  300. null,
  301. { enforceHIBPCheck: false }
  302. )
  303. expect(isPasswordReused).to.equal(false)
  304. })
  305. })
  306. })
  307. describe('when the encrypted passwords do not match', function () {
  308. beforeEach(async function () {
  309. this.user.hashedPassword = this.hashedPassword = 'asdfjadflasdf'
  310. this.bcrypt.compare = sinon.stub().resolves(false)
  311. ;({ user: this.result } =
  312. await this.AuthenticationManager.promises.authenticate(
  313. { email: this.email },
  314. this.unencryptedPassword,
  315. null,
  316. { enforceHIBPCheck: false }
  317. ))
  318. })
  319. it('should not return the user', function () {
  320. expect(this.result).to.equal(null)
  321. this.UserAuditLogHandler.promises.addEntry.callCount.should.equal(0)
  322. })
  323. })
  324. describe('when the encrypted passwords do not match, with auditLog', function () {
  325. beforeEach(async function () {
  326. this.user.hashedPassword = this.hashedPassword = 'asdfjadflasdf'
  327. this.bcrypt.compare = sinon.stub().resolves(false)
  328. this.auditLog = { ipAddress: 'ip', info: { method: 'foo' } }
  329. ;({ user: this.result } =
  330. await this.AuthenticationManager.promises.authenticate(
  331. { email: this.email },
  332. this.unencryptedPassword,
  333. this.auditLog,
  334. { enforceHIBPCheck: false }
  335. ))
  336. })
  337. it('should not return the user, but add entry to audit log', function () {
  338. expect(this.result).to.equal(null)
  339. this.UserAuditLogHandler.promises.addEntry.callCount.should.equal(1)
  340. this.UserAuditLogHandler.promises.addEntry
  341. .calledWith(
  342. this.user._id,
  343. 'failed-password-match',
  344. this.user._id,
  345. this.auditLog.ipAddress,
  346. this.auditLog.info
  347. )
  348. .should.equal(true)
  349. })
  350. })
  351. describe('when the hashed password matches but the number of rounds is too low', function () {
  352. beforeEach(async function () {
  353. this.user.hashedPassword = this.hashedPassword = 'asdfjadflasdf'
  354. this.bcrypt.compare = sinon.stub().resolves(true)
  355. this.bcrypt.getRounds = sinon.stub().returns(1)
  356. this.AuthenticationManager.promises._setUserPasswordInMongo = sinon
  357. .stub()
  358. .resolves()
  359. ;({ user: this.result } =
  360. await this.AuthenticationManager.promises.authenticate(
  361. { email: this.email },
  362. this.unencryptedPassword,
  363. null,
  364. { enforceHIBPCheck: false }
  365. ))
  366. })
  367. it('should look up the correct user in the database', function () {
  368. this.User.findOne.calledWith({ email: this.email }).should.equal(true)
  369. })
  370. it('should check that the passwords match', function () {
  371. this.bcrypt.compare
  372. .calledWith(this.unencryptedPassword, this.hashedPassword)
  373. .should.equal(true)
  374. })
  375. it('should check the number of rounds', function () {
  376. expect(this.metrics.inc).to.have.been.calledWith(
  377. 'bcrypt_check_rounds',
  378. 1,
  379. { status: 'upgrade' }
  380. )
  381. })
  382. it('should set the users password (with a higher number of rounds)', function () {
  383. this.AuthenticationManager.promises._setUserPasswordInMongo
  384. .calledWith(this.user, this.unencryptedPassword)
  385. .should.equal(true)
  386. })
  387. it('should return the user', function () {
  388. this.result.should.equal(this.user)
  389. })
  390. })
  391. describe('when the hashed password matches but the number of rounds is too low, but upgrades disabled', function () {
  392. beforeEach(async function () {
  393. this.settings.security.disableBcryptRoundsUpgrades = true
  394. this.user.hashedPassword = this.hashedPassword = 'asdfjadflasdf'
  395. this.bcrypt.compare = sinon.stub().resolves(true)
  396. this.bcrypt.getRounds = sinon.stub().returns(1)
  397. this.AuthenticationManager.promises.setUserPassword = sinon
  398. .stub()
  399. .resolves()
  400. ;({ user: this.result } =
  401. await this.AuthenticationManager.promises.authenticate(
  402. { email: this.email },
  403. this.unencryptedPassword,
  404. null,
  405. { enforceHIBPCheck: false }
  406. ))
  407. })
  408. it('should not check the number of rounds', function () {
  409. expect(this.metrics.inc).to.have.been.calledWith(
  410. 'bcrypt_check_rounds',
  411. 1,
  412. { status: 'disabled' }
  413. )
  414. })
  415. it('should not set the users password (with a higher number of rounds)', function () {
  416. this.AuthenticationManager.promises.setUserPassword
  417. .calledWith(this.user, this.unencryptedPassword)
  418. .should.equal(false)
  419. })
  420. it('should return the user', function () {
  421. this.result.should.equal(this.user)
  422. })
  423. })
  424. })
  425. describe('when the user does not exist in the database', function () {
  426. beforeEach(async function () {
  427. this.User.findOne = sinon
  428. .stub()
  429. .returns({ exec: sinon.stub().resolves(null) })
  430. ;({ user: this.result } =
  431. await this.AuthenticationManager.promises.authenticate(
  432. { email: this.email },
  433. this.unencrpytedPassword,
  434. null,
  435. { enforceHIBPCheck: false }
  436. ))
  437. })
  438. it('should not return a user', function () {
  439. expect(this.result).to.equal(null)
  440. })
  441. })
  442. })
  443. describe('validateEmail', function () {
  444. describe('valid', function () {
  445. it('should return null', function () {
  446. const result =
  447. this.AuthenticationManager.validateEmail('foo@example.com')
  448. expect(result).to.equal(null)
  449. })
  450. })
  451. describe('invalid', function () {
  452. it('should return validation error object for no email', function () {
  453. const result = this.AuthenticationManager.validateEmail('')
  454. expect(result).to.an.instanceOf(AuthenticationErrors.InvalidEmailError)
  455. expect(result.message).to.equal('email not valid')
  456. })
  457. it('should return validation error object for invalid', function () {
  458. const result = this.AuthenticationManager.validateEmail('notanemail')
  459. expect(result).to.be.an.instanceOf(
  460. AuthenticationErrors.InvalidEmailError
  461. )
  462. expect(result.message).to.equal('email not valid')
  463. })
  464. })
  465. })
  466. describe('validatePassword', function () {
  467. beforeEach(function () {
  468. // 73 characters:
  469. this.longPassword =
  470. '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef012345678'
  471. })
  472. describe('with a null password', function () {
  473. it('should return an error', function () {
  474. const result = this.AuthenticationManager.validatePassword()
  475. expect(result).to.be.an.instanceOf(
  476. AuthenticationErrors.InvalidPasswordError
  477. )
  478. expect(result.message).to.equal('password not set')
  479. expect(result.info.code).to.equal('not_set')
  480. })
  481. })
  482. describe('password length', function () {
  483. describe('with the default password length options', function () {
  484. beforeEach(function () {
  485. this.metrics.inc.reset()
  486. })
  487. it('should send a metric', function () {
  488. this.AuthenticationManager.validatePassword('foo')
  489. expect(this.metrics.inc.calledWith('try-validate-password')).to.equal(
  490. true
  491. )
  492. })
  493. it('should reject passwords that are too short', function () {
  494. const result1 = this.AuthenticationManager.validatePassword('')
  495. expect(result1).to.be.an.instanceOf(
  496. AuthenticationErrors.InvalidPasswordError
  497. )
  498. expect(result1.message).to.equal('password is too short')
  499. expect(result1.info.code).to.equal('too_short')
  500. const result2 = this.AuthenticationManager.validatePassword('foo')
  501. expect(result2).to.be.an.instanceOf(
  502. AuthenticationErrors.InvalidPasswordError
  503. )
  504. expect(result2.message).to.equal('password is too short')
  505. expect(result2.info.code).to.equal('too_short')
  506. })
  507. it('should reject passwords that are too long', function () {
  508. const result = this.AuthenticationManager.validatePassword(
  509. this.longPassword
  510. )
  511. expect(result).to.be.an.instanceOf(
  512. AuthenticationErrors.InvalidPasswordError
  513. )
  514. expect(result.message).to.equal('password is too long')
  515. expect(result.info.code).to.equal('too_long')
  516. })
  517. it('should accept passwords that are a good length', function () {
  518. expect(
  519. this.AuthenticationManager.validatePassword('l337h4x0r')
  520. ).to.equal(null)
  521. })
  522. })
  523. describe('when the password length is specified in settings', function () {
  524. beforeEach(function () {
  525. this.settings.passwordStrengthOptions = {
  526. length: {
  527. min: 10,
  528. max: 12,
  529. },
  530. }
  531. })
  532. it('should reject passwords that are too short', function () {
  533. const result =
  534. this.AuthenticationManager.validatePassword('012345678')
  535. expect(result).to.be.an.instanceOf(
  536. AuthenticationErrors.InvalidPasswordError
  537. )
  538. expect(result.message).to.equal('password is too short')
  539. expect(result.info.code).to.equal('too_short')
  540. })
  541. it('should accept passwords of exactly minimum length', function () {
  542. expect(
  543. this.AuthenticationManager.validatePassword('0123456789')
  544. ).to.equal(null)
  545. })
  546. it('should reject passwords that are too long', function () {
  547. const result =
  548. this.AuthenticationManager.validatePassword('0123456789abc')
  549. expect(result).to.be.an.instanceOf(
  550. AuthenticationErrors.InvalidPasswordError
  551. )
  552. expect(result.message).to.equal('password is too long')
  553. expect(result.info.code).to.equal('too_long')
  554. })
  555. it('should accept passwords of exactly maximum length', function () {
  556. expect(
  557. this.AuthenticationManager.validatePassword('0123456789ab')
  558. ).to.equal(null)
  559. })
  560. })
  561. describe('when the maximum password length is set to >72 characters in settings', function () {
  562. beforeEach(function () {
  563. this.settings.passwordStrengthOptions = {
  564. length: {
  565. max: 128,
  566. },
  567. }
  568. })
  569. it('should still reject passwords > 72 characters in length', function () {
  570. const result = this.AuthenticationManager.validatePassword(
  571. this.longPassword
  572. )
  573. expect(result).to.be.an.instanceOf(
  574. AuthenticationErrors.InvalidPasswordError
  575. )
  576. expect(result.message).to.equal('password is too long')
  577. expect(result.info.code).to.equal('too_long')
  578. })
  579. })
  580. })
  581. describe('allowed characters', function () {
  582. describe('with the default settings for allowed characters', function () {
  583. it('should allow passwords with valid characters', function () {
  584. expect(
  585. this.AuthenticationManager.validatePassword(
  586. 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'
  587. )
  588. ).to.equal(null)
  589. expect(
  590. this.AuthenticationManager.validatePassword(
  591. '1234567890@#$%^&*()-_=+[]{};:<>/?!£€.,'
  592. )
  593. ).to.equal(null)
  594. })
  595. it('should not allow passwords with invalid characters', function () {
  596. const result = this.AuthenticationManager.validatePassword(
  597. 'correct horse battery staple'
  598. )
  599. expect(result).to.be.an.instanceOf(
  600. AuthenticationErrors.InvalidPasswordError
  601. )
  602. expect(result.message).to.equal(
  603. 'password contains an invalid character'
  604. )
  605. expect(result.info.code).to.equal('invalid_character')
  606. })
  607. })
  608. describe('when valid characters are overridden in settings', function () {
  609. beforeEach(function () {
  610. this.settings.passwordStrengthOptions = {
  611. chars: {
  612. symbols: ' ',
  613. },
  614. }
  615. })
  616. it('should allow passwords with valid characters', function () {
  617. expect(
  618. this.AuthenticationManager.validatePassword(
  619. 'correct horse battery staple'
  620. )
  621. ).to.equal(null)
  622. })
  623. it('should disallow passwords with invalid characters', function () {
  624. const result = this.AuthenticationManager.validatePassword(
  625. '1234567890@#$%^&*()-_=+[]{};:<>/?!£€.,'
  626. )
  627. expect(result).to.be.an.instanceOf(
  628. AuthenticationErrors.InvalidPasswordError
  629. )
  630. expect(result.message).to.equal(
  631. 'password contains an invalid character'
  632. )
  633. expect(result.info.code).to.equal('invalid_character')
  634. })
  635. })
  636. describe('when allowAnyChars is set', function () {
  637. beforeEach(function () {
  638. this.settings.passwordStrengthOptions = {
  639. allowAnyChars: true,
  640. }
  641. })
  642. it('should allow any characters', function () {
  643. expect(
  644. this.AuthenticationManager.validatePassword(
  645. 'correct horse battery staple'
  646. )
  647. ).to.equal(null)
  648. expect(
  649. this.AuthenticationManager.validatePassword(
  650. '1234567890@#$%^&*()-_=+[]{};:<>/?!£€.,'
  651. )
  652. ).to.equal(null)
  653. })
  654. })
  655. })
  656. })
  657. describe('_validatePasswordNotTooSimilar', function () {
  658. beforeEach(function () {
  659. this.metrics.inc.reset()
  660. })
  661. it('should return an error when the password is too similar to email', function () {
  662. const password = '12someuser34'
  663. const email = 'someuser@example.com'
  664. const error = this.AuthenticationManager._validatePasswordNotTooSimilar(
  665. password,
  666. email
  667. )
  668. expect(error).to.exist
  669. })
  670. it('should return an error when the password is re-arranged elements of the email', function () {
  671. const badPasswords = [
  672. 'su2oe1em3oolc',
  673. 'someone.cool',
  674. 'someonecool',
  675. 'cool.someone',
  676. 'coolsomeone',
  677. 'example.com',
  678. 'examplecom',
  679. 'com.example',
  680. 'comexample',
  681. ]
  682. const email = 'someone.cool@example.com'
  683. for (const password of badPasswords) {
  684. const error = this.AuthenticationManager._validatePasswordNotTooSimilar(
  685. password,
  686. email
  687. )
  688. expect(error).to.exist
  689. }
  690. })
  691. it('should return nothing when the password different from email', function () {
  692. const password = '58WyLvr'
  693. const email = 'someuser@example.com'
  694. const error = this.AuthenticationManager._validatePasswordNotTooSimilar(
  695. password,
  696. email
  697. )
  698. expect(error).to.not.exist
  699. })
  700. it('should return nothing when the password is much longer than parts of the email', function () {
  701. const password = new Array(30).fill('a').join('')
  702. const email = 'a@cd.com'
  703. const error = this.AuthenticationManager._validatePasswordNotTooSimilar(
  704. password,
  705. email
  706. )
  707. expect(error).to.not.exist
  708. })
  709. })
  710. describe('setUserPassword', function () {
  711. beforeEach(function () {
  712. this.user_id = new ObjectId()
  713. this.password = 'bananagram'
  714. this.hashedPassword = 'asdkjfa;osiuvandf'
  715. this.salt = 'saltaasdfasdfasdf'
  716. this.user = {
  717. _id: this.user_id,
  718. email: 'user@example.com',
  719. hashedPassword: this.hashedPassword,
  720. }
  721. this.bcrypt.compare = sinon.stub().resolves(false)
  722. this.bcrypt.genSalt = sinon.stub().resolves(this.salt)
  723. this.bcrypt.hash = sinon.stub().resolves(this.hashedPassword)
  724. this.User.findOne = sinon
  725. .stub()
  726. .returns({ exec: sinon.stub().resolves(this.user) })
  727. this.db.users.updateOne = sinon.stub().resolves()
  728. })
  729. describe('same as previous password', function () {
  730. beforeEach(function () {
  731. this.bcrypt.compare.resolves(true)
  732. })
  733. it('should be rejected', async function () {
  734. await expect(
  735. this.AuthenticationManager.promises.setUserPassword(
  736. this.user,
  737. this.password
  738. )
  739. ).to.be.rejectedWith(AuthenticationErrors.PasswordMustBeDifferentError)
  740. })
  741. })
  742. describe('too long', function () {
  743. beforeEach(function () {
  744. this.settings.passwordStrengthOptions = {
  745. length: {
  746. max: 10,
  747. },
  748. }
  749. this.password = 'dsdsadsadsadsadsadkjsadjsadjsadljs'
  750. })
  751. it('should return and error', async function () {
  752. await expect(
  753. this.AuthenticationManager.promises.setUserPassword(
  754. this.user,
  755. this.password
  756. )
  757. ).to.be.rejectedWith('password is too long')
  758. })
  759. it('should not start the bcrypt process', async function () {
  760. await expect(
  761. this.AuthenticationManager.promises.setUserPassword(
  762. this.user,
  763. this.password
  764. )
  765. ).to.be.rejected
  766. this.bcrypt.genSalt.called.should.equal(false)
  767. this.bcrypt.hash.called.should.equal(false)
  768. })
  769. })
  770. describe('contains full email', function () {
  771. beforeEach(function () {
  772. this.password = `some${this.user.email}password`
  773. })
  774. it('should reject the password', async function () {
  775. await expect(
  776. this.AuthenticationManager.promises.setUserPassword(
  777. this.user,
  778. this.password
  779. )
  780. ).to.be.rejectedWith(AuthenticationErrors.InvalidPasswordError)
  781. })
  782. })
  783. describe('contains first part of email', function () {
  784. beforeEach(function () {
  785. this.password = `some${this.user.email.split('@')[0]}password`
  786. })
  787. it('should reject the password', async function () {
  788. await expect(
  789. this.AuthenticationManager.promises.setUserPassword(
  790. this.user,
  791. this.password
  792. )
  793. ).to.be.rejectedWith(AuthenticationErrors.InvalidPasswordError)
  794. })
  795. })
  796. describe('email contains password', function () {
  797. let user, password
  798. beforeEach(function () {
  799. password = 'somedomain'
  800. user = { _id: 'some-user-id', email: 'someuser@somedomain.com' }
  801. })
  802. it('should reject the password', async function () {
  803. try {
  804. await this.AuthenticationManager.promises.setUserPassword(
  805. user,
  806. password
  807. )
  808. expect.fail('should have thrown')
  809. } catch (err) {
  810. expect(err.name).to.equal('InvalidPasswordError')
  811. expect(err?.info?.code).to.equal('contains_email')
  812. }
  813. })
  814. })
  815. describe('too short', function () {
  816. beforeEach(function () {
  817. this.settings.passwordStrengthOptions = {
  818. length: {
  819. max: 10,
  820. min: 6,
  821. },
  822. }
  823. this.password = 'dsd'
  824. })
  825. it('should return and error', async function () {
  826. await expect(
  827. this.AuthenticationManager.promises.setUserPassword(
  828. this.user,
  829. this.password
  830. )
  831. ).to.be.rejectedWith('password is too short')
  832. })
  833. it('should not start the bcrypt process', async function () {
  834. await expect(
  835. this.AuthenticationManager.promises.setUserPassword(
  836. this.user,
  837. this.password
  838. )
  839. ).to.be.rejected
  840. this.bcrypt.genSalt.called.should.equal(false)
  841. this.bcrypt.hash.called.should.equal(false)
  842. })
  843. })
  844. describe('password too similar to email', function () {
  845. beforeEach(function () {
  846. this.user.email = 'foobarbazquux@example.com'
  847. this.password = 'foo21barbaz'
  848. this.metrics.inc.reset()
  849. })
  850. it('should produce an error when the password is too similar to the email', async function () {
  851. try {
  852. await this.AuthenticationManager.promises.setUserPassword(
  853. this.user,
  854. this.password
  855. )
  856. expect.fail('should have thrown')
  857. } catch (err) {
  858. expect(err.message).to.equal(
  859. 'password is too similar to email address'
  860. )
  861. expect(err?.info?.code).to.equal('too_similar')
  862. }
  863. expect(
  864. this.metrics.inc.calledWith('password-too-similar-to-email')
  865. ).to.equal(true)
  866. })
  867. it('should produce an error when the password is too similar to the email, regardless of case', async function () {
  868. try {
  869. await this.AuthenticationManager.promises.setUserPassword(
  870. this.user,
  871. this.password.toUpperCase()
  872. )
  873. expect.fail('should have thrown')
  874. } catch (err) {
  875. expect(err.message).to.equal(
  876. 'password is too similar to email address'
  877. )
  878. expect(err?.info?.code).to.equal('too_similar')
  879. }
  880. expect(
  881. this.metrics.inc.calledWith('password-too-similar-to-email')
  882. ).to.equal(true)
  883. })
  884. })
  885. describe('successful password set attempt', function () {
  886. beforeEach(async function () {
  887. this.metrics.inc.reset()
  888. this.UserGetter.promises.getUser = sinon
  889. .stub()
  890. .resolves({ overleaf: null })
  891. await this.AuthenticationManager.promises.setUserPassword(
  892. this.user,
  893. this.password
  894. )
  895. })
  896. it("should update the user's password in the database", function () {
  897. const { args } = this.db.users.updateOne.lastCall
  898. expect(args[0]).to.deep.equal({
  899. _id: new ObjectId(this.user_id.toString()),
  900. })
  901. expect(args[1]).to.deep.equal({
  902. $set: {
  903. hashedPassword: this.hashedPassword,
  904. },
  905. $unset: {
  906. password: true,
  907. },
  908. })
  909. })
  910. it('should hash the password', function () {
  911. this.bcrypt.genSalt.calledWith(4).should.equal(true)
  912. this.bcrypt.hash.calledWith(this.password, this.salt).should.equal(true)
  913. })
  914. it('should not send a metric for password-too-similar-to-email', function () {
  915. expect(
  916. this.metrics.inc.calledWith('password-too-similar-to-email')
  917. ).to.equal(false)
  918. })
  919. })
  920. })
  921. })