diff --git a/.eslintrc.json b/.eslintrc.json index fdaf608..d8b4f9f 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -1,25 +1,17 @@ { - "env": { - "es6": true, - "node": true - }, - "extends": "eslint:recommended", - "rules": { - "indent": [ - "error", - "tab" - ], - "linebreak-style": [ - "error", - "unix" - ], - "quotes": [ - "error", - "double" - ], - "semi": [ - "error", - "always" - ] - } -} \ No newline at end of file + "env": { + "es6": true, + "node": true, + "mocha": true + }, + "extends": "eslint:recommended", + "rules": { + "indent": ["error", "tab"], + "linebreak-style": ["error", "unix"], + "quotes": ["error", "double"], + "semi": ["error", "always"] + }, + "parserOptions": { + "ecmaVersion": 8 + } +} diff --git a/.travis.yml b/.travis.yml index a63de4e..138f926 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,7 +1,7 @@ language: node_js node_js: - - "4" - - "node" + - '10' + - 'node' install: - npm install script: diff --git a/index.js b/index.js index d9e0e3a..feba782 100644 --- a/index.js +++ b/index.js @@ -1,9 +1,3 @@ -var EasyNoPassword = require("./lib/token_handler"); -var EasyStrategy = require("./lib/strategy"); +let enp = require('./lib/handler') -function construct(secret, maxTokenAge) { - return new EasyNoPassword(secret, maxTokenAge); -} -construct.Strategy = EasyStrategy; - -module.exports = construct; +module.exports = enp diff --git a/lib/base62.js b/lib/base62.js index 83b1230..c6d523b 100644 --- a/lib/base62.js +++ b/lib/base62.js @@ -9,46 +9,52 @@ const UINT32_CAPACITY = new UInt64(0, 1); /** * Encodes an 8-byte buffer as base 62. + * @param {Buffer} buffer - buffer must be length 8 */ -function encode(buf) { - if (buf.length != 8) { - throw new TypeError("Buffer must be length 8"); +const encode = (buffer) => { + if (buffer.length != 8) { + throw new TypeError("buffer must be length 8"); } - var num = new UInt64(buf.readUInt32BE(4), buf.readUInt32BE(0)); - var str = ""; + let number = new UInt64(buffer.readUInt32BE(4), buffer.readUInt32BE(0)); + + let encoded = ""; do { - num.div(SIXTY_TWO); - str = CHARS[num.remainder] + str; - } while (num.greaterThan(ZERO)); + number.div(SIXTY_TWO); + encoded = CHARS[number.remainder] + encoded; + } while (number.greaterThan(ZERO)); - return str; -} + return encoded; +}; -/* +/** * Decodes a base 62 string into an 8-byte buffer. + * @param {String} string - Number encoded must be length 12 digits al least */ -function decode(str) { +const decode = (string) => { // FIXME: Check for multiplication overflow - if (str.length > 12) { + if (string.length > 12) { throw new TypeError("Number encoded is too large"); } - var num = new UInt64(); - for (let i=0; i { + if (!username) throw new Error("An identifier for the user is required."); + if (typeof username !== "string") + throw new Error("The first argument must be a username string."); + + let encrypted = await encrypt(username, secret); + return encrypted; +}; + +/** + * Parse a no-password auth token and check if is expiry + * + * @param {string} token A token created by {@link #create}. + * @param {string} username The identifier used when creating the token + * @param {string} secret A secret string to encrypt then decrypt no-password auth token + * @return `boolean` which is true if the token is valid, or false if the token is invalid. + */ +const validate = async (token, username, secret, expiry = 900000) => { + if (!username) throw new Error("username is required."); + if (typeof username !== "string") + throw new Error("username must be a string."); + if (!token) throw new Error("token required."); + if (typeof token !== "string") throw new Error("token must be a string."); + + let timestamp = await decrypt(token, username, secret); + + let currentTimestamp = new Date().getTime(); + + if (currentTimestamp - timestamp < expiry && currentTimestamp - timestamp > 0) + return true; + else return false; +}; + +/** + * Create Key to use with cipher and decipher + * @param {string} username An identifier for this user, which could be their username, user ID, email address, and so forth. This is required to ensure that tokens are unique to a particular user. + * @param {string} secret A secret string to encrypt then decrypt no-password auth token + * @param {number} iterations + */ +const createKey = (username, secret, iterations = 1000) => { + let usernameBuffer = new Buffer.from(username, "utf-8"); + let key = crypto.pbkdf2Sync(usernameBuffer, secret, iterations, 16, "sha512"); + return key; +}; + +/** + * Encrypt username with secret + * + * @param {string} username An identifier for this user, which could be their username, user ID, email address, and so forth. This is required to ensure that tokens are unique to a particular user. + * @param {secret} secret A secret string to encrypt then decrypt no-password auth token + * @returns {string} alphanumeric no-password auth token + */ +const encrypt = async (username, secret) => { + let key = createKey(username, secret); + + // timestamp -> buf + let timestamp = new Date().getTime(); + let buffer = new Buffer.alloc(8); + buffer.writeUInt32BE(timestamp / UINT32_CAPACITY, 0, true); + buffer.writeUInt32BE(timestamp % UINT32_CAPACITY, 4, true); + + let cipher = createCipher(key); + let encrypted = cipher.update(buffer); + assert.strictEqual(0, cipher.final().length); + + // encrypted -> token + let token = base62.encode(encrypted); + return token; +}; + +/** + * Decrypt username with secret + * + * @param {string} token The no-password auth token to decrypt + * @param {string} username An identifier for this user, which could be their username, user ID, email address, and so forth. This is required to ensure that tokens are unique to a particular user. + * @param {secret} secret A secret used to encrypt the no-password auth token + * @returns {string} alphanumeric no-password auth token + */ +const decrypt = (token, username, secret) => { + let key = createKey(username, secret); + // token -> encrypted + // This can fail if an invalid token is passed. Fail silently and return timestamp zero. + let encrypted; + try { + encrypted = base62.decode(token); + } catch (error) { + throw error; + } + + let decipher = createDecipher(key); + let buffer = decipher.update(encrypted); + assert.strictEqual(0, decipher.final().length); + + // buf -> timestamp + let timestamp = 0; + timestamp += buffer.readUInt32BE(0) * UINT32_CAPACITY; // note: timestamps are small enough that there is no floating-point overflow happening here + timestamp += buffer.readUInt32BE(4); + + return timestamp; +}; + +/** + * + * Cipher and Decipher + * + */ + +const createCipher = (key) => { + // Use a zero vector as the default IV. It isn't necessary to enforce distinct ciphertexts. + let iv = new Buffer.alloc(8); + iv.fill(0); + // buf -> encrypted + let cipher = crypto.createCipheriv("bf", key, iv); + cipher.setAutoPadding(false); + return cipher; +}; + +const createDecipher = (key) => { + // Use a zero vector as the default IV. It isn't necessary to enforce distinct ciphertexts. + let iv = new Buffer.alloc(8); + iv.fill(0); + // encrypted -> buf + let decipher = crypto.createDecipheriv("bf", key, iv); + decipher.setAutoPadding(false); + return decipher; +}; + +module.exports = { + create, + validate, +}; diff --git a/lib/strategy.js b/lib/strategy.js deleted file mode 100644 index 54e4427..0000000 --- a/lib/strategy.js +++ /dev/null @@ -1,66 +0,0 @@ -"use strict"; - -const PassportStrategy = require("passport-strategy"); -const EasyNoPassword = require("./token_handler"); - -class EasyStrategy extends PassportStrategy { - constructor(options, parseRequest, sendToken, verify) { - if (!options.secret) throw new Error("Easy No Password authentication strategy requires an encryption secret"); - if (!parseRequest) throw new Error("Easy No Password authentication strategy requires a parseRequest function"); - if (!sendToken) throw new Error("Easy No Password authentication strategy requires a sendToken function"); - if (!verify) throw new Error("Easy No Password authentication strategy requires a verify function"); - super(); - - this.name = "easy"; - this._parseRequest = parseRequest; - this._sendToken = sendToken; - this._verify = verify; - this._passReqToCallback = options.passReqToCallback; - - this.enp = new EasyNoPassword(options.secret, options.maxTokenAge); - } - - authenticate(req /*, options */) { - var self = this; - var data = self._parseRequest(req); - if (data === null) return self.pass(); - - if (data.stage === 2) { - let username = data.username; - let token = data.token; - - self.enp.isValid(token, username, (err, isValid) => { - if (isValid) { - let verified = function(err, user, info) { - if (err) return self.error(err); - if (!user) return self.fail(info); - self.success(user, info); - }; - - if (self._passReqToCallback) { - self._verify(req, username, verified); - } else { - self._verify(username, verified); - } - } else { - self.fail({ "message": "invalid token" }); - } - }); - } else if (data.stage === 1) { - let username = data.username; - - self.enp.createToken(username, (err, token) => { - if (err) return self.error(err); - self._sendToken(username, token, (err) => { - if (err) return self.error(err); - self.pass(); - }); - }); - - } else { - self.pass(); - } - } -} - -module.exports = EasyStrategy; diff --git a/lib/token_handler.js b/lib/token_handler.js deleted file mode 100644 index 5e55ad4..0000000 --- a/lib/token_handler.js +++ /dev/null @@ -1,157 +0,0 @@ -"use strict"; - -const assert = require("assert"); -const async = require("async"); -const base62 = require("./base62"); -const crypto = require("crypto"); - -const UINT32_CAPACITY = 0xffffffff + 1; - -class EasyNoPassword { - /** - * Create a new token handler instance for easy-no-password. - * - * @param secret A secret string to be used as a salt when creating tokens. Anyone with access to this secret will be able to create a valid token for any user! Should be long enough to prevent brute-force attacks. - * @param maxTokenAge The maximum number of milliseconds for which a token is considered valid. Defaults to 5 minutes (900000 milliseconds). - */ - constructor(secret, maxTokenAge) { - if (!secret) { - throw new Error("You must provide a secret!"); - } - - this.secret = new Buffer(secret); - this.maxTokenAge = maxTokenAge || 900000; // 15 minutes - this.iterations = 1000; - - // Use a zero vector as the default IV. It isn't necessary to enforce distinct ciphertexts. - this.iv = new Buffer(8); - this.iv.fill(0); - } - - /** - * Create a no-password auth token. Can be emailed or texted to the user. - * - * @param username An identifier for this user, which could be their username, user ID, email address, and so forth. This is required to ensure that tokens are unique to a particular user. - * @param next(err,token) A callback function that will be called with the token. The first argument will be a possible error. If there is an error, the second argument is undefined. - */ - createToken(username, next) { - if (!username) { - throw new Error("An identifier for the user is required."); - } - if (typeof username !== "string") { - throw new Error("The first argument must be a username string."); - } - - var timestamp = new Date().getTime(); - this._encrypt(timestamp, username, next); - } - - /** - * Parse a no-password auth token and ensure that it was created no more than the number of milliseconds in the past specified in the constructor. - * - * @param token A token, probably created by {@link #createToken}. - * @param username The identifier used when creating the token. - * @param next(err,isValid) A callback function that will be called with a boolean, which is true if the token is valid, or false if the token is invalid. If an error occurs, the second parameter will be false. The error is never propagated out of this function. - */ - isValid(token, username, next) { - if (!username) { - throw new Error("An identifier for the user is required."); - } - if (typeof token !== "string") { - throw new Error("The first argument must be a token string."); - } - if (typeof username !== "string") { - throw new Error("The first argument must be a username string."); - } - - async.waterfall([ - (_next) => { - this._decrypt(token, username, _next); - }, - (timestamp, _next) => { - var currentTimestamp = new Date().getTime(); - // Check to make sure the timestamp is within the acceptable window - var isValid = (currentTimestamp - timestamp < this.maxTokenAge) && (currentTimestamp - timestamp > 0); - return _next(null, isValid); - } - ], next); - } - - /** - * Encrypts a timestamp into a token string. Uses Blowfish, which has a block size of 64 bits, appropriate for a timestamp. - */ - _encrypt(timestamp, username, next) { - async.waterfall([ - (_next) => { - this._createKey(username, _next); - }, - (key, _next) => { - // timestamp -> buf - var buf = new Buffer(8); - buf.writeUInt32BE(timestamp / UINT32_CAPACITY, 0, true); - buf.writeUInt32BE(timestamp % UINT32_CAPACITY, 4, true); - - // buf -> encrypted - var cipher = crypto.createCipheriv("bf", key, this.iv); - cipher.setAutoPadding(false); - var encrypted = cipher.update(buf); - assert.equal(0, cipher.final().length); - - // encrypted -> token - var token = base62.encode(encrypted); - - _next(null, token); - } - ], next); - } - - /** - * Decrypts a token string into a timestamp. - */ - _decrypt(token, username, next) { - async.waterfall([ - (_next) => { - this._createKey(username, _next); - }, - (key, _next) => { - // token -> encrypted - // This can fail if an invalid token is passed. Fail silently and return timestamp zero. - var encrypted; - try { - encrypted = base62.decode(token); - } catch (err) { - return _next(null, 0); - } - - // encrypted -> buf - var decipher = crypto.createDecipheriv("bf", key, this.iv); - decipher.setAutoPadding(false); - var buf = decipher.update(encrypted); - assert.equal(0, decipher.final().length); - - // buf -> timestamp - var timestamp = 0; - timestamp += buf.readUInt32BE(0) * UINT32_CAPACITY; // note: timestamps are small enough that there is no floating-point overflow happening here - timestamp += buf.readUInt32BE(4); - - _next(null, timestamp); - } - ], next); - } - - /** - * Creates an encryption key from the secret specified in the constructor and the provided username. - */ - _createKey(username, next) { - var usernameBuffer = new Buffer(username, "utf-8"); - if (crypto.pbkdf2.length === 5) { - // Node.JS v0.10 - crypto.pbkdf2(usernameBuffer, this.secret, this.iterations, 16, next); - } else { - // Node.JS v0.12+ - crypto.pbkdf2(usernameBuffer, this.secret, this.iterations, 16, "sha512", next); - } - } -} - -module.exports = EasyNoPassword; diff --git a/package.json b/package.json index 295387a..0aad422 100644 --- a/package.json +++ b/package.json @@ -1,35 +1,35 @@ { - "name": "easy-no-password", - "version": "1.2.0", - "description": "Generates secure, timestamped tokens for passwordless authentication without a database backend.", - "main": "index.js", - "scripts": { - "lint": "eslint .", - "test": "./node_modules/.bin/mocha test" - }, - "keywords": [ - "password", - "passwordless", - "encryption", - "timestamp", - "two", - "factor", - "authentication", - "2FA" - ], - "author": "Shane Carr", - "license": "MIT", - "repository": "sffc/easy-no-password", - "dependencies": { - "async": "^2.0.1", - "cuint": "^0.2.1", - "passport-strategy": "^1.0.0" - }, - "devDependencies": { - "eslint": "^4.19.1", - "mocha": "^5.2.0" - }, - "engines": { - "node" : ">=4" - } + "name": "easy-no-password", + "version": "1.2.0", + "description": "Generates secure, timestamped tokens for passwordless authentication without a database backend.", + "main": "index.js", + "scripts": { + "lint": "eslint .", + "test": "mocha test " + }, + "keywords": [ + "password", + "passwordless", + "encryption", + "timestamp", + "two", + "factor", + "authentication", + "2FA" + ], + "author": "Shane Carr", + "license": "MIT", + "repository": "sffc/easy-no-password", + "dependencies": { + "async": "^2.0.1", + "cuint": "^0.2.1", + "passport-strategy": "^1.0.0" + }, + "devDependencies": { + "eslint": "^4.19.1", + "mocha": "^5.2.0" + }, + "engines": { + "node": ">=4" + } } diff --git a/test/base62.js b/test/base62.js deleted file mode 100644 index 542a6cb..0000000 --- a/test/base62.js +++ /dev/null @@ -1,70 +0,0 @@ -"use strict"; - -/* eslint-env mocha */ - -const assert = require("assert"); -const base62 = require("../lib/base62"); -const crypto = require("crypto"); - -const TEST_CASES = [ - [ new Buffer([0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]), "0" ], - [ new Buffer([0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x05]), "5" ], - [ new Buffer([0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0d]), "d" ], - [ new Buffer([0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10]), "g" ], - [ new Buffer([0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20]), "w" ], - [ new Buffer([0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x24]), "A" ], - [ new Buffer([0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x3d]), "Z" ], - [ new Buffer([0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x3e]), "10" ], - [ new Buffer([0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x3f]), "11" ], - [ new Buffer([0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00]), "4GFfc4" ], - [ new Buffer([0x00, 0x00, 0xdd, 0xdd, 0xdd, 0xdd, 0xdd, 0xdd]), "17gMRISDz" ], - [ new Buffer([0xdd, 0xdd, 0xdd, 0xdd, 0xdd, 0xdd, 0xdd, 0xdd]), "j2ZrDtSWvKd" ], - [ new Buffer([0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff]), "lYGhA16ahyf" ], -]; - -describe("base62.js", () => { - describe("#base62.encode()", () => { - for (let test of TEST_CASES) { - let buf = test[0]; - let str = test[1]; - it(`should return '${str}' upon evaluating buffer '${buf.toString("hex")}'`, () => { - assert.equal(str, base62.encode(buf)); - }); - } - }); - - describe("#base62.decode()", () => { - for (let test of TEST_CASES) { - let buf = test[0]; - let str = test[1]; - it(`should return '${buf.toString("hex")}' upon evaluating string '${str}'`, () => { - assert.equal(buf.toString("hex"), base62.decode(str).toString("hex")); - }); - } - it("should throw an exception for invalid entries", () => { - assert.throws( - () => { - base62.decode("QWE!@#éåñ"); - }, - TypeError - ); - assert.throws( - () => { - base62.decode("veryveryveryverylong"); - }, - TypeError - ); - }); - }); - - describe("round-trip", () => { - it("should produce correct round-trip results 1000 times in a row", () => { - for (let i = 0; i < 1000; i++) { - let buf = crypto.randomBytes(8); - let str = base62.encode(buf); - let decoded = base62.decode(str); - assert.equal(buf.toString("hex"), decoded.toString("hex"), `iteration ${i+1}: '${buf.toString("hex")}' != '${decoded.toString("hex")}' (intermediate: '${str}')`); - } - }); - }); -}); diff --git a/test/base62.test.js b/test/base62.test.js new file mode 100644 index 0000000..14e4b3c --- /dev/null +++ b/test/base62.test.js @@ -0,0 +1,83 @@ +"use strict"; + +/* eslint-env mocha */ + +const assert = require("assert"); +const base62 = require("../lib/base62"); +const crypto = require("crypto"); + +const TEST_CASES = [ + [new Buffer.from([0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]), "0"], + [new Buffer.from([0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x05]), "5"], + [new Buffer.from([0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0d]), "d"], + [new Buffer.from([0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10]), "g"], + [new Buffer.from([0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20]), "w"], + [new Buffer.from([0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x24]), "A"], + [new Buffer.from([0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x3d]), "Z"], + [new Buffer.from([0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x3e]), "10"], + [new Buffer.from([0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x3f]), "11"], + [new Buffer.from([0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00]), "4GFfc4"], + [ + new Buffer.from([0x00, 0x00, 0xdd, 0xdd, 0xdd, 0xdd, 0xdd, 0xdd]), + "17gMRISDz", + ], + [ + new Buffer.from([0xdd, 0xdd, 0xdd, 0xdd, 0xdd, 0xdd, 0xdd, 0xdd]), + "j2ZrDtSWvKd", + ], + [ + new Buffer.from([0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff]), + "lYGhA16ahyf", + ], +]; + +describe("base62.js", () => { + describe("#base62.encode()", () => { + for (let test of TEST_CASES) { + let buf = test[0]; + let str = test[1]; + it(`should return '${str}' upon evaluating buffer '${buf.toString( + "hex" + )}'`, () => { + assert.equal(str, base62.encode(buf)); + }); + } + }); + + describe("#base62.decode()", () => { + for (let test of TEST_CASES) { + let buf = test[0]; + let str = test[1]; + it(`should return '${buf.toString( + "hex" + )}' upon evaluating string '${str}'`, () => { + assert.equal(buf.toString("hex"), base62.decode(str).toString("hex")); + }); + } + it("should throw an exception for invalid entries", () => { + assert.throws(() => { + base62.decode("QWE!@#éåñ"); + }, TypeError); + assert.throws(() => { + base62.decode("veryveryveryverylong"); + }, TypeError); + }); + }); + + describe("round-trip", () => { + it("should produce correct round-trip results 1000 times in a row", () => { + for (let i = 0; i < 1000; i++) { + let buf = crypto.randomBytes(8); + let str = base62.encode(buf); + let decoded = base62.decode(str); + assert.equal( + buf.toString("hex"), + decoded.toString("hex"), + `iteration ${i + 1}: '${buf.toString("hex")}' != '${decoded.toString( + "hex" + )}' (intermediate: '${str}')` + ); + } + }); + }); +}); diff --git a/test/handler.test.js b/test/handler.test.js new file mode 100644 index 0000000..af70748 --- /dev/null +++ b/test/handler.test.js @@ -0,0 +1,59 @@ +const assert = require("assert"); +const enp = require("../lib/handler.js"); + +const TOKEN_CREATION_TEST_CASES = [ + { + secret: "Gf6y0SWRiiiTj5x2", + cases: [ + { username: "bob", token: "46GmWoaOb7J" }, + { username: "bob", token: "dUcEZowiWM3" }, + { username: "bob", token: "j0VGVYxQY6D" }, + { username: "alice", token: "7y9YoDy1uvF" }, + { username: "alice", token: "czbPzOQ9OJF" }, + { username: "日本", token: "lNvpdDMaoRM" }, + { username: "中国", token: "2owiBw1Rira" }, + ], + }, + { + secret: "3cCVBl232kQ3xtw5", + cases: [ + { username: "bob", token: "46GmWoaOb7J" }, + { username: "bob", token: "dUcEZowiWM3" }, + { username: "bob", token: "j0VGVYxQY6D" }, + { username: "alice", token: "7y9YoDy1uvF" }, + { username: "alice", token: "czbPzOQ9OJF" }, + { username: "日本", token: "lNvpdDMaoRM" }, + { username: "中国", token: "2owiBw1Rira" }, + ], + }, +]; + + +describe("enp.create()", () => { + for (let testCases of TOKEN_CREATION_TEST_CASES) { + for (let testCase of testCases.cases) { + let { secret } = testCases; + let { username, token } = testCase; + + it(`should not be the same token created and token: '${token}' `, async () => { + let tokenCreated = await enp.create(username, secret); + return assert.notStrictEqual(tokenCreated, token); + }); + } + } +}); + +describe("enp.validate()", async () => { + for (let testCases of TOKEN_CREATION_TEST_CASES) { + for (let testCase of testCases.cases) { + let { secret } = testCases; + let { username } = testCase; + + it(`should return secret '${secret}', username '${username}' created a valid token `, async () => { + let tokenCreated = await enp.create(username, secret); + let validated = await enp.validate(tokenCreated, username, secret); + return assert.strictEqual(true, validated); + }); + } + } +}); diff --git a/test/token_handler.js b/test/token_handler.js deleted file mode 100644 index 9d020f3..0000000 --- a/test/token_handler.js +++ /dev/null @@ -1,163 +0,0 @@ -"use strict"; - -/* eslint-env mocha */ - -const assert = require("assert"); -const async = require("async"); -const crypto = require("crypto"); -const EasyNoPassword = require("../lib/token_handler"); - -const CURRENT_TIMESTAMP = new Date().getTime(); - -const TOKEN_CREATION_TEST_CASES = [ - // secret, [ username, timestamp, token ] - [ "Gf6y0SWRiiiTj5x2", [ - [ "bob", new Date(Date.UTC(2016, 8, 21, 0, 0, 0, 0)).getTime(), "46GmWoaOb7J" ], - [ "bob", new Date(Date.UTC(2016, 8, 21, 0, 0, 0, 1)).getTime(), "dUcEZowiWM3" ], - [ "bob", new Date(Date.UTC(2050, 1, 2, 3, 4, 5, 0)).getTime(), "j0VGVYxQY6D" ], - [ "alice", new Date(Date.UTC(2016, 8, 21, 0, 0, 0, 0)).getTime(), "7y9YoDy1uvF" ], - [ "alice", new Date(Date.UTC(2050, 1, 2, 3, 4, 5, 0)).getTime(), "czbPzOQ9OJF" ], - [ "日本", new Date(Date.UTC(2017, 0, 1, 0, 0, 0, 0)).getTime(), "lNvpdDMaoRM" ], - [ "中国", new Date(Date.UTC(2017, 0, 1, 0, 0, 0, 0)).getTime(), "2owiBw1Rira" ], - ]], - [ "3cCVBl232kQ3xtw5", [ - [ "bob", new Date(Date.UTC(2016, 8, 21, 0, 0, 0, 0)).getTime(), "lGXE4jFLwtP" ], - [ "bob", new Date(Date.UTC(2016, 8, 21, 0, 0, 0, 1)).getTime(), "9fRORLwpEw5" ], - [ "bob", new Date(Date.UTC(2050, 1, 2, 3, 4, 5, 0)).getTime(), "gTR7AbSJFu6" ], - [ "alice", new Date(Date.UTC(2016, 8, 21, 0, 0, 0, 0)).getTime(), "60in3qeoSrx" ], - [ "alice", new Date(Date.UTC(2050, 1, 2, 3, 4, 5, 0)).getTime(), "4HxIgyibsDA" ], - [ "日本", new Date(Date.UTC(2017, 0, 1, 0, 0, 0, 0)).getTime(), "kVO0qRiRLxw" ], - [ "中国", new Date(Date.UTC(2017, 0, 1, 0, 0, 0, 0)).getTime(), "48vxT2SDEuD" ], - ]], -]; - -const IS_VALID_TEST_CASES = [ - // timestamp offset, isValid - // All test cases are at least 10 seconds (allows for execution time) - [ -1e8, false ], - [ -1e6, false ], - [ -1e5, true ], - [ -1e4, true ], - [ 1e4, false ], - [ 1e8, false ], -]; - -const CHARS = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"; -function randomString(length) { - var str = ""; - for (let i=0; i { - describe("enp#_encrypt()", () => { - for (let test of TOKEN_CREATION_TEST_CASES) { - let secret = test[0]; - let enp = new EasyNoPassword(secret); - for (let line of test[1]) { - let username = line[0]; - let timestamp = line[1]; - let token = line[2]; - - it(`should return '${token}' with secret '${secret}', username '${username}', and timestamp '${timestamp}'`, (done) => { - async.waterfall([ - (_next) => { - enp._encrypt(timestamp, username, _next); - }, - (_token, _next) => { - assert.equal(token, _token); - _next(null); - } - ], done); - }); - } - } - }); - - describe("enp#_decrypt()", () => { - for (let test of TOKEN_CREATION_TEST_CASES) { - let secret = test[0]; - let enp = new EasyNoPassword(secret); - for (let line of test[1]) { - let username = line[0]; - let timestamp = line[1]; - let token = line[2]; - - it(`should return '${timestamp}' with secret '${secret}', username '${username}', and token '${token}'`, (done) => { - async.waterfall([ - (_next) => { - enp._decrypt(token, username, _next); - }, - (_timestamp, _next) => { - assert.equal(timestamp, _timestamp); - _next(null); - } - ], done); - }); - } - } - }); - - describe("enp#isValid", () => { - for (let test of IS_VALID_TEST_CASES) { - let offset = test[0]; - let isValid = test[1]; - it(`should return '${isValid}' 100 times in a row for offset ${offset} with random usernames and secrets`, (done) => { - async.each( - new Array(100), - (_, _next) => { - let secret = crypto.randomBytes(8); - let username = randomString(10); - let timestamp = CURRENT_TIMESTAMP + offset; - let enp = new EasyNoPassword(secret); - async.auto({ - "token": (__next) => { - enp._encrypt(timestamp, username, __next); - }, - "decrypt": ["token", (results, __next) => { - enp._decrypt(results.token, username, __next); - }], - "isValid": ["token", (results, __next) => { - enp.isValid(results.token, username, __next); - }], - "check": ["decrypt", "isValid", (results, __next) => { - assert.equal(results.decrypt, timestamp); - assert.equal(results.isValid, isValid); - __next(null); - }] - }, _next); - }, - done - ); - }); - } - - it("should return 'false' 99% of the time for random keys", (done) => { - async.reduce( - new Array(500), - 0, - (memo, _, _next) => { - let secret = crypto.randomBytes(8); - let username = randomString(10); - let token = randomString(Math.trunc(8 + Math.random() * 6)); - let enp = new EasyNoPassword(secret); - enp.isValid(token, username, (_, isValid) => { - if (isValid) { - // Very rare occurence - console.error("Random token passed as valid:", secret, username, token); // eslint-disable-line no-console - } - _next(null, isValid ? memo + 1 : memo); - }); - }, - (err, successes) => { - if (err) return done(err); - assert(successes <= 1, successes + " successes out of 500 trials"); - done(); - } - ); - }); - }); -}); -