/* 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/. */ /* 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: * +--- email -------+ * +--- user --------------------+ |*int id | * |*int id |<-----|*int user | * | string passwd | |*string address | * | timestamp lastPasswordReset | | enum type | * +-----------------------------+ | bool verified | * +-----------------+ * * * +------ staged ----------+ * |*int id | * |*string secret | * | bool new_acct | * | int existing_user | * |*string email | * |*string passwd | * | timestamp ts | * +------------------------+ */ /*global dne:true */ const mysql = require('./mysql_wrapper.js'), secrets = require('../secrets.js'), logger = require('../logging.js').logger, conf = require('../configuration.js'); var client = undefined; // for testing! when 'STALL_MYSQL_WHEN_PRESENT' is defined in the environment, // it causes the driver to simulate stalling whent said file is present if (conf.get('env') === 'test_mysql' && process.env['STALL_MYSQL_WHEN_PRESENT']) { logger.debug('database driver will be stalled when file is present: ' + process.env['STALL_MYSQL_WHEN_PRESENT']); const fs = require('fs'); fs.watchFile( process.env['STALL_MYSQL_WHEN_PRESENT'], { persistent: false, interval: 1 }, function (curr, prev) { // stall the database driver when specified file is present fs.stat(process.env['STALL_MYSQL_WHEN_PRESENT'], function(err, stats) { if (client) { var stall = !(err && err.code === 'ENOENT'); logger.debug("database driver is " + (stall ? "stalled" : "unblocked")); client.stall(stall); } }); }); } // If you change these schemas, please notify <services-ops@mozilla.com> const schemas = [ "CREATE TABLE IF NOT EXISTS user (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY," + "passwd CHAR(64)," + "lastPasswordReset TIMESTAMP DEFAULT 0 NOT NULL" + ") ENGINE=InnoDB;", "CREATE TABLE IF NOT EXISTS email (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY," + "user BIGINT NOT NULL," + "address VARCHAR(255) UNIQUE NOT NULL," + "type ENUM('secondary', 'primary') DEFAULT 'secondary' NOT NULL," + "verified BOOLEAN DEFAULT TRUE NOT NULL, " + "FOREIGN KEY user_fkey (user) REFERENCES user(id)" + ") ENGINE=InnoDB;", "CREATE TABLE IF NOT EXISTS staged (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY," + "secret CHAR(48) UNIQUE NOT NULL," + "new_acct BOOL NOT NULL," + "existing_user BIGINT," + "email VARCHAR(255) UNIQUE NOT NULL," + "passwd CHAR(64)," + "ts TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL," + "FOREIGN KEY existing_user_fkey (existing_user) REFERENCES user(id)" + ") ENGINE=InnoDB;", ]; function now() { return Math.floor(new Date().getTime() / 1000); } // 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.warn("unexpected database failure: " + detail + " -- " + where); } // open & create the mysql database exports.open = function(cfg, cb) { if (client) throw "database is already open!"; // mysql config requires var options = { host: '127.0.0.1', port: "3306", user: undefined, password: undefined, unit_test: false }; Object.keys(options).forEach(function(param) { options[param] = (cfg[param] !== undefined ? cfg[param] : options[param]); if (options[param] === undefined) delete options[param]; }); // let's figure out the database name var database = cfg.name; if (!database) database = "browserid"; // create the client function doConnect() { logger.debug("connecting to database: " + database); options.database = database; client = mysql.createClient(options); client.ping(function(err) { logger.debug("connection to database " + (err ? ("fails: " + err) : "established")); cb(err); }); } // now create the databse if (cfg.create_schema) { logger.debug("creating database and tables if required"); var createClient = mysql.createClient(options); createClient.query("CREATE DATABASE IF NOT EXISTS " + database, function(err) { if (err) { logUnexpectedError(err); cb(err); return; } createClient.useDatabase(database, function(err) { if (err) { logUnexpectedError(err); cb(err); return; } // now create tables function createNextTable(i) { if (i < schemas.length) { createClient.query(schemas[i], function(err) { if (err) { logUnexpectedError(err); cb(err); } else { createNextTable(i+1); } }); } else { createClient.end(function(err) { if (err) { logUnexpectedError(err); cb(err); } else { doConnect(); } }); } } createNextTable(0); }); }); } else { doConnect(); } }; exports.close = function(cb) { client.end(function(err) { client = undefined; if (err) logUnexpectedError(err); if (cb) cb(err === undefined ? null : err); }); }; exports.closeAndRemove = function(cb) { var db_to_remove = client.database; // don't let this happen if the name of the database is 'browserid', // as a sanity check if (db_to_remove === 'browserid') { throw "dropping a database named 'browserid' is not allowed"; } client.query("DROP DATABASE " + db_to_remove, function(err) { exports.close(cb); }); }; exports.emailKnown = function(email, cb) { client.query( "SELECT COUNT(*) as N FROM email WHERE address = ?", [ email ], function(err, rows) { cb(err, rows && rows.length > 0 && rows[0].N > 0); } ); }; exports.userKnown = function(uid, cb) { client.query( "SELECT COUNT(*) as N FROM user WHERE id = ?", [ uid ], function(err, rows) { cb(err, rows && rows.length > 0 && rows[0].N > 0); } ); }; exports.emailType = function(email, cb) { client.query( "SELECT type FROM email WHERE address = ?", [ email ], function(err, rows) { cb(err, (rows && rows.length > 0) ? rows[0].type : undefined); } ); } exports.emailIsVerified = function(email, cb) { client.query( "SELECT verified FROM email WHERE address = ?", [ email ], function(err, rows) { if (rows && rows.length > 0) cb(err, !!rows[0].verified); else cb('no such email'); } ); }; exports.isStaged = function(email, cb) { client.query( "SELECT COUNT(*) as N FROM staged WHERE email = ?", [ email ], function(err, rows) { cb(err, rows && rows.length > 0 && rows[0].N > 0); } ); } exports.lastStaged = function(email, cb) { client.query( "SELECT UNIX_TIMESTAMP(ts) as ts FROM staged WHERE email = ?", [ email ], function(err, rows) { if (err) cb(err); else if (!rows || rows.length === 0) cb(null); else cb(null, new Date(rows[0].ts * 1000)); } ); }; exports.stageUser = function(email, hash, cb) { secrets.generate(48, function(secret) { // overwrite previously staged users client.query('INSERT INTO staged (secret, new_acct, email, passwd) VALUES(?,TRUE,?,?) ' + 'ON DUPLICATE KEY UPDATE secret=VALUES(secret), existing_user=NULL, new_acct=TRUE, ts=NOW()', [ secret, email, hash ], function(err) { cb(err, err ? undefined : secret); }); }); }; exports.haveVerificationSecret = function(secret, cb) { client.query( "SELECT count(*) as n FROM staged WHERE secret = ?", [ secret ], function(err, rows) { cb(err, rows && rows.length === 1 && rows[0].n === 1); }); }; exports.emailForVerificationSecret = function(secret, cb) { client.query( "SELECT email, existing_user, passwd FROM staged WHERE secret = ?", [ secret ], function(err, rows) { if (err) return cb("database unavailable"); // if the record was not found, fail out if (!rows || rows.length != 1) return cb("no such secret"); cb(null, rows[0].email, rows[0].existing_user, rows[0].passwd); }); }; exports.authForVerificationSecret = function(secret, cb) { client.query( "SELECT existing_user, passwd FROM staged WHERE secret = ?", [ secret ], function(err, rows) { if (err) return cb("database unavailable"); // if the record was not found, fail out if (!rows || rows.length != 1) return cb("no such secret"); var o = rows[0]; // if there is a hashed passwd in the result, we're done if (o.passwd) return cb(null, o.passwd, o.existing_user); // otherwise, let's get the passwd from the user record if (!o.existing_user) return cb("no password for user"); exports.checkAuth(o.existing_user, function(err, hash) { cb(err, hash, o.existing_user); }); }); }; exports.verificationSecretForEmail = function(email, cb) { client.query( "SELECT secret FROM staged WHERE email = ?", [ email ], function(err, rows) { cb(err, (rows && rows.length > 0) ? rows[0].secret : undefined); }); }; function addEmailToUser(userID, email, type, cb) { // issue #170 - delete any old records with the same // email address. this is necessary because // gotVerificationSecret is invoked both for // forgotten password flows and for new user signups. client.query( "DELETE FROM email WHERE address = ?", [ email ], function(err, info) { if (err) return cb(err); else { client.query( "INSERT INTO email(user, address, type) VALUES(?, ?, ?)", [ userID, email, type ], function(err, info) { if (err) logUnexpectedError(err); cb(err, email, userID); }); } }); } function getAndDeleteRowForSecret(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 { // delete the record client.query("DELETE LOW_PRIORITY FROM staged WHERE secret = ?", [ secret ]); cb(null, rows[0]); } }); } exports.completeCreateUser = function(secret, cb) { getAndDeleteRowForSecret(secret, function(err, o) { if (err) return cb(err); if (!o.new_acct) return cb("this verification link is not for a new account"); // we're creating a new account, add appropriate entries into user and email tables. client.query( "INSERT INTO user(passwd, lastPasswordReset) VALUES(?,FROM_UNIXTIME(?))", [ o.passwd, now() ], function(err, info) { if (err) return cb(err); addEmailToUser(info.insertId, o.email, 'secondary', cb); }); }); }; // either a email re-verification, or an email addition - we treat these things // the same exports.completeConfirmEmail = function(secret, cb) { getAndDeleteRowForSecret(secret, function(err, o) { if (err) return cb(err); if (o.new_acct) return cb("this verification link is not for an email addition"); // ensure the expected existing_user field is populated, which it must always be when // new_acct is false if (typeof o.existing_user !== 'number') { return cb("data inconsistency, no numeric existing user associated with staged email address"); } // we're adding or reverifying an email address to an existing user account. add appropriate // entries into email table. if (o.passwd) { exports.updatePassword(o.existing_user, o.passwd, false, function(err) { if (err) return cb('could not set user\'s password'); addEmailToUser(o.existing_user, o.email, 'secondary', cb); }); } else { addEmailToUser(o.existing_user, o.email, 'secondary', cb); } }); }; exports.completePasswordReset = function(secret, cb) { getAndDeleteRowForSecret(secret, function(err, o) { if (err) return cb(err); if (o.new_acct || !o.passwd || !o.existing_user) { return cb("this verification link is not for a password reset"); } // verify that the email still exists in the database, and the the user with whom it is // associated is the same as the user in the database exports.emailToUID(o.email, function(err, uid) { if (err) return cb(err); // if for some reason the email is associated with a different user now than when // the action was initiated, error out. if (uid !== o.existing_user) { return cb("cannot update password, data inconsistency"); } // flip the verification bit on all emails for the user other than the one just verified client.query( 'UPDATE email SET verified = FALSE WHERE user = ? AND type = ? AND address != ?', [ uid, 'secondary', o.email ], function(err) { if (err) return cb(err); // update the password! exports.updatePassword(uid, o.passwd, true, function(err) { cb(err, o.email, uid); }); }); }); }); }; exports.addPrimaryEmailToAccount = function(uid, emailToAdd, cb) { // we're adding an email address to an existing user account. add appropriate entries into // email table addEmailToUser(uid, emailToAdd, 'primary', cb); } exports.createUserWithPrimaryEmail = function(email, cb) { // create a new user acct with no password client.query( "INSERT INTO user(lastPasswordReset) VALUES(FROM_UNIXTIME(?))", [ now() ], function(err, info) { if (err) return cb(err); var uid = info.insertId; client.query( "INSERT INTO email(user, address, type) VALUES(?, ?, ?)", [ uid, email, 'primary' ], function(err, info) { cb(err, uid); }); }); }; 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) { cb(err, rows && rows.length === 1 && rows[0].n === 1); }); } exports.userOwnsEmail = function(uid, email, cb) { client.query( 'SELECT COUNT(*) AS n FROM email WHERE address = ? AND user = ?', [ email, uid ], function (err, rows) { cb(err, rows && rows.length === 1 && rows[0].n === 1); }); } exports.stageEmail = function(existing_user, new_email, hash, cb) { secrets.generate(48, function(secret) { // overwrite previously staged users client.query('INSERT INTO staged (secret, new_acct, existing_user, email, passwd) VALUES(?,FALSE,?,?,?) ' + 'ON DUPLICATE KEY UPDATE secret=VALUES(secret), existing_user=VALUES(existing_user), new_acct=FALSE, ts=NOW()', [ secret, existing_user, new_email, hash ], function(err) { cb(err, err ? undefined : secret); }); }); }; exports.emailToUID = function(email, cb) { client.query( 'SELECT user FROM email WHERE address = ?', [ email ], function (err, rows) { cb(err, (rows && rows.length == 1) ? rows[0].user : undefined); }); }; exports.checkAuth = function(uid, cb) { client.query( 'SELECT passwd FROM user WHERE id = ?', [ uid ], function (err, rows) { cb(err, (rows && rows.length == 1) ? rows[0].passwd : undefined); }); } exports.lastPasswordReset = function(uid, cb) { client.query( 'SELECT UNIX_TIMESTAMP(lastPasswordReset) AS lastPasswordReset FROM user WHERE id = ?', [ uid ], function (err, rows) { cb(err, (rows && rows.length == 1) ? rows[0].lastPasswordReset : undefined); }); } exports.updatePassword = function(uid, hash, invalidateSessions, cb) { var query = invalidateSessions ? 'UPDATE user SET passwd = ?, lastPasswordReset = FROM_UNIXTIME(?) WHERE id = ?' : 'UPDATE user SET passwd = ? WHERE id = ?'; var args = invalidateSessions ? [ hash, now(), uid ] : [ hash, uid ]; client.query(query, args, function (err, rows) { if (!err && (!rows || rows.affectedRows !== 1)) { err = "no record with id " + uid; } cb(err); }); } /* * list the user's emails. * * returns an object keyed by email address with properties for each email. */ exports.listEmails = function(uid, cb) { client.query( 'SELECT address, type, verified FROM email WHERE user = ?', [ uid ], function (err, rows) { if (err) cb(err); else { var emails = {}; for (var i = 0; i < rows.length; i++) { var o = { type: rows[i].type }; if (o.type === 'secondary') { o.verified = rows[i].verified ? true : false; } emails[rows[i].address] = o; } cb(null,emails); } }); }; exports.removeEmail = function(authenticated_user, email, cb) { exports.userOwnsEmail(authenticated_user, email, function(err, ok) { if (err) return cb(err); if (!ok) { logger.warn(authenticated_user + ' 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 email WHERE address = ?', [ email ], function(err, info) { cb(err); }); }); }; exports.cancelAccount = function(uid, cb) { client.query("DELETE LOW_PRIORITY FROM email WHERE user = ?", [ uid ], function(err) { if (err) return cb(err); client.query("DELETE LOW_PRIORITY FROM user WHERE id = ?", [ uid ], cb); }); }; exports.addTestUser = function(email, hash, cb) { client.query( "INSERT INTO user(passwd, lastPasswordReset) VALUES(FROM_UNIXTIME(?))", [ hash, now() ], function(err, info) { if (err) return cb(err); client.query( "INSERT INTO email(user, address) VALUES(?, ?)", [ info.insertId, email ], function(err, info) { if (err) logUnexpectedError(err); cb(err, err ? null : email); }); }); }; exports.ping = function(cb) { client.ping(function(err) { cb(err); }); };