feat(recovery): add recovery code api (#273), r=@philbooth
This commit is contained in:
Родитель
216547f921
Коммит
582615a85b
|
@ -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',
|
||||
|
|
|
@ -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"]}'
|
||||
}
|
||||
};
|
||||
});
|
||||
|
|
Загрузка…
Ссылка в новой задаче