diff --git a/lib/db.js b/lib/db.js index 0dca5d2f9f7e01f37ef48b3a1a505e80f733ed88..a3bcb33e7f203c465e5fe6577a970c00ef3b5eab 100644 --- a/lib/db.js +++ b/lib/db.js @@ -81,6 +81,7 @@ exports.onReady = function(f) { 'emailType', 'emailIsVerified', 'emailsBelongToSameAccount', + 'lastPasswordReset', 'haveVerificationSecret', 'isStaged', 'lastStaged', diff --git a/lib/db/json.js b/lib/db/json.js index a1d8623e4265f1aab1dbf2ba919c75647b4da9e4..6c9555b650f93328ed947c03cc8ba83997ff086c 100644 --- a/lib/db/json.js +++ b/lib/db/json.js @@ -33,6 +33,7 @@ var dbPath = path.join(configuration.get('var_path'), "authdb.json"); * { * id: <numerical user id> * password: "somepass", + * lastPasswordReset: 123456, (seconds-since-epoch, integer) * emails: { * "lloyd@hilaiel.com": { * type: 'secondary' @@ -42,6 +43,8 @@ var dbPath = path.join(configuration.get('var_path'), "authdb.json"); * ] */ +function now() { return Math.floor(new Date().getTime() / 1000); } + function getNextUserID() { var max = 1; jsel.forEach(".id", db.users, function(id) { @@ -232,6 +235,7 @@ exports.createUserWithPrimaryEmail = function(email, cb) { db.users.push({ id: uid, password: null, + lastPasswordReset: now(), emails: emailVal }); flush(); @@ -306,7 +310,7 @@ exports.completeConfirmEmail = function(secret, cb) { exports.emailToUID(o.email, function(err, uid) { if(err) return cb(err, o.email, o.existing_user); - exports.updatePassword(uid, hash, function(err) { + exports.updatePassword(uid, hash, true, function(err) { cb(err || null, o.email, o.existing_user); }); }); @@ -335,6 +339,7 @@ exports.completeCreateUser = function(secret, cb) { db.users.push({ id: uid, password: hash, + lastPasswordReset: now(), emails: emailVal }); flush(); @@ -385,7 +390,7 @@ exports.completePasswordReset = function(secret, cb) { flush(); // update the password! - exports.updatePassword(uid, o.passwd, function(err) { + exports.updatePassword(uid, o.passwd, true, function(err) { cb(err, o.email, uid); }); }); @@ -421,6 +426,17 @@ exports.checkAuth = function(userID, cb) { process.nextTick(function() { cb(null, m) }); }; +exports.lastPasswordReset = function(userID, cb) { + sync(); + var m = undefined; + if (userID) { + m = jsel.match(":root > object:has(:root > .id:expr(x=" + ESC(userID) + ")) > .lastPasswordReset", db.users); + if (m.length === 0) m = undefined; + else m = m[0]; + } + process.nextTick(function() { cb(null, m) }); +}; + exports.userKnown = function(userID, cb) { sync(); var m = jsel.match(":root > object:has(:root > .id:expr(x=" + ESC(userID) + "))", db.users); @@ -429,12 +445,16 @@ exports.userKnown = function(userID, cb) { process.nextTick(function() { cb(null, m) }); }; -exports.updatePassword = function(userID, hash, cb) { +exports.updatePassword = function(userID, hash, invalidateSessions, cb) { sync(); 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; + else { + m[0].password = hash; + if (invalidateSessions) + m[0].lastPasswordReset = now(); + } flush(); process.nextTick(function() { cb(err) }); }; @@ -498,6 +518,7 @@ exports.addTestUser = function(email, hash, cb) { db.users.push({ id: getNextUserID(), password: hash, + lastPasswordReset: now(), emails: emailVal }); flush(); diff --git a/lib/db/mysql.js b/lib/db/mysql.js index 4b3cc13a2adaea54e84ce205be8c4a0ef2de4677..4c8f1edf36efbd8d10c23e423ac188cd462ba78e 100644 --- a/lib/db/mysql.js +++ b/lib/db/mysql.js @@ -9,14 +9,13 @@ /* * The Schema: - * - * +--- user ------+ +--- email -----+ - * |*int id | <-\ |*int id | - * | string passwd | \- |*int user | - * +---------------+ |*string address| - * | enum type | - * | bool verified | - * +---------------+ + * +--- email -------+ + * +--- user --------------------+ |*int id | + * |*int id |<-----|*int user | + * | string passwd | |*string address | + * | timestamp lastPasswordReset | | enum type | + * +-----------------------------+ | bool verified | + * +-----------------+ * * * +------ staged ----------+ @@ -65,7 +64,8 @@ if (conf.get('env') === 'test_mysql' && process.env['STALL_MYSQL_WHEN_PRESENT']) const schemas = [ "CREATE TABLE IF NOT EXISTS user (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY," + - "passwd CHAR(64)" + + "passwd CHAR(64)," + + "lastPasswordReset TIMESTAMP DEFAULT 0 NOT NULL" + ") ENGINE=InnoDB;", "CREATE TABLE IF NOT EXISTS email (" + @@ -89,6 +89,8 @@ const schemas = [ ") 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 @@ -369,8 +371,8 @@ exports.completeCreateUser = function(secret, cb) { // we're creating a new account, add appropriate entries into user and email tables. client.query( - "INSERT INTO user(passwd) VALUES(?)", - [ o.passwd ], + "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); @@ -395,7 +397,7 @@ exports.completeConfirmEmail = function(secret, cb) { // 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, function(err) { + exports.updatePassword(o.existing_user, o.passwd, true, function(err) { if (err) return cb('could not set user\'s password'); addEmailToUser(o.existing_user, o.email, 'secondary', cb); }); @@ -432,7 +434,7 @@ exports.completePasswordReset = function(secret, cb) { if (err) return cb(err); // update the password! - exports.updatePassword(uid, o.passwd, function(err) { + exports.updatePassword(uid, o.passwd, true, function(err) { cb(err, o.email, uid); }); }); @@ -449,7 +451,8 @@ exports.addPrimaryEmailToAccount = function(uid, emailToAdd, cb) { exports.createUserWithPrimaryEmail = function(email, cb) { // create a new user acct with no password client.query( - "INSERT INTO user() VALUES()", + "INSERT INTO user(lastPasswordReset) VALUES(FROM_UNIXTIME(?))", + [ now() ], function(err, info) { if (err) return cb(err); var uid = info.insertId; @@ -510,10 +513,21 @@ exports.checkAuth = function(uid, cb) { }); } -exports.updatePassword = function(uid, hash, cb) { +exports.lastPasswordReset = function(uid, cb) { client.query( - 'UPDATE user SET passwd = ? WHERE id = ?', - [ hash, uid ], + '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; @@ -577,8 +591,8 @@ exports.cancelAccount = function(uid, cb) { exports.addTestUser = function(email, hash, cb) { client.query( - "INSERT INTO user(passwd) VALUES(?)", - [ hash ], + "INSERT INTO user(passwd, lastPasswordReset) VALUES(FROM_UNIXTIME(?))", + [ hash, now() ], function(err, info) { if (err) return cb(err); diff --git a/lib/wsapi.js b/lib/wsapi.js index 7cee435e2afba35e628923bb3a1c2583447eca40..9d8f8d825e46071fab9fdc0f366c838d282eccc0 100644 --- a/lib/wsapi.js +++ b/lib/wsapi.js @@ -29,7 +29,8 @@ path = require('path'), validate = require('./validate'), statsd = require('./statsd'), bcrypt = require('./bcrypt'), -i18n = require('./i18n'); +i18n = require('./i18n'), +db = require('./db'); var abide = i18n.abide({ supported_languages: config.get('supported_languages'), @@ -79,22 +80,82 @@ function bcryptPassword(password, cb) { }); } -function authenticateSession(session, uid, level, duration_ms) { +function authenticateSession(options, cb) { + var session = options.session; + var uid = options.uid; + var level = options.level; + var duration_ms = options.duration_ms; if (['assertion', 'password'].indexOf(level) === -1) - throw "invalid authentication level: " + level; - - // if the user is *already* authenticated as this uid with an equal or better - // level of auth, let's not lower them. Issue #1049 - if (session.userid === uid && session.auth_level === 'password' && - session.auth_level !== level) { - logger.info("not resetting cookies to 'assertion' authenticate a user who is already password authenticated"); - } else { - if (duration_ms) { - session.setDuration(duration_ms); + cb(new Error("invalid authentication level: " + level)); + + db.lastPasswordReset(uid, function(err, lastPasswordReset) { + if (err) + return cb(err); + if (lastPasswordReset === undefined) + return cb(new Error("authenticateSession called with undefined lastPasswordReset")); + // if the user is *already* authenticated as this uid with an equal or + // better level of auth, let's not lower them. Issue #1049 + if (session.userid === uid && session.auth_level === 'password' && + session.auth_level !== level) { + logger.info("not resetting cookies to 'assertion' authenticate a user who is already password authenticated"); + } else { + if (duration_ms) { + session.setDuration(duration_ms); + } + session.userid = uid; + session.auth_level = level; + session.lastPasswordReset = lastPasswordReset; } - session.userid = uid; - session.auth_level = level; + cb(null); + }); +} + +function checkCSRF(req, resp, next) { + // only on POSTs + if (req.method !== "POST") + return next(); + + // there must be a session + if (req.session === undefined || typeof req.session.csrf !== 'string') { + logger.warn("POST calls to /wsapi require a cookie to be sent, this user may have cookies disabled"); + return httputils.forbidden(resp, "no cookie"); + } + + // and the token must match what is sent in the post body + if (!req.body || !req.session || !req.session.csrf || req.body.csrf != req.session.csrf) { + // if any of these things are false, then we'll block the request + var b = req.body ? req.body.csrf : "<none>"; + var s = req.session ? req.session.csrf : "<none>"; + logger.warn("CSRF validation failure, token mismatch. got:" + b + " want:" + s); + return httputils.badRequest(resp, "CSRF violation"); } + + // all good + next(); +} + +function checkExpiredSession(req, resp, next) { + // all requests (both GET and POST) must have a session + if (req.session === undefined) { + logger.warn("calls to /wsapi require a cookie to be sent, this user may have cookies disabled"); + return httputils.forbidden(resp, "no cookie"); + } + if (!req.session.userid) { + // not yet authenticated, so nothing to expire, avoid the DB fetch + return next(); + } + db.lastPasswordReset(req.session.userid, function(err, token) { + if (err) return databaseDown(resp, err); + // if token is 0 (or undefined), they haven't changed their password + // since the server was updated to use lastPasswordResets. Allow the + // session to pass, otherwise the server upgrade would gratuitously + // expire innocent sessions. + if (token && token != req.session.lastPasswordReset) { + logger.warn("expired cookie (password changed since issued)"); + req.session.reset(); + } + next(); + }); } function langContext(req) { @@ -177,60 +238,44 @@ exports.setup = function(options, app) { // by layers higher up based on cache control headers. // the fallout is that all code that interacts with sessions // should be under /wsapi - if (purl.pathname.substr(0, WSAPI_PREFIX.length) === WSAPI_PREFIX) { - // explicitly disallow caching on all /wsapi calls (issue #294) - resp.setHeader('Cache-Control', 'no-cache, max-age=0'); + if (purl.pathname.substr(0, WSAPI_PREFIX.length) !== WSAPI_PREFIX) + return next(); - // we set this parameter so the connect-cookie-session - // sends the cookie even though the local connection is HTTP - // (the load balancer does SSL) - if (overSSL) - req.connection.proxySecure = true; + // explicitly disallow caching on all /wsapi calls (issue #294) + resp.setHeader('Cache-Control', 'no-cache, max-age=0'); - const operation = purl.pathname.substr(WSAPI_PREFIX.length); + // we set this parameter so the connect-cookie-session + // sends the cookie even though the local connection is HTTP + // (the load balancer does SSL) + if (overSSL) + req.connection.proxySecure = true; - // count the number of WSAPI operation - statsd.increment("wsapi." + operation); - - // check to see if the api is known here, before spending more time with - // the request. - if (!wsapis.hasOwnProperty(operation) || - wsapis[operation].method.toLowerCase() !== req.method.toLowerCase()) - { - // if the fake verification api is enabled (for load testing), - // then let this request fall through - if (operation !== 'fake_verification' || !process.env['BROWSERID_FAKE_VERIFICATION']) - return httputils.badRequest(resp, "no such api"); - } + const operation = purl.pathname.substr(WSAPI_PREFIX.length); + + // count the number of WSAPI operation + statsd.increment("wsapi." + operation); + + // check to see if the api is known here, before spending more time with + // the request. + if (!wsapis.hasOwnProperty(operation) || + wsapis[operation].method.toLowerCase() !== req.method.toLowerCase()) + { + // if the fake verification api is enabled (for load testing), + // then let this request fall through + if (operation !== 'fake_verification' || !process.env['BROWSERID_FAKE_VERIFICATION']) + return httputils.badRequest(resp, "no such api"); + } - // perform full parsing and validation - return cookieParser(req, resp, function() { - bodyParser(req, resp, function() { - cookieSessionMiddleware(req, resp, function() { - // only on POSTs - if (req.method === "POST") { - - if (req.session === undefined || typeof req.session.csrf !== 'string') { // there must be a session - logger.warn("POST calls to /wsapi require a cookie to be sent, this user may have cookies disabled"); - return httputils.forbidden(resp, "no cookie"); - } - - // and the token must match what is sent in the post body - else if (!req.body || !req.session || !req.session.csrf || req.body.csrf != req.session.csrf) { - // if any of these things are false, then we'll block the request - var b = req.body ? req.body.csrf : "<none>"; - var s = req.session ? req.session.csrf : "<none>"; - logger.warn("CSRF validation failure, token mismatch. got:" + b + " want:" + s); - return httputils.badRequest(resp, "CSRF violation"); - } - } - return next(); + // perform full parsing and validation + return cookieParser(req, resp, function() { + bodyParser(req, resp, function() { + cookieSessionMiddleware(req, resp, function() { + checkExpiredSession(req, resp, function() { + return checkCSRF(req, resp, next); }); }); }); - } else { - return next(); - } + }); }); // load all of the APIs supported by this process diff --git a/lib/wsapi/auth_with_assertion.js b/lib/wsapi/auth_with_assertion.js index 3d76fa43a00e40f437838afa14af4403c3d81965..5a3b1d667264bf9880a6268b6b8c3acea8f3c616 100644 --- a/lib/wsapi/auth_with_assertion.js +++ b/lib/wsapi/auth_with_assertion.js @@ -45,10 +45,15 @@ exports.process = function(req, res) { return db.emailToUID(email, function(err, uid) { if (err) return wsapi.databaseDown(res, err); if (!uid) return res.json({ success: false, reason: "internal error" }); - wsapi.authenticateSession(req.session, uid, 'assertion', - req.params.ephemeral ? config.get('ephemeral_session_duration_ms') - : config.get('authentication_duration_ms')); - return res.json({ success: true, userid: uid }); + wsapi.authenticateSession({session: req.session, uid: uid, + level: 'assertion', + duration_ms: req.params.ephemeral ? + config.get('ephemeral_session_duration_ms') + : config.get('authentication_duration_ms') + }, function(err) { + if (err) return wsapi.databaseDown(res, err); + return res.json({ success: true, userid: uid }); + }); }); } else if (type === 'secondary') { @@ -96,10 +101,15 @@ exports.process = function(req, res) { } logger.info("successfully created primary acct for " + email + " (" + r.userid + ")"); - wsapi.authenticateSession(req.session, r.userid, 'assertion', - req.params.ephemeral ? config.get('ephemeral_session_duration_ms') - : config.get('authentication_duration_ms')); - res.json({ success: true, userid: r.userid }); + wsapi.authenticateSession({session: req.session, uid: r.userid, + level: 'assertion', + duration_ms: req.params.ephemeral ? + config.get('ephemeral_session_duration_ms') + : config.get('authentication_duration_ms') + }, function (err) { + if (err) return wsapi.databaseDown(res, err); + res.json({ success: true, userid: r.userid }); + }); }); }).on('error', function(e) { logger.error("failed to create primary user with assertion for " + email + ": " + e); diff --git a/lib/wsapi/authenticate_user.js b/lib/wsapi/authenticate_user.js index 97ee9f325a5035cbf21be6eda320e6c478697109..54ee33e9b19bc10f78c4e2c0f1224c458922b81a 100644 --- a/lib/wsapi/authenticate_user.js +++ b/lib/wsapi/authenticate_user.js @@ -64,59 +64,68 @@ exports.process = function(req, res) { } else { if (!req.session) req.session = {}; - wsapi.authenticateSession(req.session, uid, 'password', - req.params.ephemeral ? config.get('ephemeral_session_duration_ms') - : config.get('authentication_duration_ms')); - res.json({ success: true, userid: uid }); - - - // if the work factor has changed, update the hash here. issue #204 - // NOTE: this runs asynchronously and will not delay the response - if (config.get('bcrypt_work_factor') != bcrypt.getRounds(hash)) { - logger.info("updating bcrypted password for user " + uid); - - // this request must be forwarded to dbwriter, and we'll use the - // authentication cookie of the user just sent out. - var u = wsapi.forwardWritesTo; - - var m = u.scheme === 'http' ? http : https; - - var post_body = querystring.stringify({ - oldpass: req.params.pass, - newpass: req.params.pass, - csrf: req.params.csrf - }); - var preq = m.request({ - host: u.host, - port: u.port, - path: '/wsapi/update_password', - method: "POST", - headers: { - 'Cookie': res._headers['set-cookie'], - 'Content-Type': 'application/x-www-form-urlencoded', - 'Content-Length': post_body.length - } - }, function(pres) { - pres.on('end', function() { - if (pres.statusCode !== 200) { - logger.error("failed to update bcrypt rounds of password for " + uid + - " dbwriter returns " + pres.statusCode); - } else { - logger.info("bcrypt rounds of password for " + uid + - " successfully updated (from " + - bcrypt.getRounds(hash) + " to " - + config.get('bcrypt_work_factor') + ")"); - } - }); - }).on('error', function(e) { - logger.error("failed to update bcrypt rounds of password for " + uid + ": " + e); - }); - - preq.write(post_body); - preq.end(); - } + wsapi.authenticateSession({session: req.session, uid: uid, + level: 'password', + duration_ms: req.params.ephemeral ? + config.get('ephemeral_session_duration_ms') + : config.get('authentication_duration_ms') + }, function(err) { + if (err) + return wsapi.databaseDown(res, err); + res.json({ success: true, userid: uid }); + + // if the work factor has changed, update the hash here. issue #204 + // NOTE: this runs asynchronously and will not delay the response + if (config.get('bcrypt_work_factor') != bcrypt.getRounds(hash)) + updateHash(req, res, uid, hash); + }); } }); }); }); }; + + +function updateHash(req, res, uid, hash) { + logger.info("updating bcrypted password for user " + uid); + + // this request must be forwarded to dbwriter, and we'll use the + // authentication cookie of the user just sent out. + var u = wsapi.forwardWritesTo; + + var m = u.scheme === 'http' ? http : https; + + var post_body = querystring.stringify({ + oldpass: req.params.pass, + newpass: req.params.pass, + csrf: req.params.csrf + }); + var preq = m.request({ + host: u.host, + port: u.port, + path: '/wsapi/update_password', + method: "POST", + headers: { + 'Cookie': res._headers['set-cookie'], + 'Content-Type': 'application/x-www-form-urlencoded', + 'Content-Length': post_body.length + } + }, function(pres) { + pres.on('end', function() { + if (pres.statusCode !== 200) { + logger.error("failed to update bcrypt rounds of password for " + uid + + " dbwriter returns " + pres.statusCode); + } else { + logger.info("bcrypt rounds of password for " + uid + + " successfully updated (from " + + bcrypt.getRounds(hash) + " to " + + config.get('bcrypt_work_factor') + ")"); + } + }); + }).on('error', function(e) { + logger.error("failed to update bcrypt rounds of password for " + uid + ": " + e); + }); + + preq.write(post_body); + preq.end(); +} diff --git a/lib/wsapi/complete_email_confirmation.js b/lib/wsapi/complete_email_confirmation.js index f3ba86e791dadd98de37b3ef7e2ccba68ff205ed..816afc7330b758ecf6ff2dfa658fc1a0fe672e8a 100644 --- a/lib/wsapi/complete_email_confirmation.js +++ b/lib/wsapi/complete_email_confirmation.js @@ -64,8 +64,13 @@ exports.process = function(req, res) { logger.warn("couldn't complete email verification: " + e); wsapi.databaseDown(res, e); } else { - wsapi.authenticateSession(req.session, uid, 'password'); - res.json({ success: true }); + wsapi.authenticateSession({session: req.session, uid: uid, + level: 'password', duration_ms: undefined}, + function(err) { + if (err) + return wsapi.databaseDown(res, err); + res.json({ success: true }); + }); } }); }; diff --git a/lib/wsapi/complete_reset.js b/lib/wsapi/complete_reset.js index 49d8b2c58a342ffeb1339aeb3b51738f2489d1c1..6c2e915fd932540b069e3ec574df11d5e883cd3f 100644 --- a/lib/wsapi/complete_reset.js +++ b/lib/wsapi/complete_reset.js @@ -82,10 +82,14 @@ exports.process = function(req, res) { // At this point, the user is either on the same browser with a token from // their email address, OR they've provided their account password. It's // safe to grant them an authenticated session. - wsapi.authenticateSession(req.session, uid, 'password', - config.get('ephemeral_session_duration_ms')); - - res.json({ success: true }); + wsapi.authenticateSession({session: req.session, + uid: uid, + level: 'password', + duration_ms: config.get('ephemeral_session_duration_ms') + }, function(err) { + if (err) return wsapi.databaseDown(res, err); + res.json({ success: true }); + }); } }); }); diff --git a/lib/wsapi/complete_user_creation.js b/lib/wsapi/complete_user_creation.js index 66955d37849514d1ed8a83351507b8458bc5cc8a..2db73cfdb64a0db6c6f3a6694c4360a0d1db2bd6 100644 --- a/lib/wsapi/complete_user_creation.js +++ b/lib/wsapi/complete_user_creation.js @@ -87,9 +87,14 @@ exports.process = function(req, res) { // At this point, the user is either on the same browser with a token from // their email address, OR they've provided their account password. It's // safe to grant them an authenticated session. - wsapi.authenticateSession(req.session, uid, 'password', - config.get('ephemeral_session_duration_ms')); - res.json({ success: true }); + wsapi.authenticateSession({session: req.session, + uid: uid, + level: 'password', + duration_ms: config.get('ephemeral_session_duration_ms') + }, function(err) { + if (err) return wsapi.databaseDown(res, err); + res.json({ success: true }); + }); } }); }); diff --git a/lib/wsapi/prolong_session.js b/lib/wsapi/prolong_session.js index 2c9d5c02edb4ab2b9d04ee76ad519573f3c7debb..0fdaecddbf1f8b76483f54fd741a50fa96e1f006 100644 --- a/lib/wsapi/prolong_session.js +++ b/lib/wsapi/prolong_session.js @@ -12,7 +12,12 @@ exports.authed = 'assertion'; exports.i18n = false; exports.process = function(req, res) { - wsapi.authenticateSession(req.session, req.session.userid, req.session.auth_level, - config.get('authentication_duration_ms')); - res.send(200); + wsapi.authenticateSession({session: req.session, + uid: req.session.userid, + level: req.session.auth_level, + duration_ms: config.get('authentication_duration_ms') + }, function(err) { + if (err) return wsapi.databaseDown(res, err); + res.send(200); + }); }; diff --git a/lib/wsapi/update_password.js b/lib/wsapi/update_password.js index 52471aa05c45d18c5baa67ef2dde104855213395..d68a00560c7b1140c74ad4505db67e8adda8e13d 100644 --- a/lib/wsapi/update_password.js +++ b/lib/wsapi/update_password.js @@ -55,13 +55,24 @@ exports.process = function(req, res) { return res.json({ success: false }); } - db.updatePassword(req.session.userid, hash, function(err) { + var passwordChanged = (req.params.oldpass != req.params.newpass); + db.updatePassword(req.session.userid, hash, passwordChanged, + function(err) { var success = true; if (err) { logger.error("error updating bcrypted password for user " + req.session.userid, err); wsapi.databaseDown(res, err); } else { - res.json({ success: success }); + // need to update the session + wsapi.authenticateSession({session: req.session, + uid: req.session.userid, + level: req.session.auth_level, + duration_ms: req.session.duration_ms + }, function(err) { + if (err) + return wsapi.databaseDown(res, err); + res.json({ success: success }); + }); } }); }); diff --git a/lib/wsapi_client.js b/lib/wsapi_client.js index 81dbce7e09b56896a9dbdccc027eba02d74dbceb..a71202306d74fade5e5053939159e17b78c0db2b 100644 --- a/lib/wsapi_client.js +++ b/lib/wsapi_client.js @@ -95,7 +95,8 @@ function withCSRF(cfg, context, cb) { exports.get(cfg, '/wsapi/session_context', context, undefined, function(err, r) { if (err) return cb(err); try { - if (r.code !== 200) throw 'http error'; + if (r.code !== 200) + return cb({what: "http error", resp: r}); // report first error context.session = JSON.parse(r.body); context.sessionStartedAt = new Date().getTime(); cb(null, context.session.csrf_token); @@ -109,7 +110,14 @@ function withCSRF(cfg, context, cb) { exports.post = function(cfg, path, context, postArgs, cb) { withCSRF(cfg, context, function(err, csrf) { - if (err) return cb(err); + if (err) { + if (err.what == "http error") { + // let the session_context HTTP return code speak for the overall + // POST + return cb(null, err.resp); + } + return cb(err); + } // parse the server URL (cfg.browserid) var uObj; diff --git a/tests/lib/wsapi.js b/tests/lib/wsapi.js index cd35cb64be9a49c0865901ad410e1e9b434c9a32..a64f362d4ed7d6a628ec3c25a764865dfde0b1fc 100644 --- a/tests/lib/wsapi.js +++ b/tests/lib/wsapi.js @@ -13,31 +13,32 @@ var configuration = { browserid: 'http://127.0.0.1:10002/' } -exports.clearCookies = function() { - wcli.clearCookies(context); +exports.clearCookies = function(ctx) { + wcli.clearCookies(ctx||context); }; -exports.injectCookies = function(cookies) { - wcli.injectCookies({cookieJar: cookies}, context); +exports.injectCookies = function(cookies, ctx) { + wcli.injectCookies({cookieJar: cookies}, ctx||context); }; -exports.getCookie = function(which) { - return wcli.getCookie(context, which); +exports.getCookie = function(which, ctx) { + return wcli.getCookie(ctx||context, which); }; -exports.get = function (path, getArgs) { +exports.get = function (path, getArgs, ctx) { return function () { - wcli.get(configuration, path, context, getArgs, this.callback); + wcli.get(configuration, path, ctx||context, getArgs, this.callback); }; }; -exports.post = function (path, postArgs) { +exports.post = function (path, postArgs, ctx) { return function () { - wcli.post(configuration, path, context, postArgs, this.callback); + wcli.post(configuration, path, ctx||context, postArgs, this.callback); }; }; -exports.getCSRF = function() { +exports.getCSRF = function(ctx) { + var context = ctx||context; if (context && context.session && context.session.csrf_token) { return context.session.csrf_token; } diff --git a/tests/password-update-test.js b/tests/password-update-test.js index a33ac55680e34f4f5bfc6c2c3c06691b4d77d0a2..e2d9b77a3475a9f5361a862f193a7647cd146208 100755 --- a/tests/password-update-test.js +++ b/tests/password-update-test.js @@ -92,6 +92,30 @@ suite.addBatch({ } }); +var context2 = {}; +suite.addBatch({ + "establishing a second session": { + topic: wsapi.post('/wsapi/authenticate_user', { + email: TEST_EMAIL, + pass: OLD_PASSWORD, + ephemeral: false + }, context2), + "works as expected": function(err, r) { + assert.strictEqual(JSON.parse(r.body).success, true); + } + } +}); + +suite.addBatch({ + "using the second session": { + topic: wsapi.post('/wsapi/prolong_session', {}, context2), + "works as expected": function(err, r) { + assert.strictEqual(r.code, 200); + assert.strictEqual(r.body, "OK"); + } + } +}); + suite.addBatch({ "updating the password without specifying a proper old password": { topic: wsapi.post('/wsapi/update_password', { @@ -117,13 +141,31 @@ suite.addBatch({ }); suite.addBatch({ - "updating the password": { - topic: wsapi.post('/wsapi/update_password', { - oldpass: OLD_PASSWORD, - newpass: NEW_PASSWORD - }), - "works as expected": function(err, r) { - assert.strictEqual(JSON.parse(r.body).success, true); + "after waiting for lastPasswordReset's now() to increment": { + topic: function() { + // we introduce a 2s delay here to ensure that the now() call in + // lib/db/{json,mysql}.js will return a different value than it did + // during complete_user_creation(), thus expiring the old session still + // hanging out in context2. now() returns an integer + // seconds-since-epoch, so the shortest delay that will reliably get a + // different result is 1.0s+epsilon (depending upon the resolution of + // the system clock). To avoid this stall (and make the test suite run + // 2s faster), either: + // 1: change now() to include a mutable offset, expose a + // db.addNowOffset() to "accelerate the universe", have this code + // add 1s instead of using setTimeout. Or: + // 2: add a db function to modify (increment) lastPasswordReset by 1s, + // have this code call it instead of using setTimeout + setTimeout(this.callback, 2000); + }, + "updating the password": { + topic: wsapi.post('/wsapi/update_password', { + oldpass: OLD_PASSWORD, + newpass: NEW_PASSWORD + }), + "works as expected": function(err, r) { + assert.strictEqual(JSON.parse(r.body).success, true); + } } } }); @@ -148,6 +190,12 @@ suite.addBatch({ "fails as expected": function(err, r) { assert.strictEqual(JSON.parse(r.body).success, false); } + }, + "using the other (expired) session": { + topic: wsapi.post('/wsapi/prolong_session', {}, context2), + "fails as expected": function(err, r) { + assert.strictEqual(r.code, 403); + } } }); diff --git a/tests/stalled-mysql-test.js b/tests/stalled-mysql-test.js index 46b529baa6d2ab661e975122016de5a493a8d005..653ab279592b5e77e58e18e4de12a1617c7466a1 100755 --- a/tests/stalled-mysql-test.js +++ b/tests/stalled-mysql-test.js @@ -80,7 +80,6 @@ suite.addBatch({ "ping": { topic: wsapi.get('/wsapi/ping', {}), "fails with 500 when db is stalled": function(err, r) { - // address info with a primary address doesn't need db access. assert.strictEqual(r.code, 500); } }, @@ -216,7 +215,7 @@ suite.addBatch({ "ping": { topic: wsapi.get('/wsapi/ping', { }), "fails": function(err, r) { - assert.strictEqual(r.code, 500); + assert.strictEqual(r.code, 503); } }, @@ -391,15 +390,11 @@ suite.addBatch({ "fails with 404": function(err, r) { assert.strictEqual(r.code, 404); } - } -}); - -// logout doesn't need database, it should still succeed -suite.addBatch({ - "logout": { + }, + "logout": { // logout needs the database too topic: wsapi.post('/wsapi/logout', { }), - "succeeds": function(err, r) { - assert.strictEqual(r.code, 200); + "fails with 503": function(err, r) { + assert.strictEqual(r.code, 503); } } });