AuthenticationManagerTests.js 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676
  1. const sinon = require('sinon')
  2. const { expect } = require('chai')
  3. const SandboxedModule = require('sandboxed-module')
  4. const { ObjectId } = require('mongodb')
  5. const AuthenticationErrors = require('../../../../app/src/Features/Authentication/AuthenticationErrors')
  6. const modulePath =
  7. '../../../../app/src/Features/Authentication/AuthenticationManager.js'
  8. describe('AuthenticationManager', function () {
  9. beforeEach(function () {
  10. this.settings = { security: { bcryptRounds: 4 } }
  11. this.AuthenticationManager = SandboxedModule.require(modulePath, {
  12. requires: {
  13. '../../models/User': {
  14. User: (this.User = {}),
  15. },
  16. '../../infrastructure/mongodb': {
  17. db: (this.db = { users: {} }),
  18. ObjectId,
  19. },
  20. bcrypt: (this.bcrypt = {}),
  21. '@overleaf/settings': this.settings,
  22. '../User/UserGetter': (this.UserGetter = {}),
  23. './AuthenticationErrors': AuthenticationErrors,
  24. './HaveIBeenPwned': {
  25. checkPasswordForReuseInBackground: sinon.stub(),
  26. },
  27. },
  28. })
  29. this.callback = sinon.stub()
  30. })
  31. describe('with real bcrypt', function () {
  32. beforeEach(function () {
  33. const bcrypt = require('bcrypt')
  34. this.bcrypt.compare = bcrypt.compare
  35. this.bcrypt.getRounds = bcrypt.getRounds
  36. this.bcrypt.genSalt = bcrypt.genSalt
  37. this.bcrypt.hash = bcrypt.hash
  38. // Hash of 'testpassword'
  39. this.testPassword =
  40. '$2a$04$DcU/3UeJf1PfsWlQL./5H.rGTQL1Z1iyz6r7bN9Do8cy6pVWxpKpK'
  41. })
  42. describe('authenticate', function () {
  43. beforeEach(function () {
  44. this.user = {
  45. _id: 'user-id',
  46. email: (this.email = 'USER@sharelatex.com'),
  47. }
  48. this.User.findOne = sinon.stub().callsArgWith(1, null, this.user)
  49. })
  50. describe('when the hashed password matches', function () {
  51. beforeEach(function (done) {
  52. this.unencryptedPassword = 'testpassword'
  53. this.user.hashedPassword = this.testPassword
  54. this.AuthenticationManager.authenticate(
  55. { email: this.email },
  56. this.unencryptedPassword,
  57. (error, user) => {
  58. this.callback(error, user)
  59. done()
  60. }
  61. )
  62. })
  63. it('should look up the correct user in the database', function () {
  64. this.User.findOne.calledWith({ email: this.email }).should.equal(true)
  65. })
  66. it('should return the user', function () {
  67. this.callback.calledWith(null, this.user).should.equal(true)
  68. })
  69. })
  70. describe('when the encrypted passwords do not match', function () {
  71. beforeEach(function () {
  72. this.AuthenticationManager.authenticate(
  73. { email: this.email },
  74. 'notthecorrectpassword',
  75. this.callback
  76. )
  77. })
  78. it('should not return the user', function () {
  79. this.callback.calledWith(null, null).should.equal(true)
  80. })
  81. })
  82. })
  83. describe('setUserPasswordInV2', function () {
  84. beforeEach(function () {
  85. this.user = {
  86. _id: '5c8791477192a80b5e76ca7e',
  87. email: (this.email = 'USER@sharelatex.com'),
  88. }
  89. this.db.users.updateOne = sinon
  90. this.User.findOne = sinon.stub().callsArgWith(2, null, this.user)
  91. this.db.users.updateOne = sinon
  92. .stub()
  93. .callsArgWith(2, null, { modifiedCount: 1 })
  94. })
  95. it('should not produce an error', function (done) {
  96. this.AuthenticationManager.setUserPasswordInV2(
  97. this.user,
  98. 'testpassword',
  99. (err, updated) => {
  100. expect(err).to.not.exist
  101. expect(updated).to.equal(true)
  102. done()
  103. }
  104. )
  105. })
  106. it('should set the hashed password', function (done) {
  107. this.AuthenticationManager.setUserPasswordInV2(
  108. this.user,
  109. 'testpassword',
  110. err => {
  111. expect(err).to.not.exist
  112. const { hashedPassword } =
  113. this.db.users.updateOne.lastCall.args[1].$set
  114. expect(hashedPassword).to.exist
  115. expect(hashedPassword.length).to.equal(60)
  116. expect(hashedPassword).to.match(/^\$2a\$04\$[a-zA-Z0-9/.]{53}$/)
  117. done()
  118. }
  119. )
  120. })
  121. })
  122. })
  123. describe('authenticate', function () {
  124. describe('when the user exists in the database', function () {
  125. beforeEach(function () {
  126. this.user = {
  127. _id: 'user-id',
  128. email: (this.email = 'USER@sharelatex.com'),
  129. }
  130. this.unencryptedPassword = 'banana'
  131. this.User.findOne = sinon.stub().callsArgWith(1, null, this.user)
  132. })
  133. describe('when the hashed password matches', function () {
  134. beforeEach(function (done) {
  135. this.user.hashedPassword = this.hashedPassword = 'asdfjadflasdf'
  136. this.bcrypt.compare = sinon.stub().callsArgWith(2, null, true)
  137. this.bcrypt.getRounds = sinon.stub().returns(4)
  138. this.AuthenticationManager.authenticate(
  139. { email: this.email },
  140. this.unencryptedPassword,
  141. (error, user) => {
  142. this.callback(error, user)
  143. done()
  144. }
  145. )
  146. })
  147. it('should look up the correct user in the database', function () {
  148. this.User.findOne.calledWith({ email: this.email }).should.equal(true)
  149. })
  150. it('should check that the passwords match', function () {
  151. this.bcrypt.compare
  152. .calledWith(this.unencryptedPassword, this.hashedPassword)
  153. .should.equal(true)
  154. })
  155. it('should return the user', function () {
  156. this.callback.calledWith(null, this.user).should.equal(true)
  157. })
  158. })
  159. describe('when the encrypted passwords do not match', function () {
  160. beforeEach(function () {
  161. this.AuthenticationManager.authenticate(
  162. { email: this.email },
  163. this.unencryptedPassword,
  164. this.callback
  165. )
  166. })
  167. it('should not return the user', function () {
  168. this.callback.calledWith(null, null).should.equal(true)
  169. })
  170. })
  171. describe('when the hashed password matches but the number of rounds is too low', function () {
  172. beforeEach(function (done) {
  173. this.user.hashedPassword = this.hashedPassword = 'asdfjadflasdf'
  174. this.bcrypt.compare = sinon.stub().callsArgWith(2, null, true)
  175. this.bcrypt.getRounds = sinon.stub().returns(1)
  176. this.AuthenticationManager.setUserPassword = sinon
  177. .stub()
  178. .callsArgWith(2, null)
  179. this.AuthenticationManager.authenticate(
  180. { email: this.email },
  181. this.unencryptedPassword,
  182. (error, user) => {
  183. this.callback(error, user)
  184. done()
  185. }
  186. )
  187. })
  188. it('should look up the correct user in the database', function () {
  189. this.User.findOne.calledWith({ email: this.email }).should.equal(true)
  190. })
  191. it('should check that the passwords match', function () {
  192. this.bcrypt.compare
  193. .calledWith(this.unencryptedPassword, this.hashedPassword)
  194. .should.equal(true)
  195. })
  196. it('should check the number of rounds', function () {
  197. this.bcrypt.getRounds.called.should.equal(true)
  198. })
  199. it('should set the users password (with a higher number of rounds)', function () {
  200. this.AuthenticationManager.setUserPassword
  201. .calledWith(this.user, this.unencryptedPassword)
  202. .should.equal(true)
  203. })
  204. it('should return the user', function () {
  205. this.callback.calledWith(null, this.user).should.equal(true)
  206. })
  207. })
  208. describe('when the hashed password matches but the number of rounds is too low, but upgrades disabled', function () {
  209. beforeEach(function (done) {
  210. this.settings.security.disableBcryptRoundsUpgrades = true
  211. this.user.hashedPassword = this.hashedPassword = 'asdfjadflasdf'
  212. this.bcrypt.compare = sinon.stub().callsArgWith(2, null, true)
  213. this.bcrypt.getRounds = sinon.stub().returns(1)
  214. this.AuthenticationManager.setUserPassword = sinon
  215. .stub()
  216. .callsArgWith(2, null)
  217. this.AuthenticationManager.authenticate(
  218. { email: this.email },
  219. this.unencryptedPassword,
  220. (error, user) => {
  221. this.callback(error, user)
  222. done()
  223. }
  224. )
  225. })
  226. it('should not check the number of rounds', function () {
  227. this.bcrypt.getRounds.called.should.equal(false)
  228. })
  229. it('should not set the users password (with a higher number of rounds)', function () {
  230. this.AuthenticationManager.setUserPassword
  231. .calledWith(this.user, this.unencryptedPassword)
  232. .should.equal(false)
  233. })
  234. it('should return the user', function () {
  235. this.callback.calledWith(null, this.user).should.equal(true)
  236. })
  237. })
  238. })
  239. describe('when the user does not exist in the database', function () {
  240. beforeEach(function () {
  241. this.User.findOne = sinon.stub().callsArgWith(1, null, null)
  242. this.AuthenticationManager.authenticate(
  243. { email: this.email },
  244. this.unencrpytedPassword,
  245. this.callback
  246. )
  247. })
  248. it('should not return a user', function () {
  249. this.callback.calledWith(null, null).should.equal(true)
  250. })
  251. })
  252. })
  253. describe('validateEmail', function () {
  254. describe('valid', function () {
  255. it('should return null', function () {
  256. const result =
  257. this.AuthenticationManager.validateEmail('foo@example.com')
  258. expect(result).to.equal(null)
  259. })
  260. })
  261. describe('invalid', function () {
  262. it('should return validation error object for no email', function () {
  263. const result = this.AuthenticationManager.validateEmail('')
  264. expect(result).to.an.instanceOf(AuthenticationErrors.InvalidEmailError)
  265. expect(result.message).to.equal('email not valid')
  266. })
  267. it('should return validation error object for invalid', function () {
  268. const result = this.AuthenticationManager.validateEmail('notanemail')
  269. expect(result).to.be.an.instanceOf(
  270. AuthenticationErrors.InvalidEmailError
  271. )
  272. expect(result.message).to.equal('email not valid')
  273. })
  274. })
  275. })
  276. describe('validatePassword', function () {
  277. beforeEach(function () {
  278. // 73 characters:
  279. this.longPassword =
  280. '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef012345678'
  281. })
  282. describe('with a null password', function () {
  283. it('should return an error', function () {
  284. const result = this.AuthenticationManager.validatePassword()
  285. expect(result).to.be.an.instanceOf(
  286. AuthenticationErrors.InvalidPasswordError
  287. )
  288. expect(result.message).to.equal('password not set')
  289. expect(result.info.code).to.equal('not_set')
  290. })
  291. })
  292. describe('password length', function () {
  293. describe('with the default password length options', function () {
  294. it('should reject passwords that are too short', function () {
  295. const result1 = this.AuthenticationManager.validatePassword('')
  296. expect(result1).to.be.an.instanceOf(
  297. AuthenticationErrors.InvalidPasswordError
  298. )
  299. expect(result1.message).to.equal('password is too short')
  300. expect(result1.info.code).to.equal('too_short')
  301. const result2 = this.AuthenticationManager.validatePassword('foo')
  302. expect(result2).to.be.an.instanceOf(
  303. AuthenticationErrors.InvalidPasswordError
  304. )
  305. expect(result2.message).to.equal('password is too short')
  306. expect(result2.info.code).to.equal('too_short')
  307. })
  308. it('should reject passwords that are too long', function () {
  309. const result = this.AuthenticationManager.validatePassword(
  310. this.longPassword
  311. )
  312. expect(result).to.be.an.instanceOf(
  313. AuthenticationErrors.InvalidPasswordError
  314. )
  315. expect(result.message).to.equal('password is too long')
  316. expect(result.info.code).to.equal('too_long')
  317. })
  318. it('should accept passwords that are a good length', function () {
  319. expect(
  320. this.AuthenticationManager.validatePassword('l337h4x0r')
  321. ).to.equal(null)
  322. })
  323. })
  324. describe('when the password length is specified in settings', function () {
  325. beforeEach(function () {
  326. this.settings.passwordStrengthOptions = {
  327. length: {
  328. min: 10,
  329. max: 12,
  330. },
  331. }
  332. })
  333. it('should reject passwords that are too short', function () {
  334. const result =
  335. this.AuthenticationManager.validatePassword('012345678')
  336. expect(result).to.be.an.instanceOf(
  337. AuthenticationErrors.InvalidPasswordError
  338. )
  339. expect(result.message).to.equal('password is too short')
  340. expect(result.info.code).to.equal('too_short')
  341. })
  342. it('should accept passwords of exactly minimum length', function () {
  343. expect(
  344. this.AuthenticationManager.validatePassword('0123456789')
  345. ).to.equal(null)
  346. })
  347. it('should reject passwords that are too long', function () {
  348. const result =
  349. this.AuthenticationManager.validatePassword('0123456789abc')
  350. expect(result).to.be.an.instanceOf(
  351. AuthenticationErrors.InvalidPasswordError
  352. )
  353. expect(result.message).to.equal('password is too long')
  354. expect(result.info.code).to.equal('too_long')
  355. })
  356. it('should accept passwords of exactly maximum length', function () {
  357. expect(
  358. this.AuthenticationManager.validatePassword('0123456789ab')
  359. ).to.equal(null)
  360. })
  361. })
  362. describe('when the maximum password length is set to >72 characters in settings', function () {
  363. beforeEach(function () {
  364. this.settings.passwordStrengthOptions = {
  365. length: {
  366. max: 128,
  367. },
  368. }
  369. })
  370. it('should still reject passwords > 72 characters in length', function () {
  371. const result = this.AuthenticationManager.validatePassword(
  372. this.longPassword
  373. )
  374. expect(result).to.be.an.instanceOf(
  375. AuthenticationErrors.InvalidPasswordError
  376. )
  377. expect(result.message).to.equal('password is too long')
  378. expect(result.info.code).to.equal('too_long')
  379. })
  380. })
  381. })
  382. describe('allowed characters', function () {
  383. describe('with the default settings for allowed characters', function () {
  384. it('should allow passwords with valid characters', function () {
  385. expect(
  386. this.AuthenticationManager.validatePassword(
  387. 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'
  388. )
  389. ).to.equal(null)
  390. expect(
  391. this.AuthenticationManager.validatePassword(
  392. '1234567890@#$%^&*()-_=+[]{};:<>/?!£€.,'
  393. )
  394. ).to.equal(null)
  395. })
  396. it('should not allow passwords with invalid characters', function () {
  397. const result = this.AuthenticationManager.validatePassword(
  398. 'correct horse battery staple'
  399. )
  400. expect(result).to.be.an.instanceOf(
  401. AuthenticationErrors.InvalidPasswordError
  402. )
  403. expect(result.message).to.equal(
  404. 'password contains an invalid character'
  405. )
  406. expect(result.info.code).to.equal('invalid_character')
  407. })
  408. })
  409. describe('when valid characters are overridden in settings', function () {
  410. beforeEach(function () {
  411. this.settings.passwordStrengthOptions = {
  412. chars: {
  413. symbols: ' ',
  414. },
  415. }
  416. })
  417. it('should allow passwords with valid characters', function () {
  418. expect(
  419. this.AuthenticationManager.validatePassword(
  420. 'correct horse battery staple'
  421. )
  422. ).to.equal(null)
  423. })
  424. it('should disallow passwords with invalid characters', function () {
  425. const result = this.AuthenticationManager.validatePassword(
  426. '1234567890@#$%^&*()-_=+[]{};:<>/?!£€.,'
  427. )
  428. expect(result).to.be.an.instanceOf(
  429. AuthenticationErrors.InvalidPasswordError
  430. )
  431. expect(result.message).to.equal(
  432. 'password contains an invalid character'
  433. )
  434. expect(result.info.code).to.equal('invalid_character')
  435. })
  436. })
  437. describe('when allowAnyChars is set', function () {
  438. beforeEach(function () {
  439. this.settings.passwordStrengthOptions = {
  440. allowAnyChars: true,
  441. }
  442. })
  443. it('should allow any characters', function () {
  444. expect(
  445. this.AuthenticationManager.validatePassword(
  446. 'correct horse battery staple'
  447. )
  448. ).to.equal(null)
  449. expect(
  450. this.AuthenticationManager.validatePassword(
  451. '1234567890@#$%^&*()-_=+[]{};:<>/?!£€.,'
  452. )
  453. ).to.equal(null)
  454. })
  455. })
  456. })
  457. })
  458. describe('setUserPassword', function () {
  459. beforeEach(function () {
  460. this.user_id = ObjectId()
  461. this.user = {
  462. _id: this.user_id,
  463. email: 'user@example.com',
  464. }
  465. this.password = 'banana'
  466. this.hashedPassword = 'asdkjfa;osiuvandf'
  467. this.salt = 'saltaasdfasdfasdf'
  468. this.bcrypt.genSalt = sinon.stub().callsArgWith(2, null, this.salt)
  469. this.bcrypt.hash = sinon.stub().callsArgWith(2, null, this.hashedPassword)
  470. this.User.findOne = sinon.stub().callsArgWith(2, null, this.user)
  471. this.db.users.updateOne = sinon.stub().callsArg(2)
  472. })
  473. describe('too long', function () {
  474. beforeEach(function () {
  475. this.settings.passwordStrengthOptions = {
  476. length: {
  477. max: 10,
  478. },
  479. }
  480. this.password = 'dsdsadsadsadsadsadkjsadjsadjsadljs'
  481. })
  482. it('should return and error', function (done) {
  483. this.AuthenticationManager.setUserPassword(
  484. this.user,
  485. this.password,
  486. err => {
  487. expect(err).to.exist
  488. done()
  489. }
  490. )
  491. })
  492. it('should not start the bcrypt process', function (done) {
  493. this.AuthenticationManager.setUserPassword(
  494. this.user,
  495. this.password,
  496. () => {
  497. this.bcrypt.genSalt.called.should.equal(false)
  498. this.bcrypt.hash.called.should.equal(false)
  499. done()
  500. }
  501. )
  502. })
  503. })
  504. describe('contains full email', function () {
  505. beforeEach(function () {
  506. this.password = `some${this.user.email}password`
  507. })
  508. it('should reject the password', function (done) {
  509. this.AuthenticationManager.setUserPassword(
  510. this.user,
  511. this.password,
  512. err => {
  513. expect(err).to.exist
  514. expect(err.name).to.equal('InvalidPasswordError')
  515. done()
  516. }
  517. )
  518. })
  519. })
  520. describe('contains first part of email', function () {
  521. beforeEach(function () {
  522. this.password = `some${this.user.email.split('@')[0]}password`
  523. })
  524. it('should reject the password', function (done) {
  525. this.AuthenticationManager.setUserPassword(
  526. this.user,
  527. this.password,
  528. err => {
  529. expect(err).to.exist
  530. expect(err.name).to.equal('InvalidPasswordError')
  531. done()
  532. }
  533. )
  534. })
  535. })
  536. describe('too short', function () {
  537. beforeEach(function () {
  538. this.settings.passwordStrengthOptions = {
  539. length: {
  540. max: 10,
  541. min: 6,
  542. },
  543. }
  544. this.password = 'dsd'
  545. })
  546. it('should return and error', function (done) {
  547. this.AuthenticationManager.setUserPassword(
  548. this.user,
  549. this.password,
  550. err => {
  551. expect(err).to.exist
  552. done()
  553. }
  554. )
  555. })
  556. it('should not start the bcrypt process', function (done) {
  557. this.AuthenticationManager.setUserPassword(
  558. this.user,
  559. this.password,
  560. () => {
  561. this.bcrypt.genSalt.called.should.equal(false)
  562. this.bcrypt.hash.called.should.equal(false)
  563. done()
  564. }
  565. )
  566. })
  567. })
  568. describe('successful password set attempt', function () {
  569. beforeEach(function () {
  570. this.UserGetter.getUser = sinon.stub().yields(null, { overleaf: null })
  571. this.AuthenticationManager.setUserPassword(
  572. this.user,
  573. this.password,
  574. this.callback
  575. )
  576. })
  577. it("should update the user's password in the database", function () {
  578. const { args } = this.db.users.updateOne.lastCall
  579. expect(args[0]).to.deep.equal({
  580. _id: ObjectId(this.user_id.toString()),
  581. })
  582. expect(args[1]).to.deep.equal({
  583. $set: {
  584. hashedPassword: this.hashedPassword,
  585. },
  586. $unset: {
  587. password: true,
  588. },
  589. })
  590. })
  591. it('should hash the password', function () {
  592. this.bcrypt.genSalt.calledWith(4).should.equal(true)
  593. this.bcrypt.hash.calledWith(this.password, this.salt).should.equal(true)
  594. })
  595. it('should call the callback', function () {
  596. this.callback.called.should.equal(true)
  597. })
  598. })
  599. })
  600. })