diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index 54a350162cdea99ed069928217df0c21cf9814b7..8171cb45f80faedd7a842d2a3721caa9dab89e4b 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -85,7 +85,6 @@ Subsequent steps use different software which you might need to install. * **curl** - used to iniate http requests from the cmd line (to kick the browserid server) * **java** - used to minify css - * **libsqlite3-dev** - database libraries ### 4. Set up post-update hook diff --git a/README.md b/README.md index e84edefb0d776fd66d4553e07bd87f77352498a3..518808162dd1ff1aad9c10ecd7bd72330778e21b 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,6 @@ Here's the software you'll need installed: * node.js (>= 0.4.5): http://nodejs.org/ * npm: http://npmjs.org/ -* sqlite (3) development libraries: http://www.sqlite.org/ * Several node.js 3rd party libraries - see `package.json` for details ## Getting started: diff --git a/browserid/app.js b/browserid/app.js index 5fdaba92699847e157a04d5fb3ade48caf0a0252..db877ddc8ed2edaf7b4d37e650525da38d1595a1 100644 --- a/browserid/app.js +++ b/browserid/app.js @@ -21,6 +21,9 @@ configuration = require('../libs/configuration.js'), substitution = require('../libs/substitute.js'); logging = require("../libs/logging.js"); +// open the databse +db.open(configuration.get('database')); + // looks unused, see run.js // const STATIC_DIR = path.join(path.dirname(__dirname), "static"); const COOKIE_SECRET = secrets.hydrateSecret('cookie_secret', VAR_DIR); @@ -34,7 +37,7 @@ function internal_redirector(new_url) { } function router(app) { - app.set("views", __dirname + '/views'); + app.set("views", __dirname + '/views'); app.set('view options', { production: configuration.get('use_minified_resources') diff --git a/browserid/lib/db.js b/browserid/lib/db.js index 482fad4c1d92afa2b94ae982959d540a6f880571..56c59253c75fb5667ace08b18166a82aca5a7d30 100644 --- a/browserid/lib/db.js +++ b/browserid/lib/db.js @@ -1,391 +1,76 @@ -const -sqlite = require('sqlite'), -path = require('path'); - -var VAR_DIR = path.join(path.dirname(__dirname), "var"); - -var db = new sqlite.Database(); - -// a configurable parameter if set immediately after require() of db.js -exports.dbPath = path.join(VAR_DIR, "authdb.sqlite"); +var driver; var ready = false; var waiting = []; +function checkReady() { + if (!ready) throw "database not ready. did you call open()?"; +} + // async break allow database path to be configured by calling code // a touch tricky cause client must set dbPath before releasing // control of the runloop -setTimeout(function() { - db.open(exports.dbPath, function (error) { - if (error) { - console.log("Couldn't open database: " + error); - throw error; - } - db.executeScript( - "CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY, password TEXT );" + - "CREATE TABLE IF NOT EXISTS emails ( id INTEGER PRIMARY KEY, user INTEGER, address TEXT UNIQUE );" + - "CREATE TABLE IF NOT EXISTS keys ( id INTEGER PRIMARY KEY, email INTEGER, key TEXT, expires INTEGER )", - function (error) { - if (error) { - throw error; - } - ready = true; - waiting.forEach(function(f) { f() }); - waiting = []; - }); - }); -}, 0); - -// accepts a function that will be invoked once the database is ready for transactions. -// this hook is important to pause the rest of application startup until async database -// connection establishment is complete. -exports.onReady = function(f) { - setTimeout(function() { - if (ready) f(); - else waiting.push(f); - }, 0); -}; - -// XXX: g_staged and g_stagedEmails should be moved into persistent/fast storage. - -// half created user accounts (pending email verification) -// OR -// half added emails (pending verification) -var g_staged = { -}; - -// an email to secret map for efficient fulfillment of isStaged queries -var g_stagedEmails = { -}; - -function executeTransaction(statements, cb) { - function executeTransaction2(statements, cb) { - if (statements.length == 0) cb(); - else { - var s = statements.shift(); - db.execute(s[0], s[1], function(err, rows) { - if (err) cb(err); - else executeTransaction2(statements, cb); - }); - } +exports.open = function(cfg, cb) { + var driverName = "json"; + if (cfg && cfg.driver) driverName = cfg.driver; + try { + driver = require('./db_' + driverName + '.js'); + } catch(e) { + var msg = "FATAL: couldn't find database driver: " + driverName; + console.log(msg); + throw msg + ": " + e.toString(); } - db.execute('BEGIN', function(err, rows) { - executeTransaction2(statements, function(err) { - if (err) cb(err); - else db.execute('COMMIT', function(err, rows) { - cb(err); - }); - }); - }); -} - -function emailToUserID(email, cb) { - db.execute( - 'SELECT users.id FROM emails, users WHERE emails.address = ? AND users.id == emails.user', - [ email ], - function (err, rows) { - if (rows && rows.length == 1) { - cb(rows[0].id); - } else { - if (err) console.log("database error: " + err); - cb(undefined); + driver.open(cfg, function(error) { + if (error) { + if (cb) cb(error); + else { + console.log("ERROR:" + error); + process.exit(1); } - }); -} - -exports.emailKnown = function(email, cb) { - db.execute( - "SELECT id FROM emails WHERE address = ?", - [ email ], - function(error, rows) { - cb(rows.length > 0); - }); -}; - -// XXX: should be moved to async. -exports.isStaged = function(email) { - return g_stagedEmails.hasOwnProperty(email); -}; - -function generateSecret() { - var str = ""; - const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; - for (var i=0; i < 48; i++) { - str += alphabet.charAt(Math.floor(Math.random() * alphabet.length)); - } - return str; -} - -function addEmailToAccount(existing_email, email, pubkey, cb) { - emailToUserID(existing_email, function(userID) { - if (userID == undefined) { - cb("no such email: " + existing_email, undefined); } else { - executeTransaction([ - [ "INSERT INTO emails (user, address) VALUES(?,?)", [ userID, email ] ], - [ "INSERT INTO keys (email, key, expires) VALUES(last_insert_rowid(),?,?)", - [ pubkey, ((new Date()).getTime() + (14 * 24 * 60 * 60 * 1000)) ] - ] - ], function (error) { - if (error) cb(error); - else cb(); - }); + ready = true; + waiting.forEach(function(f) { f() }); + waiting = []; + if (cb) cb(); } }); -} - -exports.emailsBelongToSameAccount = function(lhs, rhs, cb) { - emailToUserID(lhs, function(lhs_uid) { - emailToUserID(rhs, function(rhs_uid) { - cb(lhs_uid === rhs_uid); - }, function (error) { - cb(false); - }); - }, function (error) { - cb(false); - }); }; -exports.addKeyToEmail = function(existing_email, email, pubkey, cb) { - emailToUserID(existing_email, function(userID) { - if (userID == undefined) { - cb("no such email: " + existing_email, undefined); - return; - } - db.execute("SELECT emails.id FROM emails,users WHERE users.id = ? AND emails.address = ? AND emails.user = users.id", - [ userID, email ], - function(err, rows) { - if (err || rows.length != 1) { - cb(err); - return; - } - executeTransaction([ - [ "INSERT INTO keys (email, key, expires) VALUES(?,?,?)", - [ rows[0].id, pubkey, ((new Date()).getTime() + (14 * 24 * 60 * 60 * 1000)) ] - ] - ], function (error) { - if (error) cb(error); - else cb(); - }); - }); +exports.close = function(cb) { + driver.close(function(err) { + ready = false; + cb(err); }); -} - -/* takes an argument object including email, password hash, and pubkey. */ -exports.stageUser = function(obj) { - var secret = generateSecret(); - - // overwrite previously staged users - g_staged[secret] = { - type: "add_account", - email: obj.email, - pubkey: obj.pubkey, - pass: obj.hash - }; - - g_stagedEmails[obj.email] = secret; - return secret; }; -/* takes an argument object including email, pass, and pubkey. */ -// XXX: change to async -exports.stageEmail = function(existing_email, new_email, pubkey) { - var secret = generateSecret(); - // overwrite previously staged users - g_staged[secret] = { - type: "add_email", - existing_email: existing_email, - email: new_email, - pubkey: pubkey - }; - g_stagedEmails[new_email] = secret; - return secret; -}; - -/* invoked when a user clicks on a verification URL in their email */ -exports.gotVerificationSecret = function(secret, cb) { - if (!g_staged.hasOwnProperty(secret)) return cb("unknown secret"); - - // simply move from staged over to the emails "database" - var o = g_staged[secret]; - delete g_staged[secret]; - delete g_stagedEmails[o.email]; - if (o.type === 'add_account') { - exports.emailKnown(o.email, function(known) { - function createAccount() { - executeTransaction([ - [ "INSERT INTO users (password) VALUES(?)", [ o.pass ] ] , - [ "INSERT INTO emails (user, address) VALUES(last_insert_rowid(),?)", [ o.email ] ], - [ "INSERT INTO keys (email, key, expires) VALUES(last_insert_rowid(),?,?)", - [ o.pubkey, ((new Date()).getTime() + (14 * 24 * 60 * 60 * 1000)) ] - ] - ], function (error) { - if (error) cb(error); - else cb(); - }); - } - - // if this email address is known and a user has completed a re-verification of this email - // address, remove the email from the old account that it was associated with, and then - // create a brand new account with only this email. - // NOTE: this might be sub-optimal, but it's a dead simple approach that mitigates many attacks - // and gives us reasonable behavior (without explicitly supporting) in the face of shared email - // addresses. - if (known) { - exports.removeEmail(o.email, o.email, function (err) { - if (err) cb(err); - else createAccount(); - }); - } else { - createAccount(); - } - }); - } else if (o.type === 'add_email') { - exports.emailKnown(o.email, function(known) { - function addIt() { - addEmailToAccount(o.existing_email, o.email, o.pubkey, cb); - } - if (known) { - exports.removeEmail(o.email, o.email, function (err) { - if (err) cb(err); - else addIt(); - }); - } else { - addIt(); - } - }); - } else { - cb("internal error"); - } -}; - -// check authentication credentials for a given email address. This will invoke the -// users callback with the authentication (password/hash/whatever - the database layer -// doesn't care). callback will be passed undefined if email cannot be found -exports.checkAuth = function(email, cb) { - db.execute("SELECT users.password FROM emails, users WHERE users.id = emails.user AND emails.address = ?", - [ email ], - function (error, rows) { - cb(rows.length !== 1 ? undefined : rows[0].password); - }); +// accepts a function that will be invoked once the database is ready for transactions. +// this hook is important to pause the rest of application startup until async database +// connection establishment is complete. +exports.onReady = function(f) { + setTimeout(function() { + if (ready) f(); + else waiting.push(f); + }, 0); }; -function emailHasPubkey(email, pubkey, cb) { - db.execute( - 'SELECT keys.key FROM keys, emails WHERE emails.address = ? AND keys.email = emails.id AND keys.key = ?', - [ email, pubkey ], - function(err, rows) { - cb(rows.length === 1); - }); -} - -/* a high level operation that attempts to sync a client's view with that of the - * server. email is the identity of the authenticated channel with the user, - * identities is a map of email -> pubkey. - * We'll return an object that expresses three different types of information: - * there are several things we need to express: - * 1. emails that the client knows about but we do not - * 2. emails that we know about and the client does not - * 3. emails that we both know about but who need to be re-keyed - * NOTE: it's not neccesary to differentiate between #2 and #3, as the client action - * is the same (regen keypair and tell us about it). - */ -exports.getSyncResponse = function(email, identities, cb) { - var respBody = { - unknown_emails: [ ], - key_refresh: [ ] +[ + 'emailKnown', + 'isStaged', + 'emailsBelongToSameAccount', + 'addKeyToEmail', + 'stageUser', + 'stageEmail', + 'gotVerificationSecret', + 'checkAuth', + 'getSyncResponse', + 'pubkeysForEmail', + 'removeEmail', + 'cancelAccount' +].forEach(function(fn) { + exports[fn] = function() { + checkReady(); + driver[fn].apply(undefined, arguments); }; - - // get the user id associated with this account - emailToUserID(email, function(userID) { - if (userID === undefined) { - cb("no such email: " + email); - return; - } - db.execute( - 'SELECT address FROM emails WHERE ? = user', - [ userID ], - function (err, rows) { - if (err) cb(err); - else { - var emails = [ ]; - var keysToCheck = [ ]; - for (var i = 0; i < rows.length; i++) emails.push(rows[i].address); - - // #1 - for (var e in identities) { - if (emails.indexOf(e) == -1) respBody.unknown_emails.push(e); - else keysToCheck.push(e); - } - - // #2 - for (var e in emails) { - e = emails[e]; - if (!identities.hasOwnProperty(e)) respBody.key_refresh.push(e); - - } - - // #3 -- yes, this is sub-optimal in terms of performance. when we - // move away from public keys this will be unnec. - if (keysToCheck.length) { - var checked = 0; - keysToCheck.forEach(function(e) { - emailHasPubkey(e, identities[e], function(v) { - checked++; - if (!v) respBody.key_refresh.push(e); - if (checked === keysToCheck.length) { - cb(undefined, respBody); - } - }); - }); - } else { - cb(undefined, respBody); - } - } - }); - }); -}; - -// get all public keys associated with an email address -exports.pubkeysForEmail = function(identity, cb) { - db.execute( - 'SELECT keys.key FROM keys, emails WHERE emails.address = ? AND keys.email = emails.id', - [ identity ], - function(err, rows) { - var keys = undefined; - if (!err && rows && rows.length) { - keys = [ ]; - for (var i = 0; i < rows.length; i++) keys.push(rows[i].key); - } - cb(keys); - }); -}; - -exports.removeEmail = function(authenticated_email, email, cb) { - // figure out the user, and remove Email only from addressed - // linked to the authenticated email address - emailToUserID(authenticated_email, function(user_id) { - executeTransaction([ - [ "delete from emails where emails.address = ? and user = ?", [ email,user_id ] ] , - [ "delete from keys where email in (select address from emails where emails.address = ? and user = ?)", [ email,user_id ] ], - ], function (error) { - if (error) cb(error); - else cb(); - }); - }); -}; - -exports.cancelAccount = function(authenticated_email, cb) { - emailToUserID(authenticated_email, function(user_id) { - executeTransaction([ - [ "delete from emails where user = ?", [ user_id ] ] , - [ "delete from keys where email in (select address from emails where user = ?)", [ user_id ] ], - [ "delete from users where id = ?", [ user_id ] ], - ], function (error) { - if (error) cb(error); - else cb(); - }); - }); -}; +}); diff --git a/browserid/lib/db_json.js b/browserid/lib/db_json.js new file mode 100644 index 0000000000000000000000000000000000000000..af01b2c954305b256de6979577ad2714bff29e7e --- /dev/null +++ b/browserid/lib/db_json.js @@ -0,0 +1,322 @@ +/* db_json is a json database driver. It is designed for use in + * local development, is intended to be extremely easy to maintain, + * have minimal dependencies on 3rd party libraries, and we could + * care less if it performs well with more than 10 or so users. + */ +const +path = require('path'), +fs = require('fs'), +secrets = require('./secrets'), +jsel = require('JSONSelect'); + +// a little alias for stringify +const ESC = JSON.stringify; + +var VAR_DIR = path.join(path.dirname(__dirname), "var"); + +var dbPath = path.join(VAR_DIR, "authdb.json"); + +/* The JSON database. The structure is thus: + * [ + * { + * password: "somepass", + * emails: [ + * { + * address: "lloyd@hilaiel.com", + * keys: [ + * { + * key: "SOMESTRINGOFTEXT", + * expires: 1231541615125 + * } + * ] + * } + * ] + * } + * ] + */ + +var db = []; +var stagedEmails = { }; +var staged = { }; + +function flush() { + fs.writeFileSync(dbPath, JSON.stringify(db)); +} + +// when should a key created right now expire? +function getExpiryTime() { + return ((new Date()).getTime() + (14 * 24 * 60 * 60 * 1000)); +} + +exports.open = function(cfg, cb) { + if (cfg && cfg.path) dbPath = cfg.path; + try { + db = JSON.parse(fs.readFileSync(dbPath)); + } catch(e) { + } + + setTimeout(cb, 0); +}; + +exports.close = function(cb) { + flush(); + setTimeout(cb, 0); +}; + +exports.emailKnown = function(email, cb) { + var m = jsel.match(".address:val(" + ESC(email) + ")", db); + setTimeout(function() { cb(m.length > 0) }, 0); +}; + +exports.isStaged = function(email, cb) { + if (cb) { + setTimeout(function() { + cb(stagedEmails.hasOwnProperty(email)); + }, 0); + } +}; + +exports.emailsBelongToSameAccount = function(lhs, rhs, cb) { + emailToUserID(lhs, function(lhs_uid) { + emailToUserID(rhs, function(rhs_uid) { + cb(lhs_uid === rhs_uid); + }, function (error) { + cb(false); + }); + }, function (error) { + cb(false); + }); +}; + +function addEmailToAccount(existing_email, email, pubkey, cb) { + emailToUserID(existing_email, function(userID) { + if (userID == undefined) { + cb("no such email: " + existing_email, undefined); + } else { + db[userID].emails.push({ + address: email, + keys: [ + { + key: pubkey, + expires: getExpiryTime() + } + ] + }); + flush(); + cb(); + } + }); +} + +exports.addKeyToEmail = function(existing_email, email, pubkey, cb) { + emailToUserID(existing_email, function(userID) { + if (userID == undefined) { + cb("no such email: " + existing_email, undefined); + return; + } + + if (!(db[userID].emails)) { + db[userID].emails = [ ]; + } + + var m = jsel.match("object:has(.address:val(" + ESC(email) + ")) > .keys", db[userID].emails); + + var kobj = { + key: pubkey, + expires: getExpiryTime() + }; + + if (m.length) { + m[0].push(kobj); + } else { + db[userID].emails.push({ + address: email, + keys: [ kobj ] + }); + } + + flush(); + if (cb) setTimeout(function() { cb(); }, 0); + }); +} + +exports.stageUser = function(obj, cb) { + var secret = secrets.generate(48); + + // overwrite previously staged users + staged[secret] = { + type: "add_account", + email: obj.email, + pubkey: obj.pubkey, + pass: obj.hash + }; + + stagedEmails[obj.email] = secret; + setTimeout(function() { cb(secret); }, 0); +}; + +exports.stageEmail = function(existing_email, new_email, pubkey, cb) { + var secret = secrets.generate(48); + // overwrite previously staged users + staged[secret] = { + type: "add_email", + existing_email: existing_email, + email: new_email, + pubkey: pubkey + }; + stagedEmails[new_email] = secret; + setTimeout(function() { cb(secret); }, 0); +}; + +exports.gotVerificationSecret = function(secret, cb) { + if (!staged.hasOwnProperty(secret)) return cb("unknown secret"); + + // simply move from staged over to the emails "database" + var o = staged[secret]; + delete staged[secret]; + delete stagedEmails[o.email]; + if (o.type === 'add_account') { + exports.emailKnown(o.email, function(known) { + function createAccount() { + db.push({ + password: o.pass, + emails: [ + { + address: o.email, + keys: [ { + key: o.pubkey, + expires: getExpiryTime(), + } ] + } + ] + }); + flush(); + cb(); + } + + // if this email address is known and a user has completed a re-verification of this email + // address, remove the email from the old account that it was associated with, and then + // create a brand new account with only this email. + // NOTE: this might be sub-optimal, but it's a dead simple approach that mitigates many attacks + // and gives us reasonable behavior (without explicitly supporting) in the face of shared email + // addresses. + + if (known) { + exports.removeEmail(o.email, o.email, function (err) { + if (err) cb(err); + else createAccount(); + }); + } else { + createAccount(); + } + }); + } else if (o.type === 'add_email') { + exports.emailKnown(o.email, function(known) { + function addIt() { + addEmailToAccount(o.existing_email, o.email, o.pubkey, cb); + } + if (known) { + exports.removeEmail(o.email, o.email, function (err) { + if (err) cb(err); + else addIt(); + }); + } else { + addIt(); + } + }); + } else { + cb("internal error"); + } +}; + +exports.checkAuth = function(email, cb) { + var m = jsel.match(":root > object:has(.address:val(" + ESC(email) + ")) > .password", db); + if (m.length === 0) m = undefined; + else m = m[0]; + setTimeout(function() { cb(m) }, 0); +}; + +function emailToUserID(email, cb) { + var id = undefined; + + for (var i = 0; i < db.length; i++) { + if (jsel.match(".address:val(" + JSON.stringify(email) + ")", db[i]).length) { + id = i; + break; + } + if (id !== undefined) break; + } + + setTimeout(function() { cb(id); }, 0); +} + +exports.getSyncResponse = function(email, identities, cb) { + var respBody = { + unknown_emails: [ ], + key_refresh: [ ] + }; + + // get the user id associated with this account + emailToUserID(email, function(userID) { + if (userID === undefined) { + cb("no such email: " + email); + return; + } + var emails = jsel.match(".address", db[userID]); + var keysToCheck = [ ]; + + // #1 emails that the client knows about but we do not + for (var e in identities) { + if (emails.indexOf(e) == -1) respBody.unknown_emails.push(e); + else keysToCheck.push(e); + } + + // #2 emails that we know about and the client does not + for (var e in emails) { + e = emails[e]; + if (!identities.hasOwnProperty(e)) respBody.key_refresh.push(e); + } + + // #3 emails that we both know about but who need to be re-keyed + if (keysToCheck.length) { + var checked = 0; + keysToCheck.forEach(function(e) { + if (!jsel.match(".key:val(" + ESC(identities[e]) + ")", db[userID]).length) + respBody.key_refresh.push(e); + checked++; + if (checked === keysToCheck.length) cb(undefined, respBody); + }); + } else { + cb(undefined, respBody); + } + }); +}; + +exports.pubkeysForEmail = function(identity, cb) { + var m = jsel.match(".emails object:has(.address:val(" + ESC(identity)+ ")) .key", db); + setTimeout(function() { cb(m); }, 0); +}; + +exports.removeEmail = function(authenticated_email, email, cb) { + var m = jsel.match(":root > object:has(.address:val("+ESC(authenticated_email)+")):has(.address:val("+ESC(email)+")) .emails", db); + + if (m.length) { + var emails = m[0]; + for (var i = 0; i < emails.length; i++) { + if (emails[i].address === email) { + emails.splice(i, 1); + break; + } + } + } + + setTimeout(function() { cb(); }, 0); +}; + +exports.cancelAccount = function(authenticated_email, cb) { + emailToUserID(authenticated_email, function(user_id) { + db.splice(user_id, 1); + flush(); + cb(); + }); +}; diff --git a/browserid/lib/db_mysql.js b/browserid/lib/db_mysql.js new file mode 100644 index 0000000000000000000000000000000000000000..eec4eefd383006fb7e5f0c944a40d4d18c071b59 --- /dev/null +++ b/browserid/lib/db_mysql.js @@ -0,0 +1,422 @@ +/* This is a mysql driver for the browserid server. It maps the data + * storage requirements of browserid onto a relational schema. This + * driver is intended to be fast and scalable. + */ + +/* + * The Schema: + * + * +--- user ------+ +--- email ----+ +--- pubkey -----+ + * |*int id | <-\ |*int id | <-\ |*int id | + * | string passwd | \- |*int user | \-- |*int email | + * +---------------+ |*string address | string pubkey | + * +--------------+ | int expires | + * +----------------+ + * + * + * +------ staged ----------+ + * |*string secret | + * | bool new_acct | + * | string existing | + * |*string email | + * | string pubkey | + * | string passwd | + * | timestamp ts | + * +------------------------+ + */ + +const +mysql = require('mysql'), +secrets = require('./secrets'), +logger = require('../../libs/logging.js'); + +var client = undefined; + +// may get defined at open() time causing a database to be dropped upon connection closing. +var drop_on_close = undefined; + +const schemas = [ + "CREATE TABLE IF NOT EXISTS user ( id INTEGER AUTO_INCREMENT PRIMARY KEY, passwd VARCHAR(64) );", + "CREATE TABLE IF NOT EXISTS email ( id INTEGER AUTO_INCREMENT PRIMARY KEY, user INTEGER, address VARCHAR(255) UNIQUE, INDEX(address) );", + "CREATE TABLE IF NOT EXISTS pubkey ( id INTEGER AUTO_INCREMENT PRIMARY KEY, email INTEGER, content TEXT, expiry DATETIME );", + "CREATE TABLE IF NOT EXISTS staged ( secret VARCHAR(48) PRIMARY KEY, new_acct BOOL, existing VARCHAR(255), email VARCHAR(255) UNIQUE, INDEX(email), pubkey TEXT, passwd VARCHAR(64), ts TIMESTAMP DEFAULT CURRENT_TIMESTAMP);" +]; + +// log an unexpected database error +function logUnexpectedError(detail) { + // first, get line number of callee + var where; + try { dne; } catch (e) { where = e.stack.split('\n')[2].trim(); }; + // now log it! + logger.log('db', { type: "unexpected", message: "unexpected database failure", detail: detail, where: where }); +} + +// open & create the mysql database +exports.open = function(cfg, cb) { + if (client) throw "database is already open!"; + client = new mysql.Client(); + // mysql config requires + const defParams = { + host: '127.0.0.1', + port: "3306", + user: 'test', + password: 'pass', + unit_test: false + }; + + Object.keys(defParams).forEach(function(param) { + client[param] = cfg[param] ? cfg.param : defParams[param]; + }); + + // let's figure out the database name + var database = cfg.database; + if (!database) database = "browserid"; + if (cfg.unit_test) { + database += "_" + secrets.generate(8); + drop_on_close = database; + } + + client.connect(function(error) { + if (error) { + logUnexpectedError(error); + cb(error); + } else { + // now create the databse + client.query("CREATE DATABASE IF NOT EXISTS " + database, function(err) { + if (err) { + logUnexpectedError(err); + cb(err); + return; + } + client.useDatabase(database, function(err) { + if (err) { + logUnexpectedError(err); + cb(err); + return; + } + + // now create tables + function createNextTable(i) { + if (i < schemas.length) { + client.query(schemas[i], function(err) { + if (err) { + logUnexpectedError(err); + cb(err); + } else { + createNextTable(i+1); + } + }); + } else { + cb(); + } + } + createNextTable(0); + }); + }); + } + }); +}; + +exports.close = function(cb) { + function endConn() { + client.end(function(err) { + client = undefined; + if (err) logUnexpectedError(err); + if (cb) cb(err); + }); + } + // when unit_test is specified at open time, we use a temporary database, + // and clean it up upon close. + if (drop_on_close) { + client.query("DROP DATABASE " + drop_on_close, function() { + endConn(); + }); + } else { + endConn(); + } +}; + +exports.emailKnown = function(email, cb) { + client.query( + "SELECT COUNT(*) as N FROM email WHERE address = ?", [ email ], + function(err, rows) { + if (err) logUnexpectedError(err); + cb(rows && rows.length > 0 && rows[0].N > 0); + } + ); +} + +exports.isStaged = function(email, cb) { + client.query( + "SELECT COUNT(*) as N FROM staged WHERE email = ?", [ email ], + function(err, rows) { + if (err) logUnexpectedError(err); + cb(rows && rows.length > 0 && rows[0].N > 0); + } + ); +} + +exports.stageUser = function(obj, cb) { + var secret = secrets.generate(48); + // overwrite previously staged users + client.query('INSERT INTO staged (secret, new_acct, email, pubkey, passwd) VALUES(?,TRUE,?,?,?) ' + + 'ON DUPLICATE KEY UPDATE secret=?, existing="", new_acct=TRUE, pubkey=?, passwd=?', + [ secret, obj.email, obj.pubkey, obj.hash, secret, obj.pubkey, obj.hash], + function(err) { + if (err) { + logUnexpectedError(err); + cb(undefined, err); + } else cb(secret); + }); +} + +exports.gotVerificationSecret = function(secret, cb) { + client.query( + "SELECT * FROM staged WHERE secret = ?", [ secret ], + function(err, rows) { + if (err) { + logUnexpectedError(err); + cb(err); + } else if (rows.length === 0) cb("unknown secret"); + else { + var o = rows[0]; + + function addEmailAndPubkey(userID) { + client.query( + "INSERT INTO email(user, address) VALUES(?, ?)", + [ userID, o.email ], + function(err, info) { + if (err) { logUnexpectedError(err); cb(err); return; } + addKeyToEmailRecord(info.insertId, o.pubkey, cb); + }); + } + + // delete the record + client.query("DELETE LOW_PRIORITY FROM staged WHERE secret = ?", [ secret ]); + + if (o.new_acct) { + // we're creating a new account, add appropriate entries into user, email, and pubkey. + client.query( + "INSERT INTO user(passwd) VALUES(?)", + [ o.passwd ], + function(err, info) { + if (err) { logUnexpectedError(err); cb(err); return; } + addEmailAndPubkey(info.insertId); + }); + } else { + // we're adding an email address to an existing user account. add appropriate entries into email and + // pubkey + client.query( + "SELECT user FROM email WHERE address = ?", [ o.existing ], + function(err, rows) { + if (err) { logUnexpectedError(err); cb(err); } + else if (rows.length === 0) cb("cannot find email address: " + o.existing); + else { + addEmailAndPubkey(rows[0].user); + } + }); + } + } + } + ); +} + +exports.emailsBelongToSameAccount = function(lhs, rhs, cb) { + client.query( + 'SELECT COUNT(*) AS n FROM email WHERE address = ? AND user = ( SELECT user FROM email WHERE address = ? );', + [ lhs, rhs ], + function (err, rows) { + if (err) cb(false); + else cb(rows.length === 1 && rows[0].n === 1); + }); +} + +function addKeyToEmailRecord(emailId, pubkey, cb) { + client.query( + // XXX: 2 weeks is wrong, but then so is keypairs. + "INSERT INTO pubkey(email, content, expiry) VALUES(?, ?, DATE_ADD(NOW(), INTERVAL 2 WEEK))", + [ emailId, pubkey ], + function(err, info) { + if (err) logUnexpectedError(err); + // smash null into undefined. + cb(err ? err : undefined); + }); +} + +exports.addKeyToEmail = function(existing_email, email, pubkey, cb) { + // this function will NOT add a new email address to a user record. The only + // way that happens is when a verification secret is provided to us. Limiting + // the code paths that result in us concluding that a user owns an email address + // is a Good Thing. + exports.emailsBelongToSameAccount(existing_email, email, function(ok) { + if (!ok) { + cb("authenticated user doesn't have permission to add a public key to " + email); + return; + } + + // now we know that the user has permission to add a key. + client.query( + "SELECT id FROM email WHERE address = ?", [ email ], + function(err, rows) { + if (err) { logUnexpectedError(err); cb(err); } + else if (rows.length === 0) cb("cannot find email address: " + email); + else { + addKeyToEmailRecord(rows[0].id, pubkey, cb); + } + }); + }); +} + +exports.stageEmail = function(existing_email, new_email, pubkey, cb) { + var secret = secrets.generate(48); + // overwrite previously staged users + client.query('INSERT INTO staged (secret, new_acct, existing, email, pubkey) VALUES(?,FALSE,?,?,?) ' + + 'ON DUPLICATE KEY UPDATE secret=?, existing=?, new_acct=FALSE, pubkey=?, passwd=""', + [ secret, existing_email, new_email, pubkey, secret, existing_email, pubkey], + function(err) { + if (err) { + logUnexpectedError(err); + cb(undefined, err); + } + else cb(secret); + }); +} + +exports.checkAuth = function(email, cb) { + client.query( + 'SELECT passwd FROM user WHERE id = ( SELECT user FROM email WHERE address = ? )', + [ email ], + function (err, rows) { + if (err) logUnexpectedError(err); + cb((rows && rows.length == 1) ? rows[0].passwd : undefined); + }); +} + +function emailHasPubkey(email, pubkey, cb) { + client.query( + 'SELECT pubkey.content FROM pubkey, email WHERE email.address = ? AND pubkey.email = email.id AND pubkey.content = ?', + [ email, pubkey ], + function(err, rows) { + if (err) logUnexpectedError(err); + cb(rows && rows.length === 1); + }); +} + +/* a high level operation that attempts to sync a client's view with that of the + * server. email is the identity of the authenticated channel with the user, + * identities is a map of email -> pubkey. + * We'll return an object that expresses three different types of information: + * there are several things we need to express: + * 1. emails that the client knows about but we do not + * 2. emails that we know about and the client does not + * 3. emails that we both know about but who need to be re-keyed + * NOTE: it's not neccesary to differentiate between #2 and #3, as the client action + * is the same (regen keypair and tell us about it). + */ +exports.getSyncResponse = function(email, identities, cb) { + var respBody = { + unknown_emails: [ ], + key_refresh: [ ] + }; + + client.query( + 'SELECT address FROM email WHERE user = ( SELECT user FROM email WHERE address = ? ) ', + [ email ], + function (err, rows) { + if (err) cb(err); + else { + var emails = [ ]; + var keysToCheck = [ ]; + for (var i = 0; i < rows.length; i++) emails.push(rows[i].address); + + // #1 + for (var e in identities) { + if (emails.indexOf(e) == -1) respBody.unknown_emails.push(e); + else keysToCheck.push(e); + } + + // #2 + for (var e in emails) { + e = emails[e]; + if (!identities.hasOwnProperty(e)) respBody.key_refresh.push(e); + } + + // #3 -- yes, this is sub-optimal in terms of performance. when we + // move away from public keys this will be unnec. + if (keysToCheck.length) { + var checked = 0; + keysToCheck.forEach(function(e) { + emailHasPubkey(e, identities[e], function(v) { + checked++; + if (!v) respBody.key_refresh.push(e); + if (checked === keysToCheck.length) { + cb(undefined, respBody); + } + }); + }); + } else { + cb(undefined, respBody); + } + } + }); +}; + + +exports.pubkeysForEmail = function(email, cb) { + client.query( + 'SELECT content FROM pubkey WHERE email = (SELECT id FROM email WHERE address = ?)', + [ email ], + function (err, rows) { + var ar = [ ]; + if (!err) rows.forEach(function(r) { ar.push(r.content); }); + else logUnexpectedError(err); + cb(ar); + }); +} + +exports.removeEmail = function(authenticated_email, email, cb) { + exports.emailsBelongToSameAccount(authenticated_email, email, function(ok) { + if (!ok) { + logger.log('security', authenticated_email + ' attempted to delete an email that doesn\'t belong to her: ' + email); + cb("authenticated user doesn't have permission to remove specified email " + email); + return; + } + + client.query( + 'DELETE FROM pubkey WHERE email = ( SELECT id FROM email WHERE address = ? )', + [ email ], + function (err, info) { + if (err) { + logUnexpectedError(err); + cb(err); + } else { + client.query( + 'DELETE FROM email WHERE address = ?', + [ email ], + function(err, info) { + if (err) logUnexpectedError(err); + // smash null into undefined + cb(err ? err : undefined); + }); + } + }); + }); +} + +exports.cancelAccount = function(email, cb) { + function reportErr(err) { if (err) logUnexpectedError(err); } + client.query( + "SELECT user FROM email WHERE address = ?", [ email ], + function (err, rows) { + if (err) { + logUnexpectedError(err) + cb(err); + return + } + var uid = rows[0].user; + client.query("DELETE LOW_PRIORITY FROM pubkey WHERE email in ( SELECT id FROM email WHERE user = ? )", [ uid ], reportErr); + client.query("DELETE LOW_PRIORITY FROM email WHERE user = ?", [ uid ], reportErr); + client.query("DELETE LOW_PRIORITY FROM user WHERE id = ?", [ uid ], reportErr); + cb(); + }); +} diff --git a/browserid/lib/secrets.js b/browserid/lib/secrets.js index 7aca40b986e7e82366fd531e22421f17f57b7dff..8018edfbc725f9a1f552f388bab4dcb77e611b1e 100644 --- a/browserid/lib/secrets.js +++ b/browserid/lib/secrets.js @@ -2,10 +2,10 @@ const path = require('path'), fs = require('fs'); -function generateSecret() { +exports.generate = function(chars) { var str = ""; const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; - for (var i=0; i < 128; i++) { + for (var i=0; i < chars; i++) { str += alphabet.charAt(Math.floor(Math.random() * alphabet.length)); } return str; @@ -19,7 +19,7 @@ exports.hydrateSecret = function(name, dir) { try{ secret = fs.readFileSync(p).toString(); } catch(e) {}; if (secret === undefined) { - secret = generateSecret(); + secret = exports.generate(128); fs.writeFileSync(p, secret); } return secret; diff --git a/browserid/lib/wsapi.js b/browserid/lib/wsapi.js index e7d757956aa8ddae61e51ab6f7ccc4230ce0d0be..d01b952b78b2dd4311b35065fe13bb9d3290e657 100644 --- a/browserid/lib/wsapi.js +++ b/browserid/lib/wsapi.js @@ -72,25 +72,26 @@ function setup(app) { try { // upon success, stage_user returns a secret (that'll get baked into a url // and given to the user), on failure it throws - var secret = db.stageUser(stageParams); - - // store the email being registered in the session data - if (!req.session) req.session = {}; + db.stageUser(stageParams, function(secret) { + // store the email being registered in the session data + if (!req.session) req.session = {}; - // store inside the session the details of this pending verification - req.session.pendingVerification = { - email: stageParams.email, - hash: stageParams.hash // we must store both email and password to handle the case where - // a user re-creates an account - specifically, registration status - // must ensure the new credentials work to properly verify that - // the user has clicked throught the email link. note, this salted, bcrypted - // representation of a user's password will get thrust into an encrypted cookie - // served over an encrypted (SSL) session. guten, yah. - }; + // store inside the session the details of this pending verification + req.session.pendingVerification = { + email: stageParams.email, + hash: stageParams.hash // we must store both email and password to handle the case where + // a user re-creates an account - specifically, registration status + // must ensure the new credentials work to properly verify that + // the user has clicked throught the email link. note, this salted, bcrypted + // representation of a user's password will get thrust into an encrypted cookie + // served over an encrypted (SSL) session. guten, yah. + }; - resp.json(true); - email.sendVerificationEmail(stageParams.email, stageParams.site, secret); + resp.json(true); + // let's now kick out a verification email! + email.sendVerificationEmail(stageParams.email, stageParams.site, secret); + }); } catch(e) { // we should differentiate tween' 400 and 500 here. httputils.badRequest(resp, e.toString()); @@ -128,7 +129,6 @@ function setup(app) { } else { // this is a pending registration, let's check if the creds stored on the // session are good yet. - var v = req.session.pendingVerification; db.checkAuth(v.email, function(hash) { if (hash === v.hash) { @@ -161,17 +161,17 @@ function setup(app) { app.post('/wsapi/add_email', checkAuthed, checkParams(["email", "pubkey", "site"]), function (req, resp) { try { - // upon success, stage_user returns a secret (that'll get baked into a url - // and given to the user), on failure it throws - var secret = db.stageEmail(req.session.authenticatedUser, req.body.email, req.body.pubkey); + // on failure stageEmail may throw + db.stageEmail(req.session.authenticatedUser, req.body.email, req.body.pubkey, function(secret) { - // store the email being added in session data - req.session.pendingAddition = req.body.email; + // store the email being added in session data + req.session.pendingAddition = req.body.email; - resp.json(true); + resp.json(true); - // let's now kick out a verification email! - email.sendVerificationEmail(req.body.email, req.body.site, secret); + // let's now kick out a verification email! + email.sendVerificationEmail(req.body.email, req.body.site, secret); + }); } catch(e) { // we should differentiate tween' 400 and 500 here. httputils.badRequest(resp, e.toString()); diff --git a/browserid/tests/db-test.js b/browserid/tests/db-test.js index bd7afd9145bf30bb15b9d2d0f9c6a645b1c353be..f83bde7a6a75f63074dbd573be523aa6668cbff6 100755 --- a/browserid/tests/db-test.js +++ b/browserid/tests/db-test.js @@ -12,364 +12,425 @@ var suite = vows.describe('db'); // disable vows (often flakey?) async error behavior suite.options.error = false; -db.dbPath = temp.path({suffix: '.sqlite'}); +function addTestsForDriver(driver) { + var dbPath = temp.path({suffix: '.db'}); -suite.addBatch({ - "waiting for the database to become ready": { - topic: function() { - var cb = this.callback; - db.onReady(function() { cb(true) }); - }, - "the database is ready": function(r) { - assert.strictEqual(r, true); - } - } -}); - -// caching of secrets between test batches. -var secret = undefined; - -suite.addBatch({ - "an email address is not reported as staged before it is": { - topic: function() { - return db.isStaged('lloyd@nowhe.re'); - }, - "isStaged returns false": function (r) { - assert.strictEqual(r, false); - } - }, - "an email address is not reported as known before it is": { - topic: function() { - db.emailKnown('lloyd@nowhe.re', this.callback); - }, - "emailKnown returns false": function (r) { - assert.strictEqual(r, false); - } - } -}); - -suite.addBatch({ - "stage a user for creation pending verification": { - topic: function() { - return secret = db.stageUser({ - email: 'lloyd@nowhe.re', - pubkey: 'fakepubkey', - hash: 'fakepasswordhash' - }); - }, - "staging returns a valid secret": function(r) { - assert.isString(secret); - assert.strictEqual(secret.length, 48); - } - } -}); - -suite.addBatch({ - "an email address is reported": { - topic: function() { - return db.isStaged('lloyd@nowhe.re'); - }, - " as staged after it is": function (r) { - assert.strictEqual(r, true); - } - }, - "an email address is not reported": { - topic: function() { - db.emailKnown('lloyd@nowhe.re', this.callback); - }, - " as known when it is only staged": function (r) { - assert.strictEqual(r, false); - } - } -}); - -suite.addBatch({ - "upon receipt of a secret": { - topic: function() { - db.gotVerificationSecret(secret, this.callback); - }, - "gotVerificationSecret completes without error": function (r) { - assert.strictEqual(r, undefined); - } + if (driver === 'mysql') { + // let's check to see if we can connect and render a nice + // error message if not. For community members making casual + // contributions, we should expect that they might not want to + // set up mysql. + suite.addBatch({ + "mysql server": { + topic: function() { db.open({driver: driver, unit_test: true}, this.callback) }, + "accepting connections": function(err) { + if (err) { + console.log("MYSQL TESTS WILL FAIL cause cannot connect to a local mysql database (" + err.message + ")"); + } + }, + "connection closes": { + topic: function() { db.close(this.callback); }, + "without error": function(err) { + assert.isUndefined(err); + } + } + } + }); } -}); -suite.addBatch({ - "an email address is not reported": { - topic: function() { - return db.isStaged('lloyd@nowhe.re'); + suite.addBatch({ + "onReady": { + topic: function() { db.onReady(this.callback); }, + "works": function(r) { } }, - "as staged immediately after its verified": function (r) { - assert.strictEqual(r, false); - } - }, - "an email address is known": { - topic: function() { - db.emailKnown('lloyd@nowhe.re', this.callback); + "onReady still": { + topic: function() { db.onReady(this.callback); }, + "works for more than one caller": function(r) { } }, - "when it is": function (r) { - assert.strictEqual(r, true); + "opening the database": { + topic: function() { + db.open({ driver: driver, unit_test: true, path: dbPath }, this.callback); + }, + "and its ready": function(r) { + assert.isUndefined(r); + }, + "doesn't prevent onReady": { + topic: function() { db.onReady(this.callback); }, + "from working": function(r) { } + } } - } -}); + }); -suite.addBatch({ - "adding keys to email": { - topic: function() { - db.addKeyToEmail('lloyd@nowhe.re', 'lloyd@nowhe.re', 'fakepubkey2', this.callback); - }, - "works": function(r) { - assert.isUndefined(r); - } - } -}); + // caching of secrets between test batches. + var secret = undefined; -suite.addBatch({ - "adding multiple keys to email": { - topic: function() { - db.addKeyToEmail('lloyd@nowhe.re', 'lloyd@nowhe.re', 'fakepubkey3', this.callback); + suite.addBatch({ + "an email address is not reported as staged before it is": { + topic: function() { + db.isStaged('lloyd@nowhe.re', this.callback); + }, + "isStaged returns false": function (r) { + assert.isFalse(r); + } }, - "works too": function(r) { - assert.isUndefined(r); + "an email address is not reported as known before it is": { + topic: function() { + db.emailKnown('lloyd@nowhe.re', this.callback); + }, + "emailKnown returns false": function (r) { + assert.isFalse(r); + } } - } -}); + }); -suite.addBatch({ - "pubkeysForEmail": { - topic: function() { - db.pubkeysForEmail('lloyd@nowhe.re', this.callback); - }, - "returns all public keys properly": function(r) { - assert.isArray(r); - assert.strictEqual(r.length, 3); + suite.addBatch({ + "stage a user for creation pending verification": { + topic: function() { + db.stageUser({ + email: 'lloyd@nowhe.re', + pubkey: 'fakepubkey', + hash: 'fakepasswordhash' + }, this.callback); + }, + "staging returns a valid secret": function(r) { + secret = r; + assert.isString(secret); + assert.strictEqual(secret.length, 48); + } } - } -}); + }); -suite.addBatch({ - "checkAuth returns": { - topic: function() { - db.checkAuth('lloyd@nowhe.re', this.callback); + suite.addBatch({ + "an email address is reported": { + topic: function() { + db.isStaged('lloyd@nowhe.re', this.callback); + }, + " as staged after it is": function (r) { + assert.strictEqual(r, true); + } }, - "the correct password": function(r) { - assert.strictEqual(r, "fakepasswordhash"); + "an email address is not reported": { + topic: function() { + db.emailKnown('lloyd@nowhe.re', this.callback); + }, + " as known when it is only staged": function (r) { + assert.strictEqual(r, false); + } } - } -}); + }); -suite.addBatch({ - "staging an email": { - topic: function() { - return db.stageEmail('lloyd@nowhe.re', 'lloyd@somewhe.re', 'fakepubkey4'); - }, - "yields a valid secret": function(secret) { - assert.isString(secret); - assert.strictEqual(secret.length, 48); - }, - "makes email addr via isStaged": { - topic: function() { return db.isStaged('lloyd@somewhe.re'); }, - "visible": function(r) { assert.isTrue(r); } - }, - "and verifying it": { - topic: function(secret) { + suite.addBatch({ + "upon receipt of a secret": { + topic: function() { db.gotVerificationSecret(secret, this.callback); }, - "returns no error": function(r) { - assert.isUndefined(r); - }, - "makes email addr via knownEmail": { - topic: function() { db.emailKnown('lloyd@somewhe.re', this.callback); }, - "visible": function(r) { assert.isTrue(r); } - }, - "makes email addr via isStaged": { - topic: function() { return db.isStaged('lloyd@somewhe.re'); }, - "not visible": function(r) { assert.isFalse(r); } + "gotVerificationSecret completes without error": function (r) { + assert.strictEqual(r, undefined); } } - } -}); + }); -// exports.emailsBelongToSameAccount -suite.addBatch({ - "emails do belong to the same account": { - "is true": { + suite.addBatch({ + "an email address is not reported": { topic: function() { - db.emailsBelongToSameAccount('lloyd@nowhe.re', 'lloyd@somewhe.re', this.callback); + db.isStaged('lloyd@nowhe.re', this.callback); }, - "when they do": function(r) { - assert.isTrue(r); + "as staged immediately after its verified": function (r) { + assert.strictEqual(r, false); } }, - "is false": { + "an email address is known": { topic: function() { - db.emailsBelongToSameAccount('lloyd@anywhe.re', 'lloyd@somewhe.re', this.callback); + db.emailKnown('lloyd@nowhe.re', this.callback); }, - "when they don't": function(r) { - assert.isFalse(r); + "when it is": function (r) { + assert.strictEqual(r, true); } } - } -}); + }); -// exports.getSyncResponse -suite.addBatch({ - "sync responses": { - "are empty": { + suite.addBatch({ + "adding keys to email": { topic: function() { - db.getSyncResponse('lloyd@nowhe.re', - { - 'lloyd@nowhe.re': 'fakepubkey', - 'lloyd@somewhe.re': 'fakepubkey4' - }, - this.callback); + db.addKeyToEmail('lloyd@nowhe.re', 'lloyd@nowhe.re', 'fakepubkey2', this.callback); }, - "when everything is in sync": function (err, resp) { - assert.isUndefined(err); - assert.isArray(resp.unknown_emails); - assert.isArray(resp.key_refresh); - assert.strictEqual(resp.unknown_emails.length, 0); - assert.strictEqual(resp.key_refresh.length, 0); + "works": function(r) { + assert.isUndefined(r); } - }, - "handles client unknown emails": { + } + }); + + suite.addBatch({ + "adding multiple keys to email": { topic: function() { - db.getSyncResponse('lloyd@nowhe.re', - { - 'lloyd@nowhe.re': 'fakepubkey' - }, - this.callback); + db.addKeyToEmail('lloyd@nowhe.re', 'lloyd@nowhe.re', 'fakepubkey3', this.callback); }, - "by returning them in the key_refresh list": function (err, resp) { - assert.isUndefined(err); - assert.isArray(resp.unknown_emails); - assert.isArray(resp.key_refresh); - assert.strictEqual(resp.unknown_emails.length, 0); - assert.strictEqual(resp.key_refresh.length, 1); - assert.strictEqual(resp.key_refresh[0], 'lloyd@somewhe.re'); + "works too": function(r) { + assert.isUndefined(r); } - }, - "handles server unknown emails": { + } + }); + + suite.addBatch({ + "pubkeysForEmail": { topic: function() { - db.getSyncResponse('lloyd@nowhe.re', - { - 'lloyd@nowhe.re': 'fakepubkey', - 'lloyd@somewhe.re': 'fakepubkey4', - 'lloyd@anywhe.re': 'nofakepubkey', - }, - this.callback); + db.pubkeysForEmail('lloyd@nowhe.re', this.callback); }, - "by returning them in the unknown_emails list": function (err, resp) { - assert.isUndefined(err); - assert.isArray(resp.unknown_emails); - assert.strictEqual(resp.unknown_emails.length, 1); - assert.strictEqual(resp.unknown_emails[0], 'lloyd@anywhe.re'); - assert.isArray(resp.key_refresh); - assert.strictEqual(resp.key_refresh.length, 0); + "returns all public keys properly": function(r) { + assert.isArray(r); + assert.strictEqual(r.length, 3); } - }, - "handles server unknown keys": { + } + }); + + suite.addBatch({ + "checkAuth returns": { topic: function() { - db.getSyncResponse('lloyd@nowhe.re', - { - 'lloyd@nowhe.re': 'fakepubkeyINVALID', - 'lloyd@somewhe.re': 'fakepubkey4' - }, - this.callback); + db.checkAuth('lloyd@nowhe.re', this.callback); }, - "by returning them in the key_refresh list": function (err, resp) { - assert.isUndefined(err); - assert.isArray(resp.unknown_emails); - assert.strictEqual(resp.unknown_emails.length, 0); - assert.isArray(resp.key_refresh); - assert.strictEqual(resp.key_refresh.length, 1); - assert.strictEqual(resp.key_refresh[0], 'lloyd@nowhe.re'); + "the correct password": function(r) { + assert.strictEqual(r, "fakepasswordhash"); } - }, - "handle more than one case at a time": { + } + }); + + suite.addBatch({ + "staging an email": { topic: function() { - db.getSyncResponse('lloyd@nowhe.re', - { - 'lloyd@somewhe.re': 'fakepubkeyINVALID', - 'lloyd@anywhe.re': 'notreally' - }, - this.callback); + db.stageEmail('lloyd@nowhe.re', 'lloyd@somewhe.re', 'fakepubkey4', this.callback); }, - "when everything is outta sync": function (err, resp) { - assert.isUndefined(err); - assert.isArray(resp.unknown_emails); - assert.strictEqual(resp.unknown_emails.length, 1); - assert.strictEqual(resp.unknown_emails[0], 'lloyd@anywhe.re'); + "yields a valid secret": function(secret) { + assert.isString(secret); + assert.strictEqual(secret.length, 48); + }, + "then": { + topic: function(secret) { + var cb = this.callback; + db.isStaged('lloyd@somewhe.re', function(r) { cb(secret, r); }); + }, + "makes it visible via isStaged": function(sekret, r) { assert.isTrue(r); }, + "and lets you verify it": { + topic: function(secret, r) { + db.gotVerificationSecret(secret, this.callback); + }, + "successfully": function(r) { + assert.isUndefined(r); + }, + "and knownEmail": { + topic: function() { db.emailKnown('lloyd@somewhe.re', this.callback); }, + "returns true": function(r) { assert.isTrue(r); } + }, + "and isStaged": { + topic: function() { db.isStaged('lloyd@somewhe.re', this.callback); }, + "returns false": function(r) { assert.isFalse(r); } + } + } + } + } + }); - assert.isArray(resp.key_refresh); - assert.strictEqual(resp.key_refresh.length, 2); - assert.strictEqual(resp.key_refresh[0], 'lloyd@nowhe.re'); - assert.strictEqual(resp.key_refresh[1], 'lloyd@somewhe.re'); + // exports.emailsBelongToSameAccount + suite.addBatch({ + "emails do belong to the same account": { + "is true": { + topic: function() { + db.emailsBelongToSameAccount('lloyd@nowhe.re', 'lloyd@somewhe.re', this.callback); + }, + "when they do": function(r) { + assert.isTrue(r); + } + }, + "is false": { + topic: function() { + db.emailsBelongToSameAccount('lloyd@anywhe.re', 'lloyd@somewhe.re', this.callback); + }, + "when they don't": function(r) { + assert.isFalse(r); + } } } - } -}); + }); -suite.addBatch({ - "removing an existing email": { - topic: function() { - db.removeEmail("lloyd@somewhe.re", "lloyd@nowhe.re", this.callback); - }, - "returns no error": function(r) { - assert.isUndefined(r); - }, - "causes emailKnown": { - topic: function() { - db.emailKnown('lloyd@nowhe.re', this.callback); + // exports.getSyncResponse + suite.addBatch({ + "sync responses": { + "are empty": { + topic: function() { + db.getSyncResponse('lloyd@nowhe.re', + { + 'lloyd@nowhe.re': 'fakepubkey', + 'lloyd@somewhe.re': 'fakepubkey4' + }, + this.callback); + }, + "when everything is in sync": function (err, resp) { + assert.isUndefined(err); + assert.isArray(resp.unknown_emails); + assert.isArray(resp.key_refresh); + assert.strictEqual(resp.unknown_emails.length, 0); + assert.strictEqual(resp.key_refresh.length, 0); + } }, - "to return false": function (r) { - assert.strictEqual(r, false); + "handles client unknown emails": { + topic: function() { + db.getSyncResponse('lloyd@nowhe.re', + { + 'lloyd@nowhe.re': 'fakepubkey' + }, + this.callback); + }, + "by returning them in the key_refresh list": function (err, resp) { + assert.isUndefined(err); + assert.isArray(resp.unknown_emails); + assert.isArray(resp.key_refresh); + assert.strictEqual(resp.unknown_emails.length, 0); + assert.strictEqual(resp.key_refresh.length, 1); + assert.strictEqual(resp.key_refresh[0], 'lloyd@somewhe.re'); + } + }, + "handles server unknown emails": { + topic: function() { + db.getSyncResponse('lloyd@nowhe.re', + { + 'lloyd@nowhe.re': 'fakepubkey', + 'lloyd@somewhe.re': 'fakepubkey4', + 'lloyd@anywhe.re': 'nofakepubkey', + }, + this.callback); + }, + "by returning them in the unknown_emails list": function (err, resp) { + assert.isUndefined(err); + assert.isArray(resp.unknown_emails); + assert.strictEqual(resp.unknown_emails.length, 1); + assert.strictEqual(resp.unknown_emails[0], 'lloyd@anywhe.re'); + assert.isArray(resp.key_refresh); + assert.strictEqual(resp.key_refresh.length, 0); + } + }, + "handles server unknown keys": { + topic: function() { + db.getSyncResponse('lloyd@nowhe.re', + { + 'lloyd@nowhe.re': 'fakepubkeyINVALID', + 'lloyd@somewhe.re': 'fakepubkey4' + }, + this.callback); + }, + "by returning them in the key_refresh list": function (err, resp) { + assert.isUndefined(err); + assert.isArray(resp.unknown_emails); + assert.strictEqual(resp.unknown_emails.length, 0); + assert.isArray(resp.key_refresh); + assert.strictEqual(resp.key_refresh.length, 1); + assert.strictEqual(resp.key_refresh[0], 'lloyd@nowhe.re'); + } + }, + "handle more than one case at a time": { + topic: function() { + db.getSyncResponse('lloyd@nowhe.re', + { + 'lloyd@somewhe.re': 'fakepubkeyINVALID', + 'lloyd@anywhe.re': 'notreally' + }, + this.callback); + }, + "when everything is outta sync": function (err, resp) { + assert.isUndefined(err); + assert.isArray(resp.unknown_emails); + assert.strictEqual(resp.unknown_emails.length, 1); + assert.strictEqual(resp.unknown_emails[0], 'lloyd@anywhe.re'); + + assert.isArray(resp.key_refresh); + assert.strictEqual(resp.key_refresh.length, 2); + assert.strictEqual(resp.key_refresh[0], 'lloyd@nowhe.re'); + assert.strictEqual(resp.key_refresh[1], 'lloyd@somewhe.re'); + } } } - } -}); + }); -suite.addBatch({ - "canceling an account": { - topic: function() { - db.cancelAccount("lloyd@somewhe.re", this.callback); - }, - "returns no error": function(r) { - assert.isUndefined(r); - }, - "causes emailKnown": { + suite.addBatch({ + "removing an existing email": { topic: function() { - db.emailKnown('lloyd@somewhe.re', this.callback); + db.removeEmail("lloyd@somewhe.re", "lloyd@nowhe.re", this.callback); }, - "to return false": function (r) { - assert.strictEqual(r, false); + "returns no error": function(r) { + assert.isUndefined(r); + }, + "causes emailKnown": { + topic: function() { + db.emailKnown('lloyd@nowhe.re', this.callback); + }, + "to return false": function (r) { + assert.strictEqual(r, false); + } } } - } -}); + }); -// exports.cancelAccount -// exports.removeEmail + suite.addBatch({ + "canceling an account": { + topic: function() { + db.cancelAccount("lloyd@somewhe.re", this.callback); + }, + "returns no error": function(r) { + assert.isUndefined(r); + }, + "causes emailKnown": { + topic: function() { + db.emailKnown('lloyd@somewhe.re', this.callback); + }, + "to return false": function (r) { + assert.strictEqual(r, false); + } + } + } + }); -suite.addBatch({ - "remove the database file": { - topic: function() { - fs.unlink(db.dbPath, this.callback); - }, - "and unlink should not error": function(err) { - assert.isNull(err); - }, - "and the file": { + suite.addBatch({ + "closing the database": { topic: function() { - path.exists(db.dbPath, this.callback); + db.close(this.callback); }, - "should be missing": function(r) { - assert.isFalse(r); + "should work": function(err) { + assert.isUndefined(err); } } + }); + + if (driver !== 'mysql') { + suite.addBatch({ + "remove the database file": { + topic: function() { + fs.unlink(dbPath, this.callback); + }, + "and unlink should not error": function(err) { + assert.isNull(err); + }, + "and the file": { + topic: function() { + path.exists(dbPath, this.callback); + }, + "should be missing": function(r) { + assert.isFalse(r); + } + } + } + }); + } +} + +// test all available drivers +files = fs.readdirSync(path.join(__dirname, "..", "lib")); + +files.forEach(function(f) { + var m = /^db_(.+)\.js$/.exec(f); + if (m) { + addTestsForDriver(m[1]); } }); // run or export the suite. if (process.argv[1] === __filename) suite.run(); else suite.export(module); + diff --git a/browserid/tests/forgotten-email-test.js b/browserid/tests/forgotten-email-test.js index 1a7d015476b3ec4076256c9fe4f3ace339a597f5..2fe7cffc5a8dcde8d685f3260a5cfc45192c134c 100755 --- a/browserid/tests/forgotten-email-test.js +++ b/browserid/tests/forgotten-email-test.js @@ -1,13 +1,16 @@ #!/usr/bin/env node const assert = require('assert'), - vows = require('vows'), - start_stop = require('./lib/start-stop.js'), - wsapi = require('./lib/wsapi.js'), - interceptor = require('./lib/email-interceptor.js'); +vows = require('vows'), +start_stop = require('./lib/start-stop.js'), +wsapi = require('./lib/wsapi.js'), +interceptor = require('./lib/email-interceptor.js'); var suite = vows.describe('forgotten-email'); +// disable vows (often flakey?) async error behavior +suite.options.error = false; + start_stop.addStartupBatches(suite); // ever time a new token is sent out, let's update the global diff --git a/libs/configuration.js b/libs/configuration.js index 2690dc1f2fa1eb02b72e072f1f0cca4e436cd439..4ad59da7fe5963ac8b5d9489e910e3b5378c7ff8 100644 --- a/libs/configuration.js +++ b/libs/configuration.js @@ -26,28 +26,32 @@ const g_configs = { port: '443', scheme: 'https', use_minified_resources: true, - log_path: '/home/browserid/var/' + log_path: '/home/browserid/var/', + database: { driver: "mysql" } }, development: { hostname: 'dev.diresworb.org', port: '443', scheme: 'https', use_minified_resources: true, - log_path: '/home/browserid/var/' + log_path: '/home/browserid/var/', + database: { driver: "mysql" } }, beta: { hostname: 'diresworb.org', port: '443', scheme: 'https', use_minified_resources: true, - log_path: '/home/browserid/var/' + log_path: '/home/browserid/var/', + database: { driver: "mysql" } }, local: { hostname: '127.0.0.1', port: '10002', scheme: 'http', use_minified_resources: false, - log_path: './' + log_path: './', + database: { driver: "json" } } }; diff --git a/libs/logging.js b/libs/logging.js index c34aaa0c15ad10cd60c1f1c93b3f7968fb02dc0e..1d18ef520ea635ec36a57ed302728a543e37a6a7 100644 --- a/libs/logging.js +++ b/libs/logging.js @@ -35,7 +35,7 @@ exports.log = function(category, entry) { // timestamp entry.at = new Date().toUTCString(); - + // if no logger, go to console (FIXME: do we really want to log to console?) LOGGERS[category].info(JSON.stringify(entry)); }; diff --git a/package.json b/package.json index 2b803d4f04ccf5e29ec72e3e131872acbfbbab18..4691170022a0f6cfdb923871eabe62af751055ca 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,6 @@ , "dependencies": { "express": "2.4.3" , "xml2js": "0.1.5" - , "sqlite": "1.0.3" , "nodemailer": "0.1.18" , "mustache": "0.3.1-dev" , "cookie-sessions": "0.0.2" @@ -15,7 +14,9 @@ , "temp": "0.2.0" , "express-csrf": "0.3.2" , "uglify-js": "1.0.6" + , "JSONSelect": "0.2.1" , "winston" : "0.3.3" - , "connect-cookie-session" : "0.0.1" + , "connect-cookie-session" : "0.0.1" + , "mysql" : "0.9.1" } -} \ No newline at end of file +} diff --git a/test.sh b/test.sh new file mode 100755 index 0000000000000000000000000000000000000000..a0ef1563a8e8f5556edc3d85fce2c29c7936278f --- /dev/null +++ b/test.sh @@ -0,0 +1,14 @@ +#!/bin/bash + +VOWS=`which vows 2> /dev/null` +if [ ! -x "$VOWS" ]; then + echo "vows not found in your path. try: npm install -g vows" + exit 1 +fi + +for file in browserid/tests/*.js ; do + vows $file + if [[ $? != 0 ]] ; then + exit 1 + fi +done