feat(recovery): add recovery code api (#273), r=@philbooth

This commit is contained in:
Vijay Budhram 2018-03-27 14:51:04 +00:00 коммит произвёл GitHub
Родитель 216547f921
Коммит 582615a85b
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
6 изменённых файлов: 199 добавлений и 3 удалений

Просмотреть файл

@ -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.
*

Просмотреть файл

@ -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',

104
tests/lib/recoveryCodes.js Normal file
Просмотреть файл

@ -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');
});
});
});
}
});

Просмотреть файл

@ -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) {

Просмотреть файл

@ -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);
});

Просмотреть файл

@ -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"]}'
}
};
});