AuthenticationManager.test.mjs 36 KB

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