From 582615a85b384f5e394b69d12d367faf97227dfa Mon Sep 17 00:00:00 2001 From: Vijay Budhram Date: Tue, 27 Mar 2018 14:51:04 +0000 Subject: [PATCH] feat(recovery): add recovery code api (#273), r=@philbooth --- client/FxAccountClient.js | 58 +++++++++++++++++++++ tests/all.js | 1 + tests/lib/recoveryCodes.js | 104 +++++++++++++++++++++++++++++++++++++ tests/lib/tokenCodes.js | 14 ++++- tests/lib/totp.js | 5 +- tests/mocks/request.js | 20 +++++++ 6 files changed, 199 insertions(+), 3 deletions(-) create mode 100644 tests/lib/recoveryCodes.js diff --git a/client/FxAccountClient.js b/client/FxAccountClient.js index 1fe0496..a56a7bc 100644 --- a/client/FxAccountClient.js +++ b/client/FxAccountClient.js @@ -1818,6 +1818,64 @@ define([ }); }; + /** + * Replace user's recovery codes. + * + * @method replaceRecoveryCodes + * @param {String} sessionToken SessionToken obtained from signIn + */ + FxAccountClient.prototype.replaceRecoveryCodes = function (sessionToken) { + var request = this.request; + return Promise.resolve() + .then(function () { + required(sessionToken, 'sessionToken'); + + return hawkCredentials(sessionToken, 'sessionToken', HKDF_SIZE); + }) + .then(function (creds) { + + return request.send('/recoveryCodes', 'GET', creds); + }); + }; + + /** + * Consume recovery code. + * + * @method consumeRecoveryCode + * @param {String} sessionToken SessionToken obtained from signIn + * @param {String} code recovery code + * @param {Object} [options.metricsContext={}] Metrics context metadata + * @param {String} options.metricsContext.deviceId identifier for the current device + * @param {String} options.metricsContext.flowId identifier for the current event flow + * @param {Number} options.metricsContext.flowBeginTime flow.begin event time + * @param {Number} options.metricsContext.utmCampaign marketing campaign identifier + * @param {Number} options.metricsContext.utmContent content identifier + * @param {Number} options.metricsContext.utmMedium acquisition medium + * @param {Number} options.metricsContext.utmSource traffic source + * @param {Number} options.metricsContext.utmTerm search terms + */ + FxAccountClient.prototype.consumeRecoveryCode = function (sessionToken, code, options) { + var request = this.request; + return Promise.resolve() + .then(function () { + required(sessionToken, 'sessionToken'); + required(code, 'code'); + + return hawkCredentials(sessionToken, 'sessionToken', HKDF_SIZE); + }) + .then(function (creds) { + var data = { + code: code + }; + + if (options && options.metricsContext) { + data.metricsContext = metricsContext.marshall(options.metricsContext); + } + + return request.send('/session/verify/recoveryCode', 'POST', creds, data); + }); + }; + /** * Check for a required argument. Exposed for unit testing. * diff --git a/tests/all.js b/tests/all.js index 041b119..1243c4c 100644 --- a/tests/all.js +++ b/tests/all.js @@ -16,6 +16,7 @@ define([ 'tests/lib/metricsContext', 'tests/lib/misc', 'tests/lib/passwordChange', + 'tests/lib/recoveryCodes', 'tests/lib/recoveryEmail', 'tests/lib/request', 'tests/lib/session', diff --git a/tests/lib/recoveryCodes.js b/tests/lib/recoveryCodes.js new file mode 100644 index 0000000..ff1e66d --- /dev/null +++ b/tests/lib/recoveryCodes.js @@ -0,0 +1,104 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +define([ + 'intern!tdd', + 'intern/chai!assert', + 'tests/addons/environment', + 'tests/addons/sinon', + 'node_modules/otplib/otplib-browser' +], function (tdd, assert, Environment, sinon, otplib) { + + with (tdd) { + suite('recovery codes', function () { + var account; + var accountHelper; + var respond; + var client; + var RequestMocks; + var env; + var xhr; + var xhrOpen; + var xhrSend; + var recoveryCodes; + var metricsContext; + + beforeEach(function () { + env = new Environment(); + accountHelper = env.accountHelper; + respond = env.respond; + client = env.client; + RequestMocks = env.RequestMocks; + metricsContext = { + flowBeginTime: Date.now(), + flowId: '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef' + }; + + return accountHelper.newVerifiedAccount() + .then(function (newAccount) { + account = newAccount; + return respond(client.createTotpToken(account.signIn.sessionToken), RequestMocks.createTotpToken); + }) + .then(function (res) { + assert.ok(res.qrCodeUrl, 'should return QR code data encoded url'); + assert.ok(res.secret, 'should return secret that is encoded in url'); + + var authenticator = new otplib.authenticator.Authenticator(); + authenticator.options = otplib.authenticator.options; + + var code = authenticator.generate(res.secret); + return respond(client.verifyTotpCode(account.signIn.sessionToken, code), RequestMocks.verifyTotpCodeTrueEnableToken); + }) + .then(function (res) { + assert.equal(res.recoveryCodes.length, 8, 'should return recovery codes'); + recoveryCodes = res.recoveryCodes; + + xhr = env.xhr; + xhrOpen = sinon.spy(xhr.prototype, 'open'); + xhrSend = sinon.spy(xhr.prototype, 'send'); + }); + }); + + afterEach(function () { + xhrOpen.restore(); + xhrSend.restore(); + }); + + test('#consumeRecoveryCode - fails for invalid code', function () { + return respond(client.consumeRecoveryCode(account.signIn.sessionToken, '00000000'), RequestMocks.consumeRecoveryCodeInvalidCode) + .then(assert.fail, function (err) { + assert.equal(xhrOpen.args[0][0], 'POST', 'method is correct'); + assert.include(xhrOpen.args[0][1], '/session/verify/recoveryCode', 'path is correct'); + assert.equal(err.errno, 156, 'invalid recovery code errno'); + }); + }); + + test('#consumeRecoveryCode - consumes valid code', function () { + var code = recoveryCodes[0]; + return respond(client.consumeRecoveryCode(account.signIn.sessionToken, code, {metricsContext: metricsContext}), RequestMocks.consumeRecoveryCodeSuccess) + .then(function (res) { + assert.equal(xhrOpen.args[0][0], 'POST', 'method is correct'); + assert.include(xhrOpen.args[0][1], '/session/verify/recoveryCode', 'path is correct'); + var sentData = JSON.parse(xhrSend.args[0][0]); + assert.equal(Object.keys(sentData).length, 2); + assert.equal(sentData.code, code, 'code is correct'); + assert.deepEqual(sentData.metricsContext, metricsContext, 'metricsContext is correct'); + + assert.equal(res.remaining, 7, 'correct remaining recovery codes'); + }); + }); + + test('#replaceRecoveryCodes - replaces current recovery codes', function () { + return respond(client.replaceRecoveryCodes(account.signIn.sessionToken), RequestMocks.replaceRecoveryCodesSuccessNew) + .then(function (res) { + assert.equal(xhrOpen.args[0][0], 'GET', 'method is correct'); + assert.include(xhrOpen.args[0][1], '/recoveryCodes', 'path is correct'); + + assert.equal(res.recoveryCodes.length, 8, 'should return recovery codes'); + assert.notDeepEqual(res.recoveryCodes, recoveryCodes, 'should not be the same codes'); + }); + }); + }); + } +}); diff --git a/tests/lib/tokenCodes.js b/tests/lib/tokenCodes.js index 4869d36..202dc8a 100644 --- a/tests/lib/tokenCodes.js +++ b/tests/lib/tokenCodes.js @@ -35,7 +35,19 @@ define([ // This test is intended to run against a local auth-server. To test // against a mock auth-server would be pointless for this assertion. test('verify session with invalid tokenCode', function () { - return client.verifyTokenCode(account.signIn.sessionToken, account.signIn.uid, 'INVALIDCODE') + var opts = {verificationMethod: 'email-2fa'}; + return respond(client.signIn(account.input.email, account.input.password, opts), RequestMocks.signInWithVerificationMethodEmail2faResponse) + .then(function (res) { + assert.equal(res.verificationMethod, 'email-2fa', 'should return correct verificationMethod'); + assert.equal(res.verificationReason, 'login', 'should return correct verificationReason'); + return respond(mail.wait(account.input.user, 3), RequestMocks.signInWithVerificationMethodEmail2faCode); + }) + .then(function (emails) { + // should contain token code + var code = emails[2].headers['x-signin-verify-code']; + code = code === '00000000' ? '00000001' : '00000000'; + return client.verifyTokenCode(account.signIn.sessionToken, account.signIn.uid, code); + }) .then(function () { assert.fail('should reject if tokenCode is invalid'); }, function (err) { diff --git a/tests/lib/totp.js b/tests/lib/totp.js index 8f40fb1..e974a8f 100644 --- a/tests/lib/totp.js +++ b/tests/lib/totp.js @@ -130,13 +130,14 @@ define([ }); test('#verifyTotpCode - fails for invalid code', function () { - return respond(client.verifyTotpCode(account.signIn.sessionToken, 'wrongCode'), RequestMocks.verifyTotpCodeFalse) + var code = authenticator.generate(secret) === '000000' ? '000001' : '000000'; + return respond(client.verifyTotpCode(account.signIn.sessionToken, code), RequestMocks.verifyTotpCodeFalse) .then(function (res) { assert.equal(xhrOpen.args[0][0], 'POST', 'method is correct'); assert.include(xhrOpen.args[0][1], '/session/verify/totp', 'path is correct'); var sentData = JSON.parse(xhrSend.args[0][0]); assert.equal(Object.keys(sentData).length, 1); - assert.equal(sentData.code, 'wrongCode', 'code is correct'); + assert.equal(sentData.code, code, 'code is correct'); assert.equal(res.success, false); }); diff --git a/tests/mocks/request.js b/tests/mocks/request.js index cf28d64..a7b2b10 100644 --- a/tests/mocks/request.js +++ b/tests/mocks/request.js @@ -369,6 +369,10 @@ define([ status: 200, body: '{"exists": true}' }, + verifyTotpCodeTrueEnableToken: { + status: 200, + body: '{"success": true, "recoveryCodes": ["01001112", "01001113", "01001114", "01001115", "01001116", "01001117", "01001118", "01001119"]}' + }, verifyTotpCodeTrue: { status: 200, body: '{"success": true}' @@ -376,6 +380,22 @@ define([ verifyTotpCodeFalse: { status: 200, body: '{"success": false}' + }, + consumeRecoveryCodeInvalidCode: { + status: 400, + body: '{"errno": 156}' + }, + consumeRecoveryCodeSuccess: { + status: 200, + body: '{"remaining": 7}' + }, + replaceRecoveryCodesSuccess: { + status: 200, + body: '{"recoveryCodes": ["01001112", "01001113", "01001114", "01001115", "01001116", "01001117", "01001118", "01001119"]}' + }, + replaceRecoveryCodesSuccessNew: { + status: 200, + body: '{"recoveryCodes": ["99999999", "01001113", "01001114", "01001115", "01001116", "01001117", "01001118", "01001119"]}' } }; });