diff --git a/lib/browserid/wsapi.js b/lib/browserid/wsapi.js index 7934f85d3936029e4c90cfd17729354a9ad7c457..c46c514b117396acf36f74a53c5b425e7ece36ca 100644 --- a/lib/browserid/wsapi.js +++ b/lib/browserid/wsapi.js @@ -47,7 +47,7 @@ bcrypt = require('bcrypt'), crypto = require('crypto'), logger = require('logging.js').logger, ca = require('./ca.js'), -configuration = require('configuration.js'); +config = require('configuration.js'); function checkParams(params) { return function(req, resp, next) { @@ -79,7 +79,6 @@ function clearAuthenticatedUser(session) { }); } - function setAuthenticatedUser(session, email) { session.authenticatedUser = email; session.authenticatedAt = new Date(); @@ -91,7 +90,7 @@ function isAuthed(req) { if (req.session.authenticatedUser) { if (!Date.parse(req.session.authenticatedAt) > 0) throw "bad timestamp"; if (new Date() - new Date(req.session.authenticatedAt) > - configuration.get('authentication_duration_ms')) + config.get('authentication_duration_ms')) { throw "expired"; } @@ -177,27 +176,35 @@ function setup(app) { // staging a user logs you out. clearAuthenticatedUser(req.session); - try { - // upon success, stage_user returns a secret (that'll get baked into a url - // and given to the user), on failure it throws - db.stageUser(req.body.email, function(secret) { - // store the email being registered in the session data - if (!req.session) req.session = {}; + db.lastStaged(req.body.email, function (last) { + if (last && (new Date() - last) < config.get('min_time_between_emails_ms')) { + logger.warn('throttling request to stage email address ' + req.body.email + ', only ' + + ((new Date() - last) / 1000.0) + "s elapsed"); + return httputils.forbidden(resp, "throttling. try again later."); + } - // store the secret we're sending via email in the users session, as checking - // that it still exists in the database is the surest way to determine the - // status of the email verification. - req.session.pendingCreation = secret; + try { + // upon success, stage_user returns a secret (that'll get baked into a url + // and given to the user), on failure it throws + db.stageUser(req.body.email, function(secret) { + // store the email being registered in the session data + if (!req.session) req.session = {}; - resp.json({ success: true }); + // store the secret we're sending via email in the users session, as checking + // that it still exists in the database is the surest way to determine the + // status of the email verification. + req.session.pendingCreation = secret; - // let's now kick out a verification email! - email.sendNewUserEmail(req.body.email, req.body.site, secret); - }); - } catch(e) { - // we should differentiate tween' 400 and 500 here. - httputils.badRequest(resp, e.toString()); - } + resp.json({ success: true }); + + // let's now kick out a verification email! + email.sendNewUserEmail(req.body.email, req.body.site, secret); + }); + } catch(e) { + // we should differentiate tween' 400 and 500 here. + httputils.badRequest(resp, e.toString()); + } + }); }); app.get('/wsapi/user_creation_status', function(req, resp) { @@ -233,7 +240,7 @@ function setup(app) { }); function bcrypt_password(password, cb) { - var bcryptWorkFactor = configuration.get('bcrypt_work_factor'); + var bcryptWorkFactor = config.get('bcrypt_work_factor'); bcrypt.gen_salt(bcryptWorkFactor, function (err, salt) { if (err) { @@ -293,22 +300,30 @@ function setup(app) { }); app.post('/wsapi/stage_email', checkAuthed, checkParams(["email", "site"]), function (req, resp) { - try { - // on failure stageEmail may throw - db.stageEmail(req.session.authenticatedUser, req.body.email, function(secret) { + db.lastStaged(req.body.email, function (last) { + if (last && (new Date() - last) < config.get('min_time_between_emails_ms')) { + logger.warn('throttling request to stage email address ' + req.body.email + ', only ' + + ((new Date() - last) / 1000.0) + "s elapsed"); + return httputils.forbidden(resp, "throttling. try again later."); + } - // store the email being added in session data - req.session.pendingAddition = secret; + try { + // on failure stageEmail may throw + db.stageEmail(req.session.authenticatedUser, req.body.email, function(secret) { - resp.json({ success: true }); + // store the email being added in session data + req.session.pendingAddition = secret; - // let's now kick out a verification email! - email.sendAddAddressEmail(req.body.email, req.body.site, secret); - }); - } catch(e) { - // we should differentiate tween' 400 and 500 here. - httputils.badRequest(resp, e.toString()); - } + resp.json({ success: true }); + + // let's now kick out a verification email! + email.sendAddAddressEmail(req.body.email, req.body.site, secret); + }); + } catch(e) { + // we should differentiate tween' 400 and 500 here. + httputils.badRequest(resp, e.toString()); + } + }); }); app.get('/wsapi/email_for_token', checkParams(["token"]), function(req,resp) { @@ -387,7 +402,7 @@ function setup(app) { // if the work factor has changed, update the hash here. issue #204 // NOTE: this runs asynchronously and will not delay the response - if (configuration.get('bcrypt_work_factor') != bcrypt.get_rounds(hash)) { + if (config.get('bcrypt_work_factor') != bcrypt.get_rounds(hash)) { logger.info("updating bcrypted password for email " + req.body.email); bcrypt_password(req.body.pass, function(err, hash) { db.updatePassword(req.body.email, hash, function(err) { @@ -436,7 +451,7 @@ function setup(app) { // same account, we certify the key // we certify it for a day for now var expiration = new Date(); - expiration.setTime(new Date().valueOf() + configuration.get('certificate_validity_ms')); + expiration.setTime(new Date().valueOf() + config.get('certificate_validity_ms')); var cert = ca.certify(req.body.email, pk, expiration); resp.writeHead(200, {'Content-Type': 'text/plain'}); diff --git a/lib/configuration.js b/lib/configuration.js index 09fa82aa463925bdfd788a86fa8af5d83f69dc29..050f7020623cc18771ab1f7490a7adc983e9b923 100644 --- a/lib/configuration.js +++ b/lib/configuration.js @@ -84,7 +84,8 @@ g_configs.production = { }, bcrypt_work_factor: 12, authentication_duration_ms: (7 * 24 * 60 * 60 * 1000), - certificate_validity_ms: (24 * 60 * 60 * 1000) + certificate_validity_ms: (24 * 60 * 60 * 1000), + min_time_between_emails_ms: (60 * 1000) }; @@ -97,7 +98,8 @@ g_configs.local = { database: { driver: "json" }, bcrypt_work_factor: g_configs.production.bcrypt_work_factor, authentication_duration_ms: g_configs.production.authentication_duration_ms, - certificate_validity_ms: g_configs.production.certificate_validity_ms + certificate_validity_ms: g_configs.production.certificate_validity_ms, + min_time_between_emails_ms: g_configs.production.min_time_between_emails_ms }; if (undefined !== process.env['NODE_EXTRA_CONFIG']) { @@ -105,16 +107,6 @@ if (undefined !== process.env['NODE_EXTRA_CONFIG']) { eval(fs.readFileSync(process.env['NODE_EXTRA_CONFIG']) + ''); } -Object.keys(g_configs).forEach(function(config) { - if (!g_configs[config].smtp) { - g_configs[config].smtp = { - host: process.env['SMTP_HOST'], - user: process.env['SMTP_USER'], - pass: process.env['SMTP_PASS'] - }; - } -}); - // test environments are variations on local g_configs.test_json = JSON.parse(JSON.stringify(g_configs.local)); g_configs.test_json.database = { @@ -155,6 +147,15 @@ if (process.env['VERIFIER_URL']) { g_config.verifier_url = url; } +// extract smtp params from the environment +if (!g_config.smtp) { + g_config.smtp = { + host: process.env['SMTP_HOST'], + user: process.env['SMTP_USER'], + pass: process.env['SMTP_PASS'] + }; +} + // now handle ephemeral database configuration. Used in testing. if (g_config.database.driver === 'mysql') { if (process.env['MYSQL_DATABASE_NAME']) { diff --git a/lib/db.js b/lib/db.js index 97232454d422467662c6bb28eea7965c1b1184fa..e3a4092039378bd409c8f9a0f42a50d1e092c3f9 100644 --- a/lib/db.js +++ b/lib/db.js @@ -104,7 +104,8 @@ exports.onReady = function(f) { 'listEmails', 'removeEmail', 'cancelAccount', - 'updatePassword' + 'updatePassword', + 'lastStaged' ].forEach(function(fn) { exports[fn] = function() { checkReady(); diff --git a/lib/db/json.js b/lib/db/json.js index 2fa8409131bc98673b5c24d1cb34c80e8743196e..9b5b68095b810031fbc27e0921c49fab390af76f 100644 --- a/lib/db/json.js +++ b/lib/db/json.js @@ -129,6 +129,17 @@ exports.isStaged = function(email, cb) { } }; +exports.lastStaged = function(email, cb) { + if (cb) { + sync(); + var d; + if (db.stagedEmails.hasOwnProperty(email)) { + d = new Date(db.staged[db.stagedEmails[email]].when); + } + setTimeout(function() { cb(d); }, 0); + } +}; + exports.emailsBelongToSameAccount = function(lhs, rhs, cb) { sync(); emailToUserID(lhs, function(lhs_uid) { @@ -161,7 +172,8 @@ exports.stageUser = function(email, cb) { sync(); db.staged[secret] = { type: "add_account", - email: email + email: email, + when: (new Date()).getTime() }; db.stagedEmails[email] = secret; flush(); @@ -176,7 +188,8 @@ exports.stageEmail = function(existing_email, new_email, cb) { db.staged[secret] = { type: "add_email", existing_email: existing_email, - email: new_email + email: new_email, + when: (new Date()).getTime() }; db.stagedEmails[new_email] = secret; flush(); diff --git a/lib/db/mysql.js b/lib/db/mysql.js index b1209b091b4572e8b321a98b74a5c7aae04b6ce4..25416817488b5fcd0f683e58dcb0271c3516b4e7 100644 --- a/lib/db/mysql.js +++ b/lib/db/mysql.js @@ -78,7 +78,7 @@ const schemas = [ "CREATE TABLE IF NOT EXISTS email (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY," + "user BIGINT NOT NULL," + - "address VARCHAR(255) UNIQUE NOT NULL," + + "address VARCHAR(255) UNIQUE NOT NULL," + "FOREIGN KEY user_fkey (user) REFERENCES user(id)" + ") ENGINE=InnoDB;", @@ -88,7 +88,7 @@ const schemas = [ "new_acct BOOL NOT NULL," + "existing VARCHAR(255)," + "email VARCHAR(255) UNIQUE NOT NULL," + - "ts TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL" + + "ts TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL" + ") ENGINE=InnoDB;", ]; @@ -204,6 +204,17 @@ exports.isStaged = function(email, cb) { ); } +exports.lastStaged = function(email, cb) { + client.query( + "SELECT UNIX_TIMESTAMP(ts) as ts FROM staged WHERE email = ?", [ email ], + function(err, rows) { + if (err) logUnexpectedError(err); + if (!rows || rows.length === 0) cb(); + else cb(new Date(rows[0].ts * 1000)); + } + ); +} + exports.stageUser = function(email, cb) { var secret = secrets.generate(48); // overwrite previously staged users diff --git a/lib/httputils.js b/lib/httputils.js index f88539a9b06dfc40c448fdef9e5cac1c5032bc2e..6b389251d2d807da6b86106a53b0e33b45df4810 100644 --- a/lib/httputils.js +++ b/lib/httputils.js @@ -63,6 +63,16 @@ exports.badRequest = function(resp, reason) resp.end(); }; +exports.forbidden = function(resp, reason) +{ + resp.writeHead(403, {"Content-Type": "text/plain"}); + resp.write("Forbidden"); + if (reason) { + resp.write(": " + reason); + } + resp.end(); +}; + exports.jsonResponse = function(resp, obj) { resp.writeHead(200, {"Content-Type": "application/json"}); diff --git a/lib/wsapi_client.js b/lib/wsapi_client.js index 1db1af746b15c8f808bd4090fc341517c137750e..29eeabec6db9d852dcba220ead0e3990a06f8833 100644 --- a/lib/wsapi_client.js +++ b/lib/wsapi_client.js @@ -54,7 +54,7 @@ function injectCookies(ctx, headers) { headers['Cookie'] += k + "=" + ctx.cookieJar[k]; } } -} +} function extractCookies(ctx, res) { if (ctx.cookieJar === undefined) ctx.cookieJar = {};