diff --git a/package-lock.json b/package-lock.json index fee34df..6a6f5d6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4,6 +4,12 @@ "lockfileVersion": 1, "requires": true, "dependencies": { + "@types/jest": { + "version": "23.3.1", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-23.3.1.tgz", + "integrity": "sha512-/UMY+2GkOZ27Vrc51pqC5J8SPd39FKt7kkoGAtWJ8s4msj0b15KehDWIiJpWY3/7tLxBQLLzJhIBhnEsXdzpgw==", + "dev": true + }, "@types/node": { "version": "10.5.3", "resolved": "https://registry.npmjs.org/@types/node/-/node-10.5.3.tgz", @@ -1763,6 +1769,11 @@ "resolved": "https://registry.npmjs.org/chardet/-/chardet-0.4.2.tgz", "integrity": "sha1-tUc7M9yXxCTl2Y3IfVXU2KKci/I=" }, + "charenc": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz", + "integrity": "sha1-wKHS86cJLgN3S/qD8UwPxXkKhmc=" + }, "cheerio": { "version": "1.0.0-rc.2", "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.0.0-rc.2.tgz", @@ -2266,6 +2277,11 @@ "which": "1.3.1" } }, + "crypt": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz", + "integrity": "sha1-iNf/fsDfuG9xPch7u0LQRNPmxBs=" + }, "crypto-browserify": { "version": "3.12.0", "resolved": "https://registry.npmjs.org/crypto-browserify/-/crypto-browserify-3.12.0.tgz", @@ -5912,6 +5928,17 @@ "jest-util": "20.0.3" } }, + "jest-fetch-mock": { + "version": "1.6.5", + "resolved": "https://registry.npmjs.org/jest-fetch-mock/-/jest-fetch-mock-1.6.5.tgz", + "integrity": "sha512-qPz5Zf8+W16pu6cvdwXkb2SwRfxGoQbbGB6HcIBFND0gnWKMfQilZew3PSODnOWQZF/pzBPi7ZIT6Yz5D0va1Q==", + "dev": true, + "requires": { + "@types/jest": "23.3.1", + "isomorphic-fetch": "2.2.1", + "promise-polyfill": "7.1.2" + } + }, "jest-haste-map": { "version": "20.0.5", "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-20.0.5.tgz", @@ -8773,6 +8800,12 @@ "asap": "2.0.6" } }, + "promise-polyfill": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/promise-polyfill/-/promise-polyfill-7.1.2.tgz", + "integrity": "sha512-FuEc12/eKqqoRYIGBrUptCBRhobL19PS2U31vMNTfyck1FxPyMfgsXyW4Mav85y/ZN1hop3hOwRlUDok23oYfQ==", + "dev": true + }, "prop-types": { "version": "15.6.2", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.6.2.tgz", @@ -9751,6 +9784,15 @@ "safe-buffer": "5.1.2" } }, + "sha1": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/sha1/-/sha1-1.1.1.tgz", + "integrity": "sha1-rdqnqTFo85PxnrKxUJFhjicA+Eg=", + "requires": { + "charenc": "0.0.2", + "crypt": "0.0.2" + } + }, "shebang-command": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz", diff --git a/package.json b/package.json index 167877d..e96bfba 100644 --- a/package.json +++ b/package.json @@ -15,12 +15,16 @@ "react": "^16.4.1", "react-dom": "^16.4.1", "react-scripts": "1.1.4", - "react-test-renderer": "^16.4.1" + "react-test-renderer": "^16.4.1", + "sha1": "^1.1.1" }, "scripts": { "start": "react-scripts start", "build": "react-scripts build", "test": "react-scripts test --env=jsdom", "eject": "react-scripts eject" + }, + "devDependencies": { + "jest-fetch-mock": "^1.6.5" } } diff --git a/src/Pwnedpasswords.api.js b/src/Pwnedpasswords.api.js new file mode 100644 index 0000000..3934729 --- /dev/null +++ b/src/Pwnedpasswords.api.js @@ -0,0 +1,21 @@ +const sha1 = require('sha1'); + +/** + * Check given password agains pwnedpasswords API. + * @param password + * @returns + * @link https://haveibeenpwned.com/API/v2#SearchingPwnedPasswordsByRange + */ +export const isPasswordPwned = (password) => { + + // Send the first 5 digits of the hash to API + const hash = sha1(password); + const prefix = hash.substring(0,5); // First 5 characters of SHA-1 hash + const suffix = hash.substring(5); // Remaining characters of SHA-1 hash + + return fetch('https://api.pwnedpasswords.com/range/' + prefix) + .then(response => response.text()) // Response is plain text - 1 result per line + .then(response => response.split(/\r?\n/)) // Make an array of results + .then(lines => lines.map(line => line.split(':')[0].toLowerCase())) // Hash suffix is string up to colon + .then(lines => lines.includes(suffix.toLowerCase())); +}; diff --git a/src/Pwnedpasswords.api.test.js b/src/Pwnedpasswords.api.test.js new file mode 100644 index 0000000..ab30ca6 --- /dev/null +++ b/src/Pwnedpasswords.api.test.js @@ -0,0 +1,42 @@ +import {isPasswordPwned} from "./Pwnedpasswords.api"; + +describe('isPasswordPwned', () => { + + const password = 'password'; + const uniquePassword = 'aPasswordNotInThePwnedList'; + + /** + * First 5 characters of SHA-1 hash of password + * @type {string} + */ + const expectedHashPrefix = '5baa6'; + + /** + * Partial response from API + * https://api.pwnedpasswords.com/range/5baa6 + * @type {string} + */ + const apiResponse = '1E2AAA439972480CEC7F16C795BBB429372:1\n' + + '1E3687A61BFCE35F69B7408158101C8E414:1\n' + + '1E4C9B93F3F0682250B6CF8331B7EE68FD8:3533661'; // This hash corresponds to 'password' + + beforeEach(() => { + fetch.resetMocks(); + fetch.mockResponseOnce(apiResponse); + }); + + it('requests pwned passwords by partial hash', () => { + isPasswordPwned(password); + expect(fetch.mock.calls[0][0]).toBe('https://api.pwnedpasswords.com/range/' + expectedHashPrefix); + }); + + it('returns true when password is pwned', async () => { + const result = await isPasswordPwned(password); + expect(result).toBe(true); + }); + + it('returns false when password is not pwned', async () => { + const result = await isPasswordPwned(uniquePassword); + expect(result).toBe(false); + }); +}); \ No newline at end of file diff --git a/src/setupTests.js b/src/setupTests.js index 7a1fee7..3652ec8 100644 --- a/src/setupTests.js +++ b/src/setupTests.js @@ -1,4 +1,6 @@ import { configure } from 'enzyme'; import Adapter from 'enzyme-adapter-react-16'; -configure({ adapter: new Adapter() }); \ No newline at end of file +configure({ adapter: new Adapter() }); + +global.fetch = require('jest-fetch-mock') \ No newline at end of file