diff --git a/README.md b/README.md index df6ef245753e0fbb986b008adcaf684816a4edfc..82a91127921c63310072122520cf29cb664f3bdf 100644 --- a/README.md +++ b/README.md @@ -6,5 +6,6 @@ Getting started: 1. install node.js (>= 0.4.5): http://nodejs.org/ 2. install the Connect framework (>= 1.3.0): http://senchalabs.github.com/connect/ 3. install xml2js -4. run the top level `run.js` script -5. visit the demo application ('rp') in your web browser (url output on the console at runtime)â +4. install sqlite (npm install sqlite will get it for you, this is the flavor: https://github.com/orlandov/node-sqlite) +5. run the top level `run.js` script +6. visit the demo application ('rp') in your web browser (url output on the console at runtime)â diff --git a/authority/.gitignore b/authority/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..ae9f5955b3f2463d8aa275899dfa93fe2d05c42b --- /dev/null +++ b/authority/.gitignore @@ -0,0 +1 @@ +/authdb.sqlite diff --git a/authority/server/db.js b/authority/server/db.js index 6f13cadf8bb88cbe2b7f4c253480c1140ba3e9df..726d5c9dc2fef1ec11e57f02bdfd35065b4c1ba3 100644 --- a/authority/server/db.js +++ b/authority/server/db.js @@ -1,7 +1,27 @@ -// Registered users. This is a horribly inefficient data structure -// which only exists for prototype purposes. -var g_users = [ -]; +const sqlite = require('sqlite'), + path = require('path'); + +var db = new sqlite.Database(); + +db.open(path.join(path.dirname(__dirname), "authdb.sqlite"), function (error) { + if (error) { + console.log("Couldn't open database: " + error); + throw error; + } + + function createTable(name, sql) { + db.execute(sql, function (error, rows) { + if (error) { + console.log("Couldn't create " + name + " table: " + error); + throw error; + } + }); + } + + createTable('users', "CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY, password TEXT )"); + createTable('emails', "CREATE TABLE IF NOT EXISTS emails ( id INTEGER PRIMARY KEY, user INTEGER, address TEXT UNIQUE )"); + createTable('keys', "CREATE TABLE IF NOT EXISTS keys ( id INTEGER PRIMARY KEY, email INTEGER, key TEXT, expires INTEGER )"); +}); // half created user accounts (pending email verification) // OR @@ -9,6 +29,46 @@ var g_users = [ 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); + }); + } + } + + 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); + } + }); +} + exports.findByEmail = function(email) { for (var i = 0; i < g_users.length; i++) { for (var j = 0; j < g_users[i].emails.length; j++) { @@ -18,12 +78,17 @@ exports.findByEmail = function(email) { return undefined; }; +exports.emailKnown = function(email, cb) { + db.execute( + "SELECT id FROM emails WHERE address = ?", + [ email ], + function(error, rows) { + cb(rows.length > 0); + }); +}; + exports.isStaged = function(email) { - // XXX: not efficient - for (var k in g_staged) { - if (g_staged[k].email === email) return true; - } - return false; + return g_stagedEmails.hasOwnProperty(email); }; function generateSecret() { @@ -35,12 +100,48 @@ function generateSecret() { return str; } -exports.addEmailToAccount = function(existing_email, email, pubkey) { - var acct = exports.findByEmail(existing_email); - if (acct === undefined) throw "no such email: " + existing_email; - if (acct.emails.indexOf(email) == -1) acct.emails.push(email); - acct.keys.push(email); - return; +exports.addEmailToAccount = function(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(); + }); + } + }); +} + +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(); + }); + }); + }); } /* takes an argument object including email, pass, and pubkey. */ @@ -53,6 +154,7 @@ exports.stageUser = function(obj) { pubkey: obj.pubkey, pass: obj.pass }; + g_stagedEmails[obj.email] = secret; return secret; }; @@ -66,39 +168,48 @@ exports.stageEmail = function(existing_email, new_email, pubkey) { 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) { - if (!g_staged.hasOwnProperty(secret)) return false; +exports.gotVerificationSecret = function(secret, cb) { + if (!g_staged.hasOwnProperty(secret)) 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') { - if (undefined != exports.findByEmail(o.email)) { - throw "email already exists!"; - } - g_users.push({ - emails: [ o.email ], - keys: [ o.pubkey ], - pass: o.pass + exports.emailKnown(o.email, function(known) { + if (known) cb("email already exists!"); + else { + 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(); + }); + } }); } else if (o.type === 'add_email') { - exports.addEmailToAccount(o.existing_email, o.email, o.pubkey); + exports.addEmailToAccount(o.existing_email, o.email, o.pubkey, cb); } else { - return false; + cb("internal error"); } - - return true; }; /* takes an argument object including email, pass, and pubkey. */ -exports.checkAuth = function(email, pass) { - var acct = exports.findByEmail(email); - if (acct === undefined) return false; - return pass === acct.pass; +exports.checkAuth = function(email, pass, cb) { + db.execute("SELECT users.id FROM emails, users WHERE users.id = emails.user AND emails.address = ? AND users.password = ?", + [ email, pass ], + function (error, rows) { + cb(rows.length === 1); + }); }; /* a high level operation that attempts to sync a client's view with that of the @@ -112,28 +223,57 @@ exports.checkAuth = function(email, pass) { * 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) { +exports.getSyncResponse = function(email, identities, cb) { var respBody = { unknown_emails: [ ], key_refresh: [ ] }; - // fetch user acct - var acct = exports.findByEmail(email); + // 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 = [ ]; + for (var i = 0; i < rows.length; i++) emails.push(rows[i].address); - // #1 - for (var e in identities) { - if (acct.emails.indexOf(e) == -1) respBody.unknown_emails.push(e); - } + // #1 + for (var e in identities) { + if (emails.indexOf(e) == -1) respBody.unknown_emails.push(e); + } - // #2 - for (var e in acct.emails) { - e = acct.emails[e]; - if (!identities.hasOwnProperty(e)) respBody.key_refresh.push(e); - } + // #2 + for (var e in emails) { + e = emails[e]; + if (!identities.hasOwnProperty(e)) respBody.key_refresh.push(e); + } + + // #3 + // XXX todo + + cb(undefined, respBody); + } + }); + }); +}; - // #3 - // XXX todo - return respBody; +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); + }); }; \ No newline at end of file diff --git a/authority/server/email.js b/authority/server/email.js index edd8c02c1aff6829818b754dab9e023d7407c364..2777ab6cae4deca9b47678052cf80e554f6185f9 100644 --- a/authority/server/email.js +++ b/authority/server/email.js @@ -6,6 +6,10 @@ exports.sendVerificationEmail = function(email, secret) { // we'll just wait 5 seconds and manually feed the secret back into the // system, as if a user had clicked a link setTimeout(function() { - db.gotVerificationSecret(secret); + db.gotVerificationSecret(secret, function(e) { + if (e) { + console.log("error completing the verification: " + e); + } + }); }, 5000); }; \ No newline at end of file diff --git a/authority/server/httputils.js b/authority/server/httputils.js index 31a77d6c950eaea6ed25d79a5125d1c62bcd7083..669cc049449a9958eee9958726404a01ea5fd0f8 100644 --- a/authority/server/httputils.js +++ b/authority/server/httputils.js @@ -35,6 +35,13 @@ exports.jsonResponse = function(resp, obj) resp.end(); }; +exports.xmlResponse = function(resp, doc) +{ + resp.writeHead(200, {"Content-Type": "text/xml"}); + if (doc !== undefined) resp.write(doc); + resp.end(); +}; + exports.checkGetArgs = function(req, args) { [ "email", "pass", "pubkey" ].forEach(function(k) { if (!urlobj.hasOwnProperty(k) || typeof urlobj[k] !== 'string') { diff --git a/authority/server/run.js b/authority/server/run.js index c36d32f929950780db38b08f38ca27fc92735d78..a44146ae3c124d360d118287b9828cc4d6221344 100644 --- a/authority/server/run.js +++ b/authority/server/run.js @@ -2,8 +2,8 @@ const path = require('path'), url = require('url'), wsapi = require('./wsapi.js'), httputils = require('./httputils.js'), - connect = require('connect'); - + connect = require('connect'), + webfinger = require('./webfinger.js'); const STATIC_DIR = path.join(path.dirname(__dirname), "static"); @@ -22,6 +22,16 @@ exports.handler = function(request, response, serveFile) { console.log(errMsg); httputils.fourOhFour(response, errMsg); } + } else if (/^\/users\/[^\/]+.xml$/.test(urlpath)) { + var identity = path.basename(urlpath).replace(/.xml$/, ''); + + webfinger.renderUserPage(identity, function (resultDocument) { + if (resultDocument === undefined) { + httputils.fourOhFour(response, "I don't know anything about: " + identity); + } else { + httputils.xmlResponse(response, resultDocument); + } + }); } else { // node.js takes care of sanitizing the request path serveFile(path.join(STATIC_DIR, urlpath), response); diff --git a/authority/server/webfinger.js b/authority/server/webfinger.js new file mode 100644 index 0000000000000000000000000000000000000000..ecabf1ffc64e9f91abfbad9f9299f8c1d7873dda --- /dev/null +++ b/authority/server/webfinger.js @@ -0,0 +1,21 @@ +const db = require('./db.js'); + +const HEADER = "<?xml version='1.0' encoding='UTF-8'?>\n<XRD xmlns='http://docs.oasis-open.org/ns/xri/xrd-1.0'\n xmlns:hm='http://host-meta.net/xrd/1.0'>\n"; + +const FOOTER ="</XRD>\n"; +const KEYHEADER = " <Link rel=\"public-key\" value=\""; +const KEYFOOTER = "\"/>\n"; + +exports.renderUserPage = function(identity, cb) { + db.pubkeysForEmail(identity, function(keys) { + var respDoc = undefined; + if (keys && keys.length) { + respDoc = HEADER; + for (var i = 0; i < keys.length; i++) { + respDoc += (KEYHEADER + keys[i] + KEYFOOTER) ; + } + respDoc += FOOTER; + } + cb(respDoc); + }); +}; \ No newline at end of file diff --git a/authority/server/wsapi.js b/authority/server/wsapi.js index 103a5962cbb779d795d10128828aa8504ad16922..1dc316de155b6049e64d15df1618db98f18ed75d 100644 --- a/authority/server/wsapi.js +++ b/authority/server/wsapi.js @@ -38,7 +38,9 @@ exports.have_email = function(req, resp) { // get inputs from get data! var email = url.parse(req.url, true).query['email']; logRequest("have_email", {email: email}); - httputils.jsonResponse(resp, undefined != db.findByEmail(email)); + db.emailKnown(email, function(known) { + httputils.jsonResponse(resp, known); + }); }; /* First half of account creation. Stages a user account for creation. @@ -74,15 +76,17 @@ exports.registration_status = function(req, resp) { logRequest("registration_status", req.session); var email = req.session.pendingRegistration; - if (undefined != db.findByEmail(email)) { - delete req.session.pendingRegistration; - req.session.authenticatedUser = email; - httputils.jsonResponse(resp, "complete"); - } else if (db.isStaged(email)) { - httputils.jsonResponse(resp, "pending"); - } else { - httputils.jsonResponse(resp, "noRegistration"); - } + db.emailKnown(email, function(known) { + if (known) { + delete req.session.pendingRegistration; + req.session.authenticatedUser = email; + httputils.jsonResponse(resp, "complete"); + } else if (db.isStaged(email)) { + httputils.jsonResponse(resp, "pending"); + } else { + httputils.jsonResponse(resp, "noRegistration"); + } + }); }; exports.authenticate_user = function(req, resp) { @@ -91,12 +95,10 @@ exports.authenticate_user = function(req, resp) { if (!checkParams(getArgs, resp, [ "email", "pass" ])) return; - if (db.checkAuth(getArgs.email, getArgs.pass)) { - req.session.authenticatedUser = getArgs.email; - httputils.jsonResponse(resp, true); - } else { - httputils.jsonResponse(resp, false); - } + db.checkAuth(getArgs.email, getArgs.pass, function(rv) { + if (rv) req.session.authenticatedUser = getArgs.email; + httputils.jsonResponse(resp, rv); + }); }; exports.add_email = function (req, resp) { @@ -132,8 +134,9 @@ exports.set_key = function (req, resp) { if (!checkParams(getArgs, resp, [ "email", "pubkey" ])) return; if (!isAuthed(req, resp)) return; logRequest("set_key", getArgs); - db.addEmailToAccount(req.session.authenticatedUser, getArgs.email, getArgs.pubkey); - httputils.jsonResponse(resp, true); + db.addKeyToEmail(req.session.authenticatedUser, getArgs.email, getArgs.pubkey, function (rv) { + httputils.jsonResponse(resp, rv); + }); }; exports.am_authed = function(req,resp) { @@ -152,10 +155,12 @@ exports.sync_emails = function(req,resp) { logRequest("sync_emails", requestBody); try { var emails = JSON.parse(requestBody); - var syncResponse = db.getSyncResponse(req.session.authenticatedUser, emails); - httputils.jsonResponse(resp, syncResponse); } catch(e) { httputils.badRequest(resp, "malformed payload: " + e); } + db.getSyncResponse(req.session.authenticatedUser, emails, function(err, syncResponse) { + if (err) httputils.serverError(resp, err); + else httputils.jsonResponse(resp, syncResponse); + }); }); }; diff --git a/authority/static/.well-known/host-meta b/authority/static/.well-known/host-meta new file mode 100644 index 0000000000000000000000000000000000000000..f96a548972c015172efab6b25ddec3f5c5ec8d4f --- /dev/null +++ b/authority/static/.well-known/host-meta @@ -0,0 +1,11 @@ +<?xml version='1.0' encoding='UTF-8'?> + +<XRD xmlns='http://docs.oasis-open.org/ns/xri/xrd-1.0' + xmlns:hm='http://host-meta.net/xrd/1.0'> + + <hm:Host xmlns='http://host-meta.net/xrd/1.0'>authority.mozilla.org</hm:Host> + + <Link rel='lrdd' template='http://authority.mozilla.org/users/{uri}.xml'></Link> + + <Link rel='other' value='something-different'></Link> +</XRD> diff --git a/rp/server/httputils.js b/rp/server/httputils.js new file mode 100644 index 0000000000000000000000000000000000000000..669cc049449a9958eee9958726404a01ea5fd0f8 --- /dev/null +++ b/rp/server/httputils.js @@ -0,0 +1,52 @@ +// various little utilities to make crafting boilerplate responses +// simple + +exports.fourOhFour = function(resp, reason) +{ + resp.writeHead(404, {"Content-Type": "text/plain"}); + resp.write("Not Found"); + if (reason) { + resp.write(": " + reason); + } + resp.end(); +}; + +exports.serverError = function(resp, reason) +{ + resp.writeHead(500, {"Content-Type": "text/plain"}); + if (reason) resp.write(reason); + resp.end(); +}; + +exports.badRequest = function(resp, reason) +{ + resp.writeHead(400, {"Content-Type": "text/plain"}); + resp.write("Bad Request"); + if (reason) { + resp.write(": " + reason); + } + resp.end(); +}; + +exports.jsonResponse = function(resp, obj) +{ + resp.writeHead(200, {"Content-Type": "application/json"}); + if (obj !== undefined) resp.write(JSON.stringify(obj)); + resp.end(); +}; + +exports.xmlResponse = function(resp, doc) +{ + resp.writeHead(200, {"Content-Type": "text/xml"}); + if (doc !== undefined) resp.write(doc); + resp.end(); +}; + +exports.checkGetArgs = function(req, args) { + [ "email", "pass", "pubkey" ].forEach(function(k) { + if (!urlobj.hasOwnProperty(k) || typeof urlobj[k] !== 'string') { + throw k; + } + }); + +}; diff --git a/rp/server/run.js b/rp/server/run.js new file mode 100644 index 0000000000000000000000000000000000000000..291f1e1cc25ee53b4182dcbad51aa7a222694d42 --- /dev/null +++ b/rp/server/run.js @@ -0,0 +1,45 @@ +const path = require('path'), + url = require('url'), + wsapi = require('./wsapi.js'), + httputils = require('./httputils.js'), + connect = require('connect'), + fs = require('fs'); + +const STATIC_DIR = path.join(path.dirname(__dirname), "static"); + +exports.handler = function(request, response, serveFile) { + // dispatch! + var urlpath = url.parse(request.url).pathname; + + if (urlpath === '/authenticate') { + // XXX: do something + httputils.serverError(response, "notImplemented"); + } else { + // node.js takes care of sanitizing the request path + // automatically serve index.html if this is a directory + var filename = path.join(STATIC_DIR, urlpath) + fs.stat(filename, function(err, s) { + if (err === null && s.isDirectory()) { + serveFile(path.join(filename, "index.html"), response); + } else { + serveFile(filename, response); + } + }); + } +}; + +exports.setup = function(server) { + var week = (7 * 24 * 60 * 60 * 1000); + + server + .use(connect.cookieParser()) + .use(connect.session({ + secret: "rhodesian ridgeback", + cookie: { + path: '/', + httpOnly: true, + expires: new Date(Date.now() + week),// a week XXX: think about session security, etc + maxAge: week + } + })); +} diff --git a/rp/server/wsapi.js b/rp/server/wsapi.js new file mode 100644 index 0000000000000000000000000000000000000000..e802e619dec3a7e094bab6e8b2398e92b1914b98 --- /dev/null +++ b/rp/server/wsapi.js @@ -0,0 +1,37 @@ +// a module which implements the authorities web server api. +// every export is a function which is a WSAPI method handler + +const url = require('url'), + httputils = require('./httputils.js') + +function checkParams(getArgs, resp, params) { + try { + params.forEach(function(k) { + if (!getArgs.hasOwnProperty(k) || typeof getArgs[k] !== 'string') { + throw k; + } + }); + } catch(e) { + httputils.badRequest(resp, "missing '" + e + "' argument"); + return false; + } + return true; +} + +function isAuthed(req, resp) { + if (typeof req.session.authenticatedUser !== 'string') { + httputils.badRequest(resp, "requires authentication"); + return false; + } + return true; +} + +exports.all_words = function(req,resp) { + if (!isAuthed(req,resp)) return; + httputils.serverError(resp, "notImplemented"); +}; + +exports.add_word = function(req,resp) { + if (!isAuthed(req,resp)) return; + httputils.serverError(resp, "notImplemented"); +}; diff --git a/rp/index.html b/rp/static/index.html similarity index 100% rename from rp/index.html rename to rp/static/index.html diff --git a/rp/jquery-min.js b/rp/static/jquery-min.js similarity index 100% rename from rp/jquery-min.js rename to rp/static/jquery-min.js diff --git a/run.js b/run.js index c797b49404036bf4bc9b4d1cc7eb0c76741ede44..42da0e40f86b80f70fc1a3f46391c45e6cb5e3e4 100644 --- a/run.js +++ b/run.js @@ -66,7 +66,7 @@ function serveFile(filename, response) { var a = o.server.address(); var from = o.name + ".mozilla.org"; var to = a.address + ":" + a.port; - data = data.replace(from, to); + data = data.replace(new RegExp(from, 'g'), to); } response.writeHead(200, {"Content-Type": mimeType});