feat(server): try to verify emails on the server (#4794) r=vbudhram

This commit is contained in:
Vlad Filippov 2017-03-21 12:09:34 -04:00 коммит произвёл GitHub
Родитель 5268e3ba79
Коммит 005549a9a7
11 изменённых файлов: 368 добавлений и 18 удалений

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

@ -482,14 +482,22 @@ define(function (require, exports, module) {
* @param {String} code - the verification code * @param {String} code - the verification code
* @param {Object} [options] * @param {Object} [options]
* @param {Object} [options.service] - the service issuing signup request * @param {Object} [options.service] - the service issuing signup request
* @param {String} [options.serverVerificationStatus] - the status of server verification
* @returns {Promise} - resolves when complete * @returns {Promise} - resolves when complete
*/ */
verifySignUp (code, options = {}) { verifySignUp (code, options = {}) {
return p()
.then(() => {
if (options.serverVerificationStatus !== 'verified') {
// if server verification was not present or not successful
// then attempt client verification
return this._fxaClient.verifyCode( return this._fxaClient.verifyCode(
this.get('uid'), this.get('uid'),
code, code,
options options
) );
}
})
.then(() => { .then(() => {
this.set('verified', true); this.set('verified', true);

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

@ -482,6 +482,7 @@ define(function (require, exports, module) {
* @param {String} code - verification code * @param {String} code - verification code
* @param {Object} [options] * @param {Object} [options]
* @param {Object} [options.service] - the service issuing signup request * @param {Object} [options.service] - the service issuing signup request
* @param {String} [options.serverVerificationStatus] - the status of server verification
* @returns {Promise} - resolves with the account when complete * @returns {Promise} - resolves with the account when complete
*/ */
completeAccountSignUp (account, code, options) { completeAccountSignUp (account, code, options) {

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

@ -74,6 +74,7 @@ define(function (require, exports, module) {
const code = verificationInfo.get('code'); const code = verificationInfo.get('code');
const options = { const options = {
reminder: verificationInfo.get('reminder'), reminder: verificationInfo.get('reminder'),
serverVerificationStatus: this.getSearchParam('server_verification') || null,
service: this.relier.get('service') service: this.relier.get('service')
}; };

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

@ -691,6 +691,48 @@ define(function (require, exports, module) {
}); });
describe('verifySignUp', function () { describe('verifySignUp', function () {
describe('with custom server verification value', function () {
beforeEach(function () {
sinon.stub(fxaClient, 'verifyCode', function () {
return p();
});
});
it('does not call verifyCode with verified', function () {
account.set('uid', UID);
return account.verifySignUp('CODE', {
serverVerificationStatus: 'verified'
}).then(() => {
assert.isFalse(fxaClient.verifyCode.called);
assert.isTrue(account.get('verified'));
});
});
it('calls verifyCode with other status', function () {
account.set('uid', UID);
return account.verifySignUp('CODE', {
serverVerificationStatus: 'test'
}).then(() => {
assert.isTrue(fxaClient.verifyCode.called);
assert.isTrue(account.get('verified'));
});
});
it('calls verifyCode with undefined status', function () {
account.set('uid', UID);
return account.verifySignUp('CODE', {
serverVerificationStatus: undefined
}).then(() => {
assert.isTrue(fxaClient.verifyCode.called);
assert.isTrue(account.get('verified'));
});
});
});
describe('without email opt-in', function () { describe('without email opt-in', function () {
beforeEach(function () { beforeEach(function () {
sinon.stub(fxaClient, 'verifyCode', function () { sinon.stub(fxaClient, 'verifyCode', function () {

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

@ -198,7 +198,7 @@ define(function (require, exports, module) {
var args = account.verifySignUp.getCall(0).args; var args = account.verifySignUp.getCall(0).args;
assert.isTrue(account.verifySignUp.called); assert.isTrue(account.verifySignUp.called);
assert.ok(args[0]); assert.ok(args[0]);
assert.deepEqual(args[1], {reminder: null, service: validService}); assert.deepEqual(args[1], {reminder: null, serverVerificationStatus: null, service: validService});
}); });
}); });
@ -217,7 +217,7 @@ define(function (require, exports, module) {
var args = account.verifySignUp.getCall(0).args; var args = account.verifySignUp.getCall(0).args;
assert.isTrue(account.verifySignUp.called); assert.isTrue(account.verifySignUp.called);
assert.ok(args[0]); assert.ok(args[0]);
assert.deepEqual(args[1], {reminder: validReminder, service: null}); assert.deepEqual(args[1], {reminder: validReminder, serverVerificationStatus: null, service: null});
}); });
}); });
@ -237,7 +237,27 @@ define(function (require, exports, module) {
var args = account.verifySignUp.getCall(0).args; var args = account.verifySignUp.getCall(0).args;
assert.isTrue(account.verifySignUp.called); assert.isTrue(account.verifySignUp.called);
assert.ok(args[0]); assert.ok(args[0]);
assert.deepEqual(args[1], {reminder: validReminder, service: validService}); assert.deepEqual(args[1], {reminder: validReminder, serverVerificationStatus: null, service: validService});
});
});
describe('if server_verification is in the url', function () {
beforeEach(function () {
windowMock.location.search = '?code=' + validCode + '&uid=' + validUid +
'&server_verification=verified';
relier = new Relier({}, {
window: windowMock
});
relier.fetch();
initView(account);
return view.render();
});
it('attempt to pass server_verification to verifySignUp', function () {
var args = account.verifySignUp.getCall(0).args;
assert.isTrue(account.verifySignUp.called);
assert.ok(args[0]);
assert.deepEqual(args[1], {reminder: null, serverVerificationStatus: 'verified', service: null});
}); });
}); });

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

@ -29,6 +29,7 @@ module.exports = function (config, i18n) {
redirectVersionedToUnversioned('complete_reset_password'), redirectVersionedToUnversioned('complete_reset_password'),
redirectVersionedToUnversioned('reset_password'), redirectVersionedToUnversioned('reset_password'),
redirectVersionedToUnversioned('verify_email'), redirectVersionedToUnversioned('verify_email'),
require('./routes/get-verify-email')(),
require('./routes/get-frontend')(), require('./routes/get-frontend')(),
require('./routes/get-terms-privacy')(i18n), require('./routes/get-terms-privacy')(i18n),
require('./routes/get-index')(config), require('./routes/get-index')(config),

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

@ -51,8 +51,7 @@ module.exports = function () {
'signup_verified', 'signup_verified',
'sms', 'sms',
'sms/sent', 'sms/sent',
'sms/why', 'sms/why'
'verify_email'
].join('|'); // prepare for use in a RegExp ].join('|'); // prepare for use in a RegExp
return { return {

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

@ -0,0 +1,116 @@
/* 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/. */
const url = require('url');
const got = require('got');
const logger = require('mozlog')('server.get-verify-email');
const config = require('../configuration');
const ravenClient = require('../../lib/raven').ravenMiddleware;
const joi = require('joi');
const validation = require('../validation');
const fxaAccountUrl = config.get('fxaccount_url');
const STRING_TYPE = validation.TYPES.STRING;
const VERIFICATION_ENDPOINT = `${fxaAccountUrl}/v1/recovery_email/verify_code`;
const VERIFICATION_TIMEOUT = 5000;
// Sentry combines both Info and Error into one if same name
const VERIFICATION_LEVEL_ERROR = 'VerificationValidationError';
const VERIFICATION_LEVEL_INFO = 'VerificationValidationInfo';
const REQUIRED_SCHEMA = {
'code': joi.string().hex().min(32).max(32).required(),
'uid': joi.string().hex().min(32).max(32).required()
};
const REPORT_ONLY_SCHEMA = {
'code': STRING_TYPE.alphanum().min(32).max(32).required(),
// resume token can be long, do not use the limited STRING_TYPE
'resume': joi.string().alphanum().optional(),
'service': STRING_TYPE.alphanum().max(100).optional(),
'uid': STRING_TYPE.alphanum().min(32).max(32).required(),
'utm_campaign': STRING_TYPE.alphanum().optional(),
'utm_content': STRING_TYPE.alphanum().optional(),
'utm_medium': STRING_TYPE.alphanum().optional(),
'utm_source': STRING_TYPE.alphanum().optional()
};
module.exports = function () {
return {
method: 'get',
path: '/verify_email',
process: function (req, res, next) {
const rawQuery = url.parse(req.url).query;
// reset the url for the front-end router
req.url = '/';
if (req.query.server_verification) {
return next();
}
const data = {
code: req.query.code,
uid: req.query.uid
};
joi.validate(data, REQUIRED_SCHEMA, (err) => {
if (err) {
ravenClient.captureMessage(VERIFICATION_LEVEL_ERROR, {
extra: {
details: err.details
}
});
// if cannot validate required params then just forward to front-end
return next();
}
if (req.query.service) {
data.service = req.query.service;
}
if (req.query.reminder) {
data.reminder = req.query.reminder;
}
const options = {
body: data,
retries: 0,
timeout: {
connect: VERIFICATION_TIMEOUT,
socket: VERIFICATION_TIMEOUT
}
};
got.post(VERIFICATION_ENDPOINT, options)
.then(() => {
// In some cases the code can only be used once.
// Here we add an extra query param to signal the front-end that verification succeeded.
// See issue #4800
return res.redirect(`/verify_email?${rawQuery}&server_verification=verified`);
})
.catch((err) => {
ravenClient.captureError(err);
logger.error(err);
// failed to verify, continue to front-end
next();
});
});
// Passive validation and error reporting, could be made required in the future.
joi.validate(req.query, REPORT_ONLY_SCHEMA, (err) => {
if (err) {
ravenClient.captureMessage(VERIFICATION_LEVEL_INFO, {
extra: {
details: err.details
},
level: 'info'
});
}
});
}
};
};

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

@ -4,3 +4,4 @@ extends: ../.eslintrc
rules: rules:
id-blacklist: 0 id-blacklist: 0
strict: 0

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

@ -30,6 +30,7 @@ define([
'tests/server/statsd-collector', 'tests/server/statsd-collector',
'tests/server/raven', 'tests/server/raven',
'tests/server/routes/get-config', 'tests/server/routes/get-config',
'tests/server/routes/get-verify-email',
'tests/server/routes/get-fxa-client-configuration', 'tests/server/routes/get-fxa-client-configuration',
'tests/server/routes/get-openid-configuration', 'tests/server/routes/get-openid-configuration',
'tests/server/routes/get-index', 'tests/server/routes/get-index',

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

@ -0,0 +1,160 @@
/* 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/. */
'use strict';
define([
'intern',
'intern!object',
'intern/chai!assert',
'intern/dojo/node!../../../server/lib/configuration',
'intern/dojo/Promise',
'intern/dojo/node!got',
'intern/dojo/node!fs',
'intern/dojo/node!path',
'intern/dojo/node!proxyquire',
'intern/dojo/node!sinon',
], function (intern, registerSuite, assert, config, dojoPromise, got, fs, path, proxyquire, sinon) {
function mockModule(mocks) {
return proxyquire(path.join(process.cwd(), 'server', 'lib', 'routes', 'get-verify-email'), mocks)();
}
let logger;
let mocks;
let ravenMock;
let gotMock;
const res = {
json: () => {}
};
registerSuite({
name: 'verify_email',
beforeEach() {
gotMock = {
post: sinon.spy()
};
logger = {
error: sinon.spy()
};
ravenMock = {
ravenMiddleware: {
captureError: sinon.spy(),
captureMessage: sinon.spy()
}
};
mocks = {
'../../lib/raven': ravenMock,
'got': gotMock,
mozlog: () => {
return logger;
},
};
},
'logs error without query params' () {
const dfd = new dojoPromise.Deferred();
const req = {
query: {
code: '',
uid: ''
},
url: '/verify_email'
};
mockModule(mocks).process(req, res, () => {
var c = ravenMock.ravenMiddleware.captureMessage;
var arg = c.args[0];
assert.equal(c.calledOnce, true);
assert.equal(arg[0], 'VerificationValidationError');
assert.equal(arg[1].extra.details[0].message, '"code" is not allowed to be empty');
dfd.resolve();
});
return dfd.dojoPromise;
},
'no logs if successful' () {
const dfd = new dojoPromise.Deferred();
mocks.got = {
post: (req, res, next) => {
return new Promise((resolve, reject) => {
resolve({
'statusCode': 200,
'statusMessage': 'OK'
});
});
}
};
const req = {
query: {
code: '12345678912345678912345678912312',
uid: '12345678912345678912345678912312'
},
url: '/verify_email'
};
mockModule(mocks).process(req, res, () => {
assert.equal(logger.error.callCount, 0);
assert.equal(ravenMock.ravenMiddleware.captureMessage.callCount, 0);
assert.equal(ravenMock.ravenMiddleware.captureError.callCount, 0);
req.query.something = 'else';
mockModule(mocks).process(req, res, () => {
assert.equal(logger.error.callCount, 0);
assert.equal(ravenMock.ravenMiddleware.captureMessage.callCount, 0);
assert.equal(ravenMock.ravenMiddleware.captureError.callCount, 0);
dfd.resolve();
});
});
return dfd.dojoPromise;
},
'logs errors when post fails' () {
const dfd = new dojoPromise.Deferred();
mocks.got = {
post: (req, res, next) => {
return new Promise((resolve, reject) => {
reject({
'host': '127.0.0.1:9000',
'hostname': '127.0.0.1',
'message': 'Response code 400 (Bad Request)',
'method': 'POST',
'path': '/v1/recovery_email/verify_code',
'statusCode': 400,
'statusMessage': 'Bad Request'
});
});
}
};
const req = {
query: {
code: '12345678912345678912345678912312',
uid: '12345678912345678912345678912312'
},
url: '/verify_email'
};
mockModule(mocks).process(req, res, () => {
assert.equal(logger.error.callCount, 1);
assert.equal(ravenMock.ravenMiddleware.captureMessage.callCount, 0);
assert.equal(ravenMock.ravenMiddleware.captureError.callCount, 1);
const result = ravenMock.ravenMiddleware.captureError.args[0][0];
assert.equal(result.statusCode, 400);
assert.equal(result.statusMessage, 'Bad Request');
dfd.resolve();
});
return dfd.dojoPromise;
}
});
});