diff --git a/lib/db.js b/lib/db.js index 22fc1da0021d7cc5d004a64397234040f73f617d..ea1ccd7977e61f1ff12eed45e49e0fd86792ba71 100644 --- a/lib/db.js +++ b/lib/db.js @@ -105,6 +105,7 @@ exports.onReady = function(f) { // these are read only database calls [ 'emailKnown', + 'userKnown', 'isStaged', 'emailsBelongToSameAccount', 'emailForVerificationSecret', @@ -114,7 +115,9 @@ exports.onReady = function(f) { 'listEmails', 'lastStaged', 'ping', - 'emailType' + 'emailType', + 'userOwnsEmail', + 'emailToUID' ].forEach(function(fn) { exports[fn] = function() { checkReady(); diff --git a/lib/db/json.js b/lib/db/json.js index 2523d647676bcf39403fc472e16bb463707dd2a8..9b9510bb01caddb3396c3eaba9c486c827625cc2 100644 --- a/lib/db/json.js +++ b/lib/db/json.js @@ -55,6 +55,7 @@ var dbPath = path.join(configuration.get('var_path'), "authdb.json"); /* The JSON database. The structure is thus: * [ * { + * id: <numerical user id> * password: "somepass", * emails: { * "lloyd@hilaiel.com": { @@ -65,6 +66,14 @@ var dbPath = path.join(configuration.get('var_path'), "authdb.json"); * ] */ +function getNextUserID() { + var max = 1; + jsel.forEach(".id", db.users, function(id) { + if (id >= max) max = id + 1; + }); + return max; +}; + var db = { users: [ ], stagedEmails: { }, @@ -157,8 +166,8 @@ exports.emailsBelongToSameAccount = function(lhs, rhs, cb) { sync(); emailToUserID(lhs, function(lhs_uid) { emailToUserID(rhs, function(rhs_uid) { - cb(lhs_uid === rhs_uid); - }, function (error) { + cb(lhs_uid === rhs_uid); + }, function (error) { cb(false); }); }, function (error) { @@ -166,7 +175,25 @@ exports.emailsBelongToSameAccount = function(lhs, rhs, cb) { }); }; -function addEmailToAccount(existing_email, email, type, cb) { +exports.emailToUID = function(email, cb) { + sync(); + var m = jsel.match(":root > object:has(.emails > ." + ESC(email) + ") > .id", db.users); + if (m.length === 0) m = undefined; + else m = m[0]; + process.nextTick(function() { + cb(m); + }); +}; + +exports.userOwnsEmail = function(uid, email, cb) { + sync(); + var m = jsel.match(":root > object:has(:root > .id:expr(x=" + ESC(uid) + ")):has(.emails > ." + ESC(email) + ")", db.users); + process.nextTick(function() { + cb(!!m); + }); +}; + +function addEmailToAccount(userID, email, type, cb) { // validate 'type' isn't bogus if ([ 'secondary', 'primary' ].indexOf(type) === -1) { return process.nextTick(function() { @@ -174,14 +201,14 @@ function addEmailToAccount(existing_email, email, type, cb) { }); } - emailToUserID(existing_email, function(userID) { - if (userID == undefined) { - cb("no such email: " + existing_email, undefined); - } else { - db.users[userID].emails[email] = { type: type }; + process.nextTick(function() { + sync(); + var emails = jsel.match(":has(.id:expr(x="+ ESC(userID) +")) > .emails", db.users); + if (emails && emails.length > 0) { + emails[0][email] = { type: type }; flush(); - cb(); } + cb(); }); } @@ -200,13 +227,13 @@ exports.stageUser = function(email, cb) { }); }; -exports.stageEmail = function(existing_email, new_email, cb) { +exports.stageEmail = function(existing_user, new_email, cb) { secrets.generate(48, function(secret) { // overwrite previously staged users sync(); db.staged[secret] = { type: "add_email", - existing_email: existing_email, + existing_user: existing_user, email: new_email, when: (new Date()).getTime() }; @@ -221,6 +248,7 @@ exports.createUserWithPrimaryEmail = function(email, cb) { var emailVal = { }; emailVal[email] = { type: 'primary' }; db.users.push({ + id: getNextUserID(), password: null, emails: emailVal }); @@ -242,7 +270,7 @@ exports.emailForVerificationSecret = function(secret, cb) { process.nextTick(function() { sync(); if (!db.staged[secret]) return cb("no such secret"); - exports.checkAuth(db.staged[secret].existing_email, function (hash) { + exports.checkAuth(db.staged[secret].existing_user, function (hash) { cb(undefined, { email: db.staged[secret].email, needs_password: !hash @@ -273,6 +301,7 @@ exports.gotVerificationSecret = function(secret, hash, cb) { var emailVal = {}; emailVal[o.email] = { type: 'secondary' }; db.users.push({ + id: getNextUserID(), password: hash, emails: emailVal }); @@ -286,9 +315,8 @@ exports.gotVerificationSecret = function(secret, hash, cb) { // 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) { + removeEmailNoCheck(o.email, function (err) { if (err) cb(err); else createAccount(); }); @@ -299,10 +327,10 @@ exports.gotVerificationSecret = function(secret, hash, cb) { } else if (o.type === 'add_email') { exports.emailKnown(o.email, function(known) { function addIt() { - addEmailToAccount(o.existing_email, o.email, 'secondary', cb); + addEmailToAccount(o.existing_user, o.email, 'secondary', cb); } if (known) { - exports.removeEmail(o.email, o.email, function (err) { + removeEmailNoCheck(o.email, function (err) { if (err) cb(err); else addIt(); }); @@ -315,15 +343,14 @@ exports.gotVerificationSecret = function(secret, hash, cb) { } }; -exports.addPrimaryEmailToAccount = function(acctEmail, emailToAdd, cb) { +exports.addPrimaryEmailToAccount = function(userID, emailToAdd, cb) { sync(); - exports.emailKnown(emailToAdd, function(known) { function addIt() { - addEmailToAccount(acctEmail, emailToAdd, 'primary', cb); + addEmailToAccount(userID, emailToAdd, 'primary', cb); } if (known) { - exports.removeEmail(emailToAdd, emailToAdd, function (err) { + removeEmailNoCheck(emailToAdd, function (err) { if (err) cb(err); else addIt(); }); @@ -333,22 +360,33 @@ exports.addPrimaryEmailToAccount = function(acctEmail, emailToAdd, cb) { }); }; -exports.checkAuth = function(email, cb) { +exports.checkAuth = function(userID, cb) { + sync(); + var m = undefined; + if (userID) { + m = jsel.match(":root > object:has(:root > .id:expr(x=" + ESC(userID) + ")) > .password", db.users); + if (m.length === 0) m = undefined; + else m = m[0]; + } + process.nextTick(function() { cb(m) }); +}; + +exports.userKnown = function(userID, cb) { sync(); - var m = jsel.match(":root > object:has(.emails > ." + ESC(email) + ") > .password", db.users); + var m = jsel.match(":root > object:has(:root > .id:expr(x=" + ESC(userID) + "))", db.users); if (m.length === 0) m = undefined; else m = m[0]; - setTimeout(function() { cb(m) }, 0); + process.nextTick(function() { cb(m) }); }; -exports.updatePassword = function(email, hash, cb) { +exports.updatePassword = function(userID, hash, cb) { sync(); - var m = jsel.match(":root > object:has(.emails > ." + ESC(email) + ")", db.users); + var m = jsel.match(":root > object:has(.id:expr(x=" + ESC(userID) + "))", db.users); var err = undefined; if (m.length === 0) err = "no such email address"; else m[0].password = hash; flush(); - setTimeout(function() { cb(err) }, 0); + process.nextTick(function() { cb(err) }); }; function emailToUserID(email, cb) { @@ -366,22 +404,21 @@ function emailToUserID(email, cb) { setTimeout(function() { cb(id); }, 0); } -exports.listEmails = function(email, cb) { +exports.listEmails = function(uid, cb) { sync(); - // get the user id associated with this account - emailToUserID(email, function(userID) { - if (userID === undefined) { - cb("no such email: " + email); + var emails = jsel.match(":has(.id:expr(x="+ ESC(uid) +")) > .emails", db.users); + process.nextTick(function() { + if (!emails || emails.length != 1) { + cb("no such user: " + uid); return; } - var emails = jsel.match(".emails", db.users[userID]); cb(null, emails[0]); }); }; -exports.removeEmail = function(authenticated_email, email, cb) { +exports.removeEmail = function(authenticated_user, email, cb) { sync(); - var m = jsel.match(".emails:has(."+ESC(authenticated_email)+"):has(."+ESC(email)+")", db.users); + var m = jsel.match(":has(.id:expr(x=" + ESC(authenticated_user) + ")) .emails:has(."+ESC(email)+")", db.users); if (m.length) { var emails = m[0]; @@ -391,20 +428,40 @@ exports.removeEmail = function(authenticated_email, email, cb) { setTimeout(function() { cb(); }, 0); }; -exports.cancelAccount = function(authenticated_email, cb) { - emailToUserID(authenticated_email, function(user_id) { - db.users.splice(user_id, 1); +function removeEmailNoCheck(email, cb) { + sync(); + var m = jsel.match(".emails:has(."+ESC(email)+")", db.users); + if (m.length) { + var emails = m[0]; + delete emails[email]; flush(); - cb(); - }); + } + process.nextTick(function() { cb(); }); +}; + +exports.cancelAccount = function(authenticated_uid, cb) { + sync(); + var id = undefined; + + for (var i = 0; i < db.users.length; i++) { + if (db.users[i].id === authenticated_uid) break; + } + + if (i < db.users.length) { + db.users.splice(i, 1); + flush(); + } + + process.nextTick(function() { cb(); }); }; exports.addTestUser = function(email, hash, cb) { sync(); - exports.removeEmail(email, email, function() { + removeEmailNoCheck(email, function() { var emailVal = {}; emailVal[email] = { type: 'secondary' }; db.users.push({ + id: getNextUserID(), password: hash, emails: emailVal }); diff --git a/tests/db-test.js b/tests/db-test.js index 59d4b70cf73457b3fb5ca426e13fd95bcafea579..74fa688ab9db3ebfb987d2f2994797f04cb8de04 100755 --- a/tests/db-test.js +++ b/tests/db-test.js @@ -180,7 +180,10 @@ suite.addBatch({ suite.addBatch({ "checkAuth returns": { topic: function() { - db.checkAuth('lloyd@nowhe.re', this.callback); + var cb = this.callback; + db.emailToUID('lloyd@nowhe.re', function(uid) { + db.checkAuth(uid, cb); + }); }, "the correct password": function(r) { assert.strictEqual(r, "fakepasswordhash"); @@ -189,34 +192,58 @@ suite.addBatch({ }); suite.addBatch({ - "staging an email": { + "emailToUID": { topic: function() { - db.stageEmail('lloyd@nowhe.re', 'lloyd@somewhe.re', this.callback); + db.emailToUID('lloyd@nowhe.re', this.callback); }, - "yields a valid secret": function(secret) { - assert.isString(secret); - assert.strictEqual(secret.length, 48); + "returns a valid userid": function(r) { + assert.isNumber(r); }, - "then": { - topic: function(secret) { - var cb = this.callback; - db.isStaged('lloyd@somewhe.re', function(r) { cb(secret, r); }); + "returns a UID": { + topic: function(uid) { + db.userOwnsEmail(uid, 'lloyd@nowhe.re', this.callback); }, - "makes it visible via isStaged": function(sekret, r) { assert.isTrue(r); }, - "lets you verify it": { - topic: function(secret, r) { - db.gotVerificationSecret(secret, undefined, 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); } + "that owns the original email": function(r) { + assert.ok(r); + } + } + } +}); + +suite.addBatch({ + "getting a UID, then": { + topic: function() { + db.emailToUID('lloyd@nowhe.re', this.callback); + }, + "staging an email": { + topic: function(uid) { + db.stageEmail(uid, 'lloyd@somewhe.re', this.callback); + }, + "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); }); }, - "and isStaged": { - topic: function() { db.isStaged('lloyd@somewhe.re', this.callback); }, - "returns false": function(r) { assert.isFalse(r); } + "makes it visible via isStaged": function(sekret, r) { assert.isTrue(r); }, + "lets you verify it": { + topic: function(secret, r) { + db.gotVerificationSecret(secret, undefined, 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); } + } } } } @@ -275,7 +302,10 @@ suite.addBatch({ suite.addBatch({ "removing an existing email": { topic: function() { - db.removeEmail("lloyd@somewhe.re", "lloyd@nowhe.re", this.callback); + var cb = this.callback; + db.emailToUID("lloyd@somewhe.re", function(uid) { + db.removeEmail(uid, "lloyd@nowhe.re", cb); + }); }, "returns no error": function(r) { assert.isUndefined(r); @@ -321,7 +351,10 @@ suite.addBatch({ suite.addBatch({ "adding a primary email to that account": { topic: function() { - db.addPrimaryEmailToAccount("lloyd@primary.domain", "lloyd2@primary.domain", this.callback); + var cb = this.callback; + db.emailToUID('lloyd@primary.domain', function(uid) { + db.addPrimaryEmailToAccount(uid, "lloyd2@primary.domain", cb); + }); }, "returns no error": function(r) { assert.isUndefined(r); @@ -345,7 +378,10 @@ suite.addBatch({ }, "adding a primary email to an account with only secondaries": { topic: function() { - db.addPrimaryEmailToAccount("lloyd@somewhe.re", "lloyd3@primary.domain", this.callback); + var cb = this.callback; + db.emailToUID('lloyd@somewhe.re', function(uid) { + db.addPrimaryEmailToAccount(uid, "lloyd3@primary.domain", cb); + }); }, "returns no error": function(r) { assert.isUndefined(r); @@ -372,7 +408,10 @@ suite.addBatch({ suite.addBatch({ "adding a registered primary email to an account": { topic: function() { - db.addPrimaryEmailToAccount("lloyd@primary.domain", "lloyd3@primary.domain", this.callback); + var cb = this.callback; + db.emailToUID('lloyd@primary.domain', function(uid) { + db.addPrimaryEmailToAccount(uid, "lloyd3@primary.domain", cb); + }); }, "returns no error": function(r) { assert.isUndefined(r); @@ -415,7 +454,10 @@ suite.addBatch({ suite.addBatch({ "canceling an account": { topic: function() { - db.cancelAccount("lloyd@somewhe.re", this.callback); + var cb = this.callback; + db.emailToUID("lloyd@somewhe.re", function(uid) { + db.cancelAccount(uid, cb); + }); }, "returns no error": function(r) { assert.isUndefined(r);