AuthenticationManager.test.mjs 36 KB

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